Skip to content

Commit de96d79

Browse files
sayravaiihalaij1
authored andcommitted
Create an API endpoint and permission for fetching all current teachers
Fixes #1503
1 parent e632bba commit de96d79

7 files changed

Lines changed: 194 additions & 44 deletions

File tree

course/api/tests.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from django.test import override_settings
2+
from django.contrib.auth.models import Permission
23
from rest_framework.test import APIClient
34

45
from course.models import CourseInstance
@@ -144,3 +145,85 @@ def test_put_course(self):
144145
t = map(lambda x: x.user.username, course.teachers)
145146
self.assertIn('staff', t)
146147
self.assertIn('newteacher', t)
148+
149+
def test_get_current_teachers(self):
150+
self.user.email = 'teacher1@example.com'
151+
self.user.save(update_fields=['email'])
152+
self.user1.email = 'teacher2@example.com'
153+
self.user1.save(update_fields=['email'])
154+
self.user2.email = 'pastteacher@example.com'
155+
self.user2.save(update_fields=['email'])
156+
157+
self.current_course_instance.add_teacher(self.user.userprofile)
158+
self.current_course_instance.add_teacher(self.user1.userprofile)
159+
self.future_course_instance.add_teacher(self.user1.userprofile)
160+
self.past_course_instance.add_teacher(self.user2.userprofile)
161+
162+
client = APIClient()
163+
client.force_authenticate(user=self.superuser)
164+
response = client.get('/api/v2/courses/current-teachers/')
165+
166+
self.assertEqual(response.status_code, 200)
167+
self.assertIn('count', response.data)
168+
self.assertIn('results', response.data)
169+
170+
results = response.data['results']
171+
self.assertEqual(response.data['count'], 2)
172+
self.assertEqual(len(results), 2)
173+
174+
emails = sorted(row['email'] for row in results)
175+
self.assertEqual(emails, ['teacher1@example.com', 'teacher2@example.com'])
176+
177+
def test_get_current_teachers_requires_permission(self):
178+
self.current_course_instance.add_teacher(self.user.userprofile)
179+
180+
client = APIClient()
181+
client.force_authenticate(user=self.user)
182+
response = client.get('/api/v2/courses/current-teachers/')
183+
self.assertEqual(response.status_code, 403)
184+
185+
def test_get_current_teachers_with_permission(self):
186+
self.current_course_instance.add_teacher(self.user1.userprofile)
187+
self.user1.email = 'teacher2@example.com'
188+
self.user1.save(update_fields=['email'])
189+
190+
permission = Permission.objects.get(
191+
content_type__app_label='course',
192+
codename='view_current_teachers',
193+
)
194+
self.user.user_permissions.add(permission)
195+
196+
client = APIClient()
197+
client.force_authenticate(user=self.user)
198+
response = client.get('/api/v2/courses/current-teachers/')
199+
200+
self.assertEqual(response.status_code, 200)
201+
emails = [row['email'] for row in response.data['results']]
202+
self.assertIn('teacher2@example.com', emails)
203+
204+
def test_get_current_teachers_ended_within_days(self):
205+
self.user.email = 'teacher1@example.com'
206+
self.user.save(update_fields=['email'])
207+
self.user2.email = 'pastteacher@example.com'
208+
self.user2.save(update_fields=['email'])
209+
210+
self.current_course_instance.add_teacher(self.user.userprofile)
211+
self.past_course_instance.add_teacher(self.user2.userprofile)
212+
213+
client = APIClient()
214+
client.force_authenticate(user=self.superuser)
215+
response = client.get('/api/v2/courses/current-teachers/?ended_within_days=365')
216+
217+
self.assertEqual(response.status_code, 200)
218+
emails = sorted(row['email'] for row in response.data['results'])
219+
self.assertEqual(emails, ['pastteacher@example.com', 'teacher1@example.com'])
220+
221+
def test_get_current_teachers_ended_within_days_invalid(self):
222+
client = APIClient()
223+
client.force_authenticate(user=self.superuser)
224+
225+
response = client.get('/api/v2/courses/current-teachers/?ended_within_days=abc')
226+
self.assertEqual(response.status_code, 400)
227+
228+
response = client.get('/api/v2/courses/current-teachers/?ended_within_days=-1')
229+
self.assertEqual(response.status_code, 400)

