Merged in feature/VBV-290-kn-backend (pull request #59)

Feature/VBV-290 kn backend

* Add initial assignment completion model

* Add first version of `update_assignment_completion`

* Upgrade wagtail>=4 for new functions needed in assignment api

* Add API to update assignment user data

* Post API via assignment not learning_content

* Add GET api endpoints for AssignmentCompletion

* Add some initial assignment completion data

* Add admin view for AssignmentCompletion


Approved-by: Elia Bieri
This commit is contained in:
Daniel Egger 2023-04-18 08:07:29 +00:00 committed by Elia Bieri
parent 25bf90cefd
commit adc61479fc
18 changed files with 808 additions and 83 deletions

View File

@ -9,6 +9,11 @@ from django.views import defaults as default_views
from grapple import urls as grapple_urls from grapple import urls as grapple_urls
from ratelimit.exceptions import Ratelimited 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.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.views import ( from vbv_lernwelt.core.views import (
check_rate_limit, check_rate_limit,
@ -93,6 +98,16 @@ urlpatterns = [
request_course_completion_for_user, request_course_completion_for_user,
name="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/<int:assignment_id>/<int:course_session_id>/",
request_assignment_completion,
name="request_assignment_completion"),
path(r"api/assignment/<int:assignment_id>/<int:course_session_id>/<int:user_id>/",
request_assignment_completion_for_user,
name="request_assignment_completion_for_user"),
# documents # documents
path(r'api/core/document/start/', document_upload_start, path(r'api/core/document/start/', document_upload_start,
name='file_upload_start'), name='file_upload_start'),

View File

@ -4,7 +4,7 @@
# #
# pip-compile --output-file=requirements-dev.txt requirements-dev.in # pip-compile --output-file=requirements-dev.txt requirements-dev.in
# #
aniso8601==7.0.0 aniso8601==9.0.1
# via graphene # via graphene
anyascii==0.3.1 anyascii==0.3.1
# via wagtail # via wagtail
@ -205,18 +205,19 @@ gitdb2==4.0.2
# via gitpython # via gitpython
gitpython==3.0.6 gitpython==3.0.6
# via trufflehog # via trufflehog
graphene==2.1.9 graphene==3.2.2
# via graphene-django # via graphene-django
graphene-django==2.15.0 graphene-django==3.0.0
# via wagtail-grapple # via wagtail-grapple
graphql-core==2.3.2 graphql-core==3.2.3
# via # via
# graphene # graphene
# graphene-django # graphene-django
# graphql-relay # graphql-relay
# wagtail-grapple graphql-relay==3.2.0
graphql-relay==2.0.1 # via
# via graphene # graphene
# graphene-django
gunicorn==20.1.0 gunicorn==20.1.0
# via -r requirements.in # via -r requirements.in
h11==0.13.0 h11==0.13.0
@ -291,8 +292,8 @@ mypy-extensions==0.4.3
# typing-inspect # typing-inspect
nodeenv==1.6.0 nodeenv==1.6.0
# via pre-commit # via pre-commit
openpyxl==3.0.9 openpyxl==3.1.2
# via tablib # via wagtail
packaging==21.3 packaging==21.3
# via # via
# build # build
@ -332,10 +333,7 @@ portalocker==2.4.0
pre-commit==2.17.0 pre-commit==2.17.0
# via -r requirements-dev.in # via -r requirements-dev.in
promise==2.3 promise==2.3
# via # via graphene-django
# graphene-django
# graphql-core
# graphql-relay
prompt-toolkit==3.0.28 prompt-toolkit==3.0.28
# via ipython # via ipython
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
@ -414,29 +412,20 @@ requests==2.27.1
# coreapi # coreapi
# djangorestframework-stubs # djangorestframework-stubs
# wagtail # wagtail
rx==1.6.1
# via graphql-core
s3transfer==0.6.0 s3transfer==0.6.0
# via boto3 # via boto3
sendgrid==6.9.7 sendgrid==6.9.7
# via -r requirements.in # via -r requirements.in
sentry-sdk==1.5.8 sentry-sdk==1.5.8
# via -r requirements.in # via -r requirements.in
singledispatch==3.7.0
# via graphene-django
six==1.16.0 six==1.16.0
# via # via
# asttokens # asttokens
# django-coverage-plugin # django-coverage-plugin
# graphene
# graphene-django
# graphql-core
# graphql-relay
# html5lib # html5lib
# l18n # l18n
# promise # promise
# python-dateutil # python-dateutil
# singledispatch
# virtualenv # virtualenv
smmap==5.0.0 smmap==5.0.0
# via gitdb # via gitdb
@ -458,8 +447,6 @@ structlog==21.5.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0 swapper==1.3.0
# via django-notifications-hq # via django-notifications-hq
tablib[xls,xlsx]==3.2.1
# via wagtail
telepath==0.2 telepath==0.2
# via wagtail # via wagtail
termcolor==1.1.0 termcolor==1.1.0
@ -533,7 +520,7 @@ uvloop==0.16.0
# via uvicorn # via uvicorn
virtualenv==20.14.0 virtualenv==20.14.0
# via pre-commit # via pre-commit
wagtail==3.0.1 wagtail==4.2.2
# via # via
# -r requirements.in # -r requirements.in
# wagtail-factories # wagtail-factories
@ -542,11 +529,11 @@ wagtail==3.0.1
# wagtail-localize # wagtail-localize
wagtail-factories==4.0.0 wagtail-factories==4.0.0
# via -r requirements.in # via -r requirements.in
wagtail-grapple==0.18.0 wagtail-grapple==0.19.2
# via -r requirements.in # via -r requirements.in
wagtail-headless-preview==0.4.0 wagtail-headless-preview==0.4.0
# via wagtail-grapple # via wagtail-grapple
wagtail-localize==1.2.1 wagtail-localize==1.5
# via -r requirements.in # via -r requirements.in
watchfiles==0.17.0 watchfiles==0.17.0
# via # via
@ -568,12 +555,6 @@ wrapt==1.14.0
# via # via
# astroid # astroid
# deprecated # 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: # The following packages are considered to be unsafe in a requirements file:
# pip # pip

View File

@ -36,9 +36,9 @@ structlog
python-json-logger python-json-logger
concurrent-log-handler concurrent-log-handler
wagtail>=3,<4 wagtail>=4
wagtail-factories>=4 wagtail-factories>=4
wagtail-localize wagtail-localize>=1.5
wagtail_grapple wagtail_grapple>=0.19.2
boto3 boto3

View File

@ -4,7 +4,7 @@
# #
# pip-compile --output-file=requirements.txt requirements.in # pip-compile --output-file=requirements.txt requirements.in
# #
aniso8601==7.0.0 aniso8601==9.0.1
# via graphene # via graphene
anyascii==0.3.1 anyascii==0.3.1
# via wagtail # via wagtail
@ -124,18 +124,19 @@ factory-boy==3.2.1
# via wagtail-factories # via wagtail-factories
faker==13.11.1 faker==13.11.1
# via factory-boy # via factory-boy
graphene==2.1.9 graphene==3.2.2
# via graphene-django # via graphene-django
graphene-django==2.15.0 graphene-django==3.0.0
# via wagtail-grapple # via wagtail-grapple
graphql-core==2.3.2 graphql-core==3.2.3
# via # via
# graphene # graphene
# graphene-django # graphene-django
# graphql-relay # graphql-relay
# wagtail-grapple graphql-relay==3.2.0
graphql-relay==2.0.1 # via
# via graphene # graphene
# graphene-django
gunicorn==20.1.0 gunicorn==20.1.0
# via -r requirements.in # via -r requirements.in
h11==0.13.0 h11==0.13.0
@ -162,8 +163,8 @@ l18n==2021.3
# via wagtail # via wagtail
marshmallow==3.15.0 marshmallow==3.15.0
# via environs # via environs
openpyxl==3.0.9 openpyxl==3.1.2
# via tablib # via wagtail
packaging==21.3 packaging==21.3
# via # via
# marshmallow # marshmallow
@ -177,10 +178,7 @@ polib==1.1.1
portalocker==2.4.0 portalocker==2.4.0
# via concurrent-log-handler # via concurrent-log-handler
promise==2.3 promise==2.3
# via # via graphene-django
# graphene-django
# graphql-core
# graphql-relay
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
# via -r requirements.in # via -r requirements.in
pycparser==2.21 pycparser==2.21
@ -221,27 +219,18 @@ redis==4.2.1
# django-redis # django-redis
requests==2.27.1 requests==2.27.1
# via wagtail # via wagtail
rx==1.6.1
# via graphql-core
s3transfer==0.6.0 s3transfer==0.6.0
# via boto3 # via boto3
sendgrid==6.9.7 sendgrid==6.9.7
# via -r requirements.in # via -r requirements.in
sentry-sdk==1.5.8 sentry-sdk==1.5.8
# via -r requirements.in # via -r requirements.in
singledispatch==3.7.0
# via graphene-django
six==1.16.0 six==1.16.0
# via # via
# graphene
# graphene-django
# graphql-core
# graphql-relay
# html5lib # html5lib
# l18n # l18n
# promise # promise
# python-dateutil # python-dateutil
# singledispatch
sniffio==1.2.0 sniffio==1.2.0
# via anyio # via anyio
soupsieve==2.3.2.post1 soupsieve==2.3.2.post1
@ -254,8 +243,6 @@ structlog==21.5.0
# via -r requirements.in # via -r requirements.in
swapper==1.3.0 swapper==1.3.0
# via django-notifications-hq # via django-notifications-hq
tablib[xls,xlsx]==3.2.1
# via wagtail
telepath==0.2 telepath==0.2
# via wagtail # via wagtail
text-unidecode==1.3 text-unidecode==1.3
@ -275,7 +262,7 @@ uvicorn[standard]==0.18.3
# via -r requirements.in # via -r requirements.in
uvloop==0.16.0 uvloop==0.16.0
# via uvicorn # via uvicorn
wagtail==3.0.1 wagtail==4.2.2
# via # via
# -r requirements.in # -r requirements.in
# wagtail-factories # wagtail-factories
@ -284,11 +271,11 @@ wagtail==3.0.1
# wagtail-localize # wagtail-localize
wagtail-factories==4.0.0 wagtail-factories==4.0.0
# via -r requirements.in # via -r requirements.in
wagtail-grapple==0.18.0 wagtail-grapple==0.19.2
# via -r requirements.in # via -r requirements.in
wagtail-headless-preview==0.4.0 wagtail-headless-preview==0.4.0
# via wagtail-grapple # via wagtail-grapple
wagtail-localize==1.2.1 wagtail-localize==1.5
# via -r requirements.in # via -r requirements.in
watchfiles==0.17.0 watchfiles==0.17.0
# via uvicorn # via uvicorn
@ -302,12 +289,6 @@ willow==1.4.1
# via wagtail # via wagtail
wrapt==1.14.0 wrapt==1.14.0
# via deprecated # 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: # The following packages are considered to be unsafe in a requirements file:
# setuptools # setuptools

View File

@ -1,3 +1,19 @@
from django.contrib import admin 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",
]

View File

@ -285,7 +285,7 @@ def create_uk_assignments(course_id=COURSE_UK):
assignment.save() 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) course_page = CoursePage.objects.get(course_id=course_id)
assignment_page = AssignmentListPageFactory( assignment_page = AssignmentListPageFactory(
parent=course_page, parent=course_page,
@ -555,3 +555,5 @@ def create_test_assignments(course_id=COURSE_TEST_ID):
) )
assignment.save() assignment.save()
return assignment

View File

@ -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",
),
),
]

