diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index afdc7d0d0..0f30024fd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 22.1.0 hooks: - id: black language_version: python3 exclude: (migrations/|urls\.py) - repo: https://github.com/pycqa/flake8 - rev: 3.9.2 + rev: 4.0.1 hooks: - id: flake8 exclude: (migrations/|urls\.py) - repo: https://github.com/pycqa/isort - rev: 5.6.4 + rev: 5.10.1 hooks: - id: isort args: ["--profile", "black", "--filter-files"] diff --git a/amy/communityroles/models.py b/amy/communityroles/models.py index 497e2e403..e968e5f7f 100644 --- a/amy/communityroles/models.py +++ b/amy/communityroles/models.py @@ -1,3 +1,5 @@ +from datetime import date + from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -69,3 +71,23 @@ def __str__(self) -> str: def get_absolute_url(self): return reverse("communityrole_details", kwargs={"pk": self.pk}) + + def is_active(self) -> bool: + """Determine if a community role is considered active. + + Rules for INACTIVE: + 1. `inactivation` is provided, or... + 2. End is provided and it's <= today, or... + 3. Start is provided and it's > today, or... + 4. Both start and end are provided, and today is NOT between them. + + Otherwise by default it's ACTIVE.""" + today = date.today() + if ( + self.inactivation is not None + or (self.end and self.end <= today) + or (self.start and self.start > today) + or (self.start and self.end and not (self.start <= today < self.end)) + ): + return False + return True diff --git a/amy/communityroles/templatetags/__init__.py b/amy/communityroles/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/amy/communityroles/templatetags/communityroles.py b/amy/communityroles/templatetags/communityroles.py new file mode 100644 index 000000000..8ec5557c3 --- /dev/null +++ b/amy/communityroles/templatetags/communityroles.py @@ -0,0 +1,16 @@ +from typing import Optional + +from django import template + +from communityroles.models import CommunityRole +from workshops.models import Person + +register = template.Library() + + +@register.simple_tag +def get_community_role(person: Person, role_name: str) -> Optional[CommunityRole]: + try: + return CommunityRole.objects.get(person=person, config__name=role_name) + except CommunityRole.DoesNotExist: + return None diff --git a/amy/communityroles/tests/test_models.py b/amy/communityroles/tests/test_models.py index 6f7ea95fd..191038f6e 100644 --- a/amy/communityroles/tests/test_models.py +++ b/amy/communityroles/tests/test_models.py @@ -1,3 +1,6 @@ +from datetime import date, timedelta +from typing import Optional + from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.urls.base import reverse @@ -77,3 +80,62 @@ def test_get_absolute_url(self): self.assertEqual( url, reverse("communityrole_details", args=[self.community_role.pk]) ) + + def test_is_active(self): + # Arrange + person = Person(personal="Test", family="User", email="test@user.com") + config = CommunityRoleConfig( + name="test_config", + display_name="Test Config", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + inactivation = CommunityRoleInactivation(name="test inactivation") + today = date.today() + yesterday = today - timedelta(days=1) + tomorrow = today + timedelta(days=1) + data: list[ + tuple[ + Optional[CommunityRoleInactivation], # role.inactivation + Optional[date], # role.start + Optional[date], # role.end + bool, # expected result + ] + ] = [ + # cases when we have inactivation set: always False + (inactivation, None, None, False), + (inactivation, yesterday, tomorrow, False), + (inactivation, yesterday, None, False), + (inactivation, None, tomorrow, False), + # cases when no start/no end + (None, None, None, True), + # cases when both start and end are available + (None, yesterday, tomorrow, True), + (None, tomorrow, yesterday, False), + (None, today, tomorrow, True), + (None, yesterday, today, False), + # cases when only start is provided + (None, tomorrow, None, False), + (None, today, None, True), + (None, yesterday, None, True), + # cases when only end is provided + (None, None, tomorrow, True), + (None, None, today, False), + (None, None, yesterday, False), + ] + for inactivation, start, end, expected in data: + with self.subTest(inactivation=inactivation, start=start, end=end): + community_role = CommunityRole( + config=config, + person=person, + inactivation=inactivation, + start=start, + end=end, + ) + + # Act + result = community_role.is_active() + + # Assert + self.assertEqual(result, expected) diff --git a/amy/communityroles/tests/test_templatetags.py b/amy/communityroles/tests/test_templatetags.py new file mode 100644 index 000000000..39fc2237c --- /dev/null +++ b/amy/communityroles/tests/test_templatetags.py @@ -0,0 +1,70 @@ +from django.test import TestCase + +from communityroles.models import CommunityRole, CommunityRoleConfig +from communityroles.templatetags.communityroles import get_community_role +from workshops.models import Person + + +class TestCommunityRolesTemplateTags(TestCase): + def test_get_community_role(self): + # Arrange + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + role_name = "instructor" + config = CommunityRoleConfig.objects.create( + name=role_name, + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + role_orig = CommunityRole.objects.create(config=config, person=person) + # Act + role_found = get_community_role(person, role_name) + # Assert + self.assertEqual(role_orig, role_found) + + def test_get_community_role__config_not_found(self): + # Arrange + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + role_name = "instructor" + config = CommunityRoleConfig.objects.create( + name=role_name, + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + CommunityRole.objects.create(config=config, person=person) + # Act + role_found = get_community_role(person, "fake_role") + # Assert + self.assertEqual(role_found, None) + + def test_get_community_role__person_not_found(self): + # Arrange + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + fake_person = Person.objects.create( + personal="Fake", + family="Person", + email="fake@person.com", + username="fake_user", + ) + role_name = "instructor" + config = CommunityRoleConfig.objects.create( + name=role_name, + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + CommunityRole.objects.create(config=config, person=person) + # Act + role_found = get_community_role(fake_person, role_name) + # Assert + self.assertEqual(role_found, None) diff --git a/amy/consents/tests/test_action_required_view.py b/amy/consents/tests/test_action_required_view.py index a65e723dc..617754566 100644 --- a/amy/consents/tests/test_action_required_view.py +++ b/amy/consents/tests/test_action_required_view.py @@ -132,7 +132,7 @@ def test_logged_in_user(self): the required terms is redirected to the form.""" urls = [ reverse("admin-dashboard"), - reverse("trainee-dashboard"), + reverse("instructor-dashboard"), ] # ensure we're logged in @@ -150,7 +150,7 @@ def test_logged_in_user(self): def test_no_more_redirects_after_agreement(self): """Ensure user is no longer forcefully redirected to accept the required terms.""" - url = reverse("trainee-dashboard") + url = reverse("instructor-dashboard") # ensure we're logged in self.client.force_login(self.neville) @@ -215,7 +215,7 @@ def test_old_terms_do_not_affect_terms_middleware(self): """ urls = [ reverse("admin-dashboard"), - reverse("trainee-dashboard"), + reverse("instructor-dashboard"), ] harry = Person.objects.create( personal="Harry", diff --git a/amy/dashboard/filters.py b/amy/dashboard/filters.py new file mode 100644 index 000000000..8930738a0 --- /dev/null +++ b/amy/dashboard/filters.py @@ -0,0 +1,68 @@ +from django.db.models import F, QuerySet +import django_filters as filters + +from recruitment.models import InstructorRecruitment +from workshops.filters import AMYFilterSet + + +class UpcomingTeachingOpportunitiesFilter(AMYFilterSet): + status = filters.ChoiceFilter( + choices=( + ("online", "Online only"), + ("inperson", "Inperson only"), + ), + empty_label="Any", + label="Online/inperson", + method="filter_status", + ) + + order_by = filters.OrderingFilter( + fields=("event__start",), + choices=( + ("event__start", "Event start"), + ("-event__start", "Event start (descending)"), + ("proximity", "Closer to my airport"), + ("-proximity", "Further away from my airport"), + ), + method="filter_order_by", + ) + + class Meta: + model = InstructorRecruitment + fields = [ + "status", + ] + + def filter_status(self, queryset: QuerySet, name: str, value: str) -> QuerySet: + """Filter recruitments based on the event (online/inperson) status.""" + if value == "online": + return queryset.filter(event__tags__name="online") + elif value == "inperson": + return queryset.exclude(event__tags__name="online") + else: + return queryset + + def filter_order_by(self, queryset: QuerySet, name: str, values: list) -> QuerySet: + """Order entries by proximity to user's airport.""" + try: + latitude: float = self.request.user.airport.latitude + except AttributeError: + latitude = 0.0 + + try: + longitude: float = self.request.user.airport.longitude + except AttributeError: + longitude = 0.0 + + # `0.0` is neutral element for this equation, so even if user doesn't have the + # airport specified, the sorting should still work + distance = (F("event__latitude") - latitude) ** 2 + ( + F("event__longitude") - longitude + ) ** 2 + + if values == ["proximity"]: + return queryset.annotate(distance=distance).order_by("distance") + elif values == ["-proximity"]: + return queryset.annotate(distance=distance).order_by("-distance") + else: + return queryset.order_by(*values) diff --git a/amy/dashboard/tests/test_filters.py b/amy/dashboard/tests/test_filters.py new file mode 100644 index 000000000..38b3163d3 --- /dev/null +++ b/amy/dashboard/tests/test_filters.py @@ -0,0 +1,169 @@ +from unittest.mock import ANY, MagicMock, call + +from django.core.exceptions import ValidationError +from django.db.models import F +from django.test import TestCase + +from dashboard.filters import UpcomingTeachingOpportunitiesFilter + + +class TestUpcomingTeachingOpportunitiesFilter(TestCase): + def test_fields(self): + # Arrange + data = {} + # Act + filterset = UpcomingTeachingOpportunitiesFilter(data) + # Assert + self.assertEqual(filterset.filters.keys(), {"status", "order_by"}) + + def test_invalid_values_for_status(self): + # Arrange + test_data = [ + "test", + "online/inperson", + " online", + ] + filterset = UpcomingTeachingOpportunitiesFilter({}) + field = filterset.filters["status"].field + # Act + for value in test_data: + with self.subTest(value=value): + # Assert + with self.assertRaises(ValidationError): + field.validate(value) + + def test_valid_values_for_status(self): + # Arrange + test_data = [ + "", + None, + "online", + "inperson", + ] + filterset = UpcomingTeachingOpportunitiesFilter({}) + field = filterset.filters["status"].field + # Act + for value in test_data: + with self.subTest(value=value): + # Assert no exception + field.validate(value) + + def test_invalid_values_for_order_by(self): + # Arrange + test_data = [ + "event_start", # single _ instead of double __ + "-event__end", + "distance", + " proximity", # space + ] + filterset = UpcomingTeachingOpportunitiesFilter({}) + field = filterset.filters["order_by"].field + # Act + for value in test_data: + with self.subTest(value=value): + # Assert + with self.assertRaises(ValidationError): + field.validate(value) + + def test_valid_values_for_order_by(self): + # Arrange + test_data = [ + "", + None, + "event__start", + "-event__start", + "proximity", + "-proximity", + ] + filterset = UpcomingTeachingOpportunitiesFilter({}) + field = filterset.filters["order_by"].field + # Act + for value in test_data: + with self.subTest(value=value): + # Assert no exception + field.validate(value) + + def test_filter_status__online(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "status" + # Act + filterset.filter_status(qs_mock, name, "online") + # Assert + qs_mock.filter.assert_called_once_with(event__tags__name="online") + qs_mock.exclude.assert_not_called() + + def test_filter_status__inperson(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "status" + # Act + filterset.filter_status(qs_mock, name, "inperson") + # Assert + qs_mock.filter.assert_not_called() + qs_mock.exclude.assert_called_once_with(event__tags__name="online") + + def test_filter_status__other_value(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "status" + # Act + result = filterset.filter_status(qs_mock, name, "other") + # Assert + qs_mock.filter.assert_not_called() + qs_mock.exclude.assert_not_called() + self.assertEqual(result, qs_mock) + + def test_filter_order_by__not_proximity(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "order_by" + # Act + filterset.filter_order_by(qs_mock, name, ["another value"]) + # Assert + qs_mock.annotate.assert_not_called() + qs_mock.order_by.assert_called_once_with("another value") + + def test_filter_order_by__proximity(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "order_by" + # Act + filterset.filter_order_by(qs_mock, name, ["proximity"]) + # Assert + qs_mock.annotate.assert_called_once_with(distance=ANY) + qs_mock.annotate().order_by.assert_called_once_with("distance") + + def test_filter_order_by__neg_proximity(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + name = "order_by" + # Act + filterset.filter_order_by(qs_mock, name, ["-proximity"]) + # Assert + qs_mock.annotate.assert_called_once_with(distance=ANY) + qs_mock.annotate().order_by.assert_called_once_with("-distance") + + def test_filter_order_by__latlng_provided(self): + # Arrange + qs_mock = MagicMock() + filterset = UpcomingTeachingOpportunitiesFilter({}) + filterset.request = MagicMock() + filterset.request.user.airport.latitude = 123.4 + filterset.request.user.airport.longitude = 56.7 + name = "order_by" + distance_expression = (F("event__latitude") - 123.4) ** 2 + ( + F("event__longitude") - 56.7 + ) ** 2 + # Act + filterset.filter_order_by(qs_mock, name, ["proximity"]) + # Assert + self.assertEqual( + qs_mock.annotate.call_args_list[0], call(distance=distance_expression) + ) diff --git a/amy/dashboard/tests/test_trainee_dashboard.py b/amy/dashboard/tests/test_instructor_dashboard.py similarity index 98% rename from amy/dashboard/tests/test_trainee_dashboard.py rename to amy/dashboard/tests/test_instructor_dashboard.py index 666ae5257..c0705b64c 100644 --- a/amy/dashboard/tests/test_trainee_dashboard.py +++ b/amy/dashboard/tests/test_instructor_dashboard.py @@ -6,8 +6,8 @@ from workshops.tests.base import TestBase -class TestTraineeDashboard(TestBase): - """Tests for trainee dashboard.""" +class TestInstructorDashboard(TestBase): + """Tests for instructor dashboard.""" def setUp(self): self.user = Person.objects.create_user( @@ -21,7 +21,7 @@ def setUp(self): self.client.login(username="user", password="pass") def test_dashboard_loads(self): - rv = self.client.get(reverse("trainee-dashboard")) + rv = self.client.get(reverse("instructor-dashboard")) self.assertEqual(rv.status_code, 200) content = rv.content.decode("utf-8") self.assertIn("Log out", content) @@ -29,7 +29,7 @@ def test_dashboard_loads(self): class TestInstructorStatus(TestBase): - """Test that trainee dashboard displays information about awarded SWC/DC + """Test that instructor dashboard displays information about awarded SWC/DC Instructor badges.""" def setUp(self): @@ -151,7 +151,7 @@ def test_eligible_but_not_awarded(self): class TestInstructorTrainingStatus(TestBase): - """Test that trainee dashboard displays status of passing Instructor + """Test that instructor dashboard displays status of passing Instructor Training.""" def setUp(self): diff --git a/amy/dashboard/tests/test_landing_page.py b/amy/dashboard/tests/test_landing_page.py index 4879c9533..2c674d38c 100644 --- a/amy/dashboard/tests/test_landing_page.py +++ b/amy/dashboard/tests/test_landing_page.py @@ -170,7 +170,7 @@ def test_events_assigned_to_another_user(self): class TestDispatch(TestBase): - """Test that the right dashboard (trainee or admin dashboard) is displayed + """Test that the right dashboard (instructor or admin dashboard) is displayed after logging in.""" def test_superuser_logs_in(self): @@ -239,4 +239,4 @@ def test_trainee_logs_in(self): reverse("login"), {"username": "trainee", "password": "pass"}, follow=True ) - self.assertEqual(rv.resolver_match.view_name, "trainee-dashboard") + self.assertEqual(rv.resolver_match.view_name, "instructor-dashboard") diff --git a/amy/dashboard/tests/test_upcoming_teaching_opportunities.py b/amy/dashboard/tests/test_upcoming_teaching_opportunities.py new file mode 100644 index 000000000..1513a8d44 --- /dev/null +++ b/amy/dashboard/tests/test_upcoming_teaching_opportunities.py @@ -0,0 +1,109 @@ +from django.test import TestCase, override_settings +from django.test.client import RequestFactory + +from communityroles.models import ( + CommunityRole, + CommunityRoleConfig, + CommunityRoleInactivation, +) +from dashboard.views import UpcomingTeachingOpportunitiesList +from workshops.models import Event, Organization, Person, Role, Task + + +class TestUpcomingTeachingOpportunityView(TestCase): + @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True) + def test_view_enabled__no_community_role(self): + # Arrange + request = RequestFactory().get("/") + request.user = Person(personal="Test", family="User", email="test@user.com") + # Act + view = UpcomingTeachingOpportunitiesList(request=request) + # Assert + self.assertEqual(view.get_view_enabled(), False) + + @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True) + def test_view_enabled__community_role_inactive(self): + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + request.user = person + config = CommunityRoleConfig.objects.create( + name="instructor", + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + inactivation = CommunityRoleInactivation.objects.create(name="inactivation") + role = CommunityRole.objects.create( + config=config, + person=person, + inactivation=inactivation, + ) + # Act + view = UpcomingTeachingOpportunitiesList(request=request) + # Assert + self.assertEqual(role.is_active(), False) + self.assertEqual(view.get_view_enabled(), False) + + @override_settings(INSTRUCTOR_RECRUITMENT_ENABLED=True) + def test_view_enabled__community_role_active(self): + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + request.user = person + config = CommunityRoleConfig.objects.create( + name="instructor", + display_name="Instructor", + link_to_award=False, + link_to_membership=False, + additional_url=False, + ) + role = CommunityRole.objects.create( + config=config, + person=person, + ) + # Act + view = UpcomingTeachingOpportunitiesList(request=request) + # Assert + self.assertEqual(role.is_active(), True) + self.assertEqual(view.get_view_enabled(), True) + + def test_get_context_data(self): + """Context data is extended only with person object, but it includes pre-counted + number of roles. + + This is heavy test: a lot of data needs to be created in order to run + `get_context_data` in the view.""" + # Arrange + request = RequestFactory().get("/") + person = Person.objects.create( + personal="Test", family="User", email="test@user.com" + ) + instructor = Role.objects.create(name="instructor") + supporting_instructor = Role.objects.create(name="supporting-instructor") + helper = Role.objects.create(name="helper") + host = Organization.objects.first() + event1 = Event.objects.create(slug="test-event1", host=host) + event2 = Event.objects.create(slug="test-event2", host=host) + event3 = Event.objects.create(slug="test-event3", host=host) + Task.objects.create(role=instructor, person=person, event=event1) + Task.objects.create(role=supporting_instructor, person=person, event=event1) + Task.objects.create(role=supporting_instructor, person=person, event=event2) + Task.objects.create(role=helper, person=person, event=event1) + Task.objects.create(role=helper, person=person, event=event2) + Task.objects.create(role=helper, person=person, event=event3) + request.user = person + view = UpcomingTeachingOpportunitiesList(request=request) + view.get_queryset() + # Act & Assert + with self.assertNumQueries(1): + data = view.get_context_data(object_list=[]) + # Assert + self.assertEqual(data["person"].num_taught, 1) + self.assertEqual(data["person"].num_supporting, 2) + self.assertEqual(data["person"].num_helper, 3) diff --git a/amy/dashboard/urls.py b/amy/dashboard/urls.py index a8740b859..053484e02 100644 --- a/amy/dashboard/urls.py +++ b/amy/dashboard/urls.py @@ -1,20 +1,44 @@ from django.urls import include, path +from django.views.generic import RedirectView from dashboard import views urlpatterns = [ - path('', views.dispatch, name='dispatch'), - + path("", views.dispatch, name="dispatch"), # admin dashboard main page - path('admin/', include([ - path('', views.admin_dashboard, name='admin-dashboard'), - path('search/', views.search, name='search'), - ])), - - # trainee dashboard and trainee-available views - path('trainee/', include([ - path('', views.trainee_dashboard, name='trainee-dashboard'), - path('training_progress/', views.training_progress, name='training-progress'), - path('autoupdate_profile/', views.autoupdate_profile, name='autoupdate_profile'), - ])), + path( + "admin/", + include( + [ + path("", views.admin_dashboard, name="admin-dashboard"), + path("search/", views.search, name="search"), + ] + ), + ), + # instructor dashboard and instructor-available views + path( + "instructor/", + include( + [ + path("", views.instructor_dashboard, name="instructor-dashboard"), + path( + "training_progress/", + views.training_progress, + name="training-progress", + ), + path( + "autoupdate_profile/", + views.autoupdate_profile, + name="autoupdate_profile", + ), + path( + "teaching_opportunities/", + views.UpcomingTeachingOpportunitiesList.as_view(), + name="upcoming-teaching-opportunities", + ), + ] + ), + ), + # redirect "old" trainee dashboard link to new instructor dashboard + path("trainee/", RedirectView.as_view(pattern_name="instructor-dashboard")), ] diff --git a/amy/dashboard/views.py b/amy/dashboard/views.py index 3e9048675..bff165284 100644 --- a/amy/dashboard/views.py +++ b/amy/dashboard/views.py @@ -2,6 +2,7 @@ from typing import Dict, Optional from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Case, Count, IntegerField, Prefetch, Q, Value, When from django.forms.widgets import HiddenInput from django.shortcuts import get_object_or_404, redirect, render @@ -10,8 +11,10 @@ from django.views.decorators.http import require_GET from django_comments.models import Comment +from communityroles.models import CommunityRole from consents.forms import TermBySlugsForm from consents.models import Consent, TermOption +from dashboard.filters import UpcomingTeachingOpportunitiesFilter from dashboard.forms import ( AssignmentForm, AutoUpdateProfileForm, @@ -19,6 +22,9 @@ SendHomeworkForm, ) from fiscal.models import MembershipTask +from recruitment.models import InstructorRecruitment +from recruitment.views import RecruitmentEnabledMixin +from workshops.base_views import AMYListView, ConditionallyEnabledMixin from workshops.models import ( Airport, Badge, @@ -34,18 +40,18 @@ ) from workshops.util import admin_required, login_required -# Terms shown on the trainee dashboard and can be updated by the user. +# Terms shown on the instructor dashboard and can be updated by the user. TERM_SLUGS = ["may-contact", "public-profile", "may-publish-name"] @login_required def dispatch(request): """If user is admin, then show them admin dashboard; otherwise redirect - them to trainee dashboard.""" + them to instructor dashboard.""" if request.user.is_admin: return redirect(reverse("admin-dashboard")) else: - return redirect(reverse("trainee-dashboard")) + return redirect(reverse("instructor-dashboard")) @admin_required @@ -99,11 +105,11 @@ def admin_dashboard(request): # ------------------------------------------------------------ -# Views for trainees +# Views for instructors and trainees @login_required -def trainee_dashboard(request): +def instructor_dashboard(request): qs = Person.objects.select_related("airport").prefetch_related( "badges", "lessons", @@ -134,7 +140,7 @@ def trainee_dashboard(request): consent_by_term_slug_label[label] = consent.term_option context = {"title": "Your profile", "user": user, **consent_by_term_slug_label} - return render(request, "dashboard/trainee_dashboard.html", context) + return render(request, "dashboard/instructor_dashboard.html", context) @login_required @@ -173,7 +179,7 @@ def autoupdate_profile(request): messages.success(request, "Your profile was updated.") - return redirect(reverse("trainee-dashboard")) + return redirect(reverse("instructor-dashboard")) else: messages.error(request, "Fix errors below.") @@ -257,6 +263,61 @@ def training_progress(request): return render(request, "dashboard/training_progress.html", context) +# ------------------------------------------------------------ +# Views for instructors - upcoming teaching opportunities + + +class UpcomingTeachingOpportunitiesList( + LoginRequiredMixin, RecruitmentEnabledMixin, ConditionallyEnabledMixin, AMYListView +): + permission_required = "recruitment.view_instructorrecruitment" + title = "Upcoming Teaching Opportunities" + queryset = InstructorRecruitment.objects.select_related("event").prefetch_related( + "event__curricula" + ) + template_name = "dashboard/upcoming_teaching_opportunities.html" + filter_class = UpcomingTeachingOpportunitiesFilter + + def get_view_enabled(self) -> bool: + try: + role = CommunityRole.objects.get( + person=self.request.user, config__name="instructor" + ) + return role.is_active() and super().get_view_enabled() + except CommunityRole.DoesNotExist: + return False + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + # person details with tasks counted + context["person"] = ( + Person.objects.annotate( + num_taught=Count( + Case( + When(task__role__name="instructor", then=Value(1)), + output_field=IntegerField(), + ) + ), + num_supporting=Count( + Case( + When(task__role__name="supporting-instructor", then=Value(1)), + output_field=IntegerField(), + ) + ), + num_helper=Count( + Case( + When(task__role__name="helper", then=Value(1)), + output_field=IntegerField(), + ) + ), + ) + .select_related("airport") + .get(pk=self.request.user.pk) + ) + return context + + # ------------------------------------------------------------ diff --git a/amy/templates/base_nav.html b/amy/templates/base_nav.html index fbf243ae1..268533c0a 100644 --- a/amy/templates/base_nav.html +++ b/amy/templates/base_nav.html @@ -3,6 +3,6 @@ {% if user.is_admin %} {% include 'navigation.html' %} {% elif not user.is_admin and not user.is_anonymous %} - {% include 'navigation_trainee.html' %} + {% include 'navigation_instructor_dashboard.html' %} {% endif %} {% endblock %} diff --git a/amy/templates/dashboard/autoupdate_profile.html b/amy/templates/dashboard/autoupdate_profile.html index 4a3960197..669e80aa3 100644 --- a/amy/templates/dashboard/autoupdate_profile.html +++ b/amy/templates/dashboard/autoupdate_profile.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block navbar %} - {% include 'navigation_trainee.html' %} + {% include 'navigation_instructor_dashboard.html' %} {% endblock %} {% load crispy_forms_tags %} diff --git a/amy/templates/dashboard/trainee_dashboard.html b/amy/templates/dashboard/instructor_dashboard.html similarity index 92% rename from amy/templates/dashboard/trainee_dashboard.html rename to amy/templates/dashboard/instructor_dashboard.html index 23140d42e..cd5aa5232 100644 --- a/amy/templates/dashboard/trainee_dashboard.html +++ b/amy/templates/dashboard/instructor_dashboard.html @@ -1,13 +1,21 @@ {% extends "base.html" %} {% block navbar %} - {% include 'navigation_trainee.html' %} + {% include 'navigation_instructor_dashboard.html' %} {% endblock %} +{% load communityroles %} {% load crispy_forms_tags %} {% load dates %} +{% load instructorrecruitment %} {% block content %} +{% is_instructor_recruitment_enabled as INSTRUCTOR_RECRUITMENT_ENABLED %} +{% get_community_role user role_name="instructor" as INSTRUCTOR_COMMUNITY_ROLE %} +{% if INSTRUCTOR_RECRUITMENT_ENABLED and INSTRUCTOR_COMMUNITY_ROLE.is_active %} +View upcoming teaching opportunities with The Carpentries +{% endif %} +
Click on the button at the bottom of this page to update your profile information.
diff --git a/amy/templates/dashboard/training_progress.html b/amy/templates/dashboard/training_progress.html index 15670f6a2..0149291dd 100644 --- a/amy/templates/dashboard/training_progress.html +++ b/amy/templates/dashboard/training_progress.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block navbar %} - {% include 'navigation_trainee.html' %} + {% include 'navigation_instructor_dashboard.html' %} {% endblock %} {% load crispy_forms_tags %} diff --git a/amy/templates/dashboard/upcoming_teaching_opportunities.html b/amy/templates/dashboard/upcoming_teaching_opportunities.html new file mode 100644 index 000000000..605b0272c --- /dev/null +++ b/amy/templates/dashboard/upcoming_teaching_opportunities.html @@ -0,0 +1,46 @@ +{% extends "base_nav_sidebar.html" %} + +{% load pagination %} + +{% block navbar %} + {% include 'navigation_instructor_dashboard.html' %} +{% endblock %} + +{% block content %} +
+
+
Profile snapshot
+
    +
  • Name: {{ person }}
  • +
  • Email: {{ person.email|default:"—" }}
  • +
  • Country: {% include "includes/country_flag.html" with country=person.country %}
  • +
  • Airport: {{ person.airport|default:"—" }}
  • +
  • Teaching experience: +
      +
    • Helper: {{ person.num_helper }} times
    • +
    • Supporting Instructor: {{ person.num_supporting }} times
    • +
    • Instructor: {{ person.num_taught }} times
    • +
    +
  • +
