diff --git a/server/config/urls.py b/server/config/urls.py index b8f535b0..0f110b92 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -9,6 +9,11 @@ from django.views import defaults as default_views from grapple import urls as grapple_urls from ratelimit.exceptions import Ratelimited +from vbv_lernwelt.assignment.views import ( + request_assignment_completion, + request_assignment_completion_for_user, + update_assignment_input, +) from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.views import ( check_rate_limit, @@ -93,6 +98,16 @@ urlpatterns = [ request_course_completion_for_user, name="request_course_completion_for_user"), + # assignment + path(r"api/assignment/update/", update_assignment_input, + name="update_assignment_input"), + path(r"api/assignment///", + request_assignment_completion, + name="request_assignment_completion"), + path(r"api/assignment////", + request_assignment_completion_for_user, + name="request_assignment_completion_for_user"), + # documents path(r'api/core/document/start/', document_upload_start, name='file_upload_start'), diff --git a/server/requirements/requirements-dev.txt b/server/requirements/requirements-dev.txt index 3b4ab629..0c26145d 100644 --- a/server/requirements/requirements-dev.txt +++ b/server/requirements/requirements-dev.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements-dev.txt requirements-dev.in # -aniso8601==7.0.0 +aniso8601==9.0.1 # via graphene anyascii==0.3.1 # via wagtail @@ -205,18 +205,19 @@ gitdb2==4.0.2 # via gitpython gitpython==3.0.6 # via trufflehog -graphene==2.1.9 +graphene==3.2.2 # via graphene-django -graphene-django==2.15.0 +graphene-django==3.0.0 # via wagtail-grapple -graphql-core==2.3.2 +graphql-core==3.2.3 # via # graphene # graphene-django # graphql-relay - # wagtail-grapple -graphql-relay==2.0.1 - # via graphene +graphql-relay==3.2.0 + # via + # graphene + # graphene-django gunicorn==20.1.0 # via -r requirements.in h11==0.13.0 @@ -291,8 +292,8 @@ mypy-extensions==0.4.3 # typing-inspect nodeenv==1.6.0 # via pre-commit -openpyxl==3.0.9 - # via tablib +openpyxl==3.1.2 + # via wagtail packaging==21.3 # via # build @@ -332,10 +333,7 @@ portalocker==2.4.0 pre-commit==2.17.0 # via -r requirements-dev.in promise==2.3 - # via - # graphene-django - # graphql-core - # graphql-relay + # via graphene-django prompt-toolkit==3.0.28 # via ipython psycopg2-binary==2.9.3 @@ -414,29 +412,20 @@ requests==2.27.1 # coreapi # djangorestframework-stubs # wagtail -rx==1.6.1 - # via graphql-core s3transfer==0.6.0 # via boto3 sendgrid==6.9.7 # via -r requirements.in sentry-sdk==1.5.8 # via -r requirements.in -singledispatch==3.7.0 - # via graphene-django six==1.16.0 # via # asttokens # django-coverage-plugin - # graphene - # graphene-django - # graphql-core - # graphql-relay # html5lib # l18n # promise # python-dateutil - # singledispatch # virtualenv smmap==5.0.0 # via gitdb @@ -458,8 +447,6 @@ structlog==21.5.0 # via -r requirements.in swapper==1.3.0 # via django-notifications-hq -tablib[xls,xlsx]==3.2.1 - # via wagtail telepath==0.2 # via wagtail termcolor==1.1.0 @@ -533,7 +520,7 @@ uvloop==0.16.0 # via uvicorn virtualenv==20.14.0 # via pre-commit -wagtail==3.0.1 +wagtail==4.2.2 # via # -r requirements.in # wagtail-factories @@ -542,11 +529,11 @@ wagtail==3.0.1 # wagtail-localize wagtail-factories==4.0.0 # via -r requirements.in -wagtail-grapple==0.18.0 +wagtail-grapple==0.19.2 # via -r requirements.in wagtail-headless-preview==0.4.0 # via wagtail-grapple -wagtail-localize==1.2.1 +wagtail-localize==1.5 # via -r requirements.in watchfiles==0.17.0 # via @@ -568,12 +555,6 @@ wrapt==1.14.0 # via # astroid # deprecated -xlrd==2.0.1 - # via tablib -xlsxwriter==3.0.3 - # via wagtail -xlwt==1.3.0 - # via tablib # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/server/requirements/requirements.in b/server/requirements/requirements.in index 08f03594..6840b391 100644 --- a/server/requirements/requirements.in +++ b/server/requirements/requirements.in @@ -36,9 +36,9 @@ structlog python-json-logger concurrent-log-handler -wagtail>=3,<4 +wagtail>=4 wagtail-factories>=4 -wagtail-localize -wagtail_grapple +wagtail-localize>=1.5 +wagtail_grapple>=0.19.2 boto3 diff --git a/server/requirements/requirements.txt b/server/requirements/requirements.txt index dfd3c389..2fb6cfe5 100644 --- a/server/requirements/requirements.txt +++ b/server/requirements/requirements.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements.txt requirements.in # -aniso8601==7.0.0 +aniso8601==9.0.1 # via graphene anyascii==0.3.1 # via wagtail @@ -124,18 +124,19 @@ factory-boy==3.2.1 # via wagtail-factories faker==13.11.1 # via factory-boy -graphene==2.1.9 +graphene==3.2.2 # via graphene-django -graphene-django==2.15.0 +graphene-django==3.0.0 # via wagtail-grapple -graphql-core==2.3.2 +graphql-core==3.2.3 # via # graphene # graphene-django # graphql-relay - # wagtail-grapple -graphql-relay==2.0.1 - # via graphene +graphql-relay==3.2.0 + # via + # graphene + # graphene-django gunicorn==20.1.0 # via -r requirements.in h11==0.13.0 @@ -162,8 +163,8 @@ l18n==2021.3 # via wagtail marshmallow==3.15.0 # via environs -openpyxl==3.0.9 - # via tablib +openpyxl==3.1.2 + # via wagtail packaging==21.3 # via # marshmallow @@ -177,10 +178,7 @@ polib==1.1.1 portalocker==2.4.0 # via concurrent-log-handler promise==2.3 - # via - # graphene-django - # graphql-core - # graphql-relay + # via graphene-django psycopg2-binary==2.9.3 # via -r requirements.in pycparser==2.21 @@ -221,27 +219,18 @@ redis==4.2.1 # django-redis requests==2.27.1 # via wagtail -rx==1.6.1 - # via graphql-core s3transfer==0.6.0 # via boto3 sendgrid==6.9.7 # via -r requirements.in sentry-sdk==1.5.8 # via -r requirements.in -singledispatch==3.7.0 - # via graphene-django six==1.16.0 # via - # graphene - # graphene-django - # graphql-core - # graphql-relay # html5lib # l18n # promise # python-dateutil - # singledispatch sniffio==1.2.0 # via anyio soupsieve==2.3.2.post1 @@ -254,8 +243,6 @@ structlog==21.5.0 # via -r requirements.in swapper==1.3.0 # via django-notifications-hq -tablib[xls,xlsx]==3.2.1 - # via wagtail telepath==0.2 # via wagtail text-unidecode==1.3 @@ -275,7 +262,7 @@ uvicorn[standard]==0.18.3 # via -r requirements.in uvloop==0.16.0 # via uvicorn -wagtail==3.0.1 +wagtail==4.2.2 # via # -r requirements.in # wagtail-factories @@ -284,11 +271,11 @@ wagtail==3.0.1 # wagtail-localize wagtail-factories==4.0.0 # via -r requirements.in -wagtail-grapple==0.18.0 +wagtail-grapple==0.19.2 # via -r requirements.in wagtail-headless-preview==0.4.0 # via wagtail-grapple -wagtail-localize==1.2.1 +wagtail-localize==1.5 # via -r requirements.in watchfiles==0.17.0 # via uvicorn @@ -302,12 +289,6 @@ willow==1.4.1 # via wagtail wrapt==1.14.0 # via deprecated -xlrd==2.0.1 - # via tablib -xlsxwriter==3.0.3 - # via wagtail -xlwt==1.3.0 - # via tablib # The following packages are considered to be unsafe in a requirements file: # setuptools diff --git a/server/vbv_lernwelt/assignment/admin.py b/server/vbv_lernwelt/assignment/admin.py index 8c38f3f3..cf57627e 100644 --- a/server/vbv_lernwelt/assignment/admin.py +++ b/server/vbv_lernwelt/assignment/admin.py @@ -1,3 +1,19 @@ from django.contrib import admin +from django.db.models import JSONField -# Register your models here. +from vbv_lernwelt.assignment.models import AssignmentCompletion +from vbv_lernwelt.core.admin_utils import PrettyJSONWidget + + +@admin.register(AssignmentCompletion) +class CourseSessionAdmin(admin.ModelAdmin): + formfield_overrides = { + JSONField: {"widget": PrettyJSONWidget(attrs={"rows": 16, "cols": 80})}, + } + date_hierarchy = "created_at" + list_display = [ + "id", + "assignment", + "user", + "course_session", + ] diff --git a/server/vbv_lernwelt/assignment/creators/create_assignments.py b/server/vbv_lernwelt/assignment/creators/create_assignments.py index ab78d80b..bdaa17d9 100644 --- a/server/vbv_lernwelt/assignment/creators/create_assignments.py +++ b/server/vbv_lernwelt/assignment/creators/create_assignments.py @@ -285,7 +285,7 @@ def create_uk_assignments(course_id=COURSE_UK): assignment.save() -def create_test_assignments(course_id=COURSE_TEST_ID): +def create_test_assignment(course_id=COURSE_TEST_ID): course_page = CoursePage.objects.get(course_id=course_id) assignment_page = AssignmentListPageFactory( parent=course_page, @@ -555,3 +555,5 @@ def create_test_assignments(course_id=COURSE_TEST_ID): ) assignment.save() + + return assignment diff --git a/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py b/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py new file mode 100644 index 00000000..cf3f9e04 --- /dev/null +++ b/server/vbv_lernwelt/assignment/migrations/0002_assignment_completion.py @@ -0,0 +1,76 @@ +# 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/models.py b/server/vbv_lernwelt/assignment/models.py index 205d2099..9c5f340a 100644 --- a/server/vbv_lernwelt/assignment/models.py +++ b/server/vbv_lernwelt/assignment/models.py @@ -1,4 +1,7 @@ +from enum import Enum + from django.db import models +from django.db.models import UniqueConstraint from slugify import slugify from wagtail import blocks from wagtail.admin.panels import FieldPanel @@ -6,6 +9,7 @@ from wagtail.fields import StreamField from wagtail.models import Page from vbv_lernwelt.core.model_utils import find_available_slug +from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseBasePage @@ -42,7 +46,7 @@ class PerformanceObjectiveBlock(blocks.StructBlock): icon = "tick" -class UserTextInputBlock(blocks.StaticBlock): +class UserTextInputBlock(blocks.StructBlock): text = blocks.TextBlock(blank=True) class Meta: @@ -135,3 +139,68 @@ class Assignment(CourseBasePage): ignore_page_id=self.id, ) super(Assignment, self).save(clean, user, log_action, **kwargs) + + def filter_user_subtasks(self, subtask_types=None): + """ + Filters out all the subtasks which require user input + :param subtask_types: + :return: list of subtasks with the shape: [ + { + "id": "", + "type": "user_confirmation", + "value": { + "text": "Ja, ich habe Motorfahrzeugversicherungspolice..." + } + }, + { + "id": "", + "type": "user_text_input", + "value": { + "text": "Gibt es zusätzliche Deckungen, die du der Person empfehlen..." + }, + ] + """ + if subtask_types is None: + subtask_types = ["user_text_input", "user_confirmation"] + + raw_tasks = self.tasks.raw_data + + return [ + sub_dict + for task_dict in raw_tasks + for sub_dict in task_dict["value"]["content"] + if sub_dict["type"] in subtask_types + ] + + +AssignmentCompletionStatus = Enum( + "AssignmentCompletionStatus", + ["in_progress", "submitted", "grading_in_progress", "graded"], +) + + +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) + assignment = models.ForeignKey(Assignment, on_delete=models.CASCADE) + course_session = models.ForeignKey("course.CourseSession", on_delete=models.CASCADE) + + 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) + + class Meta: + constraints = [ + UniqueConstraint( + fields=["user", "assignment", "course_session"], + name="assignment_completion_unique_user_assignment_course_session", + ) + ] diff --git a/server/vbv_lernwelt/assignment/serializers.py b/server/vbv_lernwelt/assignment/serializers.py new file mode 100644 index 00000000..95612ff5 --- /dev/null +++ b/server/vbv_lernwelt/assignment/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + +from vbv_lernwelt.assignment.models import AssignmentCompletion + + +class AssignmentCompletionSerializer(serializers.ModelSerializer): + class Meta: + model = AssignmentCompletion + fields = [ + "id", + "created_at", + "updated_at", + "user", + "assignment", + "course_session", + "completion_status", + "completion_data", + "additional_json_data", + ] diff --git a/server/vbv_lernwelt/assignment/services.py b/server/vbv_lernwelt/assignment/services.py new file mode 100644 index 00000000..a2d3f8ed --- /dev/null +++ b/server/vbv_lernwelt/assignment/services.py @@ -0,0 +1,83 @@ +from typing import Type + +from vbv_lernwelt.assignment.models import ( + Assignment, + AssignmentCompletion, + AssignmentCompletionStatus, +) +from vbv_lernwelt.core.models import User +from vbv_lernwelt.core.utils import find_first +from vbv_lernwelt.course.models import CourseSession + + +def update_assignment_completion( + user: User, + assignment: Assignment, + course_session: CourseSession, + completion_data=None, + completion_status: Type[AssignmentCompletionStatus] = "in_progress", + copy_task_data: bool = False, +) -> AssignmentCompletion: + """ + :param completion_data: should have the following structure: + { + "": {"user_data": {"text": "some text from user"}}, + "": {"user_data": {"confirmation": true}}, + } + every input field has the data stored in sub dict of the question uuid + + it can also contain "trainer_input" when the trainer has entered grading data + { + "": { + "trainer_data": {"points": 4, "text": "Gute Antwort"} + }, + } + :param copy_task_data: if true, the task data will be copied to the completion data + used for "submitted" and "graded" status, so that we don't lose the question + context + :return: AssignmentCompletion + """ + if completion_data is None: + completion_data = {} + + ac, created = AssignmentCompletion.objects.get_or_create( + user_id=user.id, + assignment_id=assignment.id, + course_session_id=course_session.id, + ) + + ac.completion_status = completion_status + + # TODO: make more validation of the provided input -> maybe with graphql + completion_data = _remove_unknown_entries(assignment, completion_data) + for key, value in completion_data.items(): + # retain already stored data + stored_entry = ac.completion_data.get(key, {}) + stored_entry.update(value) + ac.completion_data[key] = stored_entry + + if copy_task_data: + # copy over the question data, so that we don't lose the context + substasks = assignment.filter_user_subtasks() + for key, value in ac.completion_data.items(): + task_data = find_first(substasks, pred=lambda x: x["id"] == key) + ac.completion_data[key].update(task_data) + + ac.save() + + return ac + + +def _remove_unknown_entries(assignment, completion_data): + """ + Removes all entries from completion_data which are not known to the assignment + """ + possible_subtask_uuids = [ + subtask["id"] for subtask in assignment.filter_user_subtasks() + ] + filtered_completion_data = { + key: value + for key, value in completion_data.items() + if key in possible_subtask_uuids + } + return filtered_completion_data diff --git a/server/vbv_lernwelt/assignment/tests/test_assignment_api.py b/server/vbv_lernwelt/assignment/tests/test_assignment_api.py new file mode 100644 index 00000000..edc999cf --- /dev/null +++ b/server/vbv_lernwelt/assignment/tests/test_assignment_api.py @@ -0,0 +1,91 @@ +import json + +from rest_framework.test import APITestCase + +from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion +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 +from vbv_lernwelt.course.consts import COURSE_TEST_ID +from vbv_lernwelt.course.creators.test_course import create_test_course +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser + + +class AssignmentApiStudentTestCase(APITestCase): + def setUp(self) -> None: + create_default_users() + create_test_course(include_vv=False) + + self.assignment = Assignment.objects.first() + self.assignment_subtasks = self.assignment.filter_user_subtasks() + + self.cs = CourseSession.objects.create( + course_id=COURSE_TEST_ID, + title="Test Lehrgang Session", + ) + self.user = User.objects.get(username="student") + csu = CourseSessionUser.objects.create( + course_session=self.cs, + user=self.user, + ) + self.client.login(username="student", password="test") + + def test_can_updateAssignmentCompletion_asStudent(self): + url = f"/api/assignment/update/" + + user_text_input = find_first( + self.assignment_subtasks, pred=lambda x: x["type"] == "user_text_input" + ) + + response = self.client.post( + url, + { + "assignment_id": self.assignment.id, + "course_session_id": self.cs.id, + "completion_status": "in_progress", + "completion_data": { + user_text_input["id"]: {"user_data": {"text": "Hallo via API"}}, + }, + }, + format="json", + ) + response_json = response.json() + 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"], self.assignment.id) + self.assertEqual(response_json["completion_status"], "in_progress") + self.assertDictEqual( + response_json["completion_data"], + { + user_text_input["id"]: {"user_data": {"text": "Hallo via API"}}, + }, + ) + + db_entry = AssignmentCompletion.objects.get( + user=self.user, + course_session_id=self.cs.id, + assignment_id=self.assignment.id, + ) + self.assertEqual(db_entry.completion_status, "in_progress") + self.assertDictEqual( + db_entry.completion_data, + { + user_text_input["id"]: {"user_data": {"text": "Hallo via API"}}, + }, + ) + + # read data via request api + response = self.client.get( + f"/api/assignment/{self.assignment.id}/{self.cs.id}/", + format="json", + ) + response_json = response.json() + print(json.dumps(response.json(), indent=2)) + self.assertDictEqual( + response_json["completion_data"], + { + user_text_input["id"]: {"user_data": {"text": "Hallo via API"}}, + }, + ) diff --git a/server/vbv_lernwelt/assignment/tests/test_services.py b/server/vbv_lernwelt/assignment/tests/test_services.py new file mode 100644 index 00000000..8d7df67f --- /dev/null +++ b/server/vbv_lernwelt/assignment/tests/test_services.py @@ -0,0 +1,261 @@ +from django.test import TestCase + +from vbv_lernwelt.assignment.creators.create_assignments import create_test_assignment +from vbv_lernwelt.assignment.models import AssignmentCompletion +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 +from vbv_lernwelt.core.tests.helpers import create_locales_for_wagtail +from vbv_lernwelt.core.utils import find_first +from vbv_lernwelt.course.consts import COURSE_TEST_ID +from vbv_lernwelt.course.factories import CourseFactory, CoursePageFactory +from vbv_lernwelt.course.models import CourseSession + + +class UpdateAssignmentCompletionTestCase(TestCase): + def setUp(self): + create_default_users() + create_locales_for_wagtail() + course = CourseFactory( + id=COURSE_TEST_ID, + ) + course_page = CoursePageFactory(course=course) + self.assignment = create_test_assignment() + self.course_session = CourseSession.objects.create( + course_id=COURSE_TEST_ID, + title="Bern 2022 a", + ) + self.user = User.objects.get(username="student") + + def test_can_store_new_user_input(self): + subtasks = self.assignment.filter_user_subtasks() + user_text_input = find_first( + subtasks, pred=lambda x: x["type"] == "user_text_input" + ) + user_confirmation = find_first( + subtasks, pred=lambda x: x["type"] == "user_confirmation" + ) + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + user_text_input["id"]: { + "user_data": {"text": "Viel habe ich nicht zu sagen..."} + }, + user_confirmation["id"]: {"user_data": {"confirmation": True}}, + }, + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + self.assertDictEqual( + ac.completion_data, + { + user_text_input["id"]: { + "user_data": {"text": "Viel habe ich nicht zu sagen..."} + }, + user_confirmation["id"]: {"user_data": {"confirmation": True}}, + }, + ) + + def test_can_update_user_input(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( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + 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"} + }, + }, + ) + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + user_text_input1["id"]: { + "user_data": {"text": "Viel mehr gibt es nicht zu sagen."} + } + }, + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + user_input0 = ac.completion_data[user_text_input0["id"]]["user_data"]["text"] + user_input1 = ac.completion_data[user_text_input1["id"]]["user_data"]["text"] + self.assertEqual(user_input0, "Am Anfang war das Wort... 0") + self.assertEqual(user_input1, "Viel mehr gibt es nicht zu sagen.") + + def test_will_not_store_unknown_user_tasks(self): + random_uuid = "7b60903b-d2a5-4798-b6cd-5b51e63e98ab" + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + random_uuid: { + "user_data": {"text": "Viel mehr gibt es nicht zu sagen."} + } + }, + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + self.assertEqual(ac.completion_data, {}) + + def test_change_completion_status(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( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + 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"} + }, + }, + ) + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="submitted", + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + + self.assertEqual(ac.completion_status, "submitted") + + def test_copy_task_data(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( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."} + }, + }, + ) + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_status="submitted", + copy_task_data=True, + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + + self.assertEqual(ac.completion_status, "submitted") + user_input = ac.completion_data[user_text_input["id"]] + self.assertEqual( + user_input["user_data"]["text"], "Ich würde nichts weiteres empfehlen." + ) + self.assertTrue( + user_input["value"]["text"].startswith( + "Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest?" + ) + ) + + def test_can_add_trainer_data_without_loosing_user_input_data(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( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + user_text_input["id"]: { + "user_data": {"text": "Ich würde nichts weiteres empfehlen."} + }, + }, + ) + + update_assignment_completion( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + completion_data={ + user_text_input["id"]: { + "trainer_data": {"points": 1, "comment": "Gut gemacht!"} + }, + }, + completion_status="grading_in_progress", + ) + + ac = AssignmentCompletion.objects.get( + user=self.user, + assignment=self.assignment, + course_session=self.course_session, + ) + + 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!"} + ) + self.assertEqual( + user_input["user_data"]["text"], "Ich würde nichts weiteres empfehlen." + ) diff --git a/server/vbv_lernwelt/assignment/views.py b/server/vbv_lernwelt/assignment/views.py index 91ea44a2..cf708d1e 100644 --- a/server/vbv_lernwelt/assignment/views.py +++ b/server/vbv_lernwelt/assignment/views.py @@ -1,3 +1,103 @@ -from django.shortcuts import render +import structlog +from rest_framework.decorators import api_view +from rest_framework.exceptions import PermissionDenied +from rest_framework.generics import get_object_or_404 +from rest_framework.response import Response +from wagtail.models import Page -# Create your views here. +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.course.models import CourseSession +from vbv_lernwelt.course.permissions import ( + has_course_access, + has_course_access_by_page_request, + is_course_session_expert, +) + +logger = structlog.get_logger(__name__) + + +def _request_assignment_completion(assignment_id, course_session_id, user_id): + try: + response_data = AssignmentCompletionSerializer( + AssignmentCompletion.objects.get( + user_id=user_id, + assignment_id=assignment_id, + course_session_id=course_session_id, + ), + ).data + + return Response(status=200, data=response_data) + except Exception as e: + logger.error(e) + return Response({"error": str(e)}, status=404) + + +@api_view(["GET"]) +def request_assignment_completion(request, assignment_id, course_session_id): + course_id = get_object_or_404(CourseSession, id=course_session_id).course_id + if has_course_access(request.user, course_id): + return _request_assignment_completion( + assignment_id=assignment_id, + course_session_id=course_session_id, + user_id=request.user.id, + ) + raise PermissionDenied() + + +@api_view(["GET"]) +def request_assignment_completion_for_user( + request, assignment_id, course_session_id, user_id +): + if request.user.id == user_id or is_course_session_expert( + request.user, course_session_id + ): + return _request_assignment_completion( + assignment_id=assignment_id, + course_session_id=course_session_id, + user_id=user_id, + ) + raise PermissionDenied() + + +@api_view(["POST"]) +def update_assignment_input(request): + try: + assignment_id = request.data.get("assignment_id") + course_session_id = request.data.get("course_session_id") + completion_status = request.data.get("completion_status", "in_progress") + completion_data = request.data.get("completion_data", {}) + + assignment_page = Page.objects.get(id=assignment_id) + + if not has_course_access_by_page_request(request, assignment_page): + raise PermissionDenied() + + assignment = assignment_page.specific + + ac = update_assignment_completion( + user=request.user, + assignment=assignment, + course_session=CourseSession.objects.get(id=course_session_id), + completion_data=completion_data, + completion_status=completion_status, + copy_task_data=False, + ) + + logger.debug( + "store_assignment_input successful", + label="assignment_api", + assignment_id=assignment.id, + assignment_title=assignment.title, + user_id=request.user.id, + course_session_id=course_session_id, + completion_status=completion_status, + ) + + 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/core/utils.py b/server/vbv_lernwelt/core/utils.py index 97ec9a37..67b85f3e 100644 --- a/server/vbv_lernwelt/core/utils.py +++ b/server/vbv_lernwelt/core/utils.py @@ -39,7 +39,7 @@ class DayUserRateThrottle(UserRateThrottle): scope = "day-throttle" -def first_true(iterable, default=False, pred=None): +def find_first(iterable, default=False, pred=None): """Returns the first true value in the iterable. If no true value is found, returns *default* diff --git a/server/vbv_lernwelt/course/creators/test_course.py b/server/vbv_lernwelt/course/creators/test_course.py index c561173f..746e01e0 100644 --- a/server/vbv_lernwelt/course/creators/test_course.py +++ b/server/vbv_lernwelt/course/creators/test_course.py @@ -5,7 +5,7 @@ from django.conf import settings from slugify import slugify from wagtail.models import Site -from vbv_lernwelt.assignment.creators.create_assignments import create_test_assignments +from vbv_lernwelt.assignment.creators.create_assignments import create_test_assignment from vbv_lernwelt.assignment.models import Assignment from vbv_lernwelt.competence.factories import ( CompetencePageFactory, @@ -44,11 +44,11 @@ from vbv_lernwelt.media_library.tests.media_library_factories import ( def create_test_course(include_uk=True, include_vv=True, with_sessions=False): create_locales_for_wagtail() - create_test_course_with_categories() + course = create_test_course_with_categories() create_test_competence_profile() if include_uk: - create_test_assignments() + create_test_assignment() create_test_learning_path(include_uk=include_uk, include_vv=include_vv) create_test_media_library() @@ -64,6 +64,8 @@ def create_test_course(include_uk=True, include_vv=True, with_sessions=False): title="Zürich 2022 a", ) + return course + def create_test_course_with_categories(apps=None, schema_editor=None): if apps is not None: @@ -102,6 +104,8 @@ def create_test_course_with_categories(apps=None, schema_editor=None): course.slug = course_page.slug course.save() + return course + def create_test_learning_path(include_uk=True, include_vv=True): course_page = CoursePage.objects.get(course_id=COURSE_TEST_ID) 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 d6c95d50..e5c8f25d 100644 --- a/server/vbv_lernwelt/course/management/commands/create_default_courses.py +++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py @@ -2,6 +2,8 @@ import djclick as click from wagtail.models import Page from vbv_lernwelt.assignment.creators.create_assignments import create_uk_assignments +from vbv_lernwelt.assignment.models import Assignment +from vbv_lernwelt.assignment.services import update_assignment_completion from vbv_lernwelt.competence.create_uk_competence_profile import ( create_uk_competence_profile, create_uk_fr_competence_profile, @@ -62,6 +64,13 @@ def command(course): create_course_uk_de_completion_data( CourseSession.objects.get(title="Bern 2023 a") ) + create_course_uk_de_assignment_completion_data( + assignment=Assignment.objects.get( + slug="überbetriebliche-kurse-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice" + ), + course_session=CourseSession.objects.get(title="Bern 2023 a"), + user=User.objects.get(email="michael.meier@example.com"), + ) if COURSE_UK_FR in course: create_course_uk_fr() @@ -296,6 +305,23 @@ def create_course_uk_fr(): csu.expert.add(fr_circle) +def create_course_uk_de_assignment_completion_data(assignment, course_session, user): + subtasks = assignment.filter_user_subtasks(subtask_types=["user_text_input"]) + for index, subtask in enumerate(subtasks): + user_text = f"Lorem ipsum dolor sit amet... {index}" + + update_assignment_completion( + user=user, + assignment=assignment, + course_session=course_session, + completion_data={ + subtask["id"]: { + "user_data": {"text": user_text}, + } + }, + ) + + def create_course_uk_de_completion_data(course_session): # initial completion data for slug, status, email in [ diff --git a/server/vbv_lernwelt/course/permissions.py b/server/vbv_lernwelt/course/permissions.py index d409d453..84079592 100644 --- a/server/vbv_lernwelt/course/permissions.py +++ b/server/vbv_lernwelt/course/permissions.py @@ -18,12 +18,12 @@ def has_course_access(user, course_id): return False -def is_course_expert(user, course_id: int): +def is_course_session_expert(user, course_session_id: int): if user.is_superuser: return True if CourseSessionUser.objects.filter( - course_session__course_id=course_id, + course_session_id=course_session_id, user=user, role=CourseSessionUser.Role.EXPERT, ).exists(): diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py index 105fdecb..0068cffc 100644 --- a/server/vbv_lernwelt/course/views.py +++ b/server/vbv_lernwelt/course/views.py @@ -16,7 +16,7 @@ from vbv_lernwelt.course.permissions import ( has_course_access, has_course_access_by_page_request, is_circle_expert, - is_course_expert, + is_course_session_expert, ) from vbv_lernwelt.course.serializers import ( CourseCompletionSerializer, @@ -79,8 +79,9 @@ def request_course_completion(request, course_session_id): @api_view(["GET"]) def request_course_completion_for_user(request, course_session_id, user_id): - course_id = get_object_or_404(CourseSession, id=course_session_id).course_id - if request.user.id == user_id or is_course_expert(request.user, course_id): + if request.user.id == user_id or is_course_session_expert( + request.user, course_session_id + ): return _request_course_completion(course_session_id, user_id) raise PermissionDenied()