diff --git a/autograder/core/migrations/0109_late_days_change.py b/autograder/core/migrations/0109_late_days_change.py new file mode 100644 index 00000000..f2ea79b4 --- /dev/null +++ b/autograder/core/migrations/0109_late_days_change.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.25 on 2024-11-19 03:45 + +import autograder.core.models.ag_model_base +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0108_internal_admin_notes'), + ] + + operations = [ + migrations.RenameField( + model_name='LateDaysRemaining', + old_name='_extra_late_days_granted', + new_name='extra_late_days', + ), + migrations.RemoveField( + model_name='LateDaysRemaining', + name='old_late_days_remaining' + ), + migrations.RemoveField( + model_name='LateDaysRemaining', + name='late_days_used' + ), + migrations.AlterModelOptions( + name='submission', + options={'ordering': ['-pk', '-timestamp']}, + ), + migrations.AddIndex( + model_name='submission', + index=models.Index(fields=['group', 'timestamp'], name='core_submis_group_i_160046_idx'), + ), + migrations.RenameModel( + old_name='LateDaysRemaining', + new_name='ExtraLateDays' + ), + ] diff --git a/autograder/core/models/__init__.py b/autograder/core/models/__init__.py index b9008e89..22fb217a 100644 --- a/autograder/core/models/__init__.py +++ b/autograder/core/models/__init__.py @@ -19,7 +19,6 @@ from .ag_test.ag_test_suite_result import AGTestSuiteResult as AGTestSuiteResult from .ag_test.feedback_category import FeedbackCategory as FeedbackCategory from .course import Course as Course -from .course import LateDaysRemaining as LateDaysRemaining from .course import Semester as Semester from .group import Group as Group from .group import GroupInvitation as GroupInvitation @@ -43,3 +42,5 @@ from .submission import \ get_submissions_with_results_queryset as get_submissions_with_results_queryset from .task import Task as Task +from .user_late_days import LateDaysForUser as LateDaysForUser +from .user_late_days import ExtraLateDays as ExtraLateDays diff --git a/autograder/core/models/course.py b/autograder/core/models/course.py index 87d32431..d01daa3e 100644 --- a/autograder/core/models/course.py +++ b/autograder/core/models/course.py @@ -223,44 +223,6 @@ def save(self, *args: Any, **kwargs: Any) -> None: ) -class LateDaysRemaining(AutograderModel): - objects = AutograderModelManager['LateDaysRemaining']() - - class Meta: - unique_together = ('course', 'user') - - course = models.ForeignKey(Course, on_delete=models.CASCADE) - user = models.ForeignKey(User, on_delete=models.CASCADE) - - # Remove in version 5.0.0 - old_late_days_remaining = models.IntegerField( - validators=[validators.MinValueValidator(0)], blank=True, default=0) - - @property - def late_days_remaining(self) -> int: - return max(0, self._true_late_days_remaining) - - @late_days_remaining.setter - def late_days_remaining(self, value: int) -> None: - if value < 0: - raise ValidationError({ - 'late_days_remaining': 'This value cannot be negative.' - }) - - self._extra_late_days_granted += value - self._true_late_days_remaining - - @property - def _true_late_days_remaining(self) -> int: - return ( - self.course.num_late_days + self._extra_late_days_granted - - self.late_days_used - ) - - _extra_late_days_granted = models.IntegerField(blank=True, default=0) - late_days_used = models.IntegerField( - blank=True, default=0, validators=[MinValueValidator(0)]) - - def clear_cached_user_roles(course_pk: int) -> None: keys = cache.client.iter_keys(f'course_{course_pk}_user_*', itersize=5000) cache.delete_many(list(keys)) diff --git a/autograder/core/models/submission.py b/autograder/core/models/submission.py index 63484cc2..8738fe76 100644 --- a/autograder/core/models/submission.py +++ b/autograder/core/models/submission.py @@ -126,7 +126,8 @@ class Submission(ag_model_base.AutograderModel): objects = _SubmissionManager() class Meta: - ordering = ['-pk'] + ordering = ['-pk', '-timestamp'] + indexes = [models.Index(fields=['group', 'timestamp'])] class GradingStatus(models.TextChoices): # The submission has been accepted and saved to the database diff --git a/autograder/core/models/user_late_days.py b/autograder/core/models/user_late_days.py new file mode 100644 index 00000000..e10496b5 --- /dev/null +++ b/autograder/core/models/user_late_days.py @@ -0,0 +1,140 @@ +from typing import TypedDict, overload +from datetime import timedelta +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.db import models + +from .ag_model_base import ( + AutograderModel, AutograderModelManager, DictSerializable) +from .group import Group +from .submission import Submission +from .course import Course + +from autograder.rest_api.serialize_user import serialize_user + +class ExtraLateDays(AutograderModel): + objects = AutograderModelManager['ExtraLateDays']() + + class Meta: + unique_together = ('course', 'user') + + course = models.ForeignKey(Course, on_delete=models.CASCADE) + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='late_days') + + extra_late_days = models.IntegerField(blank=True, default=0) + + SERIALIZABLE_FIELDS = ('course', 'user', 'extra_late_days') + EDITABLE_FIELDS = ('extra_late_days') + + def clean(self): + super().clean() + if self.user not in self.course.students.all(): + raise ValidationError('The user is not a student in the course') + if self.extra_late_days < 0: + raise ValidationError('extra_late_days must be non-negative') + + +class LateDaysForUser(DictSerializable): + def __init__(self, + user: User, + course: Course, + extra_late_days: int, + late_days_used: int, + late_days_remaining: int): + self.user = user + self.course = course + self.extra_late_days = extra_late_days + self.late_days_used = late_days_used + self.late_days_remaining = late_days_remaining + + @staticmethod + def _days_late(group: Group, submission_timestamp: models.DateTimeField) -> int: + if group.project.closing_time is None: + return 0 + elif group.extended_due_date is None: + delta = submission_timestamp - group.project.closing_time + else: + deadline = max(group.project.closing_time, group.extended_due_date) + delta = submission_timestamp - deadline + + return delta.days + 1 if delta > timedelta() else 0 + + + def to_dict(self): + return { + 'user': serialize_user(self.user), + 'course': self.course.to_dict(), + 'extra_late_days': self.extra_late_days, + 'late_days_used': self.late_days_used, + 'late_days_remaining': self.late_days_remaining + } + + @staticmethod + def get(user: User, course: Course) -> "LateDaysForUser": + if user not in course.students.all(): + raise ValueError("user must be a student in the course") + queryset = User.objects.filter(pk=user.pk) + return LateDaysForUser.get_many(queryset, course)[0] + + @staticmethod + def get_many(users_queryset: models.QuerySet, course: Course) -> list["LateDaysForUser"]: + # Fetch all submissions for the course's groups, ordered by descending timestamp + groups_with_submissions = Group.objects.filter( + project__course=course, + project__allow_late_days=True + ).prefetch_related( + models.Prefetch( + 'submissions', + queryset=Submission.objects.order_by('-timestamp'), + to_attr='all_submissions' + ) + ) + + # Prefetch groups and late days for each user + prefetch_groups = models.Prefetch( + 'groups_is_member_of', + queryset=groups_with_submissions, + to_attr='groups_with_submissions' + ) + prefetch_late_days = models.Prefetch( + 'late_days', + queryset=ExtraLateDays.objects.filter(course=course), + to_attr='late_days_for_course' + ) + + users_with_groups = users_queryset.prefetch_related( + prefetch_groups, + prefetch_late_days + ) + + results = [] + for user in users_with_groups: + extra = user.late_days_for_course[0].extra_late_days if user.late_days_for_course else 0 + used = 0 + + for group in user.groups_with_submissions: + # Filter submissions that count for the user + user_submissions = [ + submission for submission in group.all_submissions + if user.username not in submission.does_not_count_for + ] + + # Get the first (latest) submission that counts for the user + if user_submissions: + latest_submission = user_submissions[0] + used += LateDaysForUser._days_late( + group, + latest_submission.timestamp + ) + + remaining = course.num_late_days + extra - used + results.append( + LateDaysForUser(user, course, extra, used, remaining) + ) + + return results + + @staticmethod + def get_all(course: Course) -> list["LateDaysForUser"]: + queryset = User.objects.filter(courses_is_enrolled_in=course) + return LateDaysForUser.get_many(queryset, course) diff --git a/autograder/rest_api/schema/model_schema_generators.py b/autograder/rest_api/schema/model_schema_generators.py index 13b007ac..2825db0a 100644 --- a/autograder/rest_api/schema/model_schema_generators.py +++ b/autograder/rest_api/schema/model_schema_generators.py @@ -164,6 +164,7 @@ def api_object_type_name_is_registered(api_class: APIClassType) -> bool: _API_OBJ_TYPE_NAMES: Dict[APIClassType, str] = { User: 'User', + ag_models.LateDaysForUser: ag_models.LateDaysForUser.__name__, ag_models.Course: ag_models.Course.__name__, ag_models.Semester: ag_models.Semester.__name__, ag_models.Project: ag_models.Project.__name__, @@ -253,6 +254,7 @@ def api_object_type_name_is_registered(api_class: APIClassType) -> bool: } _API_UPDATE_OBJ_TYPE_NAMES: Dict[APIClassType, str] = { + # ag_models.LateDays: 'Update' + ag_models.LateDays.__name__, ag_models.Course: 'Update' + ag_models.Course.__name__, ag_models.Project: 'Update' + ag_models.Project.__name__, ag_models.ExpectedStudentFile: 'Update' + ag_models.ExpectedStudentFile.__name__, diff --git a/autograder/rest_api/schema/schema.yml b/autograder/rest_api/schema/schema.yml index 32c8e8c0..f303cab2 100644 --- a/autograder/rest_api/schema/schema.yml +++ b/autograder/rest_api/schema/schema.yml @@ -62,17 +62,29 @@ paths: description: '' tags: - users - /api/users/{username_or_pk}/late_days/: + /api/courses/{id}/users/{username_or_pk}/late_days/: get: - operationId: getUserLateDaysRemaining + operationId: getLateDays description: '' parameters: + - name: id + in: path + required: true + description: '' + schema: + type: string - name: username_or_pk in: path required: true description: '' schema: type: string + - name: pk + in: path + required: true + schema: + type: integer + format: id - name: username_or_pk in: path required: true @@ -83,37 +95,38 @@ paths: format: username - type: integer format: id - - name: course_pk - in: query - required: true - schema: - type: integer - format: id responses: '200': content: application/json: schema: - type: object - required: - - late_days_remaining - properties: - late_days_remaining: - type: integer + $ref: '#/components/schemas/LateDaysForUser' description: '' tags: - courses - users - put: - operationId: setUserLateDaysRemaining + patch: + operationId: putLateDays description: '' parameters: + - name: id + in: path + required: true + description: '' + schema: + type: string - name: username_or_pk in: path required: true description: '' schema: type: string + - name: pk + in: path + required: true + schema: + type: integer + format: id - name: username_or_pk in: path required: true @@ -124,38 +137,58 @@ paths: format: username - type: integer format: id - - name: course_pk - in: query - required: true - schema: - type: integer - format: id requestBody: content: application/json: schema: type: object required: - - late_days_remaining + - extra_late_days_granted properties: - late_days_remaining: + extra_late_days_granted: type: integer + description: ' + + `extra_late_days_granted` + the number of late days set for the project + must be greater than or equal to `late_days_used`. + + + **Note:** the `late_days_used` field is a property computed from submission + data and can''t be changed.' required: true responses: '200': content: application/json: schema: - type: object - required: - - late_days_remaining - properties: - late_days_remaining: - type: integer + $ref: '#/components/schemas/LateDaysForUser' description: '' tags: - courses - users + /api/courses/{id}/late_days/: + get: + operationId: listLateDaysForUsers + description: '' + parameters: + - name: id + in: path + required: true + description: '' + schema: + type: string + responses: + '200': + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LateDaysForUser' + description: '' + tags: + - users + - courses /api/users/{id}/courses_is_admin_for/: get: operationId: coursesIsAdminFor @@ -4717,6 +4750,28 @@ components: - first_name - last_name - is_superuser + LateDaysForUser: + type: object + properties: + user: + $ref: '#/components/schemas/User' + course: + $ref: '#/components/schemas/Course' + extra_late_days: + type: integer + description: '' + late_days_used: + type: integer + description: '' + late_days_remaining: + type: integer + description: '' + required: + - user + - course + - extra_late_days + - late_days_used + - late_days_remaining Course: type: object properties: diff --git a/autograder/rest_api/schema/view_schema_generators.py b/autograder/rest_api/schema/view_schema_generators.py index 269f9bfa..caa747f0 100644 --- a/autograder/rest_api/schema/view_schema_generators.py +++ b/autograder/rest_api/schema/view_schema_generators.py @@ -1,7 +1,7 @@ from __future__ import annotations import enum -from typing import Any, Dict, List, Optional, TypedDict, Union, cast +from typing import Any, Dict, List, Optional, TypedDict, Union, cast, overload from rest_framework.schemas.openapi import AutoSchema diff --git a/autograder/rest_api/serialize_user.py b/autograder/rest_api/serialize_user.py index 2c7e2e8e..a04c3e58 100644 --- a/autograder/rest_api/serialize_user.py +++ b/autograder/rest_api/serialize_user.py @@ -1,7 +1,17 @@ +from typing import TypedDict + from django.contrib.auth.models import User +SerializedUser = TypedDict('SerializedUser', { + 'pk': int, + 'username': str, + 'first_name': str, + 'last_name': str, + 'email': str, + 'is_superuser': bool +}) -def serialize_user(user: User) -> dict: +def serialize_user(user: User) -> SerializedUser: return { 'pk': user.pk, 'username': user.username, @@ -10,3 +20,4 @@ def serialize_user(user: User) -> dict: 'email': user.email, 'is_superuser': user.is_superuser, } + diff --git a/autograder/rest_api/urls.py b/autograder/rest_api/urls.py index 851b1dfe..e5808765 100644 --- a/autograder/rest_api/urls.py +++ b/autograder/rest_api/urls.py @@ -33,8 +33,10 @@ def api_schema_yml_view(request: HttpRequest) -> FileResponse: path('users/current/can_create_courses/', views.CurrentUserCanCreateCoursesView.as_view(), name='user-can-create-courses'), path('users//', views.UserDetailView.as_view(), name='user-detail'), - path('users//late_days/', views.UserLateDaysView.as_view(), + path('courses//users//late_days/', views.LateDaysView.as_view(), name='user-late-days'), + path('courses//late_days/', views.ListLateDaysForUserView.as_view(), + name='list-user-late-days'), path('users//courses_is_admin_for/', views.CoursesIsAdminForView.as_view(), name='courses-is-admin-for'), path('users//courses_is_staff_for/', views.CoursesIsStaffForView.as_view(), diff --git a/autograder/rest_api/views/__init__.py b/autograder/rest_api/views/__init__.py index 21568bc4..d12966d2 100644 --- a/autograder/rest_api/views/__init__.py +++ b/autograder/rest_api/views/__init__.py @@ -65,4 +65,6 @@ CurrentUserCanCreateCoursesView, CurrentUserView, GroupInvitationsReceivedView, GroupInvitationsSentView, GroupsIsMemberOfView, RevokeCurrentUserAPITokenView, - UserDetailView, UserLateDaysView) + UserDetailView) +from .user_late_days_views import (ListLateDaysForUserView, LateDaysView) + diff --git a/autograder/rest_api/views/submission_views/submission_views.py b/autograder/rest_api/views/submission_views/submission_views.py index 09105afe..de2178d2 100644 --- a/autograder/rest_api/views/submission_views/submission_views.py +++ b/autograder/rest_api/views/submission_views/submission_views.py @@ -176,17 +176,10 @@ def _create_submission_if_allowed(self, request, group: ag_models.Group, if user_deadline > timestamp: continue - remaining = ag_models.LateDaysRemaining.objects.get_or_create( - user=user, course=course)[0] + late_days = ag_models.LateDaysForUser.get(user, course) late_days_needed = (timestamp - user_deadline).days + 1 - if remaining.late_days_remaining >= late_days_needed: - remaining.late_days_used += late_days_needed - remaining.save() - group.late_days_used.setdefault(user.username, 0) - group.late_days_used[user.username] += late_days_needed - group.save() - else: + if late_days.late_days_remaining < late_days_needed: does_not_count_for.append(user.username) if request.user.username in does_not_count_for: diff --git a/autograder/rest_api/views/user_late_days_views.py b/autograder/rest_api/views/user_late_days_views.py new file mode 100644 index 00000000..ab47a78b --- /dev/null +++ b/autograder/rest_api/views/user_late_days_views.py @@ -0,0 +1,150 @@ +from django.contrib.auth.models import User +from django.db import transaction +from django.shortcuts import get_object_or_404 +from django.utils.decorators import method_decorator +from rest_framework import response +from rest_framework.exceptions import PermissionDenied +from rest_framework.request import Request +from rest_framework.views import APIView + +import autograder.core.models as ag_models +from autograder.rest_api.schema import ( + APITags, CustomViewSchema, ParameterObject, as_content_obj) +from autograder.rest_api.schema.openapi_types import ResponseObject, OrRef, RequestBodyObject +from autograder.rest_api.schema.view_schema_generators import AGListCreateViewSchemaGenerator +from autograder.rest_api.views.ag_model_views import ( + AlwaysIsAuthenticatedMixin, require_body_params) + + +class ListLateDaysForUserView(AlwaysIsAuthenticatedMixin, APIView): + schema = AGListCreateViewSchemaGenerator( + [APITags.users, APITags.courses], ag_models.LateDaysForUser) + + def get(self, request: Request, *args, **kwargs): + course = get_object_or_404(ag_models.Course.objects, kwargs['pk']) + self._check_read_permissions(course) + late_days_for_users = [ + item.to_dict() for item in ag_models.LateDaysForUser.get_all(course) + ] + return response.Response(late_days_for_users) + + def _check_read_permissions(self, course: ag_models.Course): + if not course.is_admin(self.request.user): + raise PermissionDenied + + +class LateDaysView(AlwaysIsAuthenticatedMixin, APIView): + _RESPONSE: ResponseObject = { + 'content': as_content_obj(ag_models.LateDaysForUser), + 'description': '' + } + _REQUEST: RequestBodyObject = { + 'content': { + 'application/json': { + 'schema': { + 'type': 'object', + 'required': ['extra_late_days_granted'], + 'properties': { + 'extra_late_days_granted': { + 'type': 'integer', + }, + } + } + } + }, + 'description': """ +`extra_late_days_granted` + the number of late days set for the project must be greater than or equal to `late_days_used`. + +**Note:** the `late_days_used` field is a property computed from submission data and can't be changed.""" + } + + _PARAMS: list[OrRef[ParameterObject]] = [ + { + 'name': 'pk', + 'in': 'path', + 'required': True, + 'schema': {'type': 'integer', 'format': 'id'} + }, + { + 'name': 'username_or_pk', + 'in': 'path', + 'required': True, + 'description': 'The ID or username of the user.', + 'schema': { + # Note: swagger-ui doesn't seem to be able to render + # oneOf for params. + 'oneOf': [ + {'type': 'string', 'format': 'username'}, + {'type': 'integer', 'format': 'id'}, + ] + } + } + ] + + schema = CustomViewSchema([APITags.courses, APITags.users], { + 'GET': { + 'operation_id': 'getLateDays', + 'parameters': _PARAMS, + 'responses': { + '200': _RESPONSE + } + }, + 'PATCH': { + 'operation_id': 'putLateDays', + 'parameters': _PARAMS, + 'request': _REQUEST, + 'responses': { + '200': _RESPONSE + } + }, + }) + + def get(self, request: Request, *args, **kwargs): + try: + user = get_object_or_404(User.objects, pk=int(kwargs['username_or_pk'])) + except ValueError: + user = get_object_or_404(User.objects, username=kwargs['username_or_pk']) + + course = get_object_or_404(ag_models.Course.objects, pk=kwargs['pk']) + + late_days = ag_models.LateDaysForUser.get(user, course.pk) + + self._check_read_permissions(late_days) + + return response.Response(late_days.to_dict()) + + @method_decorator(require_body_params('extra_late_days')) + def patch(self, request: Request, *args, **kwargs): + try: + user = get_object_or_404(User.objects, pk=int(kwargs['username_or_pk'])) + except ValueError: + user = get_object_or_404(User.objects, username=kwargs['username_or_pk']) + + course = get_object_or_404(ag_models.Course.objects, pk=request.query_params['course_pk']) + + with transaction.atomic(): + extra_late_days = ag_models.ExtraLateDays.objects.get_or_create(user=user, course=course)[ + 0] + + self._check_read_permissions(extra_late_days) + self._check_write_permissions(extra_late_days) + + extra_late_days.extra_late_days = request.data['extra_late_days'] + extra_late_days.save() + late_days = ag_models.LateDaysForUser.get(user, course) + + return response.Response(late_days.to_dict()) + + def _check_read_permissions(self, late_days: ag_models.LateDaysForUser): + user = self.request.user + if user == late_days.user: + return + + if late_days.course.is_staff(user): + return + + raise PermissionDenied + + def _check_write_permissions(self, late_days: ag_models.LateDaysForUser): + if not late_days.course.is_admin(self.request.user): + raise PermissionDenied diff --git a/autograder/rest_api/views/user_views.py b/autograder/rest_api/views/user_views.py index db8ea685..809c46bf 100644 --- a/autograder/rest_api/views/user_views.py +++ b/autograder/rest_api/views/user_views.py @@ -1,5 +1,7 @@ from typing import Dict, List, Sequence +from django.core.serializers import serialize +from django.http import JsonResponse from django.contrib.auth.models import User from django.db import transaction from django.shortcuts import get_object_or_404 @@ -13,9 +15,9 @@ import autograder.core.models as ag_models from autograder.rest_api.schema import ( AGDetailViewSchemaGenerator, APIClassType, APITags, ContentType, CustomViewSchema, - ParameterObject, as_array_content_obj + ParameterObject, as_array_content_obj, as_content_obj ) -from autograder.rest_api.schema.openapi_types import SchemaObject +from autograder.rest_api.schema.openapi_types import SchemaObject, ResponseObject, OrRef, RequestBodyObject from autograder.rest_api.schema.utils import stderr from autograder.rest_api.schema.view_schema_generators import AGViewSchemaGenerator from autograder.rest_api.serialize_user import serialize_user @@ -29,7 +31,7 @@ class _Permissions(permissions.BasePermission): def has_permission(self, *args, **kwargs): return True - def has_object_permission(self, request, view, ag_test): + def has_object_permission(self, request, view, obj): return view.kwargs['pk'] == request.user.pk @@ -187,113 +189,3 @@ def get(self, request: Request, *args, **kwargs): Indicates whether the current user can create empty courses. """ return response.Response(request.user.has_perm('core.create_course')) - - -class UserLateDaysView(AlwaysIsAuthenticatedMixin, APIView): - _LATE_DAYS_REMAINING_BODY: Dict[ContentType, SchemaObject] = { - 'application/json': { - 'schema': { - 'type': 'object', - 'required': ['late_days_remaining'], - 'properties': { - 'late_days_remaining': {'type': 'integer'} - } - } - } - } - - _PARAMS: Sequence[ParameterObject] = [ - { - 'name': 'username_or_pk', - 'in': 'path', - 'required': True, - 'description': 'The ID or username of the user.', - 'schema': { - # Note: swagger-ui doesn't seem to be able to render - # oneOf for params. - 'oneOf': [ - {'type': 'string', 'format': 'username'}, - {'type': 'integer', 'format': 'id'}, - ] - } - }, - { - 'name': 'course_pk', - 'in': 'query', - 'required': True, - 'schema': {'type': 'integer', 'format': 'id'} - } - ] - - schema = CustomViewSchema([APITags.courses, APITags.users], { - 'GET': { - 'operation_id': 'getUserLateDaysRemaining', - 'parameters': _PARAMS, - 'responses': { - '200': { - 'content': _LATE_DAYS_REMAINING_BODY, - 'description': '' - } - } - }, - 'PUT': { - 'operation_id': 'setUserLateDaysRemaining', - 'parameters': _PARAMS, - 'request': {'content': _LATE_DAYS_REMAINING_BODY}, - 'responses': { - '200': { - 'content': _LATE_DAYS_REMAINING_BODY, - 'description': '' - } - } - } - }) - - @method_decorator(require_query_params('course_pk')) - def get(self, request: Request, *args, **kwargs): - try: - user = get_object_or_404(User.objects, pk=int(kwargs['username_or_pk'])) - except ValueError: - user = get_object_or_404(User.objects, username=kwargs['username_or_pk']) - - course = get_object_or_404(ag_models.Course.objects, pk=request.query_params['course_pk']) - remaining = ag_models.LateDaysRemaining.objects.get_or_create(user=user, course=course)[0] - - self._check_read_permissions(remaining) - - return response.Response({'late_days_remaining': remaining.late_days_remaining}) - - @method_decorator(require_body_params('late_days_remaining')) - def put(self, request: Request, *args, **kwargs): - try: - user = get_object_or_404(User.objects, pk=int(kwargs['username_or_pk'])) - except ValueError: - user = get_object_or_404(User.objects, username=kwargs['username_or_pk']) - - course = get_object_or_404(ag_models.Course.objects, pk=request.query_params['course_pk']) - - with transaction.atomic(): - remaining = ag_models.LateDaysRemaining.objects.select_for_update().get_or_create( - user=user, course=course)[0] - - self._check_read_permissions(remaining) - self._check_write_permissions(remaining) - - remaining.late_days_remaining = request.data['late_days_remaining'] - remaining.save() - - return response.Response({'late_days_remaining': remaining.late_days_remaining}) - - def _check_read_permissions(self, remaining: ag_models.LateDaysRemaining): - user = self.request.user - if user == remaining.user: - return - - if remaining.course.is_staff(user): - return - - raise PermissionDenied - - def _check_write_permissions(self, remaining: ag_models.LateDaysRemaining): - if not remaining.course.is_admin(self.request.user): - raise PermissionDenied