+

+ If any of this information is incorrect, please edit your information on your + profile page in AMY. +

+
+
+ +

+ Please check any of the upcoming workshops that you are interested in teaching. + This only expresses your interest and does not confirm you to teach in that workshop. + A member of the Carpentries Workshop Admin team will follow up with you to confirm. + Contact workshops@carpentries.org with any questions. +

+ + {% for object in object_list %} + {% include "includes/teaching_opportunity.html" with object=object %} + {% if not forloop.last %}
{% endif %} + {% endfor %} + {% pagination object_list %} + +{% endblock content %} diff --git a/amy/templates/includes/teaching_opportunity.html b/amy/templates/includes/teaching_opportunity.html new file mode 100644 index 000000000..ce1ecef69 --- /dev/null +++ b/amy/templates/includes/teaching_opportunity.html @@ -0,0 +1,29 @@ +{% load dates %} + +{% with event=object.event %} +

{{ event }} at {{ event.venue }}

+ +{% endwith %} diff --git a/amy/templates/navigation_trainee.html b/amy/templates/navigation_instructor_dashboard.html similarity index 75% rename from amy/templates/navigation_trainee.html rename to amy/templates/navigation_instructor_dashboard.html index 659306830..dde1a5f1b 100644 --- a/amy/templates/navigation_trainee.html +++ b/amy/templates/navigation_instructor_dashboard.html @@ -1,4 +1,6 @@ {% load navigation %} +{% load communityroles %} +{% load instructorrecruitment %} {% block navbar %}