From ecdf8c8ce92120699ce1276bb39e63ff55516980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Seppo=20=C3=84yr=C3=A4v=C3=A4inen?= Date: Mon, 23 Feb 2026 16:16:51 +0200 Subject: [PATCH] Create an API endpoint and permission for fetching all current teachers Fixes #1503 --- course/api/tests.py | 83 +++++++++++++++++++ course/api/views.py | 67 +++++++++++++++ ...stance_view_current_teachers_permission.py | 17 ++++ course/models.py | 3 + course/permissions.py | 10 +++ locale/en/LC_MESSAGES/django.po | 31 ++----- locale/fi/LC_MESSAGES/django.po | 27 ++---- 7 files changed, 194 insertions(+), 44 deletions(-) create mode 100644 course/migrations/0064_courseinstance_view_current_teachers_permission.py diff --git a/course/api/tests.py b/course/api/tests.py index 01737e491..ca8522eee 100644 --- a/course/api/tests.py +++ b/course/api/tests.py @@ -1,4 +1,5 @@ from django.test import override_settings +from django.contrib.auth.models import Permission from rest_framework.test import APIClient from course.models import CourseInstance @@ -144,3 +145,85 @@ def test_put_course(self): t = map(lambda x: x.user.username, course.teachers) self.assertIn('staff', t) self.assertIn('newteacher', t) + + def test_get_current_teachers(self): + self.user.email = 'teacher1@example.com' + self.user.save(update_fields=['email']) + self.user1.email = 'teacher2@example.com' + self.user1.save(update_fields=['email']) + self.user2.email = 'pastteacher@example.com' + self.user2.save(update_fields=['email']) + + self.current_course_instance.add_teacher(self.user.userprofile) + self.current_course_instance.add_teacher(self.user1.userprofile) + self.future_course_instance.add_teacher(self.user1.userprofile) + self.past_course_instance.add_teacher(self.user2.userprofile) + + client = APIClient() + client.force_authenticate(user=self.superuser) + response = client.get('/api/v2/courses/current-teachers/') + + self.assertEqual(response.status_code, 200) + self.assertIn('count', response.data) + self.assertIn('results', response.data) + + results = response.data['results'] + self.assertEqual(response.data['count'], 2) + self.assertEqual(len(results), 2) + + emails = sorted(row['email'] for row in results) + self.assertEqual(emails, ['teacher1@example.com', 'teacher2@example.com']) + + def test_get_current_teachers_requires_permission(self): + self.current_course_instance.add_teacher(self.user.userprofile) + + client = APIClient() + client.force_authenticate(user=self.user) + response = client.get('/api/v2/courses/current-teachers/') + self.assertEqual(response.status_code, 403) + + def test_get_current_teachers_with_permission(self): + self.current_course_instance.add_teacher(self.user1.userprofile) + self.user1.email = 'teacher2@example.com' + self.user1.save(update_fields=['email']) + + permission = Permission.objects.get( + content_type__app_label='course', + codename='view_current_teachers', + ) + self.user.user_permissions.add(permission) + + client = APIClient() + client.force_authenticate(user=self.user) + response = client.get('/api/v2/courses/current-teachers/') + + self.assertEqual(response.status_code, 200) + emails = [row['email'] for row in response.data['results']] + self.assertIn('teacher2@example.com', emails) + + def test_get_current_teachers_ended_within_days(self): + self.user.email = 'teacher1@example.com' + self.user.save(update_fields=['email']) + self.user2.email = 'pastteacher@example.com' + self.user2.save(update_fields=['email']) + + self.current_course_instance.add_teacher(self.user.userprofile) + self.past_course_instance.add_teacher(self.user2.userprofile) + + client = APIClient() + client.force_authenticate(user=self.superuser) + response = client.get('/api/v2/courses/current-teachers/?ended_within_days=365') + + self.assertEqual(response.status_code, 200) + emails = sorted(row['email'] for row in response.data['results']) + self.assertEqual(emails, ['pastteacher@example.com', 'teacher1@example.com']) + + def test_get_current_teachers_ended_within_days_invalid(self): + client = APIClient() + client.force_authenticate(user=self.superuser) + + response = client.get('/api/v2/courses/current-teachers/?ended_within_days=abc') + self.assertEqual(response.status_code, 400) + + response = client.get('/api/v2/courses/current-teachers/?ended_within_days=-1') + self.assertEqual(response.status_code, 400) diff --git a/course/api/views.py b/course/api/views.py index f003cd6d1..65544c7fd 100644 --- a/course/api/views.py +++ b/course/api/views.py @@ -45,6 +45,7 @@ ) from ..permissions import ( JWTInstanceWritePermission, + CanViewCurrentTeachersPermission, OnlyCourseTeacherPermission, IsCourseAdminOrUserObjIsSelf, OnlyEnrolledStudentOrCourseStaffPermission, @@ -116,6 +117,13 @@ class CourseViewSet(ListSerializerMixin, * `subject`: email subject * `message`: email body + + `GET /courses/current-teachers/`: + returns users who are teachers in at least one currently running course. + Optional query parameter: + + * `ended_within_days`: include teachers from courses that ended within + the given number of days (and ongoing/future courses). Example: `365`. """ lookup_url_kwarg = 'course_id' lookup_value_regex = REGEX_INT @@ -142,6 +150,65 @@ def get_serializer_class(self): return CourseWriteSerializer return super().get_serializer_class() + @action( + detail=False, + methods=['get'], + url_path='current-teachers', + url_name='current-teachers', + get_permissions=lambda: [CanViewCurrentTeachersPermission()], + ) + def current_teachers(self, request, *args, **kwargs): + now = timezone.now() + ended_within_days = request.query_params.get('ended_within_days', '').strip() + + course_filter = Q( + course_instance__starting_time__lte=now, + course_instance__ending_time__gte=now, + ) + if ended_within_days: + try: + days = int(ended_within_days) + except ValueError as exc: + raise ParseError('"ended_within_days" must be an integer.') from exc + if days < 0: + raise ParseError('"ended_within_days" must be a non-negative integer.') + course_filter = Q(course_instance__ending_time__gte=now - datetime.timedelta(days=days)) + + teacher_rows = ( + Enrollment.objects + .filter( + course_filter, + role=Enrollment.ENROLLMENT_ROLE.TEACHER, + status=Enrollment.ENROLLMENT_STATUS.ACTIVE, + ) + .values( + 'user_profile__user_id', + 'user_profile__user__username', + 'user_profile__user__email', + 'user_profile__user__first_name', + 'user_profile__user__last_name', + ) + .distinct() + .order_by('user_profile__user_id') + ) + + results = [ + { + 'id': row['user_profile__user_id'], + 'username': row['user_profile__user__username'], + 'email': row['user_profile__user__email'], + 'first_name': row['user_profile__user__first_name'], + 'last_name': row['user_profile__user__last_name'], + } + for row in teacher_rows + if row['user_profile__user__email'] + ] + + return Response({ + 'count': len(results), + 'results': results, + }) + # get_permissions lambda overwrites the normal version used for the above methods @action(detail=True, methods=["post"], get_permissions=lambda: [JWTInstanceWritePermission()]) def notify_update(self, request, *args, **kwargs): diff --git a/course/migrations/0064_courseinstance_view_current_teachers_permission.py b/course/migrations/0064_courseinstance_view_current_teachers_permission.py new file mode 100644 index 000000000..ca80999a2 --- /dev/null +++ b/course/migrations/0064_courseinstance_view_current_teachers_permission.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.14 on 2026-05-26 07:07 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0063_courseinstance_group_work_allowed'), + ] + + operations = [ + migrations.AlterModelOptions( + name='courseinstance', + options={'permissions': (('view_current_teachers', 'Can view current teachers API endpoint'),), 'verbose_name': 'MODEL_NAME_COURSE_INSTANCE', 'verbose_name_plural': 'MODEL_NAME_COURSE_INSTANCE_PLURAL'}, + ), + ] diff --git a/course/models.py b/course/models.py index 9136a5ef9..a57edf4c9 100644 --- a/course/models.py +++ b/course/models.py @@ -801,6 +801,9 @@ class Meta: verbose_name = _('MODEL_NAME_COURSE_INSTANCE') verbose_name_plural = _('MODEL_NAME_COURSE_INSTANCE_PLURAL') unique_together = ("course", "url") + permissions = ( + ('view_current_teachers', 'Can view current teachers API endpoint'), + ) def __str__(self): return "{}: {}".format(str(self.course), self.instance_name) diff --git a/course/permissions.py b/course/permissions.py index f2acabaf9..54df20efc 100644 --- a/course/permissions.py +++ b/course/permissions.py @@ -271,6 +271,16 @@ def has_object_permission(self, request: HttpRequest, view: Any, module: CourseM return True +class CanViewCurrentTeachersPermission(Permission): + message = _('COURSE_PERMISSION_MSG_CANNOT_VIEW_CURRENT_TEACHERS') + + def has_permission(self, request, view): + return ( + request.user.is_authenticated + and request.user.has_perm('course.view_current_teachers') + ) + + CourseModulePermission = CourseModulePermissionBase | JWTInstanceReadPermission diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index fba6341d6..33be143e0 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-17 12:43+0200\n" +"POT-Creation-Date: 2026-05-22 16:08+0300\n" "PO-Revision-Date: 2021-05-27 14:47+0300\n" "Last-Translator: Jimmy Ihalainen \n" "Language-Team: English<>\n" @@ -303,7 +303,6 @@ msgid "MODEL_NAME_COURSE_PLURAL" msgstr "courses" #: course/models.py exercise/exercise_models.py -#, python-brace-format msgid "TAKEN_WORDS_INCLUDE -- {}" msgstr "Reserved words include: {}" @@ -654,12 +653,10 @@ msgid "MODEL_NAME_COURSE_INSTANCE_PLURAL" msgstr "course instances" #: course/models.py -#, python-brace-format msgid "COURSE_INSTANCE_ERROR_INSTANCE_NAME -- {}" msgstr "You cannot use the word '{}' as an instance name." #: course/models.py -#, python-brace-format msgid "COURSE_INSTANCE_ERROR_URL -- {}" msgstr "You cannot use the word '{}' in the URL." @@ -890,17 +887,21 @@ msgstr "The module is not currently visible." msgid "MODULE_PERMISSION_ERROR_NOT_OPEN_YET -- {date}" msgstr "The module will open for submissions at {date}." +#: course/permissions.py +msgid "COURSE_PERMISSION_MSG_CANNOT_VIEW_CURRENT_TEACHERS" +msgstr "You do not have permission to access current teachers endpoint." + #: course/permissions.py msgid "COURSE_PERMISSION_MSG_ONLY_TEACHER" -msgstr "Only course teacher is allowed" +msgstr "Only course teacher is allowed." #: course/permissions.py msgid "COURSE_PERMISSION_MSG_ONLY_COURSE_STAFF" -msgstr "Only course staff is allowed" +msgstr "Only course staff are allowed." #: course/permissions.py msgid "COURSE_PERMISSION_MSG_ONLY_ENROLLED_STUDENTS_OR_COURSE_STAFF" -msgstr "Only enrolled students or course staff are allowed" +msgstr "Only enrolled students or course staff are allowed." #: course/staff_views.py edit_course/views.py exercise/staff_views.py msgid "SUCCESS_SAVING_CHANGES" @@ -3498,7 +3499,6 @@ msgid "ALONE" msgstr "alone" #: exercise/exercise_models.py -#, python-brace-format msgid "WITH -- {}" msgstr "with {}" @@ -3768,7 +3768,6 @@ msgid "EXERCISE_VISIBILITY_ERROR_NOT_VISIBLE" msgstr "The assignment is not currently visible." #: exercise/permissions.py -#, python-brace-format msgid "EXERCISE_VISIBILITY_ERROR_NOT_OPEN_YET -- {}" msgstr "The assignment opens for submissions at {}." @@ -3896,7 +3895,6 @@ msgid "MODEL_NAME_REVEAL_RULE_PLURAL" msgstr "Reveal rules" #: exercise/staff_views.py -#, python-brace-format msgid "GRADING_MODE_TITLE -- {}" msgstr "Grading mode: {}" @@ -6057,7 +6055,6 @@ msgid "MODEL_NAME_THRESHOLD_POINTS_PLURAL" msgstr "threshold points" #: threshold/models.py -#, python-brace-format msgid "POINTS -- {:d}" msgstr "{:d} points" @@ -6253,15 +6250,3 @@ msgstr "No system-wide accessibility statement found." #: userprofile/views.py msgid "NO_SUPPORT_PAGE" msgstr "No support page. Please notify administration!" - -#~ msgid "NUMBER_OF_ENROLLED_STUDENTS" -#~ msgstr "Enrolled active students:" - -#~ msgid "PENDING_STUDENTS" -#~ msgstr "Pending:" - -#~ msgid "REMOVED_STUDENTS" -#~ msgstr "Removed:" - -#~ msgid "BANNED_STUDENTS" -#~ msgstr "Banned:" diff --git a/locale/fi/LC_MESSAGES/django.po b/locale/fi/LC_MESSAGES/django.po index 1edb05e5a..f2c6e6b43 100644 --- a/locale/fi/LC_MESSAGES/django.po +++ b/locale/fi/LC_MESSAGES/django.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-02-17 12:43+0200\n" +"POT-Creation-Date: 2026-05-22 16:08+0300\n" "PO-Revision-Date: 2019-08-14 12:16+0200\n" "Last-Translator: Jimmy Ihalainen \n" "Language-Team: Finnish <>\n" @@ -304,7 +304,6 @@ msgid "MODEL_NAME_COURSE_PLURAL" msgstr "kurssit" #: course/models.py exercise/exercise_models.py -#, python-brace-format msgid "TAKEN_WORDS_INCLUDE -- {}" msgstr "Varatut sanat: {}" @@ -658,12 +657,10 @@ msgid "MODEL_NAME_COURSE_INSTANCE_PLURAL" msgstr "kurssikerrat" #: course/models.py -#, python-brace-format msgid "COURSE_INSTANCE_ERROR_INSTANCE_NAME -- {}" msgstr "Et voi käyttää sanaa {} kurssikerran nimenä." #: course/models.py -#, python-brace-format msgid "COURSE_INSTANCE_ERROR_URL -- {}" msgstr "Et voi käyttää sanaa {} URL-osoitteessa." @@ -896,6 +893,10 @@ msgstr "Tämä moduuli ei ole tällä hetkellä nähtävissä." msgid "MODULE_PERMISSION_ERROR_NOT_OPEN_YET -- {date}" msgstr "Tämä moduuli avautuu {date}." +#: course/permissions.py +msgid "COURSE_PERMISSION_MSG_CANNOT_VIEW_CURRENT_TEACHERS" +msgstr "Sinulla ei ole oikeutta käyttää nykyisten opettajien päätepistettä." + #: course/permissions.py msgid "COURSE_PERMISSION_MSG_ONLY_TEACHER" msgstr "Vain opettajalle." @@ -906,7 +907,7 @@ msgstr "Vain kurssihenkilökunnalle." #: course/permissions.py msgid "COURSE_PERMISSION_MSG_ONLY_ENROLLED_STUDENTS_OR_COURSE_STAFF" -msgstr "Vain ilmoittautuneille opiskelijoille tai kurssihenkilökunnalle" +msgstr "Vain ilmoittautuneille opiskelijoille tai kurssihenkilökunnalle." #: course/staff_views.py edit_course/views.py exercise/staff_views.py msgid "SUCCESS_SAVING_CHANGES" @@ -3513,7 +3514,6 @@ msgid "ALONE" msgstr "yksin" #: exercise/exercise_models.py -#, python-brace-format msgid "WITH -- {}" msgstr "ryhmässä {}" @@ -3779,7 +3779,6 @@ msgid "EXERCISE_VISIBILITY_ERROR_NOT_VISIBLE" msgstr "Tehtävä ei ole tällä hetkellä nähtävissä." #: exercise/permissions.py -#, python-brace-format msgid "EXERCISE_VISIBILITY_ERROR_NOT_OPEN_YET -- {}" msgstr "Tämän tehtävän voi palauttaa {} alkaen." @@ -3908,7 +3907,6 @@ msgid "MODEL_NAME_REVEAL_RULE_PLURAL" msgstr "Paljastamissäännöt" #: exercise/staff_views.py -#, python-brace-format msgid "GRADING_MODE_TITLE -- {}" msgstr "Arvostelutapa: {}" @@ -6077,7 +6075,6 @@ msgid "MODEL_NAME_THRESHOLD_POINTS_PLURAL" msgstr "kynnysehtojen pisteet" #: threshold/models.py -#, python-brace-format msgid "POINTS -- {:d}" msgstr "{:d} pisteet" @@ -6278,15 +6275,3 @@ msgstr "Järjestelmätason saavutettavuusselostetta ei löytynyt." #: userprofile/views.py msgid "NO_SUPPORT_PAGE" msgstr "Ei tukisivua. Ota yhteys ylläpitäjään!" - -#~ msgid "NUMBER_OF_ENROLLED_STUDENTS" -#~ msgstr "Ilmoittautuneita aktiivisia opiskelijoita:" - -#~ msgid "PENDING_STUDENTS" -#~ msgstr "Odottavia:" - -#~ msgid "REMOVED_STUDENTS" -#~ msgstr "Poistettuja:" - -#~ msgid "BANNED_STUDENTS" -#~ msgstr "Estettyjä:"