View File

@ -1,4 +1,7 @@
from enum import Enum
from django.db import models from django.db import models
from django.db.models import UniqueConstraint
from slugify import slugify from slugify import slugify
from wagtail import blocks from wagtail import blocks
from wagtail.admin.panels import FieldPanel from wagtail.admin.panels import FieldPanel
@ -6,6 +9,7 @@ from wagtail.fields import StreamField
from wagtail.models import Page from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseBasePage from vbv_lernwelt.course.models import CourseBasePage
@ -42,7 +46,7 @@ class PerformanceObjectiveBlock(blocks.StructBlock):
icon = "tick" icon = "tick"
class UserTextInputBlock(blocks.StaticBlock): class UserTextInputBlock(blocks.StructBlock):
text = blocks.TextBlock(blank=True) text = blocks.TextBlock(blank=True)
class Meta: class Meta:
@ -135,3 +139,68 @@ class Assignment(CourseBasePage):
ignore_page_id=self.id, ignore_page_id=self.id,
) )
super(Assignment, self).save(clean, user, log_action, **kwargs) 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": "<uuid>",
"type": "user_confirmation",
"value": {
"text": "Ja, ich habe Motorfahrzeugversicherungspolice..."
}
},
{
"id": "<uuid>",
"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",
)
]

View File

