diff --git a/server/config/urls.py b/server/config/urls.py index 0f110b92..e388a89e 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -10,9 +10,10 @@ from grapple import urls as grapple_urls from ratelimit.exceptions import Ratelimited from vbv_lernwelt.assignment.views import ( + grade_assignment_completion, request_assignment_completion, request_assignment_completion_for_user, - update_assignment_input, + upsert_user_assignment_completion, ) from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.views import ( @@ -99,8 +100,10 @@ urlpatterns = [ name="request_course_completion_for_user"), # assignment - path(r"api/assignment/update/", update_assignment_input, - name="update_assignment_input"), + path(r"api/assignment/upsert/", upsert_user_assignment_completion, + name="upsert_user_assignment_completion"), + path(r"api/assignment/grade/", grade_assignment_completion, + name="grade_assignment_completion"), path(r"api/assignment///", request_assignment_completion, name="request_assignment_completion"), diff --git a/server/vbv_lernwelt/assignment/admin.py b/server/vbv_lernwelt/assignment/admin.py index cf57627e..a20e7974 100644 --- a/server/vbv_lernwelt/assignment/admin.py +++ b/server/vbv_lernwelt/assignment/admin.py @@ -6,14 +6,15 @@ from vbv_lernwelt.core.admin_utils import PrettyJSONWidget @admin.register(AssignmentCompletion) -class CourseSessionAdmin(admin.ModelAdmin): +class AssignmentCompletionAdmin(admin.ModelAdmin): formfield_overrides = { JSONField: {"widget": PrettyJSONWidget(attrs={"rows": 16, "cols": 80})}, } date_hierarchy = "created_at" list_display = [ "id", + "completion_status", "assignment", - "user", + "assignment_user", "course_session", ] diff --git a/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py b/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py deleted file mode 100644 index cf3f9e04..00000000 --- a/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py +++ /dev/null @@ -1,76 +0,0 @@ -# Generated by Django 3.2.13 on 2023-04-14 11:33 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ("course", "0004_coursesession_assignment_details_list"), - ("assignment", "0001_initial"), - ] - - operations = [ - migrations.CreateModel( - name="AssignmentCompletion", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "completion_status", - models.CharField( - choices=[ - (1, "in_progress"), - (2, "submitted"), - (3, "grading_in_progress"), - (4, "graded"), - ], - default="in_progress", - max_length=255, - ), - ), - ("completion_data", models.JSONField(default=dict)), - ("additional_json_data", models.JSONField(default=dict)), - ( - "assignment", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="assignment.assignment", - ), - ), - ( - "course_session", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to="course.coursesession", - ), - ), - ( - "user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - to=settings.AUTH_USER_MODEL, - ), - ), - ], - ), - migrations.AddConstraint( - model_name="assignmentcompletion", - constraint=models.UniqueConstraint( - fields=("user", "assignment", "course_session"), - name="assignment_completion_unique_user_assignment_course_session", - ), - ), - ] diff --git a/server/vbv_lernwelt/assignment/migrations/0002_auto_20230425_0849.py b/server/vbv_lernwelt/assignment/migrations/0002_auto_20230425_0849.py new file mode 100644 index 00000000..185a5b58 --- /dev/null +++ b/server/vbv_lernwelt/assignment/migrations/0002_auto_20230425_0849.py @@ -0,0 +1,161 @@ +# Generated by Django 3.2.13 on 2023-04-25 06:49 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0004_coursesession_assignment_details_list"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("assignment", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="AssignmentCompletionAuditLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "completion_status", + models.CharField( + choices=[ + (1, "in_progress"), + (2, "submitted"), + (3, "grading_in_progress"), + (4, "graded"), + ], + default="in_progress", + max_length=255, + ), + ), + ("completion_data", models.JSONField(default=dict)), + ("additional_json_data", models.JSONField(default=dict)), + ("assignment_user_email", models.CharField(max_length=255)), + ("assignment_slug", models.CharField(max_length=255)), + ( + "grading_user_email", + models.CharField(blank=True, default="", max_length=255), + ), + ( + "assignment", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="assignment.assignment", + ), + ), + ( + "assignment_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "course_session", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="course.coursesession", + ), + ), + ( + "grading_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="AssignmentCompletion", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("submitted_at", models.DateTimeField(blank=True, null=True)), + ("graded_at", models.DateTimeField(blank=True, null=True)), + ( + "completion_status", + models.CharField( + choices=[ + (1, "in_progress"), + (2, "submitted"), + (3, "grading_in_progress"), + (4, "graded"), + ], + default="in_progress", + max_length=255, + ), + ), + ("completion_data", models.JSONField(default=dict)), + ("additional_json_data", models.JSONField(default=dict)), + ( + "assignment", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="assignment.assignment", + ), + ), + ( + "assignment_user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "course_session", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="course.coursesession", + ), + ), + ( + "grading_user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AddConstraint( + model_name="assignmentcompletion", + constraint=models.UniqueConstraint( + fields=("assignment_user", "assignment", "course_session"), + name="assignment_completion_unique_user_assignment_course_session", + ), + ), + ] diff --git a/server/vbv_lernwelt/assignment/models.py b/server/vbv_lernwelt/assignment/models.py index 9c5f340a..ba9cb055 100644 --- a/server/vbv_lernwelt/assignment/models.py +++ b/server/vbv_lernwelt/assignment/models.py @@ -183,7 +183,17 @@ class AssignmentCompletion(models.Model): created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) - user = models.ForeignKey(User, on_delete=models.CASCADE) + submitted_at = models.DateTimeField(null=True, blank=True) + graded_at = models.DateTimeField(null=True, blank=True) + grading_user = models.ForeignKey( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="+", + ) + + assignment_user = models.ForeignKey(User, on_delete=models.CASCADE) assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE) course_session = models.ForeignKey("course.CourseSession", on_delete=models.CASCADE) @@ -194,13 +204,46 @@ class AssignmentCompletion(models.Model): ) completion_data = models.JSONField(default=dict) - additional_json_data = models.JSONField(default=dict) class Meta: constraints = [ UniqueConstraint( - fields=["user", "assignment", "course_session"], + fields=["assignment_user", "assignment", "course_session"], name="assignment_completion_unique_user_assignment_course_session", ) ] + + +class AssignmentCompletionAuditLog(models.Model): + """ + This model is used to store the "submitted" and "graded" data separately + """ + + created_at = models.DateTimeField(auto_now_add=True) + + grading_user = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, blank=True, related_name="+" + ) + assignment_user = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="+" + ) + assignment = models.ForeignKey( + Assignment, on_delete=models.SET_NULL, null=True, related_name="+" + ) + course_session = models.ForeignKey( + "course.CourseSession", on_delete=models.SET_NULL, null=True, related_name="+" + ) + + completion_status = models.CharField( + max_length=255, + choices=[(acs.value, acs.name) for acs in AssignmentCompletionStatus], + default="in_progress", + ) + + completion_data = models.JSONField(default=dict) + additional_json_data = models.JSONField(default=dict) + + assignment_user_email = models.CharField(max_length=255) + assignment_slug = models.CharField(max_length=255) + grading_user_email = models.CharField(max_length=255, blank=True, default="") diff --git a/server/vbv_lernwelt/assignment/serializers.py b/server/vbv_lernwelt/assignment/serializers.py index 95612ff5..19d0bff2 100644 --- a/server/vbv_lernwelt/assignment/serializers.py +++ b/server/vbv_lernwelt/assignment/serializers.py @@ -10,10 +10,13 @@ class AssignmentCompletionSerializer(serializers.ModelSerializer): "id", "created_at", "updated_at", - "user", + "submitted_at", + "graded_at", + "assignment_user", "assignment", "course_session", "completion_status", "completion_data", + "grading_user", "additional_json_data", ] diff --git a/server/vbv_lernwelt/assignment/services.py b/server/vbv_lernwelt/assignment/services.py index a2d3f8ed..fc6ac4df 100644 --- a/server/vbv_lernwelt/assignment/services.py +++ b/server/vbv_lernwelt/assignment/services.py @@ -1,8 +1,13 @@ +from copy import deepcopy from typing import Type +from django.utils import timezone +from rest_framework import serializers + from vbv_lernwelt.assignment.models import ( Assignment, AssignmentCompletion, + AssignmentCompletionAuditLog, AssignmentCompletionStatus, ) from vbv_lernwelt.core.models import User @@ -11,11 +16,13 @@ from vbv_lernwelt.course.models import CourseSession def update_assignment_completion( - user: User, + assignment_user: User, assignment: Assignment, course_session: CourseSession, completion_data=None, completion_status: Type[AssignmentCompletionStatus] = "in_progress", + grading_user: User | None = None, + validate_completion_status_change: bool = True, copy_task_data: bool = False, ) -> AssignmentCompletion: """ @@ -29,7 +36,7 @@ def update_assignment_completion( it can also contain "trainer_input" when the trainer has entered grading data { "": { - "trainer_data": {"points": 4, "text": "Gute Antwort"} + "expert_data": {"points": 4, "text": "Gute Antwort"} }, } :param copy_task_data: if true, the task data will be copied to the completion data @@ -41,11 +48,40 @@ def update_assignment_completion( completion_data = {} ac, created = AssignmentCompletion.objects.get_or_create( - user_id=user.id, + assignment_user_id=assignment_user.id, assignment_id=assignment.id, course_session_id=course_session.id, ) + if validate_completion_status_change: + # TODO: check time? + if completion_status == "submitted": + if ac.completion_status in ["submitted", "grading_in_progress", "graded"]: + raise serializers.ValidationError( + { + "completion_status": f"Cannot update completion status from {ac.completion_status} to submitted" + } + ) + elif completion_status == "graded": + if ac.completion_status == "graded": + raise serializers.ValidationError( + { + "completion_status": f"Cannot update completion status from {ac.completion_status} to graded" + } + ) + + if completion_status in ["graded", "grading_in_progress"]: + if grading_user is None: + raise serializers.ValidationError( + {"grading_user": "grading_user is required for graded status"} + ) + ac.grading_user = grading_user + + if completion_status == "submitted": + ac.submitted_at = timezone.now() + elif completion_status == "graded": + ac.graded_at = timezone.now() + ac.completion_status = completion_status # TODO: make more validation of the provided input -> maybe with graphql @@ -65,6 +101,27 @@ def update_assignment_completion( ac.save() + if completion_status in ["graded", "submitted"]: + acl = AssignmentCompletionAuditLog.objects.create( + assignment_user=assignment_user, + assignment=assignment, + course_session=course_session, + grading_user=grading_user, + completion_status=completion_status, + assignment_user_email=assignment_user.email, + assignment_slug=assignment.slug, + completion_data=deepcopy(ac.completion_data), + ) + if grading_user: + acl.grading_user_email = grading_user.email + + # copy over the question data, so that we don't lose the context + substasks = assignment.filter_user_subtasks() + for key, value in acl.completion_data.items(): + task_data = find_first(substasks, pred=lambda x: x["id"] == key) + acl.completion_data[key].update(task_data) + acl.save() + return ac diff --git a/server/vbv_lernwelt/assignment/tests/test_assignment_api.py b/server/vbv_lernwelt/assignment/tests/test_assignment_api.py index edc999cf..eb50cf6d 100644 --- a/server/vbv_lernwelt/assignment/tests/test_assignment_api.py +++ b/server/vbv_lernwelt/assignment/tests/test_assignment_api.py @@ -1,8 +1,13 @@ import json +from django.utils import timezone from rest_framework.test import APITestCase -from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion +from vbv_lernwelt.assignment.models import ( + Assignment, + AssignmentCompletion, + AssignmentCompletionAuditLog, +) from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.core.models import User from vbv_lernwelt.core.utils import find_first @@ -11,7 +16,7 @@ from vbv_lernwelt.course.creators.test_course import create_test_course from vbv_lernwelt.course.models import CourseSession, CourseSessionUser -class AssignmentApiStudentTestCase(APITestCase): +class AssignmentApiTestCase(APITestCase): def setUp(self) -> None: create_default_users() create_test_course(include_vv=False) @@ -23,15 +28,20 @@ class AssignmentApiStudentTestCase(APITestCase): course_id=COURSE_TEST_ID, title="Test Lehrgang Session", ) - self.user = User.objects.get(username="student") - csu = CourseSessionUser.objects.create( + self.student = User.objects.get(username="student") + self.student_csu = CourseSessionUser.objects.create( course_session=self.cs, - user=self.user, + user=self.student, ) - self.client.login(username="student", password="test") - def test_can_updateAssignmentCompletion_asStudent(self): - url = f"/api/assignment/update/" + self.expert = User.objects.get(username="admin") + self.expert_csu = CourseSessionUser.objects.create( + course_session=self.cs, user=self.expert, role="EXPERT" + ) + + def test_student_can_upsertAssignmentCompletion(self): + self.client.login(username="student", password="test") + url = f"/api/assignment/upsert/" user_text_input = find_first( self.assignment_subtasks, pred=lambda x: x["type"] == "user_text_input" @@ -53,7 +63,7 @@ class AssignmentApiStudentTestCase(APITestCase): print(json.dumps(response.json(), indent=2)) self.assertEqual(response.status_code, 200) - self.assertEqual(response_json["user"], self.user.id) + self.assertEqual(response_json["assignment_user"], self.student.id) self.assertEqual(response_json["assignment"], self.assignment.id) self.assertEqual(response_json["completion_status"], "in_progress") self.assertDictEqual( @@ -64,7 +74,7 @@ class AssignmentApiStudentTestCase(APITestCase): ) db_entry = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.student, course_session_id=self.cs.id, assignment_id=self.assignment.id, ) @@ -89,3 +99,198 @@ class AssignmentApiStudentTestCase(APITestCase): user_text_input["id"]: {"user_data": {"text": "Hallo via API"}}, }, ) + + # submit the assignment + response = self.client.post( + url, + { + "assignment_id": self.assignment.id, + "course_session_id": self.cs.id, + "completion_status": "submitted", + "completion_data": { + user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}}, + }, + }, + format="json", + ) + response_json = response.json() + print(json.dumps(response.json(), indent=2)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response_json["assignment_user"], self.student.id) + self.assertEqual(response_json["assignment"], self.assignment.id) + self.assertEqual(response_json["completion_status"], "submitted") + self.assertDictEqual( + response_json["completion_data"], + { + user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}}, + }, + ) + + # second submit will fail + response = self.client.post( + url, + { + "assignment_id": self.assignment.id, + "course_session_id": self.cs.id, + "completion_status": "submitted", + "completion_data": { + user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}}, + }, + }, + format="json", + ) + response_json = response.json() + print(json.dumps(response.json(), indent=2)) + self.assertEqual(response.status_code, 404) + self.assertTrue("Cannot update completion status" in str(response_json)) + + def test_expert_can_gradeAssignmentCompletion(self): + # setup AssignmentCompletion + subtasks = self.assignment.filter_user_subtasks( + subtask_types=["user_text_input"] + ) + user_text_input = find_first( + subtasks, + pred=lambda x: (value := x.get("value")) + and value.get("text", "").startswith( + "Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest?" + ), + ) + + ac = AssignmentCompletion.objects.create( + assignment_user=self.student, + assignment=self.assignment, + course_session=self.cs, + completion_status="submitted", + submitted_at=timezone.now(), + completion_data={ + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."} + }, + }, + ) + + # make api call + self.client.login(username="admin", password="test") + url = f"/api/assignment/grade/" + + response = self.client.post( + url, + { + "assignment_id": self.assignment.id, + "assignment_user_id": self.student.id, + "course_session_id": self.cs.id, + "completion_status": "grading_in_progress", + "completion_data": { + user_text_input["id"]: { + "expert_data": {"points": 1, "comment": "Gut gemacht!"} + }, + }, + }, + format="json", + ) + response_json = response.json() + print(json.dumps(response.json(), indent=2)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response_json["assignment_user"], self.student.id) + self.assertEqual(response_json["assignment"], self.assignment.id) + self.assertEqual(response_json["completion_status"], "grading_in_progress") + self.assertDictEqual( + response_json["completion_data"], + { + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."}, + "expert_data": {"points": 1, "comment": "Gut gemacht!"}, + }, + }, + ) + + db_entry = AssignmentCompletion.objects.get( + assignment_user=self.student, + course_session_id=self.cs.id, + assignment_id=self.assignment.id, + ) + self.assertEqual(db_entry.completion_status, "grading_in_progress") + self.assertDictEqual( + db_entry.completion_data, + { + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."}, + "expert_data": {"points": 1, "comment": "Gut gemacht!"}, + }, + }, + ) + + # finish grading + response = self.client.post( + url, + { + "assignment_id": self.assignment.id, + "assignment_user_id": self.student.id, + "course_session_id": self.cs.id, + "completion_status": "graded", + "completion_data": { + user_text_input["id"]: { + "expert_data": {"points": 1, "comment": "Gut gemacht!"} + }, + }, + }, + format="json", + ) + response_json = response.json() + print(json.dumps(response.json(), indent=2)) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response_json["assignment_user"], self.student.id) + self.assertEqual(response_json["assignment"], self.assignment.id) + self.assertEqual(response_json["completion_status"], "graded") + self.assertDictEqual( + response_json["completion_data"], + { + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."}, + "expert_data": {"points": 1, "comment": "Gut gemacht!"}, + }, + }, + ) + + db_entry = AssignmentCompletion.objects.get( + assignment_user=self.student, + course_session_id=self.cs.id, + assignment_id=self.assignment.id, + ) + self.assertEqual(db_entry.completion_status, "graded") + self.assertDictEqual( + db_entry.completion_data, + { + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."}, + "expert_data": {"points": 1, "comment": "Gut gemacht!"}, + }, + }, + ) + + # `graded` will create a new AssignmentCompletionAuditLog + acl = AssignmentCompletionAuditLog.objects.get( + assignment_user=self.student, + course_session_id=self.cs.id, + assignment_id=self.assignment.id, + completion_status="graded", + ) + self.maxDiff = None + self.assertDictEqual( + acl.completion_data, + { + user_text_input["id"]: { + "id": user_text_input["id"], + "type": "user_text_input", + "value": { + "text": "Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest? Begründe deine Empfehlung", + }, + "user_data": {"text": "Ich würde nichts weiteres empfehlen."}, + "expert_data": {"points": 1, "comment": "Gut gemacht!"}, + }, + }, + ) diff --git a/server/vbv_lernwelt/assignment/tests/test_services.py b/server/vbv_lernwelt/assignment/tests/test_services.py index 8d7df67f..dca1eda8 100644 --- a/server/vbv_lernwelt/assignment/tests/test_services.py +++ b/server/vbv_lernwelt/assignment/tests/test_services.py @@ -1,7 +1,14 @@ +from datetime import date + from django.test import TestCase +from django.utils import timezone +from rest_framework import serializers from vbv_lernwelt.assignment.creators.create_assignments import create_test_assignment -from vbv_lernwelt.assignment.models import AssignmentCompletion +from vbv_lernwelt.assignment.models import ( + AssignmentCompletion, + AssignmentCompletionAuditLog, +) from vbv_lernwelt.assignment.services import update_assignment_completion from vbv_lernwelt.core.create_default_users import create_default_users from vbv_lernwelt.core.models import User @@ -26,6 +33,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): title="Bern 2022 a", ) self.user = User.objects.get(username="student") + self.trainer = User.objects.get(username="admin") def test_can_store_new_user_input(self): subtasks = self.assignment.filter_user_subtasks() @@ -37,7 +45,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -49,7 +57,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) @@ -71,7 +79,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): user_text_input1 = subtasks[1] ac = AssignmentCompletion.objects.create( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -85,7 +93,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -96,7 +104,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) @@ -109,7 +117,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): random_uuid = "7b60903b-d2a5-4798-b6cd-5b51e63e98ab" update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -120,13 +128,13 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) self.assertEqual(ac.completion_data, {}) - def test_change_completion_status(self): + def test_completion_status_submitted(self): subtasks = self.assignment.filter_user_subtasks( subtask_types=["user_text_input"] ) @@ -134,7 +142,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): user_text_input1 = subtasks[1] ac = AssignmentCompletion.objects.create( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -148,19 +156,97 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_status="submitted", ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) self.assertEqual(ac.completion_status, "submitted") + self.assertEqual(ac.submitted_at.date(), date.today()) + + # will create AssignmentCompletionAuditLog entry + acl = AssignmentCompletionAuditLog.objects.get( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="submitted", + ) + self.assertEqual(acl.created_at.date(), date.today()) + self.assertEqual(acl.assignment_user_email, "student") + self.assertEqual( + acl.assignment_slug, + "versicherungsvermittler-in-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice", + ) + + # AssignmentCompletionAuditLog entry will remain event after deletion of foreign keys + ac.delete() + self.user.delete() + self.assignment.delete() + acl = AssignmentCompletionAuditLog.objects.get(id=acl.id) + self.assertEqual(acl.created_at.date(), date.today()) + self.assertEqual(acl.assignment_user_email, "student") + self.assertEqual( + acl.assignment_slug, + "versicherungsvermittler-in-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice", + ) + self.assertIsNone(acl.assignment_user) + self.assertIsNone(acl.assignment) + + def test_completion_status_submitted_cannot_submit_twice(self): + subtasks = self.assignment.filter_user_subtasks( + subtask_types=["user_text_input"] + ) + user_text_input0 = subtasks[0] + user_text_input1 = subtasks[1] + + ac = AssignmentCompletion.objects.create( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + submitted_at=timezone.now(), + completion_status="submitted", + completion_data={ + user_text_input0["id"]: { + "user_data": {"text": "Am Anfang war das Wort... 0"} + }, + user_text_input1["id"]: { + "user_data": {"text": "Am Anfang war das Wort... 1"} + }, + }, + ) + + with self.assertRaises(serializers.ValidationError) as error: + update_assignment_completion( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="submitted", + ) + + # can submit twice with flag + update_assignment_completion( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="submitted", + validate_completion_status_change=False, + ) + + ac = AssignmentCompletion.objects.get( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + + self.assertEqual(ac.completion_status, "submitted") + self.assertEqual(ac.submitted_at.date(), date.today()) def test_copy_task_data(self): subtasks = self.assignment.filter_user_subtasks( @@ -175,7 +261,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.create( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ @@ -186,7 +272,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_status="submitted", @@ -194,7 +280,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) @@ -210,7 +296,7 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ) - def test_can_add_trainer_data_without_loosing_user_input_data(self): + def test_can_add_grading_data_without_loosing_user_input_data(self): subtasks = self.assignment.filter_user_subtasks( subtask_types=["user_text_input"] ) @@ -223,9 +309,10 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) ac = AssignmentCompletion.objects.create( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, + completion_status="submitted", completion_data={ user_text_input["id"]: { "user_data": {"text": "Ich würde nichts weiteres empfehlen."} @@ -234,19 +321,20 @@ class UpdateAssignmentCompletionTestCase(TestCase): ) update_assignment_completion( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, completion_data={ user_text_input["id"]: { - "trainer_data": {"points": 1, "comment": "Gut gemacht!"} + "expert_data": {"points": 1, "comment": "Gut gemacht!"} }, }, completion_status="grading_in_progress", + grading_user=self.trainer, ) ac = AssignmentCompletion.objects.get( - user=self.user, + assignment_user=self.user, assignment=self.assignment, course_session=self.course_session, ) @@ -254,8 +342,49 @@ class UpdateAssignmentCompletionTestCase(TestCase): self.assertEqual(ac.completion_status, "grading_in_progress") user_input = ac.completion_data[user_text_input["id"]] self.assertDictEqual( - user_input["trainer_data"], {"points": 1, "comment": "Gut gemacht!"} + user_input["expert_data"], {"points": 1, "comment": "Gut gemacht!"} ) self.assertEqual( user_input["user_data"]["text"], "Ich würde nichts weiteres empfehlen." ) + + def test_cannot_grading_data_without_grading_user(self): + subtasks = self.assignment.filter_user_subtasks( + subtask_types=["user_text_input"] + ) + user_text_input = find_first( + subtasks, + pred=lambda x: (value := x.get("value")) + and value.get("text", "").startswith( + "Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest?" + ), + ) + + ac = AssignmentCompletion.objects.create( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="in_progress", + completion_data={ + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."} + }, + }, + ) + + with self.assertRaises(serializers.ValidationError) as error: + update_assignment_completion( + assignment_user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="grading_in_progress", + completion_data={ + user_text_input["id"]: { + "expert_data": {"points": 1, "comment": "Gut gemacht!"} + }, + }, + ) + + self.assertTrue( + "grading_user" in error.exception.detail, + ) diff --git a/server/vbv_lernwelt/assignment/views.py b/server/vbv_lernwelt/assignment/views.py index cf708d1e..66eeb27a 100644 --- a/server/vbv_lernwelt/assignment/views.py +++ b/server/vbv_lernwelt/assignment/views.py @@ -8,6 +8,7 @@ from wagtail.models import Page from vbv_lernwelt.assignment.models import AssignmentCompletion from vbv_lernwelt.assignment.serializers import AssignmentCompletionSerializer from vbv_lernwelt.assignment.services import update_assignment_completion +from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSession from vbv_lernwelt.course.permissions import ( has_course_access, @@ -18,11 +19,13 @@ from vbv_lernwelt.course.permissions import ( logger = structlog.get_logger(__name__) -def _request_assignment_completion(assignment_id, course_session_id, user_id): +def _request_assignment_completion( + assignment_id, course_session_id, assignment_user_id +): try: response_data = AssignmentCompletionSerializer( AssignmentCompletion.objects.get( - user_id=user_id, + assignment_user_id=assignment_user_id, assignment_id=assignment_id, course_session_id=course_session_id, ), @@ -41,7 +44,7 @@ def request_assignment_completion(request, assignment_id, course_session_id): return _request_assignment_completion( assignment_id=assignment_id, course_session_id=course_session_id, - user_id=request.user.id, + assignment_user_id=request.user.id, ) raise PermissionDenied() @@ -56,13 +59,13 @@ def request_assignment_completion_for_user( return _request_assignment_completion( assignment_id=assignment_id, course_session_id=course_session_id, - user_id=user_id, + assignment_user_id=user_id, ) raise PermissionDenied() @api_view(["POST"]) -def update_assignment_input(request): +def upsert_user_assignment_completion(request): try: assignment_id = request.data.get("assignment_id") course_session_id = request.data.get("course_session_id") @@ -77,7 +80,7 @@ def update_assignment_input(request): assignment = assignment_page.specific ac = update_assignment_completion( - user=request.user, + assignment_user=request.user, assignment=assignment, course_session=CourseSession.objects.get(id=course_session_id), completion_data=completion_data, @@ -86,11 +89,11 @@ def update_assignment_input(request): ) logger.debug( - "store_assignment_input successful", + "upsert_user_assignment_completion successful", label="assignment_api", assignment_id=assignment.id, assignment_title=assignment.title, - user_id=request.user.id, + assignment_user_id=request.user.id, course_session_id=course_session_id, completion_status=completion_status, ) @@ -101,3 +104,52 @@ def update_assignment_input(request): except Exception as e: logger.error(e, exc_info=True) return Response({"error": str(e)}, status=404) + + +@api_view(["POST"]) +def grade_assignment_completion(request): + try: + assignment_id = request.data.get("assignment_id") + assignment_user_id = request.data.get("assignment_user_id") + course_session_id = request.data.get("course_session_id") + completion_status = request.data.get("completion_status", "grading_in_progress") + completion_data = request.data.get("completion_data", {}) + + assignment_page = Page.objects.get(id=assignment_id) + assignment_user = User.objects.get(id=assignment_user_id) + + if not has_course_access_by_page_request(request, assignment_page): + raise PermissionDenied() + + if not is_course_session_expert(request.user, course_session_id): + raise PermissionDenied() + + assignment = assignment_page.specific + + ac = update_assignment_completion( + assignment_user=assignment_user, + assignment=assignment, + course_session=CourseSession.objects.get(id=course_session_id), + completion_data=completion_data, + completion_status=completion_status, + copy_task_data=False, + grading_user=request.user, + ) + + logger.debug( + "grade_assignment_completion successful", + label="assignment_api", + assignment_id=assignment.id, + assignment_title=assignment.title, + assignment_user_id=assignment_user_id, + course_session_id=course_session_id, + completion_status=completion_status, + grading_user_id=request.user.id, + ) + + return Response(status=200, data=AssignmentCompletionSerializer(ac).data) + except PermissionDenied as e: + raise e + except Exception as e: + logger.error(e, exc_info=True) + return Response({"error": str(e)}, status=404) diff --git a/server/vbv_lernwelt/course/management/commands/create_default_courses.py b/server/vbv_lernwelt/course/management/commands/create_default_courses.py index e5c8f25d..5dd86501 100644 --- a/server/vbv_lernwelt/course/management/commands/create_default_courses.py +++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py @@ -311,7 +311,7 @@ def create_course_uk_de_assignment_completion_data(assignment, course_session, u user_text = f"Lorem ipsum dolor sit amet... {index}" update_assignment_completion( - user=user, + assignment_user=user, assignment=assignment, course_session=course_session, completion_data={