course/api/views.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
)
4646
from ..permissions import (
4747
JWTInstanceWritePermission,
48+
CanViewCurrentTeachersPermission,
4849
OnlyCourseTeacherPermission,
4950
IsCourseAdminOrUserObjIsSelf,
5051
OnlyEnrolledStudentOrCourseStaffPermission,
@@ -116,6 +117,13 @@ class CourseViewSet(ListSerializerMixin,
116117
117118
* `subject`: email subject
118119
* `message`: email body
120+
121+
`GET /courses/current-teachers/`:
122+
returns users who are teachers in at least one currently running course.
123+
Optional query parameter:
124+
125+
* `ended_within_days`: include teachers from courses that ended within
126+
the given number of days (and ongoing/future courses). Example: `365`.
119127
"""
120128
lookup_url_kwarg = 'course_id'
121129
lookup_value_regex = REGEX_INT
@@ -142,6 +150,65 @@ def get_serializer_class(self):
142150
return CourseWriteSerializer
143151
return super().get_serializer_class()
144152

153+
@action(
154+
detail=False,
155+
methods=['get'],
156+
url_path='current-teachers',
157+
url_name='current-teachers',
158+
get_permissions=lambda: [CanViewCurrentTeachersPermission()],
159+
)
160+
def current_teachers(self, request, *args, **kwargs):
161+
now = timezone.now()
162+
ended_within_days = request.query_params.get('ended_within_days', '').strip()
163+
164+
course_filter = Q(
165+
course_instance__starting_time__lte=now,
166+
course_instance__ending_time__gte=now,
167+
)
168+
if ended_within_days:
169+
try:
170+
days = int(ended_within_days)
171+
except ValueError as exc:
172+
raise ParseError('"ended_within_days" must be an integer.') from exc
173+
if days < 0:
174+
raise ParseError('"ended_within_days" must be a non-negative integer.')
175+
course_filter = Q(course_instance__ending_time__gte=now - datetime.timedelta(days=days))
176+
177+
teacher_rows = (
178+
Enrollment.objects
179+
.filter(
180+
course_filter,
181+
role=Enrollment.ENROLLMENT_ROLE.TEACHER,
182+
status=Enrollment.ENROLLMENT_STATUS.ACTIVE,
183+
)
184+
.values(
185+
'user_profile__user_id',
186+
'user_profile__user__username',
187+
'user_profile__user__email',
188+
'user_profile__user__first_name',
189+
'user_profile__user__last_name',
190+
)
191+
.distinct()
192+
.order_by('user_profile__user_id')
193+
)
194+
195+
results = [
196+
{
197+
'id': row['user_profile__user_id'],
198+
'username': row['user_profile__user__username'],
199+
'email': row['user_profile__user__email'],
200+
'first_name': row['user_profile__user__first_name'],
201+
'last_name': row['user_profile__user__last_name'],
202+
}
203+
for row in teacher_rows
204+
if row['user_profile__user__email']
205+
]
206+
207+
return Response({
208+
'count': len(results),
209+
'results': results,
210+
})
211+
145212
# get_permissions lambda overwrites the normal version used for the above methods
146213
@action(detail=True, methods=["post"], get_permissions=lambda: [JWTInstanceWritePermission()])
147214
def notify_update(self, request, *args, **kwargs):
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Generated by Django 5.2.14 on 2026-05-26 07:07
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('course', '0063_courseinstance_group_work_allowed'),
10+
]
11+
12+
operations = [
13+
migrations.AlterModelOptions(
14+
name='courseinstance',
15+
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'},
16+
),
17+
]

course/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,9 @@ class Meta:
801801
verbose_name = _('MODEL_NAME_COURSE_INSTANCE')
802802
verbose_name_plural = _('MODEL_NAME_COURSE_INSTANCE_PLURAL')
803803
unique_together = ("course", "url")
804+
permissions = (
805+
('view_current_teachers', 'Can view current teachers API endpoint'),
806+
)
804807

805808
def __str__(self):
806809
return "{}: {}".format(str(self.course), self.instance_name)

course/permissions.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,16 @@ def has_object_permission(self, request: HttpRequest, view: Any, module: CourseM
271271
return True
272272

273273

274+
class CanViewCurrentTeachersPermission(Permission):
275+
message = _('COURSE_PERMISSION_MSG_CANNOT_VIEW_CURRENT_TEACHERS')
276+
277+
def has_permission(self, request, view):
278+
return (
279+
request.user.is_authenticated
280+
and request.user.has_perm('course.view_current_teachers')
281+
)
282+
283+
274284
CourseModulePermission = CourseModulePermissionBase | JWTInstanceReadPermission
275285

276286

locale/en/LC_MESSAGES/django.po

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ msgid ""
66
msgstr ""
77
"Project-Id-Version: PACKAGE VERSION\n"
88
"Report-Msgid-Bugs-To: \n"
9-
"POT-Creation-Date: 2026-02-17 12:43+0200\n"
9+
"POT-Creation-Date: 2026-05-22 16:08+0300\n"
1010
"PO-Revision-Date: 2021-05-27 14:47+0300\n"
1111
"Last-Translator: Jimmy Ihalainen <jimmy.ihalainen@aalto.fi>\n"
1212
"Language-Team: English<>\n"
@@ -303,7 +303,6 @@ msgid "MODEL_NAME_COURSE_PLURAL"
303303
msgstr "courses"
304304

305305
#: course/models.py exercise/exercise_models.py
306-
#, python-brace-format
307306
msgid "TAKEN_WORDS_INCLUDE -- {}"
308307
msgstr "Reserved words include: {}"
309308

@@ -654,12 +653,10 @@ msgid "MODEL_NAME_COURSE_INSTANCE_PLURAL"
654653
msgstr "course instances"
655654

656655
#: course/models.py
657-
#, python-brace-format
658656
msgid "COURSE_INSTANCE_ERROR_INSTANCE_NAME -- {}"
659657
msgstr "You cannot use the word '{}' as an instance name."
660658

661659
#: course/models.py
662-
#, python-brace-format
663660
msgid "COURSE_INSTANCE_ERROR_URL -- {}"
664661
msgstr "You cannot use the word '{}' in the URL."
665662

@@ -890,17 +887,21 @@ msgstr "The module is not currently visible."
890887
msgid "MODULE_PERMISSION_ERROR_NOT_OPEN_YET -- {date}"
891888
msgstr "The module will open for submissions at {date}."
892889

890+
#: course/permissions.py
891+
msgid "COURSE_PERMISSION_MSG_CANNOT_VIEW_CURRENT_TEACHERS"
892+
msgstr "You do not have permission to access current teachers endpoint."
893+
893894
#: course/permissions.py
894895
msgid "COURSE_PERMISSION_MSG_ONLY_TEACHER"
895-
msgstr "Only course teacher is allowed"
896+
msgstr "Only course teacher is allowed."
896897

897898
#: course/permissions.py
898899
msgid "COURSE_PERMISSION_MSG_ONLY_COURSE_STAFF"
899-
msgstr "Only course staff is allowed"
900+
msgstr "Only course staff are allowed."
900901

901902
#: course/permissions.py
902903
msgid "COURSE_PERMISSION_MSG_ONLY_ENROLLED_STUDENTS_OR_COURSE_STAFF"
903-
msgstr "Only enrolled students or course staff are allowed"
904+
msgstr "Only enrolled students or course staff are allowed."
904905

905906
#: course/staff_views.py edit_course/views.py exercise/staff_views.py
906907
msgid "SUCCESS_SAVING_CHANGES"
@@ -3498,7 +3499,6 @@ msgid "ALONE"
34983499
msgstr "alone"
34993500

35003501
#: exercise/exercise_models.py
3501-
#, python-brace-format
35023502
msgid "WITH -- {}"
35033503
msgstr "with {}"
35043504

@@ -3768,7 +3768,6 @@ msgid "EXERCISE_VISIBILITY_ERROR_NOT_VISIBLE"
37683768
msgstr "The assignment is not currently visible."
37693769

37703770
#: exercise/permissions.py
3771-
#, python-brace-format
37723771
msgid "EXERCISE_VISIBILITY_ERROR_NOT_OPEN_YET -- {}"
37733772
msgstr "The assignment opens for submissions at {}."
37743773

@@ -3896,7 +3895,6 @@ msgid "MODEL_NAME_REVEAL_RULE_PLURAL"
38963895
msgstr "Reveal rules"
38973896

38983897
#: exercise/staff_views.py
3899-
#, python-brace-format
39003898
msgid "GRADING_MODE_TITLE -- {}"
39013899
msgstr "Grading mode: {}"
39023900

@@ -6057,7 +6055,6 @@ msgid "MODEL_NAME_THRESHOLD_POINTS_PLURAL"
60576055
msgstr "threshold points"
60586056

60596057
#: threshold/models.py
6060-
#, python-brace-format
60616058
msgid "POINTS -- {:d}"
60626059
msgstr "{:d} points"
60636060

@@ -6253,15 +6250,3 @@ msgstr "No system-wide accessibility statement found."
62536250
#: userprofile/views.py
62546251
msgid "NO_SUPPORT_PAGE"
62556252
msgstr "No support page. Please notify administration!"
6256-
6257-
#~ msgid "NUMBER_OF_ENROLLED_STUDENTS"
6258-
#~ msgstr "Enrolled active students:"
6259-
6260-
#~ msgid "PENDING_STUDENTS"
6261-
#~ msgstr "Pending:"
6262-
6263-
#~ msgid "REMOVED_STUDENTS"
6264-
#~ msgstr "Removed:"
6265-
6266-
#~ msgid "BANNED_STUDENTS"
6267-
#~ msgstr "Banned:"

0 commit comments

Comments
 (0)