diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0ec2cf3495f0e6cb2ed615ded22c234bd3140fab..2d62fe511bd1a7a860181ea7531b90a426d44536 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,6 +1,7 @@ stages: - build - test + - pages - staging variables: @@ -23,21 +24,15 @@ build_backend: before_script: - cd backend/ -test_coverage: +test_pytest: <<: *test_definition_backend services: - postgres:9.5 script: - - coverage run manage.py test --noinput - - coverage report --skip-covered + - DJANGO_SETTINGS_MODULE=grady.settings pytest --cov artifacts: paths: - - .coverage/ - -test_pylint: - <<: *test_definition_backend - script: - - pylint core || exit 0 + - backend/.coverage test_prospector: <<: *test_definition_backend @@ -56,7 +51,19 @@ test_frontend: script: - yarn install - yarn test --single-run - allow_failure: true + +# =========================== Gitlab pages section =========================== # +test_coverage: + <<: *test_definition_backend + stage: + pages + script: + - coverage html -d ../public + dependencies: + - test_pytest + artifacts: + paths: + - public # ============================== Staging section ============================= # .staging_template: &staging_definition diff --git a/backend/.coveragerc b/backend/.coveragerc index a7e7aac27a241c10fd195ca8bfb9b146b7cb18a3..f578fcfa0b500bed801d7aa3717a1036d669f5f2 100644 --- a/backend/.coveragerc +++ b/backend/.coveragerc @@ -1,13 +1,14 @@ [run] branch = True -source = core,util,grady +source = core,util.factories omit = core/migrations/* core/apps.py core/admin.py + core/tests/* [report] ignore_errors = False [html] -directory = coverage_html +directory = public diff --git a/backend/.gitignore b/backend/.gitignore index 438bae1f35383978b491783079cc5ab16c0546a6..86ca4a4842208f80247e2f5f30648fa73444dabe 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -7,6 +7,7 @@ __pycache__ MANIFEST .coverage cache/ +.mypy_cache/ # Django specific dist/ @@ -23,5 +24,10 @@ static/ env-grady/ env/ scripts/ +coverage_html/ +public/ *.csv .importer* + +# node +node_modules diff --git a/backend/core/migrations/0001_initial.py b/backend/core/migrations/0001_initial.py index 7fd6cdada7a80ed66d0adb09f55bb01c58a93272..5f840961f0accd6d68d03bb079d421e7f0322bcd 100644 --- a/backend/core/migrations/0001_initial.py +++ b/backend/core/migrations/0001_initial.py @@ -2,6 +2,8 @@ # Generated by Django 1.11.7 on 2017-11-04 19:10 from __future__ import unicode_literals +from typing import List, Text + import django.db.models.deletion from django.conf import settings from django.db import migrations, models @@ -13,8 +15,7 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies: List[Text] = [] operations = [ migrations.CreateModel( diff --git a/backend/core/migrations/0003_student_matrikel_no.py b/backend/core/migrations/0003_student_matrikel_no.py index 0beae351c7c15a7419470e4709ed9112a2d1f2f1..82da5d1a338e2a4c3259ebdb431ee07dfe86fc03 100644 --- a/backend/core/migrations/0003_student_matrikel_no.py +++ b/backend/core/migrations/0003_student_matrikel_no.py @@ -2,9 +2,10 @@ # Generated by Django 1.11.7 on 2017-11-10 21:46 from __future__ import unicode_literals -import core.models from django.db import migrations, models +import core.models + class Migration(migrations.Migration): diff --git a/backend/core/models.py b/backend/core/models.py index 1d1b23b19dbe7c77f4e9911035c85639578c74d3..76b024601d0fb85b549519d734f700d63d1ad2b6 100644 --- a/backend/core/models.py +++ b/backend/core/models.py @@ -6,6 +6,8 @@ See docstring of the individual models for information on the setup of the database. ''' +from typing import Union, Dict + from collections import OrderedDict from random import randrange, sample from string import ascii_lowercase @@ -15,7 +17,7 @@ from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Value as V from django.db.models import (BooleanField, Case, Count, F, IntegerField, Q, - Sum, When) + QuerySet, Sum, When) from django.db.models.functions import Coalesce @@ -28,7 +30,7 @@ def random_matrikel_no() -> str: return str(10_000_000 + randrange(90_000_000)) -def get_annotated_tutor_list(): +def get_annotated_tutor_list() -> QuerySet: """All tutor accounts are annotate with a field that includes the number of feedback that tutor has collaborated in. @@ -105,7 +107,7 @@ class SubmissionType(models.Model): verbose_name_plural = "SubmissionType Set" @classmethod - def get_annotated_feedback_count(cls): + def get_annotated_feedback_count(cls) -> QuerySet: """ Annotates submission lists with counts The following fields are annotated: @@ -146,7 +148,7 @@ class UserAccount(AbstractUser): fullname = models.CharField('full name', max_length=70, blank=True) is_admin = models.BooleanField(default=False) - def get_associated_user(self): + 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 \ @@ -159,6 +161,9 @@ class Tutor(models.Model): get_user_model(), unique=True, on_delete=models.CASCADE, related_name='tutor') + def get_feedback_count(self) -> int: + return self.feedback_list.count() + class Reviewer(models.Model): user = models.OneToOneField( @@ -195,7 +200,7 @@ class Student(models.Model): get_user_model(), unique=True, on_delete=models.CASCADE, related_name='student') - def score_per_submission(self): + def score_per_submission(self) -> Dict[str, int]: """ TODO: get rid of it and use an annotation. Returns: @@ -212,7 +217,7 @@ class Student(models.Model): }) @classmethod - def get_overall_score_annotated_submission_list(cls): + def get_overall_score_annotated_submission_list(cls) -> QuerySet: """Can be used to quickly annotate a user with the necessary information on the overall score of a student and if he does not need any more correction. @@ -329,7 +334,7 @@ class Submission(models.Model): ) @classmethod - def assign_tutor(cls, tutor, slug=None) -> bool: + 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 @@ -491,14 +496,14 @@ class Feedback(models.Model): def __str__(self) -> str: return 'Feedback for {}'.format(self.of_submission) - def is_full_score(self): + def is_full_score(self) -> bool: return self.of_submission.type.full_score == self.score - def get_full_score(self): + def get_full_score(self) -> int: return self.of_submission.type.full_score @classmethod - def get_open_feedback(cls, user): + 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. @@ -521,7 +526,7 @@ class Feedback(models.Model): ) @classmethod - def tutor_unfinished_feedback(cls, user): + 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 @@ -539,7 +544,7 @@ class Feedback(models.Model): ) return tutor_feedback[0] if tutor_feedback else None - def tutor_assigned_feedback(cls, user): + def tutor_assigned_feedback(cls, user: Union[Tutor, Reviewer]): """Gets all feedback that is assigned to the tutor including all status cases. @@ -555,7 +560,7 @@ class Feedback(models.Model): tutor_feedback = cls.objects.filter(of_tutor=user) return tutor_feedback - def finalize_feedback(self, user): + def finalize_feedback(self, user: Union[Tutor, Reviewer]): """Used to mark feedback as accepted (reviewed). Parameters @@ -567,17 +572,7 @@ class Feedback(models.Model): self.of_reviewer = user self.save() - def unfinalize_feedback(self): - """Used to mark feedback as accepted (reviewed) - - This makes it uneditable by the tutor. - """ - - self.origin = Feedback.MANUAL - self.of_reviewer = None - self.save() - - def reassign_to_tutor(self, user): + 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. diff --git a/backend/core/permissions.py b/backend/core/permissions.py index 3418f6dd13ad69513594f22ef6612261614d72fd..29b60731037a3d58727b44826b189de4b8f6217e 100644 --- a/backend/core/permissions.py +++ b/backend/core/permissions.py @@ -1,9 +1,37 @@ from rest_framework import permissions -from core.models import Student +from core.models import Reviewer, Student, Tutor +from django.http import HttpRequest +from django.views import View -class IsStudent(permissions.BasePermission): - def has_permission(self, request, view): +class IsUserGenericPermission(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 model""" + assert self.model is not None, ( + "'%s' has to include a `model` attribute" + % self.__class__.__name__ + ) + user = request.user - return user.is_authenticated() and isinstance(user.get_associated_user(), Student) + return user.is_authenticated() and isinstance( + user.get_associated_user(), self.model + ) + + +class IsStudent(IsUserGenericPermission): + """ Has student permissions """ + model = Student + + +class IsReviewer(IsUserGenericPermission): + """ Has reviewer permissions """ + model = Reviewer + + +class IsTutor(IsUserGenericPermission): + """ Has tutor permissions """ + model = Tutor diff --git a/backend/core/serializers.py b/backend/core/serializers.py index 8178ecfae6f841340d82c7e9afada2bee9dd3c52..afccb1bb6212bc3edd35c18680e022f464fb028e 100644 --- a/backend/core/serializers.py +++ b/backend/core/serializers.py @@ -1,6 +1,12 @@ +import logging + from rest_framework import serializers -from core.models import ExamType, Feedback, Student, Submission +from core.models import ExamType, Feedback, Student, Submission, Tutor +from util.factories import GradyUserFactory + +log = logging.getLogger(__name__) +user_factory = GradyUserFactory() class ExamSerializer(serializers.ModelSerializer): @@ -38,3 +44,18 @@ class StudentSerializer(serializers.ModelSerializer): class Meta: model = Student fields = ('name', 'user', 'exam', 'submissions') + + +class TutorSerializer(serializers.ModelSerializer): + username = serializers.CharField(source='user.username') + feedback_count = serializers.IntegerField(source='get_feedback_count', + read_only=True) + + def create(self, validated_data) -> Tutor: + log.info("Crating tutor from data %s", validated_data) + return user_factory.make_tutor( + username=validated_data['user']['username']) + + class Meta: + model = Tutor + fields = ('username', 'feedback_count') diff --git a/backend/core/tests/__init__.py b/backend/core/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/core/tests/data_factories.py b/backend/core/tests/data_factories.py new file mode 100644 index 0000000000000000000000000000000000000000..25e5ca71f2c33bc70e2204987b3b4c9ceb506204 --- /dev/null +++ b/backend/core/tests/data_factories.py @@ -0,0 +1,84 @@ +""" A set of factory methods that make testing easier. Each method creates all +reuired subfields if not provided by via kwargs. """ + +from core.models import (ExamType, Feedback, Reviewer, Student, Submission, + SubmissionType, Tutor, UserAccount) + +# These methods are meant to be used to provide data to insert into the test +# database + +def make_user(username='user01', + password='p', + fullname='us er01', + is_admin=False): + user = UserAccount.objects.create(username=username, + fullname=fullname, + is_admin=is_admin) + user.set_password(password) + user.save() + return user + + +def make_exam(module_reference='TestExam B.Inf.0042', + total_score=42, + pass_score=21, + **kwargs): + return ExamType.objects.create(module_reference=module_reference, + total_score=total_score, + pass_score=pass_score, **kwargs) + + +def make_submission_type(name='problem01', + full_score=10, + description='Very hard', + solution='Impossible!'): + return SubmissionType.objects.create(name=name, + full_score=full_score, + description=description, + solution=solution) + + +def make_student(user=None, exam=None): + if user is None: + user = make_user() + if exam is None: + exam = make_exam() + return Student.objects.create(user=user, exam=exam) + + +def make_tutor(user=None): + if user is None: + user = make_user() + return Tutor.objects.create(user=user) + + +def make_reviewer(user=None): + if user is None: + user = make_user() + return Reviewer.objects.create(user=user) + + +def make_submission(type=None, student=None, text='Too hard for me ;-('): + if type is None: + type = make_submission_type() + if student is None: + student = make_student() + return Submission.objects.create(text=text, type=type, student=student) + + +def make_feedback(of_tutor, of_submission=None, text='Very bad!', score=3): + if of_submission is None: + of_submission = make_submission() + return Feedback.objects.create(of_tutor=of_tutor, + of_submission=of_submission, + text=text, + score=score) + + +def make_minimal_exam(): + # also creates default examType, submissionType and student + submission = make_submission() + + tutor = make_tutor(user=make_user(username='tutor01')) + feedback = make_feedback(of_tutor=tutor, of_submission=submission) + return submission, tutor, feedback diff --git a/backend/core/tests/test_access_rights.py b/backend/core/tests/test_access_rights.py new file mode 100644 index 0000000000000000000000000000000000000000..ff9857d688f1ebde249371bbabf497c8a0743ad2 --- /dev/null +++ b/backend/core/tests/test_access_rights.py @@ -0,0 +1,44 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) + +from core.models import Reviewer +from core.views import StudentApiView +from util.factories import GradyUserFactory + + +class AccessRightsOfStudentAPIViewTests(APITestCase): + """ All tests that enshure that only students can see what students + should see belong here """ + + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + cls.user_factory = GradyUserFactory() + + def setUp(self): + self.student = self.user_factory.make_student() + self.tutor = self.user_factory.make_tutor() + self.reviewer = self.user_factory.make_reviewer() + self.request = self.factory.get(reverse('student-page')) + self.view = StudentApiView.as_view() + + def test_unauthorized_access_denied(self): + response = self.view(self.request) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_tutor_has_no_access(self): + force_authenticate(self.request, user=self.tutor.user) + 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) + 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) + response = self.view(self.request) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/core/tests/test_auth.py b/backend/core/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..85505585b10b0362b4f851f1ea16c3a88a5e3987 --- /dev/null +++ b/backend/core/tests/test_auth.py @@ -0,0 +1,22 @@ +from rest_framework.test import APIClient, APITestCase + +from core.models import UserAccount + + +class AuthTests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.credentials = {'username': 'user', 'password': 'p'} + cls.user = UserAccount.objects.create(username=cls.credentials['username']) + cls.user.set_password(cls.credentials['password']) + cls.user.save() + cls.client = APIClient() + + def test_get_token(self): + response = self.client.post('/api-token-auth/', self.credentials) + self.assertContains(response, 'token') + + def test_refresh_token(self): + token = self.client.post('/api-token-auth/', self.credentials).data + response = self.client.post('/api-token-refresh/', token) + self.assertContains(response, 'token') diff --git a/backend/core/tests/test_examlist.py b/backend/core/tests/test_examlist.py new file mode 100644 index 0000000000000000000000000000000000000000..51d55a8b9e9fe2972dd881dfabbafb26035092b9 --- /dev/null +++ b/backend/core/tests/test_examlist.py @@ -0,0 +1,32 @@ +""" Tests that we can receive information about what exams where written """ + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) + +from core.models import ExamType +from core.views import ExamListView +from util.factories import GradyUserFactory + +NUMBER_OF_TUTORS = 7 + + +class ExamListTest(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + cls.user_factory = GradyUserFactory() + + def setUp(self): + self.request = self.factory.get(reverse('exam-list')) + force_authenticate(self.request, self.user_factory.make_student().user) + self.view = ExamListView.as_view() + self.response = self.view(self.request) + + def test_can_access_when_authenticated(self): + self.assertEqual(self.response.status_code, status.HTTP_200_OK) + + def test_getting_all_available_exams(self): + self.assertEqual(ExamType.objects.count(), len(self.response.data)) diff --git a/backend/core/tests/test_student_page.py b/backend/core/tests/test_student_page.py new file mode 100644 index 0000000000000000000000000000000000000000..2b15fb974d92ec1319df33e3abfa017cd0d261ea --- /dev/null +++ b/backend/core/tests/test_student_page.py @@ -0,0 +1,91 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import (APIRequestFactory, APITestCase, + force_authenticate) + +from core.models import Reviewer, SubmissionType +from core.tests import data_factories +from core.views import StudentApiView + + +class StudentPageTests(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + + def setUp(self): + self.submission, self.tutor, self.feedback = \ + data_factories.make_minimal_exam() + self.student = self.submission.student + self.reviewer = Reviewer.objects.create( + user=data_factories.make_user(username='reviewer')) + self.request = self.factory.get(reverse('student-page')) + self.view = StudentApiView.as_view() + + force_authenticate(self.request, user=self.student.user) + self.response = self.view(self.request) + + self.exam_obj = self.response.data['exam'] + self.submission_list = self.response.data['submissions'] + self.submission_list_first_entry = self.submission_list[0] + + def test_student_information_contains_name(self): + self.assertEqual( + self.response.data['name'], self.student.user.fullname) + + def test_student_contains_associated_user(self): + self.assertEqual( + self.response.data['user'], self.student.user.username) + + def test_all_student_submissions_are_loded(self): + self.assertEqual(len(self.submission_list), + SubmissionType.objects.count()) + + # Tests concerning exam data + def test_exam_data_contains_module_reference(self): + self.assertEqual( + self.exam_obj["module_reference"], + self.student.exam.module_reference) + + def test_exam_data_contains_total_score(self): + self.assertEqual( + self.exam_obj["total_score"], self.student.exam.total_score) + + def test_exam_data_contains_pass_score(self): + self.assertEqual( + self.exam_obj["pass_score"], self.student.exam.pass_score) + + def test_exam_data_contains_pass_only_field(self): + self.assertEqual( + self.exam_obj["pass_only"], self.student.exam.pass_only) + + # Tests concerning submission data + def test_a_student_submissions_contains_type(self): + self.assertEqual( + self.submission_list_first_entry['type'], + self.student.submissions.first().type.name) + + def test_submission_data_contains_text(self): + self.assertEqual( + self.submission_list_first_entry['text'], + self.student.submissions.first().text) + + def test_submission_data_contains_feedback(self): + self.assertEqual( + self.submission_list_first_entry['feedback'], + self.student.submissions.first().feedback.text) + + def test_submission_data_contains_score(self): + self.assertEqual( + self.submission_list_first_entry['score'], + self.student.submissions.first().feedback.score) + + def test_submission_data_contains_full_score(self): + self.assertEqual( + self.submission_list_first_entry['full_score'], + self.student.submissions.first().type.full_score) + + # We don't want a matriculation number here + def test_matriculation_number_is_not_senf(self): + self.assertNotIn('matrikel_no', self.submission_list_first_entry) diff --git a/backend/core/tests/test_tutor_api_endpoints.py b/backend/core/tests/test_tutor_api_endpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..ba21ab11dae10bcef8f918afbb97ed66fe46b9bd --- /dev/null +++ b/backend/core/tests/test_tutor_api_endpoints.py @@ -0,0 +1,107 @@ +""" Two api endpoints are currently planned + + * GET /tutor/:id to retrive information about some tutor + * POST /tutor/:username/:email create a new tutor and email password + * GET /tutorlist list of all tutors with their scores +""" +import logging as log +from unittest import skip + +from django.urls import reverse +from rest_framework import status +from rest_framework.test import (APIClient, APIRequestFactory, APITestCase, + force_authenticate) + +from core.models import Feedback, Reviewer, Tutor +from core.views import TutorCreateView, TutorDetailView, TutorListApiView +from util.factories import GradyUserFactory + +NUMBER_OF_TUTORS = 7 + + +@skip +class TutorListTests(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + cls.user_factory = GradyUserFactory() + + def setUp(self): + self.tutor_list = [self.user_factory.make_tutor() + for _ in range(NUMBER_OF_TUTORS)] + self.reviewer = self.user_factory.make_reviewer() + self.request = self.factory.get(reverse('tutor-list')) + self.view = TutorListApiView.as_view() + + force_authenticate(self.request, user=self.reviewer.user) + self.response = self.view(self.request) + + def test_can_access(self): + self.assertEqual(self.response.status_code, status.HTTP_200_OK) + + def test_get_a_list_of_all_tutors(self): + self.assertEqual(len(self.response.data), NUMBER_OF_TUTORS) + + def test_feedback_count_matches_database(self): + def verify_fields(tutor_obj): + t = Tutor.objects.get(user__username=tutor_obj['username']) + return t.get_feedback_count() == tutor_obj['feedback_count'] + + self.assertTrue(all(map(verify_fields, self.response.data))) + + def test_sum_of_feedback_count(self): + self.assertEqual(sum(obj['feedback_count'] + for obj in self.response.data), + Feedback.objects.count()) + + +class TutorCreateTests(APITestCase): + + USERNAME = 'some weird name!' + + @classmethod + def setUpTestData(cls): + cls.factory = APIRequestFactory() + cls.user_factory = GradyUserFactory() + + def setUp(self): + self.reviewer = self.user_factory.make_reviewer() + self.request = self.factory.post(reverse('tutor-create'), + {'username': self.USERNAME}) + self.view = TutorCreateView.as_view() + + force_authenticate(self.request, user=self.reviewer.user) + self.response = self.view(self.request) + + def test_can_access(self): + self.assertEqual(self.response.status_code, status.HTTP_201_CREATED) + + def test_can_create(self): + self.assertEqual(Tutor.objects.first().user.username, self.USERNAME) + +# @skip("Doesn't work for dubious reasons") + + +class TutorDetailViewTests(APITestCase): + + @classmethod + def setUpTestData(cls): + cls.factory = APIClient() + cls.user_factory = GradyUserFactory() + + def setUp(self): + self.tutor = self.user_factory.make_tutor(username='fetter.otto') + self.reviewer = self.user_factory.make_reviewer() + self.client = APIClient() + self.client.force_authenticate(user=self.reviewer.user) + + url = reverse('tutor-detail', kwargs={'username': 'fetter.otto'}) + self.response = self.client.get(url, format='json') + + def test_can_access(self): + self.assertEqual(self.response.status_code, status.HTTP_200_OK) + + def test_can_view_tutor(self): + self.assertEqual(self.response.data['username'], + self.tutor.user.username) diff --git a/backend/core/tests.py b/backend/core/tests/tests.py similarity index 98% rename from backend/core/tests.py rename to backend/core/tests/tests.py index f3290fbe0132092e58ed50fa7db0e04406b9b5e3..408ebb753ada91819d0989cb32a3fdc336db9a9a 100644 --- a/backend/core/tests.py +++ b/backend/core/tests/tests.py @@ -2,7 +2,7 @@ from django.test import TestCase from core.models import (Feedback, Reviewer, Student, Submission, SubmissionType, Tutor) -from util.importer import GradyUserFactory +from util.factories import GradyUserFactory class FeedbackTestCase(TestCase): diff --git a/backend/core/urls.py b/backend/core/urls.py index f9b3345c6525e001776fd15597397f103d2dcf9b..72e447a4c83e6d6b2813a552973dd7a3e36ef3de 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -1,15 +1,20 @@ from django.conf.urls import url from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from rest_framework_jwt.views import obtain_jwt_token +from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token from core import views urlpatterns = [ - url(r'^api/student/$', views.StudentApiView.as_view()), - url(r'^api/student/submission/(?P<pk>[0-9]+)$', views.SubmissionApiView.as_view()), - url(r'^api/student/submission/(?P<pk>[0-9]+)/feedback/$', views.FeedbackApiView.as_view()), + url(r'^api/student/$', views.StudentApiView.as_view(), name='student-page'), - url(r'^api-token-auth/', obtain_jwt_token) + url(r'^api/examlist/$', views.ExamListView.as_view(), name='exam-list'), + + url(r'^api/tutor/$', views.TutorCreateView.as_view(), name='tutor-create'), + url(r'^api/tutor/(?P<username>[\w\d\.\-@_]+)$', views.TutorDetailView.as_view(), name='tutor-detail'), + url(r'^api/tutorlist/$', views.TutorListApiView.as_view(), name='tutor-list'), + + url(r'^api-token-auth/', obtain_jwt_token), + url(r'^api-token-refresh', refresh_jwt_token), ] urlpatterns += staticfiles_urlpatterns() diff --git a/backend/core/views.py b/backend/core/views.py index 2932ec09bb16aebe08c22bfdfa567ca288d737fd..4470a4ab59e360f0ef30fa6c37d1f850e4cf6aac 100644 --- a/backend/core/views.py +++ b/backend/core/views.py @@ -1,35 +1,51 @@ +""" All API views that are used to retrieve data from the database. They +can be categorized by the permissions they require. All views require a +user to be authenticated and most are only accessible by one user group """ import logging -from rest_framework.generics import RetrieveAPIView +from rest_framework import generics -from core.permissions import IsStudent -from core.serializers import (FeedbackSerializer, StudentSerializer, - SubmissionSerializer) +from core.models import ExamType, Tutor, Student +from core.permissions import IsReviewer, IsStudent +from core.serializers import ExamSerializer, StudentSerializer, TutorSerializer log = logging.getLogger(__name__) - -class StudentApiView(RetrieveAPIView): +class StudentApiView(generics.RetrieveAPIView): + """ Gets all data that belongs to one student """ permission_classes = (IsStudent,) + serializer_class = StudentSerializer - def get_object(self): - log.debug("Serializing student of user '%s'", self.request.user.username) + def get_object(self) -> Student: + """ The object in question is the student associated with the requests + user. Since the permission IsStudent is satisfied the member exists """ return self.request.user.student - serializer_class = StudentSerializer -class SubmissionApiView(RetrieveAPIView): - permission_classes = (IsStudent,) +class TutorListApiView(generics.ListAPIView): + """ A list of all tutors with information about what they corrected """ + permission_classes = (IsReviewer,) + queryset = Tutor.objects.all() + serializer_class = TutorSerializer - def get_queryset(self): - return self.request.user.student.submissions - serializer_class = SubmissionSerializer +class TutorCreateView(generics.CreateAPIView): + """ Creates a Tutor instance currently without a password """ + permission_classes = (IsReviewer,) + serializer_class = TutorSerializer + + +class ExamListView(generics.ListAPIView): + """ Gets a list of all exams available. List might be empty """ + queryset = ExamType.objects.all() + serializer_class = ExamSerializer -class FeedbackApiView(RetrieveAPIView): - permission_classes = (IsStudent,) - def get_queryset(self): - return [submission.feedback for submission in self.request.user.submissions] - serializer_class = FeedbackSerializer +class TutorDetailView(generics.RetrieveAPIView): + """ Gets information of a single tutor by their username """ + permissions_classes = (IsReviewer,) + serializer_class = TutorSerializer + lookup_field = 'user__username' + lookup_url_kwarg = 'username' + queryset = Tutor.objects.all() diff --git a/backend/delbert.py b/backend/delbert.py old mode 100644 new mode 100755 index 557260f5c0a257d7d76e6fbfca6304aae899b10c..4e05dabd289dca8aa829315cb3e21ab923eca288 --- a/backend/delbert.py +++ b/backend/delbert.py @@ -2,20 +2,22 @@ import argparse import csv import json import os +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'grady.settings') import secrets import sys import django +django.setup() from django.contrib.auth.models import User import util.importer from core.models import Student, Submission -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'grady.settings') -django.setup() +unused_variable = [] + def parseme(): parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command") @@ -83,7 +85,7 @@ def parseme(): return parser.parse_args() -def handle_passwordlist(output=sys.stdout, instance=""): +def handle_passwordlist(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}) @@ -101,7 +103,7 @@ def handle_passwordlist(output=sys.stdout, instance=""): student.user.username, password, instance]) -def handle_enableusers(switch, exclude, include): +def handle_enableusers(switch, exclude, include, **kwargs): if include: for user in User.objects.filter(username__in=include): @@ -113,7 +115,7 @@ def handle_enableusers(switch, exclude, include): user.save() -def handle_replaceusernames(matno2username_dict): +def handle_replaceusernames(matno2username_dict, **kwargs): matno2username = json.JSONDecoder().decode(matno2username_dict.read()) for student in Student.objects.all(): if student.matrikel_no in matno2username: @@ -122,12 +124,12 @@ def handle_replaceusernames(matno2username_dict): student.user.save() -def handle_extractsubmissions(): +def handle_extractsubmissions(output, **kwargs): for submission in Submission.objects.filter(feedback__isnull=False).order_by('type'): print(submission.feedback.score, repr(submission.text), file=open(str(submission.type).replace(' ', '_'), 'a')) -def handle_importer(): +def handle_importer(**kwargs): util.importer.start() def main(): diff --git a/backend/grady/settings/default.py b/backend/grady/settings/default.py index 86e0c94956f52ceb7b459c8d8e015d73c4825a6c..a4a5fd8ef3c1be98356143de28bf1b12723a38e2 100644 --- a/backend/grady/settings/default.py +++ b/backend/grady/settings/default.py @@ -154,10 +154,12 @@ REST_FRAMEWORK = { 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', ), + 'TEST_REQUEST_DEFAULT_FORMAT': 'json', } JWT_AUTH = { 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=600), + 'JWT_ALLOW_REFRESH': True, } LOGGING = { diff --git a/backend/grady/urls.py b/backend/grady/urls.py index 9acdd9b0e47126124006bc7b2ee0445a4388a42f..95d879bbd63467ba2d213c45201a3227139e0b78 100644 --- a/backend/grady/urls.py +++ b/backend/grady/urls.py @@ -18,10 +18,8 @@ from django.contrib import admin urlpatterns = [ url(r'^admin/', admin.site.urls), - url(r'^', include('core.urls')) -] + url(r'^', include('core.urls')), -urlpatterns += [ url(r'^api-auth/', include('rest_framework.urls', - namespace='rest_framework')), + namespace='rest_framework')), ] diff --git a/backend/requirements.txt b/backend/requirements.txt index 9f84b378ab58611f325b862ad2fb964b3d9144cd..bd5a9cac3be9f45f6dd82e0c1ebb5032c36423c4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,11 +2,10 @@ Django~=1.11.3 django-extensions~=1.7.7 djangorestframework~=3.6.3 djangorestframework-jwt~=1.11.0 -django_compressor~=2.1.1 +django-cors-headers~=2.1.0 gunicorn~=19.7.0 psycopg2~=2.7.1 xlrd~=1.0.0 pytest-cov~=2.5.1 +pytest-django~=3.1.2 prospector~=0.12.7 -django-cors-headers~=2.1.0 -pre-commit~=1.4.1 \ No newline at end of file diff --git a/backend/util/factories.py b/backend/util/factories.py new file mode 100644 index 0000000000000000000000000000000000000000..32c2d67fc03200556e3a44015bdf9525f7ff93b0 --- /dev/null +++ b/backend/util/factories.py @@ -0,0 +1,112 @@ +import configparser +import secrets + +from core.models import UserAccount as User, Student, Tutor, Reviewer + +STUDENTS = 'students' +TUTORS = 'tutors' +REVIEWERS = 'reviewers' + +PASSWORDS = '.importer_passwords' + + +def get_xkcd_password(k=2): + with open('/usr/share/dict/words') as words: + choose_from = list({word.strip().lower() + for word in words if 5 < len(word) < 8}) + + return ''.join(secrets.choice(choose_from) for _ in range(k)) + + +def store_password(username, groupname, password): + storage = configparser.ConfigParser() + storage.read(PASSWORDS) + + if not groupname in storage: + storage[groupname] = {} + + storage[groupname][username] = password + + with open(PASSWORDS, 'w') as passwd_file: + storage.write(passwd_file) + + +class GradyUserFactory: + + def __init__(self, + password_generator_func=get_xkcd_password, + password_storge=store_password, + *args, **kwargs): + self.password_generator_func = password_generator_func + self.password_storge = password_storge + + @staticmethod + def _get_random_name(prefix='', suffix='', k=1): + return ''.join((prefix, get_xkcd_password(k), suffix)) + + def _make_base_user(self, username, groupname, store_pw=False, **kwargs): + """ This is a specific wrapper for the django update_or_create method of + objects. + * A new user is created and password and group are set accordingly + * If the user was there before password is NOT change but group is. A + user must only have one group. + + Returns: + (User object, str): The user object that was added to the group and + the password of that user if it was created. + """ + username = username.strip() + + user, created = User.objects.update_or_create( + username=username, + defaults=kwargs) + + if created: + password = self.password_generator_func() + user.set_password(password) + user.save() + + if created and store_pw: + self.password_storge(username, groupname, password) + + return user + + def _get_user_model_for_group(self, groupname): + """ Returns the model class for a usergroup """ + return { + STUDENTS: Student, + TUTORS: Tutor, + REVIEWERS: Reviewer, + }[groupname] + + def _make_user_generic(self, username, groupname, **kwargs): + """ Provides a model with a associated user but without any defaults + """ + + if not username: + username = self._get_random_name(prefix=groupname.lower() + '_') + + model = self._get_user_model_for_group(groupname) + user = self._make_base_user(username, groupname, **kwargs) + + generic_user, _ = model.objects.get_or_create(user=user) + + return generic_user + + def make_student(self, username=None, matrikel_no=None, exam=None, **kwargs): + """ Creates a student. Defaults can be passed via kwargs like in + relation managers objects.update method. """ + user = self._make_user_generic(username, STUDENTS, **kwargs) + if matrikel_no: + user.objects.update(matrikel_no=matrikel_no) + if exam: + user.objects.update(exam=exam) + return user + + def make_tutor(self, username=None, **kwargs): + """ Creates or updates a tutor if needed with defaults """ + return self._make_user_generic(username, TUTORS, **kwargs) + + def make_reviewer(self, username=None, **kwargs): + """ Creates or updates a reviewer if needed with defaults """ + return self._make_user_generic(username, REVIEWERS, **kwargs) diff --git a/backend/util/importer.py b/backend/util/importer.py index 299f4ae547c1b8e1a8a9dcd2568bf3ca29d0b407..f9fed844f9d36970a105c7191533ad38f9b94998 100644 --- a/backend/util/importer.py +++ b/backend/util/importer.py @@ -1,9 +1,7 @@ -import configparser import csv import json import os import readline -import secrets from typing import Callable import util.convert @@ -14,9 +12,7 @@ from core.models import (ExamType, Feedback, Reviewer, Student, Submission, from util.messages import info, warn from util.processing import EmptyTest -STUDENTS = 'students' -TUTORS = 'tutors' -REVIEWERS = 'reviewers' +from util.factories import STUDENTS, REVIEWERS, TUTORS, GradyUserFactory HISTFILE = '.importer_history' RECORDS = '.importer' @@ -62,14 +58,6 @@ class chdir_context(object): os.chdir(self.old_dir) -def get_xkcd_password(k=2): - with open('/usr/share/dict/words') as words: - choose_from = list({word.strip().lower() - for word in words if 5 < len(word) < 8}) - - return ''.join(secrets.choice(choose_from) for _ in range(k)) - - def i(prompt: str, default: str='', is_path: bool=False, is_file: bool=False): if default is YES or default is NO: answer = valid[input(f'[Q] {prompt} ({default}): ').lower() or ('y' if YES == default else 'n')] @@ -84,96 +72,6 @@ def i(prompt: str, default: str='', is_path: bool=False, is_file: bool=False): return answer - -def store_password(username, groupname, password): - storage = configparser.ConfigParser() - storage.read(PASSWORDS) - - if not groupname in storage: - storage[groupname] = {} - - storage[groupname][username] = password - - with open(PASSWORDS, 'w') as passwd_file: - storage.write(passwd_file) - - -class GradyUserFactory: - - def __init__(self, password_generator_func=get_xkcd_password, *args, **kwargs): - self.password_generator_func = password_generator_func - - @staticmethod - def _get_random_name(prefix='', suffix='', k=1): - return ''.join((prefix, get_xkcd_password(k), suffix)) - - def _make_base_user(self, username, groupname, store_pw=False, **kwargs): - """ This is a specific wrapper for the django update_or_create method of - objects. - * A new user is created and password and group are set accordingly - * If the user was there before password is NOT change but group is. A - user must only have one group. - - Returns: - (User object, str): The user object that was added to the group and - the password of that user if it was created. - """ - username = username.strip() - - user, created = User.objects.update_or_create( - username=username, - defaults=kwargs) - - if created: - password = self.password_generator_func() - user.set_password(password) - user.save() - - if created and store_pw: - store_password(username, groupname, password) - - return user - - def _get_user_model_for_group(self, groupname): - """ Returns the model class for a usergroup """ - return { - STUDENTS: Student, - TUTORS: Tutor, - REVIEWERS: Reviewer, - }[groupname] - - def _make_user_generic(self, username, groupname, **kwargs): - """ Provides a model with a associated user but without any defaults - """ - - if not username: - username = self._get_random_name(prefix=groupname.lower() + '_') - - model = self._get_user_model_for_group(groupname) - user = self._make_base_user(username, groupname, **kwargs) - - generic_user, _ = model.objects.get_or_create(user=user) - - return generic_user - - def make_student(self, username=None, matrikel_no=None, exam=None, **kwargs): - """ Creates a student. Defaults can be passed via kwargs like in - relation managers objects.update method. """ - user = self._make_user_generic(username, STUDENTS, **kwargs) - if matrikel_no: - user.objects.update(matrikel_no=matrikel_no) - if exam: - user.objects.update(exam=exam) - return user - - def make_tutor(self, username=None, **kwargs): - """ Creates or updates a tutor if needed with defaults """ - return self._make_user_generic(username, TUTORS, **kwargs) - - def make_reviewer(self, username=None, **kwargs): - """ Creates or updates a reviewer if needed with defaults """ - return self._make_user_generic(username, REVIEWERS, **kwargs) - # TODO more factories def add_user(username, group, **kwargs):