Gitlab Community Edition Instance

Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 169-add-date-to-examtype
  • 233-make-exam-a-many-to-many-field-on-studentinfo-model
  • 236-improve-importer-experience
  • 243-replace-toggle-buttons-with-switches
  • 250-update-vuetify
  • 258-add-markdown-viewer
  • 265-fix-selection-changing-on-window-switching
  • 272-reviewers-should-be-able-to-assign-exercise-groups-to-tutors
  • 276-create-new-yarn-lockfile
  • 279-tutor-overview-no-scrolling
  • 282-copy-button-does-not-work-when-reviewing-corrections
  • add-exercise-util-script
  • document-frontend-components
  • jakob.dieterle-master-patch-13835
  • master
  • parallel-test
  • update-export-dialogs
  • 0.0.1
  • 0.1
  • 0.2
  • 0.3
  • 0.4
  • 0.4.1
  • 0.4.2
  • 0.5.0
  • 0.5.1
  • 1.0.0
  • 1.1.0
  • 2.0.0
  • 2.0.1
  • 2.1.0
  • 2.1.1
  • 2.2.0
  • 3.0.0
  • 3.0.1
  • 4.0.0
  • 4.1.0
  • 4.2.0
  • 4.3.0
  • 4.4.0
  • 4.4.1
  • 5.0.0
  • 5.0.1
  • 5.1.0
  • 5.1.1
  • 5.1.2
  • 5.1.3
  • 5.1.4
  • 5.1.5
  • 5.1.6
  • 5.1.7
  • 5.2.0
  • 5.3.0
  • 5.3.1
  • 5.3.2
  • 5.4.0
  • 5.4.1
  • 5.4.2
  • legacy
59 results

Target

Select target project
  • j.michal/grady
1 result
Select Git revision
  • 169-add-date-to-examtype
  • 233-make-exam-a-many-to-many-field-on-studentinfo-model
  • 236-improve-importer-experience
  • 243-replace-toggle-buttons-with-switches
  • 250-update-vuetify
  • 258-add-markdown-viewer
  • 265-fix-selection-changing-on-window-switching
  • 272-reviewers-should-be-able-to-assign-exercise-groups-to-tutors
  • 276-create-new-yarn-lockfile
  • 279-tutor-overview-no-scrolling
  • 282-copy-button-does-not-work-when-reviewing-corrections
  • add-exercise-util-script
  • document-frontend-components
  • jakob.dieterle-master-patch-13835
  • master
  • parallel-test
  • update-export-dialogs
  • 0.0.1
  • 0.1
  • 0.2
  • 0.3
  • 0.4
  • 0.4.1
  • 0.4.2
  • 0.5.0
  • 0.5.1
  • 1.0.0
  • 1.1.0
  • 2.0.0
  • 2.0.1
  • 2.1.0
  • 2.1.1
  • 2.2.0
  • 3.0.0
  • 3.0.1
  • 4.0.0
  • 4.1.0
  • 4.2.0
  • 4.3.0
  • 4.4.0
  • 4.4.1
  • 5.0.0
  • 5.0.1
  • 5.1.0
  • 5.1.1
  • 5.1.2
  • 5.1.3
  • 5.1.4
  • 5.1.5
  • 5.1.6
  • 5.1.7
  • 5.2.0
  • 5.3.0
  • 5.3.1
  • 5.3.2
  • 5.4.0
  • 5.4.1
  • 5.4.2
  • legacy
59 results
Show changes
Commits on Source (8)
Showing
with 692 additions and 343 deletions
......@@ -22,6 +22,7 @@ build_test_env:
- .venv/
expire_in: 20 minutes
cache:
key: "$CI_JOB_NAME"
paths:
- .venv
......@@ -40,16 +41,20 @@ test_pytest:
services:
- postgres:9.5
script:
- DJANGO_SETTINGS_MODULE=grady.settings pytest --cov
- pytest --cov --ds=grady.settings core/tests
artifacts:
paths:
- .coverage
cache:
key: "$CI_JOB_NAME"
paths:
- .coverage
test_flake8:
<<: *test_definition_virtualenv
stage: test
script:
- flake8 --exclude=migrations --ignore=N802 core
- flake8 --exclude=migrations --ignore=N802 core util/factories.py
# ----------------------------- Frontend subsection -------------------------- #
.test_template_frontend: &test_definition_frontend
......@@ -64,6 +69,7 @@ test_frontend:
- yarn install
- yarn test --single-run
cache:
key: "$CI_JOB_NAME"
paths:
- frontend/node_modules/
......
from django.contrib import admin
from django.contrib.auth.models import Group
from core.models import (ExamType, Feedback, Reviewer, Student, Submission,
SubmissionType, Test, Tutor, UserAccount)
from core.models import (ExamType, Feedback, StudentInfo, Submission,
SubmissionType, Test, UserAccount)
# Stuff we needwant
admin.site.register(UserAccount)
......@@ -11,9 +11,7 @@ admin.site.register(Feedback)
admin.site.register(Test)
admin.site.register(ExamType)
admin.site.register(Submission)
admin.site.register(Reviewer)
admin.site.register(Student)
admin.site.register(Tutor)
admin.site.register(StudentInfo)
# ... and stuff we don't needwant
admin.site.unregister(Group)
from django.core.management.base import BaseCommand
from core import models
class Command(BaseCommand):
help = 'Extract all submissions from this instance'
def handle(self, *args, **kwargs):
for submission in models.Submission.objects.filter(
feedback__isnull=False).order_by('type'):
print(submission.feedback.score, repr(submission.text),
file=open(str(submission.type).replace(' ', '_'), 'a'))
from django.core.management.base import BaseCommand
import util.importer
class Command(BaseCommand):
help = 'Start the Grady command line importer'
def handle(self, *args, **kwargs):
util.importer.start()
from django.core.management.base import BaseCommand
from util.factories import init_test_instance
class Command(BaseCommand):
help = 'Creates some initial test data for the application'
def handle(self, *args, **options):
init_test_instance()
import argparse
import json
import sys
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = ('replaces all usernames based on a '
'matrikel_no -> new_name dict (input should be JSON)')
def add_arguments(self, parser):
parser.add_argument(
'matno2username_dict',
help='the mapping as a JSON file',
default=sys.stdin,
type=argparse.FileType('r')
)
def _handle(self, matno2username_dict, **kwargs):
matno2username = json.JSONDecoder().decode(matno2username_dict.read())
for student in get_user_model().get_students():
if student.student.matrikel_no in matno2username:
new_name = matno2username[student.student.matrikel_no]
student.username = new_name
student.save()
def handle(self, *args, **options):
self._handle(*args, **options)
import csv
import secrets
import sys
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = ('All student passwords will be changed'
'and a list of these password will be printed')
def add_arguments(self, parser):
parser.add_argument(
'instance',
help="Name of the instance that generated the passwords"
)
def _handle(self, *args, output=sys.stdout, instance="", **kwargs):
with open('/usr/share/dict/words') as words:
choose_from = list({word.strip().lower()
for word in words if 5 < len(word) < 8})
writer = csv.writer(output)
writer.writerow(
['Name', 'Matrikel', 'Username', 'password', 'instance'])
for student in get_user_model().get_students():
password = ''.join(secrets.choice(choose_from) for _ in range(3))
student.set_password(password)
student.save()
if not student.fullname:
student.fullname = '__no_name__'
writer.writerow([student.fullname, student.student.matrikel_no,
student.username, password, instance])
def handle(self, *args, **options):
self._handle(*args, **options)
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'All user accounts will be disabled'
def add_arguments(self, parser):
parser.add_argument(
'switch',
choices=('enable', 'disable'),
default='enable',
help='enable all users (enable) or disable all (disable)'
)
filter_group = parser.add_mutually_exclusive_group()
filter_group.add_argument(
'--exclude',
default=(),
nargs='+',
help='Provide all users you want to exclude from the operation'
)
filter_group.add_argument(
'--include',
help=('Provide users you want to operate on'
'Everything else is untouched'),
nargs='+',
default=())
def handle(self, switch, exclude=None, include=None, *args, **kwargs):
if include:
for user in get_user_model().objects.filter(username__in=include):
user.is_active = switch == 'enable'
user.save()
else: # this includes nothing set
for user in get_user_model().objects.exclude(username__in=exclude):
user.is_active = switch == 'enable'
user.save()
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-04 19:10
# Generated by Django 1.11.8 on 2017-12-22 10:51
from __future__ import unicode_literals
from typing import List, Text
import uuid
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
......@@ -15,7 +18,9 @@ class Migration(migrations.Migration):
initial = True
dependencies: List[Text] = []
dependencies = [
('auth', '0008_alter_user_username_max_length'),
]
operations = [
migrations.CreateModel(
......@@ -24,16 +29,27 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, max_length=150, unique=True)),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=30, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=30, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('fullname', models.CharField(blank=True, max_length=70, verbose_name='full name')),
('is_staff', models.BooleanField(default=False, verbose_name='staff status')),
('is_admin', models.BooleanField(default=False)),
('is_superuser', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=True, verbose_name='active')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')),
],
options={
'verbose_name': 'user',
'verbose_name_plural': 'users',
'abstract': False,
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name='ExamType',
......@@ -57,7 +73,6 @@ class Migration(migrations.Migration):
('score', models.PositiveIntegerField(default=0)),
('created', models.DateTimeField(auto_now_add=True)),
('modified', models.DateTimeField(auto_now=True)),
('status', models.IntegerField(choices=[(0, 'editable'), (1, 'request reassignment'), (2, 'request review'), (3, 'accepted')], default=0)),
('origin', models.IntegerField(choices=[(0, 'was empty'), (1, 'passed unittests'), (2, 'did not compile'), (3, 'could not link'), (4, 'created by a human. yak!')], default=4)),
],
options={
......@@ -65,6 +80,15 @@ class Migration(migrations.Migration):
'verbose_name_plural': 'Feedback Set',
},
),
migrations.CreateModel(
name='GeneralTaskSubscription',
fields=[
('subscription_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('query_key', models.CharField(blank=True, max_length=75)),
('query_type', models.CharField(choices=[('random', 'Query for any submission'), ('student', 'Query for submissions of student'), ('exam', 'Query for submissions of exam type'), ('submission_type', 'Query for submissions of submissions_type')], default='random', max_length=75)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='susbscriptions', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Reviewer',
fields=[
......@@ -77,6 +101,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('has_logged_in', models.BooleanField(default=False)),
('matrikel_no', models.CharField(default=core.models.random_matrikel_no, max_length=8, unique=True)),
('exam', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='students', to='core.ExamType')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='student', to=settings.AUTH_USER_MODEL)),
],
......@@ -88,7 +113,7 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='Submission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('submission_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('seen_by_student', models.BooleanField(default=False)),
('text', models.TextField(blank=True)),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='core.Student')),
......@@ -134,6 +159,15 @@ class Migration(migrations.Migration):
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tutor', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='TutorSubmissionAssignment',
fields=[
('assignment_id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('active', models.BooleanField(default=False)),
('submission', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='assignment', to='core.Submission')),
('subscription', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.GeneralTaskSubscription')),
],
),
migrations.AddField(
model_name='submission',
name='type',
......@@ -162,4 +196,8 @@ class Migration(migrations.Migration):
name='submission',
unique_together=set([('type', 'student')]),
),
migrations.AlterUniqueTogether(
name='generaltasksubscription',
unique_together=set([('owner', 'query_key', 'query_type')]),
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-10 16:12
from __future__ import unicode_literals
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('auth', '0008_alter_user_username_max_length'),
('core', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='useraccount',
options={'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
migrations.AlterModelManagers(
name='useraccount',
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
migrations.AddField(
model_name='useraccount',
name='date_joined',
field=models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined'),
),
migrations.AddField(
model_name='useraccount',
name='email',
field=models.EmailField(blank=True, max_length=254, verbose_name='email address'),
),
migrations.AddField(
model_name='useraccount',
name='first_name',
field=models.CharField(blank=True, max_length=30, verbose_name='first name'),
),
migrations.AddField(
model_name='useraccount',
name='groups',
field=models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups'),
),
migrations.AddField(
model_name='useraccount',
name='last_name',
field=models.CharField(blank=True, max_length=30, verbose_name='last name'),
),
migrations.AddField(
model_name='useraccount',
name='user_permissions',
field=models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions'),
),
migrations.AlterField(
model_name='useraccount',
name='is_active',
field=models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active'),
),
migrations.AlterField(
model_name='useraccount',
name='is_staff',
field=models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status'),
),
migrations.AlterField(
model_name='useraccount',
name='is_superuser',
field=models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status'),
),
migrations.AlterField(
model_name='useraccount',
name='username',
field=models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username'),
),
]
# Generated by Django 2.0 on 2017-12-22 11:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='useraccount',
name='last_name',
field=models.CharField(blank=True, max_length=150, verbose_name='last name'),
),
]
# Generated by Django 2.0.1 on 2018-01-04 16:31
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0002_auto_20171222_1116'),
]
operations = [
migrations.RenameField(
model_name='tutorsubmissionassignment',
old_name='active',
new_name='is_done',
),
]
# -*- coding: utf-8 -*-
# Generated by Django 1.11.7 on 2017-11-10 21:46
from __future__ import unicode_literals
# Generated by Django 2.0.1 on 2018-01-04 16:58
from django.db import migrations, models
import core.models
class Migration(migrations.Migration):
dependencies = [
('core', '0002_auto_20171110_1612'),
('core', '0003_auto_20180104_1631'),
]
operations = [
migrations.AddField(
model_name='student',
name='matrikel_no',
field=models.CharField(default=core.models.random_matrikel_no, max_length=8, unique=True),
model_name='feedback',
name='is_final',
field=models.BooleanField(default=False),
),
]
# Generated by Django 2.0.1 on 2018-01-04 18:51
import django.db.models.deletion
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_feedback_is_final'),
]
operations = [
migrations.AddField(
model_name='tutorsubmissionassignment',
name='created',
field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AlterField(
model_name='tutorsubmissionassignment',
name='submission',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='assignments', to='core.Submission'),
),
]
# Generated by Django 2.0.1 on 2018-01-04 20:01
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0005_auto_20180104_1851'),
]
operations = [
migrations.AlterField(
model_name='feedback',
name='of_tutor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='feedback_list', to=settings.AUTH_USER_MODEL),
),
]
# Generated by Django 2.0 on 2018-01-05 11:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0006_auto_20180104_2001'),
]
operations = [
migrations.RenameModel(
old_name='Student',
new_name='StudentInfo',
),
migrations.RemoveField(
model_name='reviewer',
name='user',
),
migrations.RemoveField(
model_name='tutor',
name='user',
),
migrations.RemoveField(
model_name='feedback',
name='of_reviewer',
),
migrations.AddField(
model_name='useraccount',
name='role',
field=models.CharField(choices=[('Student', 'student'), ('Tutor', 'tutor'), ('Reviewer', 'reviewer')], default='Student', max_length=50),
preserve_default=False,
),
migrations.DeleteModel(
name='Reviewer',
),
migrations.DeleteModel(
name='Tutor',
),
]
......@@ -6,17 +6,21 @@ See docstring of the individual models for information on the setup of the
database.
'''
import logging
import uuid
from collections import OrderedDict
from random import randrange
from typing import Dict, Union
from typing import Dict
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.db import models, transaction
from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q,
QuerySet, Sum, Value, When)
from django.db.models.functions import Coalesce
log = logging.getLogger(__name__)
def random_matrikel_no() -> str:
"""Use as a default value for student's matriculation number.
......@@ -145,34 +149,48 @@ class UserAccount(AbstractUser):
fullname = models.CharField('full name', max_length=70, blank=True)
is_admin = models.BooleanField(default=False)
def get_associated_user(self) -> models.Model:
""" Returns the user type that is associated with this user obj """
return \
(hasattr(self, 'student') and self.student) or \
(hasattr(self, 'reviewer') and self.reviewer) or \
(hasattr(self, 'tutor') and self.tutor)
STUDENT = 'Student'
TUTOR = 'Tutor'
REVIEWER = 'Reviewer'
ROLE_CHOICES = (
(STUDENT, 'student'),
(TUTOR, 'tutor'),
(REVIEWER, 'reviewer')
)
role = models.CharField(max_length=50, choices=ROLE_CHOICES)
def is_student(self):
return self.role == 'Student'
def is_tutor(self):
return self.role == 'Tutor'
class Tutor(models.Model):
user = models.OneToOneField(
get_user_model(), unique=True,
on_delete=models.CASCADE, related_name='tutor')
def is_reviewer(self):
return self.role == 'Reviewer'
def get_feedback_count(self) -> int:
return self.feedback_list.count()
@classmethod
def get_students(cls):
return cls.objects.filter(role=cls.STUDENT)
@classmethod
def get_tutors(cls):
return cls.objects.filter(role=cls.TUTOR)
class Reviewer(models.Model):
user = models.OneToOneField(
get_user_model(), unique=True,
on_delete=models.CASCADE, related_name='reviewer')
@classmethod
def get_reviewers(cls):
return cls.objects.filter(role=cls.REVIEWER)
class Student(models.Model):
class StudentInfo(models.Model):
"""
The student model includes all information of a student, that we got from
the E-Learning output, along with some useful classmethods that provide
specially annotated QuerySets.
The StudentInfo model includes all information of a student, that we got
from the E-Learning output, along with some useful classmethods that
provide specially annotated QuerySets.
Information like email (if given), and the username are stored in the
associated user model.
......@@ -188,14 +206,16 @@ class Student(models.Model):
The matriculation number of the student
"""
has_logged_in = models.BooleanField(default=False)
matrikel_no = models.CharField(
unique=True, max_length=8, default=random_matrikel_no)
exam = models.ForeignKey(
'ExamType', on_delete=models.SET_NULL,
related_name='students', null=True)
user = models.OneToOneField(
get_user_model(), unique=True,
on_delete=models.CASCADE, related_name='student')
matrikel_no = models.CharField(unique=True,
max_length=8,
default=random_matrikel_no)
exam = models.ForeignKey('ExamType',
on_delete=models.SET_NULL,
related_name='students',
null=True)
user = models.OneToOneField(get_user_model(),
on_delete=models.CASCADE,
related_name='student')
def score_per_submission(self) -> Dict[str, int]:
""" TODO: get rid of it and use an annotation.
......@@ -255,7 +275,7 @@ class Student(models.Model):
class Test(models.Model):
"""Tests contain information that has been generated by automated tests,
"""Tests contain information that has been unapproved by automated tests,
and directly belongs to a submission. Often certain Feedback was already
given by information provided by these tests.
......@@ -268,16 +288,14 @@ class Test(models.Model):
name : CharField
The name of the test that was performed
submission : ForeignKey
The submission the tests where generated on
The submission the tests where unapproved on
"""
name = models.CharField(max_length=30)
label = models.CharField(max_length=50)
annotation = models.TextField()
submission = models.ForeignKey(
'submission',
related_name='tests',
on_delete=models.CASCADE,
)
submission = models.ForeignKey('submission',
related_name='tests',
on_delete=models.CASCADE,)
class Meta:
verbose_name = "Test"
......@@ -308,6 +326,9 @@ class Submission(models.Model):
type : OneToOneField
Relation to the type containing meta information
"""
submission_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
seen_by_student = models.BooleanField(default=False)
text = models.TextField(blank=True)
type = models.ForeignKey(
......@@ -315,7 +336,7 @@ class Submission(models.Model):
on_delete=models.PROTECT,
related_name='submissions')
student = models.ForeignKey(
Student,
StudentInfo,
on_delete=models.CASCADE,
related_name='submissions')
......@@ -331,63 +352,6 @@ class Submission(models.Model):
self.student
)
@classmethod
def assign_tutor(cls, tutor: Tutor, slug: str=None) -> bool:
"""Assigns a tutor to a submission
A submission is not assigned to the specified tutor in the case
1. the tutor already has a feedback in progress
2. there is no more feedback to give
Parameters
----------
tutor : User object
The tutor that a submission should be assigned to.
slug : None, optional
If a slug for a submission is given the belonging Feedback is
assigned to the tutor. If this submission had feedback before
the tutor that worked on it, is unassigned.
Returns
-------
bool
Returns True only if feedback was actually assigned otherwise False
"""
# Get a submission from the submission set
unfinished = Feedback.tutor_unfinished_feedback(tutor)
if unfinished:
return False
candidates = cls.objects.filter(
(
Q(feedback__isnull=True) |
Q(feedback__origin=Feedback.DID_NOT_COMPILE) |
Q(feedback__origin=Feedback.COULD_NOT_LINK) |
Q(feedback__origin=Feedback.FAILED_UNIT_TESTS)
) &
~Q(feedback__of_tutor=tutor)
)
# we want a submission of a specific type
if slug:
candidates = candidates.filter(type__slug=slug)
# we couldn't find any submission to correct
if not candidates:
return False
submission = candidates[0]
feedback = submission.feedback if hasattr(
submission, 'feedback') else Feedback()
feedback.origin = Feedback.MANUAL
feedback.status = Feedback.EDITABLE
feedback.of_tutor = tutor
feedback.of_submission = submission
feedback.save()
return True
class Feedback(models.Model):
"""
......@@ -404,68 +368,32 @@ class Feedback(models.Model):
points a student receives for his submission.
of_tutor : ForeignKey
The tutor/reviewer how last edited the feedback
ORIGIN : TYPE
Description
origin : IntegerField
Of whom was this feedback originally created. She below for the choices
score : PositiveIntegerField
A score that has been assigned to he submission. Is final if it was
accepted.
STATUS : The status determines
Description
status : PositiveIntegerField
The status roughly determines in which state a feedback is in. A just
initiated submission is editable. Based on the status feedback is
presented to different types of users. Students may see feedback only
if it has been accepted, while reviewers have access at any time.
text : TextField
Detailed description by the tutor about what went wrong.
Every line in the feedback should correspond with a line in the
students submission, maybe with additional comments appended.
"""
text = models.TextField()
score = models.PositiveIntegerField(default=0)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
is_final = models.BooleanField(default=False)
of_submission = models.OneToOneField(
Submission,
on_delete=models.CASCADE,
related_name='feedback',
unique=True,
blank=False,
null=False)
related_name='feedback')
of_tutor = models.ForeignKey(
Tutor,
get_user_model(),
on_delete=models.SET_NULL,
related_name='feedback_list',
blank=True,
null=True)
of_reviewer = models.ForeignKey(
Reviewer,
on_delete=models.SET_NULL,
related_name='reviewed_submissions',
blank=True,
null=True)
# what is the current status of our feedback
(
EDITABLE,
OPEN,
NEEDS_REVIEW,
ACCEPTED,
) = range(4) # this order matters
STATUS = (
(EDITABLE, 'editable'),
(OPEN, 'request reassignment'),
(NEEDS_REVIEW, 'request review'),
(ACCEPTED, 'accepted'),
)
status = models.IntegerField(
choices=STATUS,
default=EDITABLE,
)
# how was this feedback created
(
......@@ -500,87 +428,156 @@ class Feedback(models.Model):
def get_full_score(self) -> int:
return self.of_submission.type.full_score
@classmethod
def get_open_feedback(cls, user: Union[Tutor, Reviewer]) -> QuerySet:
"""For a user, returns the feedback that is up for reassignment that
does not belong to the user.
Parameters
----------
user : User object
The user for which feedback should not be returned. Often the user
that is currently searching for a task someone else does not want
to do.
class SubscriptionEnded(Exception):
pass
Returns
-------
QuerySet
All feedback objects that are open for reassignment that do not
belong to the user
"""
return cls.objects.filter(
Q(status=Feedback.OPEN) &
~Q(of_tutor=user) # you shall not request your own feedback
)
@classmethod
def tutor_unfinished_feedback(cls, user: Union[Tutor, Reviewer]):
"""Gets only the feedback that is assigned and not accepted. A tutor
should have only one feedback assigned that is not accepted
class AssignmentError(Exception):
pass
Parameters
----------
user : User object
The tutor who formed the request
Returns
-------
The feedback or none if no feedback was assigned
"""
tutor_feedback = cls.objects.filter(
Q(of_tutor=user), Q(status=Feedback.EDITABLE),
)
return tutor_feedback[0] if tutor_feedback else None
class GeneralTaskSubscription(models.Model):
@classmethod
def tutor_assigned_feedback(cls, user: Union[Tutor, Reviewer]):
"""Gets all feedback that is assigned to the tutor including
all status cases.
RANDOM = 'random'
STUDENT_QUERY = 'student'
EXAM_TYPE_QUERY = 'exam'
SUBMISSION_TYPE_QUERY = 'submission_type'
Returns
-------
a QuerySet of tasks that have been assigned to this tutor
type_query_mapper = {
RANDOM: '__any',
STUDENT_QUERY: 'student__user__username',
EXAM_TYPE_QUERY: 'student__examtype__module_reference',
SUBMISSION_TYPE_QUERY: 'type__title',
}
Parameters
----------
user : User object
The user for which the feedback should be returned
"""
tutor_feedback = cls.objects.filter(of_tutor=user)
return tutor_feedback
QUERY_CHOICE = (
(RANDOM, 'Query for any submission'),
(STUDENT_QUERY, 'Query for submissions of student'),
(EXAM_TYPE_QUERY, 'Query for submissions of exam type'),
(SUBMISSION_TYPE_QUERY, 'Query for submissions of submissions_type'),
)
def finalize_feedback(self, user: Union[Tutor, Reviewer]):
"""Used to mark feedback as accepted (reviewed).
subscription_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
owner = models.ForeignKey(get_user_model(),
on_delete=models.CASCADE,
related_name='susbscriptions')
query_key = models.CharField(max_length=75, blank=True)
query_type = models.CharField(max_length=75,
choices=QUERY_CHOICE,
default=RANDOM)
Parameters
----------
user : User object
The tutor/reviewer that marks some feedback as accepted
"""
self.status = Feedback.ACCEPTED
self.of_reviewer = user
self.save()
class Meta:
unique_together = ('owner', 'query_key', 'query_type')
def reassign_to_tutor(self, user: Union[Tutor, Reviewer]):
"""When a tutor does not want to correct some task they can pass it
along to another tutor who will accept the request.
def _get_submission_base_query(self) -> QuerySet:
if self.query_type == self.RANDOM:
return Submission.objects.all()
Parameters
----------
User object
The user to which to feedback should be assigned to
"""
assert self.status == Feedback.OPEN
self.of_tutor = user
self.status = Feedback.EDITABLE
return Submission.objects.filter(
**{self.type_query_mapper[self.query_type]: self.query_key})
def _find_unassigned_non_final_submissions(self):
unassigned_non_final_submissions = \
self._get_submission_base_query().filter(
Q(assignments__isnull=True),
Q(feedback__isnull=True)
)
log.debug('unassigned non final submissions %s',
unassigned_non_final_submissions)
return unassigned_non_final_submissions
def _find_unassigned_unapproved_non_final_submissions(self):
unapproved_not_final_submissions = \
self._get_submission_base_query().filter(
Q(feedback__isnull=False),
Q(feedback__is_final=False),
~Q(feedback__of_tutor=self.owner),
# TODO: prevent reassigning to the same tutor
)
log.debug('unapproved not final submissions %s',
unapproved_not_final_submissions)
return unapproved_not_final_submissions
def _get_next_assignment_in_subscription(self):
assignment_priority = (
self._find_unassigned_non_final_submissions,
self._find_unassigned_unapproved_non_final_submissions
)
lazy_queries = (query_set() for query_set in assignment_priority)
for query in (q for q in lazy_queries if len(q) > 0):
return query.first()
raise SubscriptionEnded(
f'The task which user {self.owner} subscribed to is done')
@transaction.atomic
def get_or_create_work_assignment(self):
task = self._get_next_assignment_in_subscription()
return TutorSubmissionAssignment.objects.get_or_create(
subscription=self,
submission=task)[0]
def reserve_all_assignments_for_a_student(self):
assert self.query_type == self.STUDENT_QUERY
try:
while True:
self.get_or_create_work_assignment()
except SubscriptionEnded as err:
log.info(f'Loaded all subscriptions of student {self.query_key}')
def _create_new_assignment_if_subscription_empty(self):
if self.assignments.filter(is_done=False).count() < 1:
self.get_or_create_work_assignment()
def _eagerly_reserve_the_next_assignment(self):
if self.assignments.filter(is_done=False).count() < 2:
self.get_or_create_work_assignment()
def get_oldest_unfinished_assignment(self):
self._create_new_assignment_if_subscription_empty()
return self.assignments \
.filter(is_done=False) \
.order_by('created') \
.first()
def get_youngest_unfinished_assignment(self):
self._create_new_assignment_if_subscription_empty()
self._eagerly_reserve_the_next_assignment()
return self.assignments \
.filter(is_done=False) \
.order_by('-created') \
.first()
class TutorSubmissionAssignment(models.Model):
assignment_id = models.UUIDField(primary_key=True,
default=uuid.uuid4,
editable=False)
submission = models.ForeignKey(Submission,
on_delete=models.CASCADE,
related_name='assignments')
subscription = models.ForeignKey(GeneralTaskSubscription,
on_delete=models.CASCADE,
related_name='assignments')
is_done = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
@transaction.atomic
def set_done(self):
self.is_done = True
self.save()
def __str__(self):
return (f'{self.assignee} assigned to {self.submission}'
f' (active={self.active})')
......@@ -4,26 +4,23 @@ from django.http import HttpRequest
from django.views import View
from rest_framework import permissions
from core.models import Reviewer, Student, Tutor
log = logging.getLogger(__name__)
class IsUserGenericPermission(permissions.BasePermission):
class IsUserRoleGenericPermission(permissions.BasePermission):
""" Generic class that encapsulates how to identify someone
as a member of a user Group """
def has_permission(self, request: HttpRequest, view: View) -> bool:
""" required by BasePermission. Check if user is instance of any
of the models provided in class' models attribute """
assert self.models is not None, (
"'%s' has to include a `models` attribute"
assert self.roles is not None, (
"'%s' has to include a `roles` attribute"
% self.__class__.__name__
)
user = request.user
is_authorized = user.is_authenticated() and any(isinstance(
user.get_associated_user(), models) for models in self.models)
is_authorized = user.is_authenticated and user.role in self.roles
if not is_authorized:
log.warn('User "%s" has no permission to view %s',
......@@ -32,16 +29,21 @@ class IsUserGenericPermission(permissions.BasePermission):
return is_authorized
class IsStudent(IsUserGenericPermission):
class IsStudent(IsUserRoleGenericPermission):
""" Has student permissions """
models = (Student,)
roles = ('Student', )
class IsReviewer(IsUserGenericPermission):
class IsReviewer(IsUserRoleGenericPermission):
""" Has reviewer permissions """
models = (Reviewer,)
roles = ('Reviewer', )
class IsTutor(IsUserGenericPermission):
class IsTutor(IsUserRoleGenericPermission):
""" Has tutor permissions """
models = (Tutor,)
roles = ('Tutor', )
class IsTutorOrReviewer(IsUserRoleGenericPermission):
""" Has tutor or reviewer permissions """
roles = ('Tutor', 'Reviewer')
import logging
from django.core.exceptions import ObjectDoesNotExist
from drf_dynamic_fields import DynamicFieldsMixin
from rest_framework import serializers
from core.models import (ExamType, Feedback, Student, Submission,
SubmissionType, Tutor)
from core import models
from core.models import (ExamType, Feedback, GeneralTaskSubscription,
StudentInfo, Submission, SubmissionType,
TutorSubmissionAssignment)
from util.factories import GradyUserFactory
log = logging.getLogger(__name__)
......@@ -25,74 +28,176 @@ class ExamSerializer(DynamicFieldsModelSerializer):
class FeedbackSerializer(DynamicFieldsModelSerializer):
assignment_id = serializers.UUIDField(write_only=True)
def validate(self, data):
log.debug(data)
assignment_id = data.pop('assignment_id')
score = data.get('score')
try:
assignment = TutorSubmissionAssignment.objects.get(
assignment_id=assignment_id)
except ObjectDoesNotExist as err:
raise serializers.ValidationError('No assignment for given id.')
submission = assignment.submission
if not 0 <= score <= submission.type.full_score:
raise serializers.ValidationError(
f'Score has to be in range [0..{submission.type.full_score}].')
if hasattr(submission, 'feedback'):
raise serializers.ValidationError(
'Feedback for this submission already exists')
return {
**data,
'assignment': assignment,
'of_submission': submission
}
def create(self, validated_data) -> Feedback:
assignment = validated_data.pop('assignment')
assignment.set_done()
return Feedback.objects.create(**validated_data)
class Meta:
model = Feedback
fields = ('text', 'score')
fields = ('assignment_id', 'text', 'score')
class SubmissionTypeSerializer(DynamicFieldsModelSerializer):
fullScore = serializers.ReadOnlyField(source='full_score')
typeId = serializers.ReadOnlyField(source='id')
class Meta:
model = SubmissionType
fields = ('name', 'full_score', 'description', 'solution')
fields = ('typeId', 'name', 'fullScore', 'description', 'solution')
class SubmissionSerializer(DynamicFieldsModelSerializer):
feedback = serializers.ReadOnlyField(source='feedback.text')
score = serializers.ReadOnlyField(source='feedback.score')
type_id = serializers.ReadOnlyField(source='type.id')
type_name = serializers.ReadOnlyField(source='type.name')
full_score = serializers.ReadOnlyField(source='type.full_score')
typeId = serializers.ReadOnlyField(source='type.id')
typeName = serializers.ReadOnlyField(source='type.name')
fullScore = serializers.ReadOnlyField(source='type.full_score')
class Meta:
model = Submission
fields = ('type_id', 'type_name', 'text',
'feedback', 'score', 'full_score')
fields = ('typeId', 'typeName', 'text',
'feedback', 'score', 'fullScore')
class StudentSerializer(DynamicFieldsModelSerializer):
class StudentInfoSerializer(DynamicFieldsModelSerializer):
name = serializers.ReadOnlyField(source='user.fullname')
user = serializers.ReadOnlyField(source='user.username')
exam = ExamSerializer()
submissions = SubmissionSerializer(many=True)
class Meta:
model = Student
model = StudentInfo
fields = ('name', 'user', 'exam', 'submissions')
class SubmissionNoTextFieldsSerializer(DynamicFieldsModelSerializer):
score = serializers.ReadOnlyField(source='feedback.score')
type = serializers.ReadOnlyField(source='type.name')
full_score = serializers.ReadOnlyField(source='type.full_score')
typeId = serializers.ReadOnlyField(source='type.id')
fullScore = serializers.ReadOnlyField(source='type.full_score')
class Meta:
model = Submission
fields = ('type', 'score', 'full_score')
fields = ('typeId', 'score', 'fullScore')
class StudentSerializerForListView(DynamicFieldsModelSerializer):
class StudentInfoSerializerForListView(DynamicFieldsModelSerializer):
name = serializers.ReadOnlyField(source='user.fullname')
user = serializers.ReadOnlyField(source='user.username')
exam = serializers.ReadOnlyField(source='exam.module_reference')
submissions = SubmissionNoTextFieldsSerializer(many=True)
class Meta:
model = Student
model = StudentInfo
fields = ('name', 'user', 'exam', 'submissions')
class TutorSerializer(DynamicFieldsModelSerializer):
username = serializers.CharField(source='user.username')
feedback_count = serializers.IntegerField(source='get_feedback_count',
read_only=True)
def create(self, validated_data) -> Tutor:
def create(self, validated_data) -> models.UserAccount:
log.info("Crating tutor from data %s", validated_data)
return user_factory.make_tutor(
username=validated_data['user']['username'])
username=validated_data['username'])
class Meta:
model = Tutor
model = models.UserAccount
fields = ('username', 'feedback_count')
class AssignmentSerializer(DynamicFieldsModelSerializer):
submission_id = serializers.ReadOnlyField(
source='submission.submission_id')
class Meta:
model = TutorSubmissionAssignment
fields = ('assignment_id', 'submission_id', 'is_done',)
class SubmissionAssignmentSerializer(DynamicFieldsModelSerializer):
text = serializers.ReadOnlyField()
typeId = serializers.ReadOnlyField(source='type.id')
fullScore = serializers.ReadOnlyField(source='type.full_score')
class Meta:
model = Submission
fields = ('submission_id', 'typeId', 'text', 'fullScore')
class AssignmentDetailSerializer(DynamicFieldsModelSerializer):
submission = SubmissionAssignmentSerializer()
feedback = FeedbackSerializer(source='submission.feedback')
class Meta:
model = TutorSubmissionAssignment
fields = ('assignment_id', 'feedback', 'submission', 'is_done',)
class SubscriptionSerializer(DynamicFieldsModelSerializer):
owner = serializers.ReadOnlyField(source='owner.username')
query_key = serializers.CharField(required=False)
assignments = AssignmentSerializer(read_only=True, many=True)
def validate(self, data):
data['owner'] = self.context['request'].user
if 'query_key' in data != \
data['query_type'] == GeneralTaskSubscription.RANDOM:
raise serializers.ValidationError(
f'The {data["query_type"]} query_type does not work with the'
f'provided key')
try:
GeneralTaskSubscription.objects.get(
owner=data['owner'],
query_type=data['query_type'],
query_key=data.get('query_key', None))
except ObjectDoesNotExist:
pass
else:
raise serializers.ValidationError(
'The user already has the subscription')
return data
def create(self, validated_data) -> GeneralTaskSubscription:
return GeneralTaskSubscription.objects.create(**validated_data)
class Meta:
model = GeneralTaskSubscription
fields = (
'subscription_id',
'owner',
'query_type',
'query_key',
'assignments')
......@@ -29,17 +29,17 @@ class AccessRightsOfStudentAPIViewTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_tutor_has_no_access(self):
force_authenticate(self.request, user=self.tutor.user)
force_authenticate(self.request, user=self.tutor)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_reviewer_has_no_access(self):
force_authenticate(self.request, user=self.reviewer.user)
force_authenticate(self.request, user=self.reviewer)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_student_is_authorized(self):
force_authenticate(self.request, user=self.student.user)
force_authenticate(self.request, user=self.student)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_200_OK)
......@@ -64,17 +64,17 @@ class AccessRightsOfTutorAPIViewTests(APITestCase):
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_student_has_no_access(self):
force_authenticate(self.request, user=self.student.user)
force_authenticate(self.request, user=self.student)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_tutor_has_no_access(self):
force_authenticate(self.request, user=self.tutor.user)
force_authenticate(self.request, user=self.tutor)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_reviewer_has_access(self):
force_authenticate(self.request, user=self.reviewer.user)
force_authenticate(self.request, user=self.reviewer)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_200_OK)
......@@ -100,17 +100,17 @@ class AccessRightsOfStudentReviewerAPIViewTest(APITestCase):
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_student_has_no_access(self):
force_authenticate(self.request, user=self.student.user)
force_authenticate(self.request, user=self.student)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_tutor_has_no_access(self):
force_authenticate(self.request, user=self.tutor.user)
force_authenticate(self.request, user=self.tutor)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_reviewer_has_access(self):
force_authenticate(self.request, user=self.reviewer.user)
force_authenticate(self.request, user=self.reviewer)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_200_OK)
......@@ -134,16 +134,16 @@ class AccessRightsOfExamTypeAPIViewTest(APITestCase):
self.view = ExamApiViewSet.as_view({'get': 'list'})
def test_student_has_no_access(self):
force_authenticate(self.request, user=self.student.user)
force_authenticate(self.request, user=self.student)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_tutor_has_no_access(self):
force_authenticate(self.request, user=self.tutor.user)
force_authenticate(self.request, user=self.tutor)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_reviewer_has_access(self):
force_authenticate(self.request, user=self.reviewer.user)
force_authenticate(self.request, user=self.reviewer)
response = self.view(self.request)
self.assertEqual(response.status_code, status.HTTP_200_OK)