@ -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",
]

View File

@ -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_text_input:uuid>": {"user_data": {"text": "some text from user"}},
"<user_confirmation:uuid>": {"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
{
"<user_text_input:uuid>": {
"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

View File

@ -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"}},
},
)

View File

@ -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."
)

View File

@ -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)

View File

@ -39,7 +39,7 @@ class DayUserRateThrottle(UserRateThrottle):
scope = "day-throttle" 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. """Returns the first true value in the iterable.
If no true value is found, returns *default* If no true value is found, returns *default*

View File

@ -5,7 +5,7 @@ from django.conf import settings
from slugify import slugify from slugify import slugify
from wagtail.models import Site 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.assignment.models import Assignment
from vbv_lernwelt.competence.factories import ( from vbv_lernwelt.competence.factories import (
CompetencePageFactory, 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): def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
create_locales_for_wagtail() create_locales_for_wagtail()
create_test_course_with_categories() course = create_test_course_with_categories()
create_test_competence_profile() create_test_competence_profile()
if include_uk: if include_uk:
create_test_assignments() create_test_assignment()
create_test_learning_path(include_uk=include_uk, include_vv=include_vv) create_test_learning_path(include_uk=include_uk, include_vv=include_vv)
create_test_media_library() 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", title="Zürich 2022 a",
) )
return course
def create_test_course_with_categories(apps=None, schema_editor=None): def create_test_course_with_categories(apps=None, schema_editor=None):
if apps is not 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.slug = course_page.slug
course.save() course.save()
return course
def create_test_learning_path(include_uk=True, include_vv=True): def create_test_learning_path(include_uk=True, include_vv=True):
course_page = CoursePage.objects.get(course_id=COURSE_TEST_ID) course_page = CoursePage.objects.get(course_id=COURSE_TEST_ID)

View File

@ -2,6 +2,8 @@ import djclick as click
from wagtail.models import Page from wagtail.models import Page
from vbv_lernwelt.assignment.creators.create_assignments import create_uk_assignments 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 ( from vbv_lernwelt.competence.create_uk_competence_profile import (
create_uk_competence_profile, create_uk_competence_profile,
create_uk_fr_competence_profile, create_uk_fr_competence_profile,
@ -62,6 +64,13 @@ def command(course):
create_course_uk_de_completion_data( create_course_uk_de_completion_data(
CourseSession.objects.get(title="Bern 2023 a") 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: if COURSE_UK_FR in course:
create_course_uk_fr() create_course_uk_fr()
@ -296,6 +305,23 @@ def create_course_uk_fr():
csu.expert.add(fr_circle) 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): def create_course_uk_de_completion_data(course_session):
# initial completion data # initial completion data
for slug, status, email in [ for slug, status, email in [

View File

@ -18,12 +18,12 @@ def has_course_access(user, course_id):
return False return False
def is_course_expert(user, course_id: int): def is_course_session_expert(user, course_session_id: int):
if user.is_superuser: if user.is_superuser:
return True return True
if CourseSessionUser.objects.filter( if CourseSessionUser.objects.filter(
course_session__course_id=course_id, course_session_id=course_session_id,
user=user, user=user,
role=CourseSessionUser.Role.EXPERT, role=CourseSessionUser.Role.EXPERT,
).exists(): ).exists():

View File

@ -16,7 +16,7 @@ from vbv_lernwelt.course.permissions import (
has_course_access, has_course_access,
has_course_access_by_page_request, has_course_access_by_page_request,
is_circle_expert, is_circle_expert,
is_course_expert, is_course_session_expert,
) )
from vbv_lernwelt.course.serializers import ( from vbv_lernwelt.course.serializers import (
CourseCompletionSerializer, CourseCompletionSerializer,
@ -79,8 +79,9 @@ def request_course_completion(request, course_session_id):
@api_view(["GET"]) @api_view(["GET"])
def request_course_completion_for_user(request, course_session_id, user_id): 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_session_expert(
if request.user.id == user_id or is_course_expert(request.user, course_id): request.user, course_session_id
):
return _request_course_completion(course_session_id, user_id) return _request_course_completion(course_session_id, user_id)
raise PermissionDenied() raise PermissionDenied()