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:
parent
25bf90cefd
commit
adc61479fc
|
|
@ -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/<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
|
||||
path(r'api/core/document/start/', document_upload_start,
|
||||
name='file_upload_start'),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
@ -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": "<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",
|
||||
)
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}},
|
||||
},
|
||||
)
|
||||
|
|
@ -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."
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue