diff --git a/.github/workflows/backend-linting.yaml b/.github/workflows/backend-linting.yaml new file mode 100644 index 00000000..d145985d --- /dev/null +++ b/.github/workflows/backend-linting.yaml @@ -0,0 +1,25 @@ +name: backend-linting + +on: + push: + branches: [main, development] + pull_request: + branches: [main, development] + workflow_dispatch: + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + pip install -r ./backend/requirements.txt + - name: Execute linting checks + run: flake8 --config ./backend/.flake8 ./backend diff --git a/.github/workflows/backend-tests.yaml b/.github/workflows/backend-tests.yaml new file mode 100644 index 00000000..c8ec08da --- /dev/null +++ b/.github/workflows/backend-tests.yaml @@ -0,0 +1,25 @@ +name: backend-tests + +on: + push: + branches: [main, development] + pull_request: + branches: [main, development] + workflow_dispatch: + +jobs: + test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + pip install -r ./backend/requirements.txt + - name: Execute tests + run: cd backend; python manage.py test diff --git a/backend/.coverage b/backend/.coverage new file mode 100644 index 00000000..73625672 Binary files /dev/null and b/backend/.coverage differ diff --git a/backend/.flake8 b/backend/.flake8 new file mode 100644 index 00000000..ee3e436d --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,15 @@ +[flake8] + +# Ignore unused imports +ignore = F401 + +max-line-length = 119 + +max-complexity = 10 + +exclude = .git, + __pycache__, + .venv, + venv, + migrations + diff --git a/backend/api/apps.py b/backend/api/apps.py index 60def201..55a607c6 100644 --- a/backend/api/apps.py +++ b/backend/api/apps.py @@ -1,8 +1,12 @@ from django.apps import AppConfig + class ApiConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'api' + default_auto_field = "django.db.models.BigAutoField" + name = "api" def ready(self): - from . import signals \ No newline at end of file + from authentication.signals import user_created + from api.signals import user_creation + + user_created.connect(user_creation) diff --git a/backend/api/fixtures/assistants.yaml b/backend/api/fixtures/assistants.yaml new file mode 100644 index 00000000..aa7d31c5 --- /dev/null +++ b/backend/api/fixtures/assistants.yaml @@ -0,0 +1,10 @@ +- model: api.assistant + pk: '235' + fields: + courses: + - 1 +- model: api.assistant + pk: '236' + fields: + courses: + - 2 diff --git a/backend/api/fixtures/checks.yaml b/backend/api/fixtures/checks.yaml new file mode 100644 index 00000000..5a9a7795 --- /dev/null +++ b/backend/api/fixtures/checks.yaml @@ -0,0 +1,26 @@ +- model: api.checks + pk: 1 + fields: + dockerfile: 'path/to/Dockerfile' + allowed_file_extensions: + - 1 + - 2 + forbidden_file_extensions: + - 3 + - 4 +- model: api.fileextension + pk: 1 + fields: + extension: 'py' +- model: api.fileextension + pk: 2 + fields: + extension: 'js' +- model: api.fileextension + pk: 3 + fields: + extension: 'html' +- model: api.fileextension + pk: 4 + fields: + extension: 'php' diff --git a/backend/api/fixtures/courses.yaml b/backend/api/fixtures/courses.yaml new file mode 100644 index 00000000..4a8677c9 --- /dev/null +++ b/backend/api/fixtures/courses.yaml @@ -0,0 +1,21 @@ +- model: api.course + pk: 1 + fields: + name: Math + academic_startyear: 2023 + description: Math course + parent_course: null +- model: api.course + pk: 2 + fields: + name: Sel2 + academic_startyear: 2023 + description: Software course + parent_course: 3 +- model: api.course + pk: 3 + fields: + name: Sel1 + academic_startyear: 2022 + description: Software course + parent_course: null diff --git a/backend/api/fixtures/groups.yaml b/backend/api/fixtures/groups.yaml new file mode 100644 index 00000000..35b2d571 --- /dev/null +++ b/backend/api/fixtures/groups.yaml @@ -0,0 +1,8 @@ +- model: api.group + pk: 1 + fields: + project: 123456 + score: 7 + students: + - '1' + - '2' diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml new file mode 100644 index 00000000..5d9692ea --- /dev/null +++ b/backend/api/fixtures/projects.yaml @@ -0,0 +1,11 @@ +- model: api.project + pk: 123456 + fields: + name: sel2 + description: make a project + visible: true + archived: false + start_date: 2024-02-26 00:00:00+00:00 + deadline: 2024-02-27 00:00:00+00:00 + checks: 1 + course: 2 diff --git a/backend/api/fixtures/students.yaml b/backend/api/fixtures/students.yaml new file mode 100644 index 00000000..ac61cbd7 --- /dev/null +++ b/backend/api/fixtures/students.yaml @@ -0,0 +1,11 @@ +- model: api.student + pk: '1' + fields: + student_id: null + courses: + - 1 +- model: api.student + pk: '2' + fields: + student_id: null + courses: [] diff --git a/backend/api/fixtures/submissions.yaml b/backend/api/fixtures/submissions.yaml new file mode 100644 index 00000000..0b8f876d --- /dev/null +++ b/backend/api/fixtures/submissions.yaml @@ -0,0 +1,24 @@ +- model: api.submission + pk: 1 + fields: + group: 1 + submission_number: 1 + submission_time: '2021-01-01T00:00:00Z' +- model: api.submission + pk: 2 + fields: + group: 1 + submission_number: 2 + submission_time: '2021-01-02T00:00:00Z' + + +- model: api.submissionfile + pk: 1 + fields: + submission: 1 + file: 'submissions/1/1/1.txt' +- model: api.submissionfile + pk: 2 + fields: + submission: 2 + file: 'submissions/1/2/1.txt' diff --git a/backend/api/fixtures/teachers.yaml b/backend/api/fixtures/teachers.yaml new file mode 100644 index 00000000..44b883f2 --- /dev/null +++ b/backend/api/fixtures/teachers.yaml @@ -0,0 +1,11 @@ +- model: api.teacher + pk: '123' + fields: + courses: + - 1 +- model: api.teacher + pk: '124' + fields: + courses: + - 1 + - 2 diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py index bb01d604..1254180c 100644 --- a/backend/api/migrations/0001_initial.py +++ b/backend/api/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-02-28 21:55 +# Generated by Django 5.0.2 on 2024-03-05 14:25 import datetime import django.db.models.deletion @@ -15,16 +15,6 @@ class Migration(migrations.Migration): ] operations = [ - migrations.CreateModel( - name='Admin', - fields=[ - ('user_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - }, - bases=('authentication.user',), - ), migrations.CreateModel( name='FileExtension', fields=[ diff --git a/backend/api/migrations/0002_populate.py b/backend/api/migrations/0002_populate.py deleted file mode 100644 index a289fd86..00000000 --- a/backend/api/migrations/0002_populate.py +++ /dev/null @@ -1,156 +0,0 @@ -from django.db import migrations, transaction -from api.models.teacher import Teacher -from api.models.student import Student -from api.models.course import Course -from api.models.assistant import Assistant -from api.models.project import Project -from api.models.group import Group -from authentication.models import Faculty -# from datetime import date - - -def populate_db(apps, schema_editor): - with transaction.atomic(): - # Faculteit Letteren en Wijsbegeerte - Faculty.objects.create(name="Letteren_Wijsbegeerte") - # Faculteit Recht en Criminologie - Faculty.objects.create(name="Recht_Criminologie") - # Faculteit Wetenschappen - f_wet = Faculty.objects.create(name="Wetenschappen") - # Faculteit Geneeskunde en Gezondheidswetenschappen - f_genGez = Faculty.objects.create( - name="Geneeskunde_Gezondheidswetenschappen" - ) - # Faculteit Ingenieurswetenschappen en Architectuur - Faculty.objects.create(name="Ingenieurswetenschappen_Architectuur") - # Faculteit Economie en Bedrijfskunde - Faculty.objects.create(name="Economie_Bedrijfskunde") - # Faculteit Diergeneeskunde - Faculty.objects.create(name="Diergeneeskunde") - # Faculteit Psychologie en Pedagogische Wetenschappen - f_psyPeda = Faculty.objects.create( - name="Psychologie_PedagogischeWetenschappen" - ) - # Faculteit Bio-ingenieurswetenschappen - Faculty.objects.create(name="Bio-ingenieurswetenschappen") - # Faculteit Farmaceutische Wetenschappen - Faculty.objects.create(name="Farmaceutische_Wetenschappen") - # Faculteit Politieke en Sociale Wetenschappen - Faculty.objects.create(name="Politieke_Sociale_Wetenschappen") - - teacher1 = Teacher.objects.create( - id=123, - first_name="Tom", - last_name="Boonen", - email="Tom.Boonen@gmail.be", - username="tboonen", - create_time="2023-01-01T00:00:00Z", - ) - - teacher1.faculty.add(f_psyPeda) - - assistant1 = Assistant.objects.create( - id=235, - first_name="Bart", - last_name="Simpson", - username="bsimpson", - email="Bart.Simpson@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant1.faculty.add(f_wet) - - assistant2 = Assistant.objects.create( - id=236, - first_name="Kim", - last_name="Clijsters", - username="kclijster", - email="Kim.Clijsters@gmail.be", - create_time="2023-01-01T00:00:00Z", - ) - - assistant2.faculty.add(f_psyPeda) - - teacher2 = Teacher.objects.create( - id=124, - first_name="Peter", - last_name="Sagan", - username="psagan", - email="Peter.Sagan@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - teacher2.faculty.add(f_psyPeda) - - student1 = Student.objects.create( - id=1, - first_name="John", - last_name="Doe", - username="jdoe", - email="John.Doe@hotmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student1.faculty.add(f_wet) - - student2 = Student.objects.create( - id=2, - first_name="Bartje", - last_name="Verhaege", - username="bverhae", - email="Bartje.Verhaege@gmail.com", - create_time="2023-01-01T00:00:00Z", - ) - - student2.faculty.add(f_genGez) - - course = Course.objects.create( - name="Math", - academic_startyear=2023, - description="Math course", - ) - - course2 = Course.objects.create( - name="Sel2", - academic_startyear=2023, - description="Software course", - ) - - project1 = Project.objects.create( - id=123456, - name="sel2", - description="make a project", - visible=True, - archived=False, - # Set the start date as 26th February 2024 - start_date="2024-02-26T00:00:00+00:00", - # Set the deadline as 27th February 2024 - deadline="2024-02-27T00:00:00+00:00", - course=course2 - ) - - group1 = Group.objects.create( - project=project1, - ) - - group1.students.add(student1) - group1.students.add(student2) - - teacher1.courses.add(course) - teacher2.courses.add(course) - student1.courses.add(course) - teacher2.courses.add(course2) - - course.assistants.add(assistant1) - course2.assistants.add(assistant2) - - -class Migration(migrations.Migration): - dependencies = [ - ("api", "0001_initial"), - ("authentication", "0001_initial"), - ] - - operations = [ - migrations.RunPython(populate_db), - ] diff --git a/backend/api/models/admin.py b/backend/api/models/admin.py deleted file mode 100644 index f577ae9b..00000000 --- a/backend/api/models/admin.py +++ /dev/null @@ -1,10 +0,0 @@ -from authentication.models import User - - -class Admin(User): - """This model represents the admin. - It extends the User model from the authentication app with - admin-specific attributes. - """ - - # At the moment, there are no additional attributes for the Admin model. diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py index 3489a776..4c6d9f19 100644 --- a/backend/api/models/assistant.py +++ b/backend/api/models/assistant.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from authentication.models import User +from api.models.course import Course class Assistant(User): @@ -10,8 +11,8 @@ class Assistant(User): # All the courses the assistant is assisting in courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the assistants from the course - related_name='assistants', - blank=True + related_name="assistants", + blank=True, ) diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py index db11e6e9..ef0595ba 100644 --- a/backend/api/models/checks.py +++ b/backend/api/models/checks.py @@ -17,21 +17,14 @@ class Checks(models.Model): # ID check should be generated automatically - dockerfile = models.FileField( - blank=True, - null=True - ) + dockerfile = models.FileField(blank=True, null=True) # Link to the file extensions that are allowed allowed_file_extensions = models.ManyToManyField( - FileExtension, - related_name='checks_allowed', - blank=True + FileExtension, related_name="checks_allowed", blank=True ) # Link to the file extensions that are forbidden forbidden_file_extensions = models.ManyToManyField( - FileExtension, - related_name='checks_forbidden', - blank=True + FileExtension, related_name="checks_forbidden", blank=True ) diff --git a/backend/api/models/course.py b/backend/api/models/course.py index f0804caf..f4ec41f2 100644 --- a/backend/api/models/course.py +++ b/backend/api/models/course.py @@ -7,33 +7,23 @@ class Course(models.Model): # ID of the course should automatically be generated - name = models.CharField( - max_length=100, - blank=False, - null=False - ) + name = models.CharField(max_length=100, blank=False, null=False) # Begin year of the academic year - academic_startyear = models.IntegerField( - blank=False, - null=False - ) + academic_startyear = models.IntegerField(blank=False, null=False) - description = models.TextField( - blank=True, - null=True - ) + description = models.TextField(blank=True, null=True) # OneToOneField is used to represent a one-to-one relationship # with the course of the previous academic year parent_course = models.OneToOneField( - 'self', + "self", # If the old course is deleted, the child course should remain on_delete=models.SET_NULL, # Allows us to access the child course from the parent course - related_name='child_course', + related_name="child_course", blank=True, - null=True + null=True, ) def __str__(self) -> str: diff --git a/backend/api/models/group.py b/backend/api/models/group.py index 3a8158c2..b963aa89 100644 --- a/backend/api/models/group.py +++ b/backend/api/models/group.py @@ -1,4 +1,6 @@ from django.db import models +from api.models.project import Project +from api.models.student import Student class Group(models.Model): @@ -7,25 +9,22 @@ class Group(models.Model): # ID should be generated automatically project = models.ForeignKey( - 'Project', + Project, # If the project is deleted, the group should be deleted as well on_delete=models.CASCADE, # This is how we can access groups from a project - related_name='groups', + related_name="groups", blank=False, - null=False + null=False, ) # Students that are part of the group students = models.ManyToManyField( - 'Student', + Student, # This is how we can access groups from a student - related_name='groups', + related_name="groups", blank=False, ) # Score of the group - score = models.FloatField( - blank=True, - null=True - ) + score = models.FloatField(blank=True, null=True) diff --git a/backend/api/models/project.py b/backend/api/models/project.py index a83ffe89..ec16ed77 100644 --- a/backend/api/models/project.py +++ b/backend/api/models/project.py @@ -1,5 +1,8 @@ -import datetime +from datetime import timedelta, datetime from django.db import models +from django.utils import timezone +from api.models.checks import Checks +from api.models.course import Course class Project(models.Model): @@ -7,53 +10,56 @@ class Project(models.Model): # ID should be generated automatically - name = models.CharField( - max_length=100, - blank=False, - null=False - ) + name = models.CharField(max_length=100, blank=False, null=False) - description = models.TextField( - blank=True, - null=True - ) + description = models.TextField(blank=True, null=True) # Project already visible to students - visible = models.BooleanField( - default=True - ) + visible = models.BooleanField(default=True) # Project archived - archived = models.BooleanField( - default=False - ) + archived = models.BooleanField(default=False) start_date = models.DateTimeField( # The default value is the current date and time - default=datetime.datetime.now, + default=datetime.now, blank=True, ) - deadline = models.DateTimeField( - blank=False, - null=False - ) + deadline = models.DateTimeField(blank=False, null=False) # Check entity that is linked to the project checks = models.ForeignKey( - 'Checks', + Checks, # If the checks are deleted, the project should remain on_delete=models.SET_NULL, blank=True, - null=True + null=True, ) # Course that the project belongs to course = models.ForeignKey( - 'Course', + Course, # If the course is deleted, the project should be deleted as well on_delete=models.CASCADE, - related_name='projects', + related_name="projects", blank=False, - null=False + null=False, ) + + def deadline_approaching_in(self, days=7): + now = timezone.now() + approaching_date = now + timezone.timedelta(days=days) + return now <= self.deadline <= approaching_date + + def deadline_passed(self): + now = timezone.now() + return now > self.deadline + + def toggle_visible(self): + self.visible = not (self.visible) + self.save() + + def toggle_archived(self): + self.archived = not (self.archived) + self.save() diff --git a/backend/api/models/student.py b/backend/api/models/student.py index 8eec4be0..c619d924 100644 --- a/backend/api/models/student.py +++ b/backend/api/models/student.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from authentication.models import User +from api.models.course import Course class Student(User): @@ -7,17 +8,14 @@ class Student(User): It extends the User model from the authentication app with student-specific attributes. """ + # The student's Ghent University ID - student_id = models.CharField( - max_length=8, - null=True, - unique=True - ) + student_id = models.CharField(max_length=8, null=True, unique=True) # All the courses the student is enrolled in courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the students from the course - related_name='students', - blank=True + related_name="students", + blank=True, ) diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py index 7adac13a..8f41018c 100644 --- a/backend/api/models/submission.py +++ b/backend/api/models/submission.py @@ -1,25 +1,5 @@ from django.db import models - - -class SubmissionFile(models.Model): - """Model for a file that is part of a submission.""" - - # File ID should be generated automatically - - submission = models.ForeignKey( - 'Submission', - # If the submission is deleted, the file should be deleted as well - on_delete=models.CASCADE, - related_name='files', - blank=False, - null=False - ) - - # TODO - Set the right place to save the file - file = models.FileField( - blank=False, - null=False - ) +from api.models.group import Group class Submission(models.Model): @@ -28,25 +8,38 @@ class Submission(models.Model): # Submission ID should be generated automatically group = models.ForeignKey( - 'Group', + Group, # If the group is deleted, the submission should be deleted as well on_delete=models.CASCADE, - related_name='submissions', + related_name="submissions", blank=False, - null=False + null=False, ) # Multiple submissions can be made by a group - submission_number = models.PositiveIntegerField( - blank=False, - null=False - ) + submission_number = models.PositiveIntegerField(blank=False, null=False) # Automatically set the submission time to the current time - submission_time = models.DateTimeField( - auto_now_add=True - ) + submission_time = models.DateTimeField(auto_now_add=True) class Meta: # A group can only have one submission with a specific number - unique_together = ('group', 'submission_number') + unique_together = ("group", "submission_number") + + +class SubmissionFile(models.Model): + """Model for a file that is part of a submission.""" + + # File ID should be generated automatically + + submission = models.ForeignKey( + Submission, + # If the submission is deleted, the file should be deleted as well + on_delete=models.CASCADE, + related_name="files", + blank=False, + null=False, + ) + + # TODO - Set the right place to save the file + file = models.FileField(blank=False, null=False) diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py index 37bb264b..89f3d471 100644 --- a/backend/api/models/teacher.py +++ b/backend/api/models/teacher.py @@ -1,5 +1,6 @@ -from authentication.models import User from django.db import models +from api.models.course import Course +from authentication.models import User class Teacher(User): @@ -10,8 +11,8 @@ class Teacher(User): # All the courses the teacher is teaching courses = models.ManyToManyField( - 'Course', + Course, # Allows us to access the teachers from the course - related_name='teachers', - blank=True + related_name="teachers", + blank=True, ) diff --git a/backend/api/serializers/admin_serializer.py b/backend/api/serializers/admin_serializer.py deleted file mode 100644 index 3cd2404c..00000000 --- a/backend/api/serializers/admin_serializer.py +++ /dev/null @@ -1,11 +0,0 @@ -from rest_framework import serializers -from ..models.admin import Admin - - -class AdminSerializer(serializers.ModelSerializer): - class Meta: - model = Admin - fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time' - ] diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py index 378f5322..16e26206 100644 --- a/backend/api/serializers/assistant_serializer.py +++ b/backend/api/serializers/assistant_serializer.py @@ -3,15 +3,24 @@ class AssistantSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='assistant-courses', + view_name="assistant-courses", read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + class Meta: model = Assistant fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py index 51bbfd84..01254ec0 100644 --- a/backend/api/serializers/checks_serializer.py +++ b/backend/api/serializers/checks_serializer.py @@ -2,16 +2,22 @@ from ..models.checks import Checks, FileExtension -class ChecksSerializer(serializers.ModelSerializer): +class FileExtensionSerializer(serializers.ModelSerializer): class Meta: - model = Checks - fields = ['id', 'dockerfile'] + model = FileExtension + fields = ["extension"] -class FileExtensionSerializer(serializers.ModelSerializer): +class ChecksSerializer(serializers.ModelSerializer): + allowed_file_extensions = FileExtensionSerializer(many=True) + + forbidden_file_extensions = FileExtensionSerializer(many=True) + class Meta: - model = FileExtension + model = Checks fields = [ - 'id', 'extension', - 'allowed_file_extensions', 'forbidden_file_extensions' - ] + "id", + "dockerfile", + "allowed_file_extensions", + "forbidden_file_extensions", + ] diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py index 3ff5e1c7..4d6edede 100644 --- a/backend/api/serializers/course_serializer.py +++ b/backend/api/serializers/course_serializer.py @@ -3,36 +3,40 @@ class CourseSerializer(serializers.ModelSerializer): - teachers = serializers.HyperlinkedIdentityField( - view_name='course-teachers', + view_name="course-teachers", read_only=True, ) assistants = serializers.HyperlinkedIdentityField( - view_name='course-assistants', + view_name="course-assistants", read_only=True, ) students = serializers.HyperlinkedIdentityField( - view_name='course-students', + view_name="course-students", read_only=True, ) projects = serializers.HyperlinkedIdentityField( - view_name='course-projects', + view_name="course-projects", read_only=True, ) parent_course = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='course-detail' + many=False, read_only=True, view_name="course-detail" ) class Meta: model = Course fields = [ - 'id', 'name', 'academic_startyear', 'description', - 'parent_course', 'teachers', 'assistants', 'students', 'projects' - ] + "id", + "name", + "academic_startyear", + "description", + "parent_course", + "teachers", + "assistants", + "students", + "projects", + ] diff --git a/backend/api/serializers/faculty_serializer.py b/backend/api/serializers/faculty_serializer.py index 9c22e1ce..eab4a48d 100644 --- a/backend/api/serializers/faculty_serializer.py +++ b/backend/api/serializers/faculty_serializer.py @@ -5,6 +5,4 @@ class facultySerializer(serializers.ModelSerializer): class Meta: model = Faculty - fields = [ - 'name' - ] + fields = ["name"] diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index 9a9e1604..d3b0ecfa 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -4,16 +4,14 @@ class GroupSerializer(serializers.ModelSerializer): project = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='project-detail' + many=False, read_only=True, view_name="project-detail" ) students = serializers.HyperlinkedIdentityField( - view_name='group-students', - read_only=True, + view_name="group-students", + read_only=True, ) class Meta: model = Group - fields = ['id', 'project', 'students', 'score'] + fields = ["id", "project", "students", "score"] diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py index be2195c5..c0e36a9a 100644 --- a/backend/api/serializers/project_serializer.py +++ b/backend/api/serializers/project_serializer.py @@ -4,14 +4,23 @@ class ProjectSerializer(serializers.ModelSerializer): course = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='course-detail' + many=False, read_only=True, view_name="course-detail" + ) + + checks = serializers.HyperlinkedRelatedField( + many=False, read_only=True, view_name="check-detail" ) class Meta: model = Project fields = [ - 'id', 'name', 'description', 'visible', 'archived', - 'start_date', 'deadline', 'checks', 'course' - ] + "id", + "name", + "description", + "visible", + "archived", + "start_date", + "deadline", + "checks", + "course", + ] diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py index 4e5b794b..9cd1f245 100644 --- a/backend/api/serializers/student_serializer.py +++ b/backend/api/serializers/student_serializer.py @@ -1,22 +1,22 @@ from rest_framework import serializers -from ..models.student import Student +from api.models.student import Student class StudentSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='student-courses', + view_name="student-courses", read_only=True, ) groups = serializers.HyperlinkedIdentityField( - view_name='student-groups', + view_name="student-groups", read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + class Meta: model = Student - fields = [ - 'id', 'first_name', 'last_name', 'email', 'faculty', - 'last_enrolled', 'create_time', 'courses', 'groups' - ] + fields = '__all__' diff --git a/backend/api/serializers/submision_serializer.py b/backend/api/serializers/submision_serializer.py index 3429022d..da7458b8 100644 --- a/backend/api/serializers/submision_serializer.py +++ b/backend/api/serializers/submision_serializer.py @@ -5,17 +5,16 @@ class SubmissionFileSerializer(serializers.ModelSerializer): class Meta: model = SubmissionFile - fields = ['id', 'submission', 'file'] + fields = ["file"] class SubmissionSerializer(serializers.ModelSerializer): - group = serializers.HyperlinkedRelatedField( - many=False, - read_only=True, - view_name='group-detail' + many=False, read_only=True, view_name="group-detail" ) + files = SubmissionFileSerializer(many=True, read_only=True) + class Meta: model = Submission - fields = ['id', 'group', 'submission_number', 'submission_time'] + fields = ["id", "group", "submission_number", "submission_time", "files"] diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py index e24b0ed0..fcfa35e1 100644 --- a/backend/api/serializers/teacher_serializer.py +++ b/backend/api/serializers/teacher_serializer.py @@ -3,15 +3,24 @@ class TeacherSerializer(serializers.ModelSerializer): - courses = serializers.HyperlinkedIdentityField( - view_name='teacher-courses', + view_name="teacher-courses", read_only=True, ) + faculties = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + class Meta: model = Teacher fields = [ - 'id', 'first_name', 'last_name', 'email', - 'faculty', 'last_enrolled', 'create_time', 'courses' - ] + "id", + "first_name", + "last_name", + "email", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] diff --git a/backend/api/signals.py b/backend/api/signals.py index 7cfd15b2..85f94211 100644 --- a/backend/api/signals.py +++ b/backend/api/signals.py @@ -1,12 +1,10 @@ -from django.dispatch import receiver -from authentication.signals import user_created from authentication.models import User from api.models.student import Student -@receiver(user_created) + def user_creation(user: User, attributes: dict, **kwargs): """Upon user creation, auto-populate additional properties""" student_id = attributes.get("ugentStudentID") if student_id: - Student(user_ptr=user,student_id=student_id).save_base(raw=True) \ No newline at end of file + Student(user_ptr=user, student_id=student_id).save_base(raw=True) diff --git a/backend/api/tests/test_admin.py b/backend/api/tests/test_admin.py new file mode 100644 index 00000000..5de600b2 --- /dev/null +++ b/backend/api/tests/test_admin.py @@ -0,0 +1,208 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from authentication.models import Faculty, User + + +def create_faculty(name): + """ + Create a Faculty with the given arguments.""" + return Faculty.objects.create(name=name) + + +def create_admin(id, first_name, last_name, email, faculty=None): + """ + Create a Admin with the given arguments. + """ + username = f"{first_name}_{last_name}" + if faculty is None: + return User.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + is_staff=True, + create_time=timezone.now(), + ) + else: + admin = User.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + is_staff=True, + create_time=timezone.now(), + ) + + for fac in faculty: + admin.faculties.add(fac) + + return admin + + +class AdminModelTests(APITestCase): + def setUp(self): + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_admins(self): + """ + able to retrieve no admin before publishing it. + """ + + response_root = self.client.get(reverse("admin-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_admin_exists(self): + """ + Able to retrieve a single admin after creating it. + """ + admin = create_admin( + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + # Make a GET request to retrieve the admin + response = self.client.get(reverse("admin-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one admin + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved admin match the created admin + retrieved_admin = content_json[0] + self.assertEqual(int(retrieved_admin["id"]), admin.id) + self.assertEqual(retrieved_admin["first_name"], admin.first_name) + self.assertEqual(retrieved_admin["last_name"], admin.last_name) + self.assertEqual(retrieved_admin["email"], admin.email) + + def test_multiple_admins(self): + """ + Able to retrieve multiple admins after creating them. + """ + # Create multiple admins + admin1 = create_admin( + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) + admin2 = create_admin( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the admins + response = self.client.get(reverse("admin-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple admins + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved admins match the created admins + retrieved_admin1, retrieved_admin2 = content_json + self.assertEqual(int(retrieved_admin1["id"]), admin1.id) + self.assertEqual(retrieved_admin1["first_name"], admin1.first_name) + self.assertEqual(retrieved_admin1["last_name"], admin1.last_name) + self.assertEqual(retrieved_admin1["email"], admin1.email) + + self.assertEqual(int(retrieved_admin2["id"]), admin2.id) + self.assertEqual(retrieved_admin2["first_name"], admin2.first_name) + self.assertEqual(retrieved_admin2["last_name"], admin2.last_name) + self.assertEqual(retrieved_admin2["email"], admin2.email) + + def test_admin_detail_view(self): + """ + Able to retrieve details of a single admin. + """ + # Create an admin for testing with the name "Bob Peeters" + admin = create_admin( + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the admin details + response = self.client.get( + reverse("admin-detail", args=[str(admin.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), admin.id) + self.assertEqual(content_json["first_name"], admin.first_name) + self.assertEqual(content_json["last_name"], admin.last_name) + self.assertEqual(content_json["email"], admin.email) + + def test_admin_faculty(self): + """ + Able to retrieve faculty details of a single admin. + """ + # Create an admin for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + admin = create_admin( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty], + ) + + # Make a GET request to retrieve the admin details + response = self.client.get( + reverse("admin-detail", args=[str(admin.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved admin match the created admin + self.assertEqual(int(content_json["id"]), admin.id) + self.assertEqual(content_json["first_name"], admin.first_name) + self.assertEqual(content_json["last_name"], admin.last_name) + self.assertEqual(content_json["email"], admin.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) diff --git a/backend/api/tests/test_assistant.py b/backend/api/tests/test_assistant.py new file mode 100644 index 00000000..81332915 --- /dev/null +++ b/backend/api/tests/test_assistant.py @@ -0,0 +1,289 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from api.models.assistant import Assistant +from api.models.course import Course +from authentication.models import Faculty, User + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create(name=name) + + +def create_assistant(id, first_name, last_name, email, faculty=None, courses=None): + """ + Create a assistant with the given arguments. + """ + username = f"{first_name}_{last_name}" + assistant = Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + if faculty is not None: + for fac in faculty: + assistant.faculties.add(fac) + + if courses is not None: + for cours in courses: + assistant.courses.add(cours) + + return assistant + + +class AssistantModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_assistant(self): + """ + able to retrieve no assistant before publishing it. + """ + + response_root = self.client.get(reverse("assistant-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_assistant_exists(self): + """ + Able to retrieve a single assistant after creating it. + """ + assistant = create_assistant( + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one assistant + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved assistant + # match the created assistant + retrieved_assistant = content_json[0] + self.assertEqual(int(retrieved_assistant["id"]), assistant.id) + self.assertEqual(retrieved_assistant["first_name"], assistant.first_name) + self.assertEqual(retrieved_assistant["last_name"], assistant.last_name) + self.assertEqual(retrieved_assistant["email"], assistant.email) + + def test_multiple_assistant(self): + """ + Able to retrieve multiple assistant after creating them. + """ + # Create multiple assistant + assistant1 = create_assistant( + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) + assistant2 = create_assistant( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the assistant + response = self.client.get(reverse("assistant-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple assistant + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved + # assistant match the created assistant + retrieved_assistant1, retrieved_assistant2 = content_json + self.assertEqual(int(retrieved_assistant1["id"]), assistant1.id) + self.assertEqual(retrieved_assistant1["first_name"], assistant1.first_name) + self.assertEqual(retrieved_assistant1["last_name"], assistant1.last_name) + self.assertEqual(retrieved_assistant1["email"], assistant1.email) + + self.assertEqual(int(retrieved_assistant2["id"]), assistant2.id) + self.assertEqual(retrieved_assistant2["first_name"], assistant2.first_name) + self.assertEqual(retrieved_assistant2["last_name"], assistant2.last_name) + self.assertEqual(retrieved_assistant2["email"], assistant2.email) + + def test_assistant_detail_view(self): + """ + Able to retrieve details of a single assistant. + """ + # Create an assistant for testing with the name "Bob Peeters" + assistant = create_assistant( + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved assistant + # match the created assistant + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + + def test_assistant_faculty(self): + """ + Able to retrieve faculty details of a single assistant. + """ + # Create an assistant for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty], + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved assistant + # match the created assistant + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_assistant_courses(self): + """ + Able to retrieve courses details of a single assistant. + """ + # Create an assistant for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science.", + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science.", + ) + + assistant = create_assistant( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2], + ) + + # Make a GET request to retrieve the assistant details + response = self.client.get( + reverse("assistant-detail", args=[str(assistant.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved assistant + # match the created assistant + self.assertEqual(int(content_json["id"]), assistant.id) + self.assertEqual(content_json["first_name"], assistant.first_name) + self.assertEqual(content_json["last_name"], assistant.last_name) + self.assertEqual(content_json["email"], assistant.email) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple assistant + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) diff --git a/backend/api/tests/test_checks.py b/backend/api/tests/test_checks.py new file mode 100644 index 00000000..b47fe651 --- /dev/null +++ b/backend/api/tests/test_checks.py @@ -0,0 +1,204 @@ +import json +from django.urls import reverse +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.checks import FileExtension, Checks + + +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create(id=id, extension=extension) + + +def create_checks(id, allowed_file_extensions, forbidden_file_extensions): + """Create a Checks with the given arguments.""" + check = Checks.objects.create( + id=id, + ) + + for ext in allowed_file_extensions: + check.allowed_file_extensions.add(ext) + for ext in forbidden_file_extensions: + check.forbidden_file_extensions.add(ext) + return check + + +class FileExtensionModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_fileExtension(self): + """ + able to retrieve no FileExtension before publishing it. + """ + response_root = self.client.get(reverse("fileExtension-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_fileExtension_exists(self): + """ + Able to retrieve a single fileExtension after creating it. + """ + fileExtension = create_fileExtension(id=5, extension="pdf") + + # Make a GET request to retrieve the fileExtension + response = self.client.get(reverse("fileExtension-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one fileExtension + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved fileExtension + # match the created fileExtension + retrieved_fileExtension = content_json[0] + self.assertEqual(retrieved_fileExtension["extension"], fileExtension.extension) + + def test_multiple_fileExtension(self): + """ + Able to retrieve multiple fileExtension after creating them. + """ + # Create multiple fileExtension + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") + + # Make a GET request to retrieve the fileExtension + response = self.client.get(reverse("fileExtension-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple fileExtension + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved fileExtension + # match the created fileExtension + retrieved_fileExtension1, retrieved_fileExtension2 = content_json + self.assertEqual( + retrieved_fileExtension1["extension"], fileExtension1.extension + ) + + self.assertEqual( + retrieved_fileExtension2["extension"], fileExtension2.extension + ) + + def test_fileExtension_detail_view(self): + """ + Able to retrieve details of a single fileExtension. + """ + # Create an fileExtension for testing. + fileExtension = create_fileExtension(id=3, extension="zip") + + # Make a GET request to retrieve the fileExtension details + response = self.client.get( + reverse("fileExtension-detail", args=[str(fileExtension.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved fileExtension + # match the created fileExtension + self.assertEqual(content_json["extension"], fileExtension.extension) + + +class ChecksModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_checks(self): + """ + Able to retrieve no Checks before publishing it. + """ + response_root = self.client.get(reverse("check-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + self.assertEqual(response_root.accepted_media_type, "application/json") + content_json = json.loads(response_root.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_checks_exists(self): + """ + Able to retrieve a single Checks after creating it. + """ + # Create a Checks instance with some file extensions + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") + fileExtension3 = create_fileExtension(id=3, extension="tar") + fileExtension4 = create_fileExtension(id=4, extension="wfp") + checks = create_checks( + id=5, + allowed_file_extensions=[fileExtension1, fileExtension4], + forbidden_file_extensions=[fileExtension2, fileExtension3], + ) + + # Make a GET request to retrieve the Checks + response = self.client.get(reverse("check-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one Checks + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved Checks match the created Checks + retrieved_checks = content_json[0] + self.assertEqual(int(retrieved_checks["id"]), checks.id) + + # Assert the file extensions of the retrieved + # Checks match the created file extensions + retrieved_allowed_file_extensions = retrieved_checks["allowed_file_extensions"] + + self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual( + retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + ) + self.assertEqual( + retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + ) + + retrieved_forbidden_file_extensions = retrieved_checks[ + "forbidden_file_extensions" + ] + self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + self.assertEqual( + retrieved_forbidden_file_extensions[0]["extension"], + fileExtension2.extension, + ) + self.assertEqual( + retrieved_forbidden_file_extensions[1]["extension"], + fileExtension3.extension, + ) diff --git a/backend/api/tests/test_course.py b/backend/api/tests/test_course.py new file mode 100644 index 00000000..a2c3d165 --- /dev/null +++ b/backend/api/tests/test_course.py @@ -0,0 +1,442 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.course import Course +from api.models.teacher import Teacher +from api.models.assistant import Assistant +from api.models.student import Student +from api.models.project import Project + + +def create_project(name, description, visible, archived, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + course=course, + ) + + +def create_student(id, first_name, last_name, email): + """ + Create a student with the given arguments. + """ + username = f"{first_name}_{last_name}" + student = Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + return student + + +def create_assistant(id, first_name, last_name, email): + """ + Create a assistant with the given arguments. + """ + username = f"{first_name}_{last_name}" + assistant = Assistant.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + return assistant + + +def create_teacher(id, first_name, last_name, email): + """ + Create a teacher with the given arguments. + """ + username = f"{first_name}_{last_name}" + teacher = Teacher.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + return teacher + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +class CourseModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_courses(self): + """ + Able to retrieve no courses before publishing any. + """ + response_root = self.client.get(reverse("course-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + self.assertEqual(response_root.accepted_media_type, "application/json") + content_json = json.loads(response_root.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_course_exists(self): + """ + Able to retrieve a single course after creating it. + """ + course = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science.", + ) + + response = self.client.get(reverse("course-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_course = content_json[0] + self.assertEqual(retrieved_course["name"], course.name) + self.assertEqual( + retrieved_course["academic_startyear"], course.academic_startyear + ) + self.assertEqual(retrieved_course["description"], course.description) + + def test_multiple_courses(self): + """ + Able to retrieve multiple courses after creating them. + """ + course1 = create_course( + name="Mathematics 101", + academic_startyear=2022, + description="A basic mathematics course.", + ) + course2 = create_course( + name="Physics 101", + academic_startyear=2022, + description="An introductory physics course.", + ) + + response = self.client.get(reverse("course-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 2) + + retrieved_course1, retrieved_course2 = content_json + self.assertEqual(retrieved_course1["name"], course1.name) + self.assertEqual( + retrieved_course1["academic_startyear"], course1.academic_startyear + ) + self.assertEqual(retrieved_course1["description"], course1.description) + + self.assertEqual(retrieved_course2["name"], course2.name) + self.assertEqual( + retrieved_course2["academic_startyear"], course2.academic_startyear + ) + self.assertEqual(retrieved_course2["description"], course2.description) + + def test_course_detail_view(self): + """ + Able to retrieve details of a single course. + """ + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course.", + ) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + def test_course_teachers(self): + """ + Able to retrieve teachers details of a single course. + """ + teacher1 = create_teacher( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be", + ) + + teacher2 = create_teacher( + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course.", + ) + course.teachers.add(teacher1) + course.teachers.add(teacher2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["teachers"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teachers + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), teacher1.id) + self.assertEqual(content["first_name"], teacher1.first_name) + self.assertEqual(content["last_name"], teacher1.last_name) + self.assertEqual(content["email"], teacher1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), teacher2.id) + self.assertEqual(content["first_name"], teacher2.first_name) + self.assertEqual(content["last_name"], teacher2.last_name) + self.assertEqual(content["email"], teacher2.email) + + def test_course_assistant(self): + """ + Able to retrieve assistant details of a single course. + """ + assistant1 = create_assistant( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be", + ) + + assistant2 = create_assistant( + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course.", + ) + course.assistants.add(assistant1) + course.assistants.add(assistant2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["assistants"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teachers + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), assistant1.id) + self.assertEqual(content["first_name"], assistant1.first_name) + self.assertEqual(content["last_name"], assistant1.last_name) + self.assertEqual(content["email"], assistant1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), assistant2.id) + self.assertEqual(content["first_name"], assistant2.first_name) + self.assertEqual(content["last_name"], assistant2.last_name) + self.assertEqual(content["email"], assistant2.email) + + def test_course_student(self): + """ + Able to retrieve student details of a single course. + """ + student1 = create_student( + id=5, + first_name="Simon", + last_name="Mignolet", + email="simon.mignolet@ugent.be", + ) + + student2 = create_student( + id=6, first_name="Ronny", last_name="Deila", email="ronny.deila@brugge.be" + ) + + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course.", + ) + course.students.add(student1) + course.students.add(student2) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["students"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple student + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), student1.id) + self.assertEqual(content["first_name"], student1.first_name) + self.assertEqual(content["last_name"], student1.last_name) + self.assertEqual(content["email"], student1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), student2.id) + self.assertEqual(content["first_name"], student2.first_name) + self.assertEqual(content["last_name"], student2.last_name) + self.assertEqual(content["email"], student2.email) + + def test_course_project(self): + """ + Able to retrieve project details of a single course. + """ + course = create_course( + name="Chemistry 101", + academic_startyear=2022, + description="An introductory chemistry course.", + ) + + project1 = create_project( + name="become champions", + description="win the jpl", + visible=True, + archived=False, + days=50, + course=course, + ) + + project2 = create_project( + name="become european champion", + description="win the cfl", + visible=True, + archived=False, + days=50, + course=course, + ) + + response = self.client.get( + reverse("course-detail", args=[str(course.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + response = self.client.get(content_json["projects"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple projects + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), project1.id) + self.assertEqual(content["name"], project1.name) + self.assertEqual(content["description"], project1.description) + self.assertEqual(content["visible"], project1.visible) + self.assertEqual(content["archived"], project1.archived) + + content = content_json[1] + self.assertEqual(int(content["id"]), project2.id) + self.assertEqual(content["name"], project2.name) + self.assertEqual(content["description"], project2.description) + self.assertEqual(content["visible"], project2.visible) + self.assertEqual(content["archived"], project2.archived) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py new file mode 100644 index 00000000..f10e87d4 --- /dev/null +++ b/backend/api/tests/test_group.py @@ -0,0 +1,268 @@ +import json +from datetime import timedelta +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.project import Project +from api.models.student import Student +from api.models.group import Group +from api.models.course import Course + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +def create_project(name, description, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + return Project.objects.create( + name=name, description=description, deadline=deadline, course=course + ) + + +def create_student(id, first_name, last_name, email): + """Create a Student with the given arguments.""" + username = f"{first_name}_{last_name}" + return Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + ) + + +def create_group(project, score): + """Create a Group with the given arguments.""" + return Group.objects.create(project=project, score=score) + + +class GroupModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_groups(self): + """Able to retrieve no groups before creating any.""" + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_group_exists(self): + """Able to retrieve a single group after creating it.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + student = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 1) + + retrieved_group = content_json[0] + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)] + ) + + self.assertEqual(retrieved_group["project"], expected_project_url) + self.assertEqual(int(retrieved_group["id"]), group.id) + self.assertEqual(retrieved_group["score"], group.score) + + def test_multiple_groups(self): + """Able to retrieve multiple groups after creating them.""" + course = create_course(name="sel2", academic_startyear=2023) + + project1 = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + project2 = create_project( + name="Project 2", description="Description 2", days=7, course=course + ) + + student1 = create_student( + id=2, first_name="Bart", last_name="Rex", email="bart.rex@example.com" + ) + student2 = create_student( + id=3, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + group1 = create_group(project=project1, score=10) + group1.students.add(student1) + + group2 = create_group(project=project2, score=10) + group2.students.add(student1, student2) + + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 2) + + retrieved_group1, retrieved_group2 = content_json + expected_project_url1 = "http://testserver" + reverse( + "project-detail", args=[str(project1.id)] + ) + expected_project_url2 = "http://testserver" + reverse( + "project-detail", args=[str(project2.id)] + ) + + self.assertEqual(retrieved_group1["project"], expected_project_url1) + self.assertEqual(int(retrieved_group1["id"]), group1.id) + self.assertEqual(retrieved_group1["score"], group1.score) + + self.assertEqual(retrieved_group2["project"], expected_project_url2) + self.assertEqual(int(retrieved_group2["id"]), group2.id) + self.assertEqual(retrieved_group2["score"], group2.score) + + def test_group_detail_view(self): + """Able to retrieve details of a single group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)] + ) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["project"], expected_project_url) + self.assertEqual(content_json["score"], group.score) + + def test_group_project(self): + """Able to retrieve details of a single group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student = create_student( + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["score"], group.score) + + response = self.client.get(content_json["project"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(content_json["name"], project.name) + self.assertEqual(content_json["description"], project.description) + self.assertEqual(content_json["visible"], project.visible) + self.assertEqual(content_json["archived"], project.archived) + self.assertEqual(content_json["course"], expected_course_url) + + def test_group_students(self): + """Able to retrieve students details of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + student1 = create_student( + id=5, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + student2 = create_student( + id=6, first_name="kom", last_name="mor_up", email="kom.mor_up@example.com" + ) + + group = create_group(project=project, score=10) + group.students.add(student1) + group.students.add(student2) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["score"], group.score) + + response = self.client.get(content_json["students"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), student1.id) + self.assertEqual(content["first_name"], student1.first_name) + self.assertEqual(content["last_name"], student1.last_name) + self.assertEqual(content["email"], student1.email) + + content = content_json[1] + self.assertEqual(int(content["id"]), student2.id) + self.assertEqual(content["first_name"], student2.first_name) + self.assertEqual(content["last_name"], student2.last_name) + self.assertEqual(content["email"], student2.email) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py new file mode 100644 index 00000000..ae9f2efb --- /dev/null +++ b/backend/api/tests/test_project.py @@ -0,0 +1,454 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.project import Project +from api.models.course import Course +from api.models.checks import Checks, FileExtension + + +def create_course(id, name, academic_startyear): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + id=id, name=name, academic_startyear=academic_startyear + ) + + +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create(id=id, extension=extension) + + +def create_checks( + id=None, allowed_file_extensions=None, forbidden_file_extensions=None +): + """Create a Checks with the given arguments.""" + if id is None and allowed_file_extensions is None: + # extra if to make line shorter + if forbidden_file_extensions is None: + return Checks.objects.create() + + check = Checks.objects.create( + id=id, + ) + + for ext in allowed_file_extensions: + check.allowed_file_extensions.add(ext) + for ext in forbidden_file_extensions: + check.forbidden_file_extensions.add(ext) + return check + + +def create_project(name, description, visible, archived, days, checks, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + checks=checks, + course=course, + ) + + +class ProjectModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_toggle_visible(self): + """ + toggle the visible state of a project. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + checks=checks, + course=course, + ) + self.assertIs(past_project.visible, True) + past_project.toggle_visible() + self.assertIs(past_project.visible, False) + past_project.toggle_visible() + self.assertIs(past_project.visible, True) + + def test_toggle_archived(self): + """ + toggle the archived state of a project. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=True, + days=-10, + checks=checks, + course=course, + ) + + self.assertIs(past_project.archived, True) + past_project.toggle_archived() + self.assertIs(past_project.archived, False) + past_project.toggle_archived() + self.assertIs(past_project.archived, True) + + def test_deadline_approaching_in_with_past_Project(self): + """ + deadline_approaching_in() returns False for Projects whose Deadline + is in the past. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + checks=checks, + course=course, + ) + self.assertIs(past_project.deadline_approaching_in(), False) + + def test_deadline_approaching_in_with_future_Project_within_time(self): + """ + deadline_approaching_in() returns True for Projects whose Deadline + is in the timerange given. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=6, + checks=checks, + course=course, + ) + self.assertIs(future_project.deadline_approaching_in(days=7), True) + + def test_deadline_approaching_in_with_future_Project_not_within_time(self): + """ + deadline_approaching_in() returns False for Projects whose Deadline + is out of the timerange given. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=8, + checks=checks, + course=course, + ) + self.assertIs(future_project.deadline_approaching_in(days=7), False) + + def test_deadline_passed_with_future_Project(self): + """ + deadline_passed() returns False for Projects whose Deadline + is not passed. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=1, + checks=checks, + course=course, + ) + self.assertIs(future_project.deadline_passed(), False) + + def test_deadline_passed_with_past_Project(self): + """ + deadline_passed() returns True for Projects whose Deadline + is passed. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-1, + checks=checks, + course=course, + ) + self.assertIs(past_project.deadline_passed(), True) + + def test_no_projects(self): + """Able to retrieve no projects before creating any.""" + response = self.client.get(reverse("group-list"), follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json, []) + + def test_project_exists(self): + """ + Able to retrieve a single project after creating it. + """ + + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course, + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) + + def test_multiple_project(self): + """ + Able to retrieve multiple projects after creating it. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course, + ) + + project2 = create_project( + name="test project2", + description="test description2", + visible=True, + archived=False, + days=7, + checks=checks, + course=course, + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 2) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) + + retrieved_project = content_json[1] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project2.name) + self.assertEqual(retrieved_project["description"], project2.description) + self.assertEqual(retrieved_project["visible"], project2.visible) + self.assertEqual(retrieved_project["archived"], project2.archived) + self.assertEqual(retrieved_project["checks"], expected_checks_url) + self.assertEqual(retrieved_project["course"], expected_course_url) + + def test_project_course(self): + """ + Able to retrieve a course of a project after creating it. + """ + + course = create_course(id=3, name="test course", academic_startyear=2024) + checks = create_checks() + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course, + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_checks_url = "http://testserver" + reverse( + "check-detail", args=[str(checks.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["checks"], expected_checks_url) + + response = self.client.get(retrieved_project["course"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(content_json["name"], course.name) + self.assertEqual(content_json["academic_startyear"], course.academic_startyear) + self.assertEqual(content_json["description"], course.description) + + def test_project_checks(self): + """ + Able to retrieve a check of a project after creating it. + """ + + course = create_course(id=3, name="test course", academic_startyear=2024) + fileExtension1 = create_fileExtension(id=1, extension="jpg") + fileExtension2 = create_fileExtension(id=2, extension="png") + fileExtension3 = create_fileExtension(id=3, extension="tar") + fileExtension4 = create_fileExtension(id=4, extension="wfp") + checks = create_checks( + id=5, + allowed_file_extensions=[fileExtension1, fileExtension4], + forbidden_file_extensions=[fileExtension2, fileExtension3], + ) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + checks=checks, + course=course, + ) + + response = self.client.get(reverse("project-list"), follow=True) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(len(content_json), 1) + + retrieved_project = content_json[0] + + expected_course_url = "http://testserver" + reverse( + "course-detail", args=[str(course.id)] + ) + + self.assertEqual(retrieved_project["name"], project.name) + self.assertEqual(retrieved_project["description"], project.description) + self.assertEqual(retrieved_project["visible"], project.visible) + self.assertEqual(retrieved_project["archived"], project.archived) + self.assertEqual(retrieved_project["course"], expected_course_url) + + response = self.client.get(retrieved_project["checks"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + self.assertEqual(int(content_json["id"]), checks.id) + + # Assert the file extensions of the retrieved + # Checks match the created file extensions + retrieved_allowed_file_extensions = content_json["allowed_file_extensions"] + + self.assertEqual(len(retrieved_allowed_file_extensions), 2) + self.assertEqual( + retrieved_allowed_file_extensions[0]["extension"], fileExtension1.extension + ) + self.assertEqual( + retrieved_allowed_file_extensions[1]["extension"], fileExtension4.extension + ) + + retrieved_forbidden_file_extensions = content_json["forbidden_file_extensions"] + self.assertEqual(len(retrieved_forbidden_file_extensions), 2) + self.assertEqual( + retrieved_forbidden_file_extensions[0]["extension"], + fileExtension2.extension, + ) + self.assertEqual( + retrieved_forbidden_file_extensions[1]["extension"], + fileExtension3.extension, + ) diff --git a/backend/api/tests/test_student.py b/backend/api/tests/test_student.py new file mode 100644 index 00000000..1fced767 --- /dev/null +++ b/backend/api/tests/test_student.py @@ -0,0 +1,288 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from api.models.student import Student +from api.models.course import Course +from authentication.models import Faculty, User + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create(name=name) + + +def create_student(id, first_name, last_name, email, faculty=None, courses=None): + """ + Create a student with the given arguments. + """ + username = f"{first_name}_{last_name}" + student = Student.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + if faculty is not None: + for fac in faculty: + student.faculties.add(fac) + + if courses is not None: + for cours in courses: + student.courses.add(cours) + + return student + + +class StudentModelTests(APITestCase): + + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_student(self): + """ + able to retrieve no student before publishing it. + """ + + response_root = self.client.get(reverse("student-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_student_exists(self): + """ + Able to retrieve a single student after creating it. + """ + student = create_student( + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + # Make a GET request to retrieve the student + response = self.client.get(reverse("student-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one student + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved student match the created student + retrieved_student = content_json[0] + self.assertEqual(int(retrieved_student["id"]), student.id) + self.assertEqual(retrieved_student["first_name"], student.first_name) + self.assertEqual(retrieved_student["last_name"], student.last_name) + self.assertEqual(retrieved_student["email"], student.email) + + def test_multiple_students(self): + """ + Able to retrieve multiple students after creating them. + """ + # Create multiple assistant + student1 = create_student( + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the student + response = self.client.get(reverse("student-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple students + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved students + # match the created students + retrieved_student1, retrieved_student2 = content_json + self.assertEqual(int(retrieved_student1["id"]), student1.id) + self.assertEqual(retrieved_student1["first_name"], student1.first_name) + self.assertEqual(retrieved_student1["last_name"], student1.last_name) + self.assertEqual(retrieved_student1["email"], student1.email) + + self.assertEqual(int(retrieved_student2["id"]), student2.id) + self.assertEqual(retrieved_student2["first_name"], student2.first_name) + self.assertEqual(retrieved_student2["last_name"], student2.last_name) + self.assertEqual(retrieved_student2["email"], student2.email) + + def test_student_detail_view(self): + """ + Able to retrieve details of a single student. + """ + # Create an student for testing with the name "Bob Peeters" + student = create_student( + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved student match the created student + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) + + def test_student_faculty(self): + """ + Able to retrieve faculty details of a single student. + """ + # Create an student for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + student = create_student( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty], + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved student + # match the created student + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_student_courses(self): + """ + Able to retrieve courses details of a single student. + """ + # Create an student for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science.", + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science.", + ) + + student = create_student( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2], + ) + + # Make a GET request to retrieve the student details + response = self.client.get( + reverse("student-detail", args=[str(student.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved student + # match the created student + self.assertEqual(int(content_json["id"]), student.id) + self.assertEqual(content_json["first_name"], student.first_name) + self.assertEqual(content_json["last_name"], student.last_name) + self.assertEqual(content_json["email"], student.email) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple student + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) diff --git a/backend/api/tests/test_submission.py b/backend/api/tests/test_submission.py new file mode 100644 index 00000000..fa7a4386 --- /dev/null +++ b/backend/api/tests/test_submission.py @@ -0,0 +1,248 @@ +import json +from datetime import timedelta +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from authentication.models import User +from api.models.submission import Submission, SubmissionFile +from api.models.project import Project +from api.models.group import Group +from api.models.course import Course + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +def create_project(name, description, days, course): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + return Project.objects.create( + name=name, description=description, deadline=deadline, course=course + ) + + +def create_group(project, score): + """Create a Group with the given arguments.""" + return Group.objects.create(project=project, score=score) + + +def create_submission(group, submission_number): + """Create an Submission with the given arguments.""" + return Submission.objects.create( + group=group, submission_number=submission_number, submission_time=timezone.now() + ) + + +def create_submissionFile(submission, file): + """Create an SubmissionFile with the given arguments.""" + return SubmissionFile.objects.create(submission=submission, file=file) + + +class SubmissionModelTests(APITestCase): + + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_submission(self): + """ + able to retrieve no submission before publishing it. + """ + + response_root = self.client.get(reverse("submission-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_submission_exists(self): + """ + Able to retrieve a single submission after creating it. + """ + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) + + # Make a GET request to retrieve the submission + response = self.client.get(reverse("submission-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one submission + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json[0] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), submission.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_multiple_submission_exists(self): + """ + Able to retrieve multiple submissions after creating them. + """ + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + submission1 = create_submission(group=group, submission_number=1) + + submission2 = create_submission(group=group, submission_number=2) + + # Make a GET request to retrieve the submission + response = self.client.get(reverse("submission-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one submission + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json[0] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission1.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission1.submission_number, + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + retrieved_submission = content_json[1] + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission2.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), + submission2.submission_number, + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_submission_detail_view(self): + """ + Able to retrieve details of a single submission. + """ + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) + + # Make a GET request to retrieve the submission + response = self.client.get( + reverse("submission-detail", args=[str(submission.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json + expected_group_url = "http://testserver" + reverse( + "group-detail", args=[str(group.id)] + ) + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), submission.submission_number + ) + self.assertEqual(retrieved_submission["group"], expected_group_url) + + def test_submission_group(self): + """ + Able to retrieve group of a single submission. + """ + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + submission = create_submission(group=group, submission_number=1) + + # Make a GET request to retrieve the submission + response = self.client.get( + reverse("submission-detail", args=[str(submission.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved submission + # match the created submission + retrieved_submission = content_json + self.assertEqual(int(retrieved_submission["id"]), submission.id) + self.assertEqual( + int(retrieved_submission["submission_number"]), submission.submission_number + ) + + response = self.client.get(content_json["group"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + expected_project_url = "http://testserver" + reverse( + "project-detail", args=[str(project.id)] + ) + + self.assertEqual(int(content_json["id"]), group.id) + self.assertEqual(content_json["project"], expected_project_url) + self.assertEqual(content_json["score"], group.score) diff --git a/backend/api/tests/test_teacher.py b/backend/api/tests/test_teacher.py new file mode 100644 index 00000000..ec58ec95 --- /dev/null +++ b/backend/api/tests/test_teacher.py @@ -0,0 +1,286 @@ +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from api.models.teacher import Teacher +from api.models.course import Course +from authentication.models import Faculty, User + + +def create_course(name, academic_startyear, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_startyear, + description=description, + parent_course=parent_course, + ) + + +def create_faculty(name): + """Create a Faculty with the given arguments.""" + return Faculty.objects.create(name=name) + + +def create_teacher(id, first_name, last_name, email, faculty=None, courses=None): + """ + Create a teacher with the given arguments. + """ + username = f"{first_name}_{last_name}" + teacher = Teacher.objects.create( + id=id, + first_name=first_name, + last_name=last_name, + username=username, + email=email, + create_time=timezone.now(), + ) + + if faculty is not None: + for fac in faculty: + teacher.faculties.add(fac) + + if courses is not None: + for cours in courses: + teacher.courses.add(cours) + + return teacher + + +class TeacherModelTests(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate( + User.get_dummy_admin() + ) + + def test_no_teacher(self): + """ + able to retrieve no teacher before publishing it. + """ + + response_root = self.client.get(reverse("teacher-list"), follow=True) + self.assertEqual(response_root.status_code, 200) + # Assert that the response is JSON + self.assertEqual(response_root.accepted_media_type, "application/json") + # Parse the JSON content from the response + content_json = json.loads(response_root.content.decode("utf-8")) + # Assert that the parsed JSON is an empty list + self.assertEqual(content_json, []) + + def test_teacher_exists(self): + """ + Able to retrieve a single teacher after creating it. + """ + teacher = create_teacher( + id=3, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + + # Make a GET request to retrieve the teacher + response = self.client.get(reverse("teacher-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with one teacher + self.assertEqual(len(content_json), 1) + + # Assert the details of the retrieved teacher match the created teacher + retrieved_teacher = content_json[0] + self.assertEqual(int(retrieved_teacher["id"]), teacher.id) + self.assertEqual(retrieved_teacher["first_name"], teacher.first_name) + self.assertEqual(retrieved_teacher["last_name"], teacher.last_name) + self.assertEqual(retrieved_teacher["email"], teacher.email) + + def test_multiple_teachers(self): + """ + Able to retrieve multiple teachers after creating them. + """ + # Create multiple assistant + teacher1 = create_teacher( + id=1, first_name="Johny", last_name="Doeg", email="john.doe@example.com" + ) + teacher2 = create_teacher( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Make a GET request to retrieve the teacher + response = self.client.get(reverse("teacher-list"), follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teacher + self.assertEqual(len(content_json), 2) + + # Assert the details of the retrieved teacher match the created teacher + retrieved_teacher1, retrieved_teacher2 = content_json + self.assertEqual(int(retrieved_teacher1["id"]), teacher1.id) + self.assertEqual(retrieved_teacher1["first_name"], teacher1.first_name) + self.assertEqual(retrieved_teacher1["last_name"], teacher1.last_name) + self.assertEqual(retrieved_teacher1["email"], teacher1.email) + + self.assertEqual(int(retrieved_teacher2["id"]), teacher2.id) + self.assertEqual(retrieved_teacher2["first_name"], teacher2.first_name) + self.assertEqual(retrieved_teacher2["last_name"], teacher2.last_name) + self.assertEqual(retrieved_teacher2["email"], teacher2.email) + + def test_teacher_detail_view(self): + """ + Able to retrieve details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + teacher = create_teacher( + id=5, first_name="Bob", last_name="Peeters", email="bob.peeters@example.com" + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) + + def test_teacher_faculty(self): + """ + Able to retrieve faculty details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + faculty = create_faculty(name="testing faculty") + teacher = create_teacher( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + faculty=[faculty], + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher + # match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) + + response = self.client.get(content_json["faculties"][0], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], faculty.name) + + def test_teacher_courses(self): + """ + Able to retrieve courses details of a single teacher. + """ + # Create an teacher for testing with the name "Bob Peeters" + course1 = create_course( + name="Introduction to Computer Science", + academic_startyear=2022, + description="An introductory course on computer science.", + ) + course2 = create_course( + name="Intermediate to Computer Science", + academic_startyear=2023, + description="An second course on computer science.", + ) + + teacher = create_teacher( + id=5, + first_name="Bob", + last_name="Peeters", + email="bob.peeters@example.com", + courses=[course1, course2], + ) + + # Make a GET request to retrieve the teacher details + response = self.client.get( + reverse("teacher-detail", args=[str(teacher.id)]), follow=True + ) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert the details of the retrieved teacher + # match the created teacher + self.assertEqual(int(content_json["id"]), teacher.id) + self.assertEqual(content_json["first_name"], teacher.first_name) + self.assertEqual(content_json["last_name"], teacher.last_name) + self.assertEqual(content_json["email"], teacher.email) + + response = self.client.get(content_json["courses"], follow=True) + + # Check if the response was successful + self.assertEqual(response.status_code, 200) + + # Assert that the response is JSON + self.assertEqual(response.accepted_media_type, "application/json") + + # Parse the JSON content from the response + content_json = json.loads(response.content.decode("utf-8")) + + # Assert that the parsed JSON is a list with multiple teacher + self.assertEqual(len(content_json), 2) + + content = content_json[0] + self.assertEqual(int(content["id"]), course1.id) + self.assertEqual(content["name"], course1.name) + self.assertEqual(int(content["academic_startyear"]), course1.academic_startyear) + self.assertEqual(content["description"], course1.description) + + content = content_json[1] + self.assertEqual(int(content["id"]), course2.id) + self.assertEqual(content["name"], course2.name) + self.assertEqual(int(content["academic_startyear"]), course2.academic_startyear) + self.assertEqual(content["description"], course2.description) diff --git a/backend/api/urls.py b/backend/api/urls.py index c95373cc..64b83a23 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,4 +1,5 @@ from django.urls import include, path +from api.views import user_view from api.views import teacher_view from api.views import admin_view from api.views import assistant_view @@ -12,51 +13,19 @@ from rest_framework.routers import DefaultRouter router = DefaultRouter() -router.register( - r'teachers', - teacher_view.TeacherViewSet, - basename='teacher') -router.register( - r'admins', - admin_view.AdminViewSet, - basename='admin') -router.register( - r'assistants', - assistant_view.AssistantViewSet, - basename='assistant') -router.register( - r'students', - student_view.StudentViewSet, - basename='student') -router.register( - r'projects', - project_view.ProjectViewSet, - basename='project') -router.register( - r'groups', - group_view.GroupViewSet, - basename='group') -router.register( - r'courses', - course_view.CourseViewSet, - basename='course') -router.register( - r'submissions', - submision_view.SubmissionViewSet, - basename='submission') -router.register( - r'checks', - checks_view.ChecksViewSet, - basename='check') -router.register( - r'fileExtensions', - checks_view.FileExtensionViewSet, - basename='fileExtension') -router.register( - r'faculties', - faculty_view.facultyViewSet, - basename='faculty') +router.register(r"users", user_view.UserViewSet, basename="user") +router.register(r"teachers", teacher_view.TeacherViewSet, basename="teacher") +router.register(r"admins", admin_view.AdminViewSet, basename="admin") +router.register(r"assistants", assistant_view.AssistantViewSet, basename="assistant") +router.register(r"students", student_view.StudentViewSet, basename="student") +router.register(r"projects", project_view.ProjectViewSet, basename="project") +router.register(r"groups", group_view.GroupViewSet, basename="group") +router.register(r"courses", course_view.CourseViewSet, basename="course") +router.register(r"submissions", submision_view.SubmissionViewSet, basename="submission") +router.register(r"checks", checks_view.ChecksViewSet, basename="check") +router.register(r"fileExtensions", checks_view.FileExtensionViewSet, basename="fileExtension") +router.register(r"faculties", faculty_view.facultyViewSet, basename="faculty") urlpatterns = [ - path('', include(router.urls)), + path("", include(router.urls)), ] diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py index f44fdcdb..63fdab43 100644 --- a/backend/api/views/admin_view.py +++ b/backend/api/views/admin_view.py @@ -1,8 +1,8 @@ from rest_framework import viewsets -from ..models.admin import Admin -from ..serializers.admin_serializer import AdminSerializer +from authentication.serializers import UserSerializer +from authentication.models import User class AdminViewSet(viewsets.ModelViewSet): - queryset = Admin.objects.all() - serializer_class = AdminSerializer + queryset = User.objects.filter(is_staff=True) + serializer_class = UserSerializer diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py index 3d7d8ba8..ea75fc8b 100644 --- a/backend/api/views/assistant_view.py +++ b/backend/api/views/assistant_view.py @@ -10,7 +10,7 @@ class AssistantViewSet(viewsets.ModelViewSet): queryset = Assistant.objects.all() serializer_class = AssistantSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given assistant""" @@ -20,11 +20,13 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Assistant.DoesNotExist: # Invalid assistant ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Assistant not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, + data={"message": "Assistant not found"}, + ) diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py index 9d136f23..654eb1f1 100644 --- a/backend/api/views/checks_view.py +++ b/backend/api/views/checks_view.py @@ -1,8 +1,6 @@ from rest_framework import viewsets from ..models.checks import Checks, FileExtension -from ..serializers.checks_serializer import ( - ChecksSerializer, FileExtensionSerializer -) +from ..serializers.checks_serializer import ChecksSerializer, FileExtensionSerializer class ChecksViewSet(viewsets.ModelViewSet): diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py index 85012452..54b1fcf2 100644 --- a/backend/api/views/course_view.py +++ b/backend/api/views/course_view.py @@ -13,7 +13,7 @@ class CourseViewSet(viewsets.ModelViewSet): queryset = Course.objects.all() serializer_class = CourseSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def teachers(self, request, pk=None): """Returns a list of teachers for the given course""" @@ -23,16 +23,17 @@ def teachers(self, request, pk=None): # Serialize the teacher objects serializer = TeacherSerializer( - teachers, many=True, context={'request': request} + teachers, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def assistants(self, request, pk=None): """Returns a list of assistants for the given course""" @@ -42,16 +43,17 @@ def assistants(self, request, pk=None): # Serialize the assistant objects serializer = AssistantSerializer( - assistants, many=True, context={'request': request} + assistants, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given course""" @@ -61,16 +63,17 @@ def students(self, request, pk=None): # Serialize the student objects serializer = StudentSerializer( - students, many=True, context={'request': request} + students, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def projects(self, request, pk=None): """Returns a list of projects for the given course""" @@ -80,11 +83,12 @@ def projects(self, request, pk=None): # Serialize the project objects serializer = ProjectSerializer( - projects, many=True, context={'request': request} + projects, many=True, context={"request": request} ) return Response(serializer.data) except Course.DoesNotExist: # Invalid course ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Course not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Course not found"} + ) diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py index 809fb893..0402f198 100644 --- a/backend/api/views/group_view.py +++ b/backend/api/views/group_view.py @@ -10,7 +10,7 @@ class GroupViewSet(viewsets.ModelViewSet): queryset = Group.objects.all() serializer_class = GroupSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def students(self, request, pk=None): """Returns a list of students for the given group""" @@ -20,11 +20,12 @@ def students(self, request, pk=None): # Serialize the student objects serializer = StudentSerializer( - students, many=True, context={'request': request} + students, many=True, context={"request": request} ) return Response(serializer.data) except Group.DoesNotExist: # Invalid group ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Group not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Group not found"} + ) diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py index b5f87e82..4fe6f92c 100644 --- a/backend/api/views/student_view.py +++ b/backend/api/views/student_view.py @@ -11,7 +11,7 @@ class StudentViewSet(viewsets.ModelViewSet): queryset = Student.objects.all() serializer_class = StudentSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given student""" @@ -21,16 +21,17 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Student.DoesNotExist: # Invalid student ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Student not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} + ) - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def groups(self, request, pk=None): """Returns a list of groups for the given student""" @@ -40,11 +41,12 @@ def groups(self, request, pk=None): # Serialize the group objects serializer = GroupSerializer( - groups, many=True, context={'request': request} + groups, many=True, context={"request": request} ) return Response(serializer.data) except Student.DoesNotExist: # Invalid student ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Student not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Student not found"} + ) diff --git a/backend/api/views/submision_view.py b/backend/api/views/submision_view.py index 72c95e45..8e0de7ad 100644 --- a/backend/api/views/submision_view.py +++ b/backend/api/views/submision_view.py @@ -1,7 +1,8 @@ from rest_framework import viewsets from ..models.submission import Submission, SubmissionFile from ..serializers.submision_serializer import ( - SubmissionSerializer, SubmissionFileSerializer + SubmissionSerializer, + SubmissionFileSerializer, ) diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py index 9f26361b..49038133 100644 --- a/backend/api/views/teacher_view.py +++ b/backend/api/views/teacher_view.py @@ -10,7 +10,7 @@ class TeacherViewSet(viewsets.ModelViewSet): queryset = Teacher.objects.all() serializer_class = TeacherSerializer - @action(detail=True, methods=['get']) + @action(detail=True, methods=["get"]) def courses(self, request, pk=None): """Returns a list of courses for the given teacher""" @@ -20,11 +20,12 @@ def courses(self, request, pk=None): # Serialize the course objects serializer = CourseSerializer( - courses, many=True, context={'request': request} + courses, many=True, context={"request": request} ) return Response(serializer.data) except Teacher.DoesNotExist: # Invalid teacher ID - return Response(status=status.HTTP_404_NOT_FOUND, - data={"message": "Teacher not found"}) + return Response( + status=status.HTTP_404_NOT_FOUND, data={"message": "Teacher not found"} + ) diff --git a/backend/authentication/views/users.py b/backend/api/views/user_view.py similarity index 67% rename from backend/authentication/views/users.py rename to backend/api/views/user_view.py index 4c6c4b2b..870243da 100644 --- a/backend/authentication/views/users.py +++ b/backend/api/views/user_view.py @@ -3,6 +3,7 @@ from authentication.models import User from authentication.serializers import UserSerializer -class UsersView(ListModelMixin, RetrieveModelMixin, GenericViewSet): + +class UserViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): queryset = User.objects.all() - serializer_class = UserSerializer \ No newline at end of file + serializer_class = UserSerializer diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py index 8bab8df0..c65f1d28 100644 --- a/backend/authentication/apps.py +++ b/backend/authentication/apps.py @@ -2,5 +2,5 @@ class AuthenticationConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'authentication' + default_auto_field = "django.db.models.BigAutoField" + name = "authentication" diff --git a/backend/authentication/cas/client.py b/backend/authentication/cas/client.py index e9133f75..8388c978 100644 --- a/backend/authentication/cas/client.py +++ b/backend/authentication/cas/client.py @@ -2,7 +2,5 @@ from ypovoli import settings client = CASClient( - server_url=settings.CAS_ENDPOINT, - service_url=settings.CAS_RESPONSE, - auth_prefix='' + server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE, auth_prefix="" ) diff --git a/backend/authentication/fixtures/faculties.yaml b/backend/authentication/fixtures/faculties.yaml new file mode 100644 index 00000000..e13fd9e5 --- /dev/null +++ b/backend/authentication/fixtures/faculties.yaml @@ -0,0 +1,33 @@ +- model: authentication.faculty + pk: Bio-ingenieurswetenschappen + fields: {} +- model: authentication.faculty + pk: Diergeneeskunde + fields: {} +- model: authentication.faculty + pk: Economie_Bedrijfskunde + fields: {} +- model: authentication.faculty + pk: Farmaceutische_Wetenschappen + fields: {} +- model: authentication.faculty + pk: Geneeskunde_Gezondheidswetenschappen + fields: {} +- model: authentication.faculty + pk: Ingenieurswetenschappen_Architectuur + fields: {} +- model: authentication.faculty + pk: Letteren_Wijsbegeerte + fields: {} +- model: authentication.faculty + pk: Politieke_Sociale_Wetenschappen + fields: {} +- model: authentication.faculty + pk: Psychologie_PedagogischeWetenschappen + fields: {} +- model: authentication.faculty + pk: Recht_Criminologie + fields: {} +- model: authentication.faculty + pk: Wetenschappen + fields: {} diff --git a/backend/authentication/fixtures/users.yaml b/backend/authentication/fixtures/users.yaml new file mode 100644 index 00000000..3e6a5801 --- /dev/null +++ b/backend/authentication/fixtures/users.yaml @@ -0,0 +1,72 @@ +- model: authentication.user + pk: '1' + fields: + last_login: null + username: jdoe + email: John.Doe@hotmail.com + first_name: John + last_name: Doe + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.690556+00:00 + faculties: + - Wetenschappen +- model: authentication.user + pk: '123' + fields: + last_login: null + username: tboonen + email: Tom.Boonen@gmail.be + first_name: Tom + last_name: Boonen + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.686541+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen +- model: authentication.user + pk: '124' + fields: + last_login: null + username: psagan + email: Peter.Sagan@gmail.com + first_name: Peter + last_name: Sagan + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.689543+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen +- model: authentication.user + pk: '2' + fields: + last_login: null + username: bverhae + email: Bartje.Verhaege@gmail.com + first_name: Bartje + last_name: Verhaege + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.691565+00:00 + faculties: + - Geneeskunde_Gezondheidswetenschappen +- model: authentication.user + pk: '235' + fields: + last_login: null + username: bsimpson + email: Bart.Simpson@gmail.be + first_name: Bart + last_name: Simpson + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.687541+00:00 + faculties: + - Wetenschappen +- model: authentication.user + pk: '236' + fields: + last_login: null + username: kclijster + email: Kim.Clijsters@gmail.be + first_name: Kim + last_name: Clijsters + last_enrolled: 2023 + create_time: 2024-02-29 20:35:45.688545+00:00 + faculties: + - Psychologie_PedagogischeWetenschappen diff --git a/backend/authentication/migrations/0001_initial.py b/backend/authentication/migrations/0001_initial.py index a86e2591..3265f487 100644 --- a/backend/authentication/migrations/0001_initial.py +++ b/backend/authentication/migrations/0001_initial.py @@ -1,6 +1,5 @@ -# Generated by Django 5.0.2 on 2024-02-28 21:55 +# Generated by Django 5.0.2 on 2024-03-05 14:25 -from django.conf import settings from django.db import migrations, models @@ -12,32 +11,28 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='Faculty', + fields=[ + ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), + ], + ), migrations.CreateModel( name='User', fields=[ ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), ('id', models.CharField(max_length=12, primary_key=True, serialize=False)), ('username', models.CharField(max_length=12, unique=True)), + ('is_staff', models.BooleanField(default=False)), ('email', models.EmailField(max_length=254, unique=True)), ('first_name', models.CharField(max_length=50)), ('last_name', models.CharField(max_length=50)), ('last_enrolled', models.IntegerField(default=1, null=True)), - ('create_time', models.DateTimeField(auto_now=True)), + ('create_time', models.DateTimeField(auto_now_add=True)), + ('faculties', models.ManyToManyField(blank=True, related_name='users', to='authentication.faculty')), ], options={ 'abstract': False, }, ), - migrations.CreateModel( - name='Faculty', - fields=[ - ('name', models.CharField(max_length=50, primary_key=True, serialize=False)), - ('user', models.ManyToManyField(blank=True, related_name='users', to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AddField( - model_name='user', - name='faculty', - field=models.ManyToManyField(blank=True, related_name='faculties', to='authentication.faculty'), - ), ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index 7bef9b4c..8a8787f4 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -1,8 +1,9 @@ -import datetime - +from datetime import MINYEAR +from typing import Self, Type from django.db import models -from django.db.models import CharField, EmailField, IntegerField, DateTimeField -from django.contrib.auth.models import AbstractBaseUser +from django.db.models import CharField, EmailField, IntegerField, DateTimeField, BooleanField, Model +from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin + class User(AbstractBaseUser): """This model represents a single authenticatable user. @@ -10,64 +11,50 @@ class User(AbstractBaseUser): """ """Model fields""" - password = None # We don't use passwords for our user model. - - id = CharField( - max_length=12, - primary_key=True - ) - - username = CharField( - max_length=12, - unique=True - ) - - email = EmailField( - null=False, - unique=True - ) - - first_name = CharField( - max_length=50, - null=False - ) - - last_name = CharField( - max_length=50, - null=False - ) - - faculty = models.ManyToManyField( - 'Faculty', - related_name='faculties', - blank=True - ) - - last_enrolled = IntegerField( - default = datetime.MINYEAR, - null = True - ) - - create_time = DateTimeField( - auto_now=True - ) + password = None # We don't use passwords for our user model. + + id = CharField(max_length=12, primary_key=True) + + username = CharField(max_length=12, unique=True) + + is_staff = BooleanField(default=False, null=False) + + email = EmailField(null=False, unique=True) + + first_name = CharField(max_length=50, null=False) + + last_name = CharField(max_length=50, null=False) + + faculties = models.ManyToManyField("Faculty", related_name="users", blank=True) + + last_enrolled = IntegerField(default=MINYEAR, null=True) + + create_time = DateTimeField(auto_now_add=True) """Model settings""" USERNAME_FIELD = "username" EMAIL_FIELD = "email" - REQUIRED_FIELDS = [] + + def has_role(self, model: Type[Self]): + """Simple generic implementation of roles. + This function looks if there exists a model (inheriting from User) with the same ID. + """ + model.objects.exists(self.id) + + @staticmethod + def get_dummy_admin(): + return User( + id="admin", + first_name="Nikkus", + last_name="Derdinus", + username="nderdinus", + email="nikkus@ypovoli.be", + is_staff=True + ) + class Faculty(models.Model): """This model represents a faculty.""" """Model fields""" - name = CharField( - max_length=50, - primary_key=True - ) - - user = models.ManyToManyField( - 'User', - related_name='users', - blank=True - ) + name = CharField(max_length=50, primary_key=True) diff --git a/backend/authentication/permissions.py b/backend/authentication/permissions.py new file mode 100644 index 00000000..b9ff5906 --- /dev/null +++ b/backend/authentication/permissions.py @@ -0,0 +1,9 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from ypovoli import settings + + +class IsDebug(BasePermission): + def has_permission(self, request: Request, view: ViewSet) -> bool: + return settings.DEBUG diff --git a/backend/authentication/serializers.py b/backend/authentication/serializers.py index 9c7e8173..60771277 100644 --- a/backend/authentication/serializers.py +++ b/backend/authentication/serializers.py @@ -1,87 +1,116 @@ +from typing import Tuple from django.contrib.auth.models import update_last_login -from rest_framework.serializers import CharField, EmailField, ModelSerializer, ValidationError, Serializer +from django.contrib.auth import login +from rest_framework.serializers import ( + CharField, + EmailField, + ModelSerializer, + ValidationError, + Serializer, + HyperlinkedIdentityField, + HyperlinkedRelatedField, +) from rest_framework_simplejwt.tokens import RefreshToken, AccessToken from rest_framework_simplejwt.settings import api_settings from authentication.signals import user_created, user_login -from authentication.models import User +from authentication.models import User, Faculty from authentication.cas.client import client + class CASTokenObtainSerializer(Serializer): """Serializer for CAS ticket validation This serializer takes the CAS ticket and tries to validate it. Upon successful validation, create a new user if it doesn't exist. """ - token = RefreshToken ticket = CharField(required=True, min_length=49, max_length=49) def validate(self, data): """Validate a ticket using CAS client""" - response = client.perform_service_validate( - ticket=data['ticket'] - ) - - if response.error: - raise ValidationError(response.error) - - # Validation success: create user if it doesn't exist yet. - attributes = response.data.get('attributes', dict) - - if attributes.get('lastenrolled'): - attributes['lastenrolled'] = int(attributes.get('lastenrolled').split()[0]) - - user = UserSerializer(data={ - 'id': attributes.get('ugentID'), - 'username': attributes.get('uid'), - 'email': attributes.get('mail'), - 'first_name': attributes.get('givenname'), - 'last_name': attributes.get('surname'), - 'last_enrolled': attributes.get('lastenrolled') - }) + # Validate the ticket and get CAS attributes. + attributes = self._validate_ticket(data["ticket"]) - if not user.is_valid(): - raise ValidationError(user.errors) - - user, created = user.get_or_create( - user.validated_data - ) + # Fetch a user model from the CAS attributes. + user, created = self._fetch_user_from_cas(attributes) # Update the user's last login. if api_settings.UPDATE_LAST_LOGIN: update_last_login(self, user) - user_login.send(sender=self, - user=user + # Login and send authentication signals. + if "request" in self.context: + login(self.context["request"], user) + + user_login.send( + sender=self, user=user ) - # Send signal upon creation. if created: - user_created.send(sender=self, - attributes=attributes, - user=user + user_created.send( + sender=self, attributes=attributes, user=user ) + # Return access tokens for the now logged-in user. return { - 'access': str(AccessToken.for_user(user)), - 'refresh': str(RefreshToken.for_user(user)) + "access": str( + AccessToken.for_user(user) + ), + "refresh": str( + RefreshToken.for_user(user) + ), } + def _validate_ticket(self, ticket: str) -> dict: + """Validate a CAS ticket using the CAS client""" + response = client.perform_service_validate(ticket=ticket) + + if response.error: + raise ValidationError(response.error) + + return response.data.get("attributes", dict) + + def _fetch_user_from_cas(self, attributes: dict) -> Tuple[User, bool]: + if attributes.get("lastenrolled"): + attributes["lastenrolled"] = int(attributes.get("lastenrolled").split()[0]) + + user = UserSerializer( + data={ + "id": attributes.get("ugentID"), + "username": attributes.get("uid"), + "email": attributes.get("mail"), + "first_name": attributes.get("givenname"), + "last_name": attributes.get("surname"), + "last_enrolled": attributes.get("lastenrolled"), + } + ) + + if not user.is_valid(): + raise ValidationError(user.errors) + + return user.get_or_create(user.validated_data) + + class UserSerializer(ModelSerializer): """Serializer for the user model This serializer validates the user fields for creation and updating. """ + id = CharField() username = CharField() email = EmailField() - + + faculties = HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + + notifications = HyperlinkedIdentityField( + view_name="notification-detail", + read_only=True, + ) + class Meta: model = User - fields = [ - 'id', 'username', 'email', - 'first_name', 'last_name', - 'faculty', - 'last_enrolled', 'last_login', 'create_time' - ] - - def get_or_create(self, validated_data: dict) -> User: + fields = "__all__" + + def get_or_create(self, validated_data: dict) -> Tuple[User, bool]: """Create or fetch the user based on the validated data.""" - return User.objects.get_or_create(**validated_data) \ No newline at end of file + return User.objects.get_or_create(**validated_data) diff --git a/backend/authentication/services/__init__.py b/backend/authentication/services/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/services/users.py b/backend/authentication/services/users.py deleted file mode 100644 index 0b621fc5..00000000 --- a/backend/authentication/services/users.py +++ /dev/null @@ -1,54 +0,0 @@ -from authentication.models import User - -def exists(user_id: str) -> bool: - """Check if a user exists""" - return User.objects.filter(id = user_id).exists() - -def get_by_id(user_id: str) -> User|None: - """Get a user by its user id""" - return User.objects.filter(id=user_id).first() - -def get_by_username(username: str) -> User: - """Get a user by its username""" - return User.objects.filter(username=username).first() - -def create( - user_id: str, username: str, email: str, - first_name: str, last_name: str, - faculty: str = None, - last_enrolled: str = None, - student_id: str = None -) -> User: - """Create a new user - Note: this does not assign specific user classes. - This should be handled by consumers of this package. - """ - return User.objects.create( - id = user_id, - student_id = student_id, - username = username, - email = email, - first_name = first_name, - last_name = last_name, - faculty = faculty, - last_enrolled = last_enrolled - ) - -def get_or_create( - user_id: str, username: str, email: str, - first_name: str, last_name: str, - faculty: str = None, - last_enrolled: str = None, - student_id: str = None -) -> User: - """Get a user by ID, or create if it doesn't exist""" - user = get_by_id(user_id) - - if user is None: - return create( - user_id, username, email, - first_name, last_name, - faculty, last_enrolled, student_id - ) - - return user \ No newline at end of file diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py index e584b6bf..40c89941 100644 --- a/backend/authentication/signals.py +++ b/backend/authentication/signals.py @@ -2,4 +2,4 @@ user_created = Signal() user_login = Signal() -user_logout = Signal() \ No newline at end of file +user_logout = Signal() diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py new file mode 100644 index 00000000..736ed2f0 --- /dev/null +++ b/backend/authentication/tests/test_authentication_serializer.py @@ -0,0 +1,195 @@ +from django.test import TestCase + +from unittest.mock import patch, Mock + +from rest_framework_simplejwt.tokens import RefreshToken + +from authentication.cas.client import client +from authentication.serializers import CASTokenObtainSerializer, UserSerializer +from authentication.signals import user_created, user_login + + +TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas6" +WRONG_TICKET = "ST-da8e1747f248a54a5f078e3905b88a9767f11d7aedcas5" + +ID = "1234" +USERNAME = "ddickwd" +EMAIL = "dummy@dummy.be" +FIRST_NAME = "Dummy" +LAST_NAME = "McDickwad" + + +class UserSerializerModelTests(TestCase): + def test_invalid_email_makes_user_serializer_invalid(self): + """ + The is_valid() method of a UserSerializer whose supplied User's email is not + formatted as an email address should return False. + """ + user = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": "dummy", + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + user2 = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": "dummy@dummy", + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + user3 = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": 21, + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + self.assertFalse(user.is_valid()) + self.assertFalse(user2.is_valid()) + self.assertFalse(user3.is_valid()) + + def test_valid_email_makes_valid_serializer(self): + """ + When the serializer is provided with a valid email, the serializer becomes valid, + thus the is_valid() method returns True. + """ + user = UserSerializer( + data={ + "id": ID, + "username": USERNAME, + "email": EMAIL, + "first_name": FIRST_NAME, + "last_name": LAST_NAME, + } + ) + self.assertTrue(user.is_valid()) + + +def customize_data(ugent_id, uid, mail): + class Response: + __slots__ = ("error", "data") + + def __init__(self): + self.error = None + self.data = {} + + def service_validate( + ticket=None, + service_url=None, + headers=None, + ): + response = Response() + if ticket != TICKET: + response.error = "This is an error" + else: + response.data["attributes"] = { + "ugentID": ugent_id, + "uid": uid, + "mail": mail, + "givenname": FIRST_NAME, + "surname": LAST_NAME, + "faculty": "Sciences", + "lastenrolled": "2023 - 2024", + "lastlogin": "", + "createtime": "", + } + return response + + return service_validate + + +class SerializersTests(TestCase): + def test_wrong_length_ticket_generates_error(self): + """ + When the provided ticket has the wrong length, a ValidationError should be raised + when validating the serializer. + """ + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": "ST"} + ) + self.assertFalse(serializer.is_valid()) + + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) + def test_wrong_ticket_generates_error(self): + """ + When the wrong ticket is provided, a ValidationError should be raised when trying to validate + the serializer. + """ + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": WRONG_TICKET} + ) + self.assertFalse(serializer.is_valid()) + + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, "dummy@dummy") + ) + def test_wrong_user_arguments_generate_error(self): + """ + If the user arguments returned by CAS are not valid, then a ValidationError + should be raised when validating the serializer. + """ + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) + self.assertFalse(serializer.is_valid()) + + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) + def test_new_user_activates_user_created_signal(self): + """ + If the authenticated user is new to the app, then the user_created signal should + be sent when trying to validate the token.""" + + mock = Mock() + user_created.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 1) + + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) + def test_old_user_does_not_activate_user_created_signal(self): + """ + If the authenticated user is new to the app, then the user_created signal should + be sent when trying to validate the token.""" + + mock = Mock() + user_created.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 0) + + @patch.object( + client, "perform_service_validate", customize_data(ID, USERNAME, EMAIL) + ) + def test_login_signal(self): + """ + When the token is correct and all user data is correct, while trying to validate + the token, then the user_login signal should be sent. + """ + mock = Mock() + user_login.connect(mock, dispatch_uid="STDsAllAround") + serializer = CASTokenObtainSerializer( + data={"token": RefreshToken(), "ticket": TICKET} + ) + # this next line triggers the retrieval of User information and logs in the user + serializer.is_valid() + self.assertEquals(mock.call_count, 1) diff --git a/backend/authentication/tests/test_authentication_views.py b/backend/authentication/tests/test_authentication_views.py new file mode 100644 index 00000000..8e7a4155 --- /dev/null +++ b/backend/authentication/tests/test_authentication_views.py @@ -0,0 +1,90 @@ +import json +from rest_framework.reverse import reverse +from rest_framework.test import APITestCase +from rest_framework_simplejwt.tokens import AccessToken +from authentication.models import User +from ypovoli import settings + + +class TestWhomAmIView(APITestCase): + def setUp(self): + """Create a user and generate a token for that user""" + self.user = User.objects.create(**{ + "id": "1234", + "username": "ddickwd", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "McDickwad", + }) + + self.token = f'Bearer {AccessToken().for_user(self.user)}' + + def test_who_am_i_view_get_returns_user_if_existing_and_authenticated(self): + """ + WhoAmIView should return the User info when requested if User + exists in database and token is supplied. + """ + self.client.credentials(HTTP_AUTHORIZATION=self.token) + + response = self.client.get(reverse("cas-whoami")) + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["id"], self.user.id) + + def test_who_am_i_view_get_does_not_return_viewer_if_deleted_but_authenticated( + self, + ): + """ + WhoAmIView should return that the user was not found if + authenticated user was deleted from the database. + """ + self.user.delete() + self.client.credentials(HTTP_AUTHORIZATION=self.token) + + response = self.client.get(reverse("cas-whoami")) + self.assertEqual(response.status_code, 401) + + def test_who_am_i_view_returns_401_when_not_authenticated(self): + """WhoAmIView should return a 401 status code when the user is not authenticated""" + response = self.client.get(reverse("cas-whoami")) + self.assertEqual(response.status_code, 401) + + +class TestLogoutView(APITestCase): + def setUp(self): + user_data = { + "id": "1234", + "username": "ddickwd", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "McDickwad", + } + self.user = User.objects.create(**user_data) + + def test_logout_view_authenticated_logout_url(self): + """LogoutView should return a logout url redirect if authenticated user sends a post request.""" + access_token = AccessToken().for_user(self.user) + self.token = f"Bearer {access_token}" + self.client.credentials(HTTP_AUTHORIZATION=self.token) + response = self.client.get(reverse("cas-logout")) + self.assertEqual(response.status_code, 302) + url = "{server_url}/logout?service={service_url}".format( + server_url=settings.CAS_ENDPOINT, service_url=settings.API_ENDPOINT + ) + self.assertEqual(response["Location"], url) + + def test_logout_view_not_authenticated_logout_url(self): + """LogoutView should return a 401 error when trying to access it while not authenticated.""" + response = self.client.get(reverse("cas-logout")) + self.assertEqual(response.status_code, 401) + + +class TestLoginView(APITestCase): + def test_login_view_returns_login_url(self): + """LoginView should return a login url redirect if a post request is sent.""" + response = self.client.get(reverse("cas-login")) + self.assertEqual(response.status_code, 302) + url = "{server_url}/login?service={service_url}".format( + server_url=settings.CAS_ENDPOINT, service_url=settings.CAS_RESPONSE + ) + self.assertEqual(response["Location"], url) diff --git a/backend/authentication/urls.py b/backend/authentication/urls.py index 4bdc45cd..2214cc67 100644 --- a/backend/authentication/urls.py +++ b/backend/authentication/urls.py @@ -1,22 +1,16 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView, TokenVerifyView -from authentication.views.auth import WhoAmIView, LoginView, LogoutView, TokenEchoView -from authentication.views.users import UsersView +from authentication.views import CASViewSet router = DefaultRouter() -router.register('users', UsersView, basename='user') +router.register("cas", CASViewSet, "cas") urlpatterns = [ - # USER endpoints. - path('', include(router.urls)), # AUTH endpoints. - path('login', LoginView.as_view(), name='auth.login'), - path('logout', LogoutView.as_view(), name='auth.logout'), - path('whoami', WhoAmIView.as_view(), name='auth.whoami'), - path('echo', TokenEchoView.as_view(), name='auth.echo'), + path("", include(router.urls)), # TOKEN endpoints. - path('token', TokenObtainPairView.as_view(), name='auth.token'), - path('token/refresh', TokenRefreshView.as_view(), name='auth.token.refresh'), - path('token/verify', TokenVerifyView.as_view(), name='auth.token.verify') -] \ No newline at end of file + path("token", TokenObtainPairView.as_view(), name="token"), + path("token/refresh", TokenRefreshView.as_view(), name="token-refresh"), + path("token/verify", TokenVerifyView.as_view(), name="token-verify") +] diff --git a/backend/authentication/views.py b/backend/authentication/views.py new file mode 100644 index 00000000..f029defd --- /dev/null +++ b/backend/authentication/views.py @@ -0,0 +1,61 @@ +from django.shortcuts import redirect +from django.contrib.auth import logout +from rest_framework.decorators import action +from rest_framework.viewsets import ViewSet +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.exceptions import AuthenticationFailed +from rest_framework.permissions import AllowAny, IsAuthenticated +from authentication.permissions import IsDebug +from authentication.serializers import UserSerializer, CASTokenObtainSerializer +from authentication.cas.client import client +from ypovoli import settings + + +class CASViewSet(ViewSet): + # The IsAuthenticated class is applied by default, + # but it's good to be verbose when it comes to security. + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['GET'], permission_classes=[AllowAny]) + def login(self, _: Request) -> Response: + """Attempt to log in. Redirect to our single CAS endpoint.""" + return redirect(client.get_login_url()) + + @action(detail=False, methods=['GET']) + def logout(self, request: Request) -> Response: + """Attempt to log out. Redirect to our single CAS endpoint. + Normally would only allow POST requests to a logout endpoint. + Since the CAS logout location handles the actual logout, we should accept GET requests. + """ + logout(request) + + return redirect( + client.get_logout_url(service_url=settings.API_ENDPOINT) + ) + + @action(detail=False, methods=['GET'], url_path='whoami', url_name='whoami') + def who_am_i(self, request: Request) -> Response: + """Get the user account data for the logged-in user. + The logged-in user is determined by the provided access token in the + Authorization HTTP header. + """ + user_serializer = UserSerializer(request.user, context={ + 'request': request + }) + + return Response( + user_serializer.data + ) + + @action(detail=False, methods=['GET'], permission_classes=[IsDebug]) + def echo(self, request: Request) -> Response: + """Echo the obtained CAS token for development and testing.""" + token_serializer = CASTokenObtainSerializer(data=request.query_params, context={ + 'request': request + }) + + if token_serializer.is_valid(): + return Response(token_serializer.validated_data) + + raise AuthenticationFailed(token_serializer.errors) diff --git a/backend/authentication/views/__init__.py b/backend/authentication/views/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/backend/authentication/views/auth.py b/backend/authentication/views/auth.py deleted file mode 100644 index 20730e50..00000000 --- a/backend/authentication/views/auth.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.shortcuts import redirect -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework.request import Request -from rest_framework.permissions import IsAuthenticated -from authentication.serializers import UserSerializer -from authentication.cas.client import client -from ypovoli import settings - -class WhoAmIView(APIView): - permission_classes = [IsAuthenticated] - - def get(self, request: Request) -> Response: - """Get the user account data for the current user""" - return Response(UserSerializer(request.user).data) - -class LogoutView(APIView): - permission_classes = [IsAuthenticated] - - def post(self, request: Request) -> Response: - """Attempt to log out. Redirect to our single CAS endpoint.""" - return redirect(client.get_logout_url(service_url=settings.API_ENDPOINT)) - -class LoginView(APIView): - def get(self, request: Request): - """Attempt to log in. Redirect to our single CAS endpoint.""" - return redirect(client.get_login_url()) - -class TokenEchoView(APIView): - def get(self, request: Request) -> Response: - return Response(request.query_params.get('ticket')) \ No newline at end of file diff --git a/backend/checks/apps.py b/backend/checks/apps.py index 28a74284..5fa5cda6 100644 --- a/backend/checks/apps.py +++ b/backend/checks/apps.py @@ -2,5 +2,5 @@ class ChecksConfig(AppConfig): - default_auto_field = 'django.db.models.BigAutoField' - name = 'checks' + default_auto_field = "django.db.models.BigAutoField" + name = "checks" diff --git a/backend/manage.py b/backend/manage.py index f2b51f89..75478bbb 100755 --- a/backend/manage.py +++ b/backend/manage.py @@ -6,7 +6,7 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") try: from django.core.management import execute_from_command_line except ImportError as exc: @@ -18,5 +18,5 @@ def main(): execute_from_command_line(sys.argv) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py index 04d5d437..3a084766 100644 --- a/backend/notifications/apps.py +++ b/backend/notifications/apps.py @@ -4,7 +4,3 @@ class NotificationsConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "notifications" - - -# TODO: Signals to send notifications -# TODO: Send emails diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py index 5565733d..c0a67c04 100644 --- a/backend/notifications/migrations/0001_initial.py +++ b/backend/notifications/migrations/0001_initial.py @@ -6,7 +6,6 @@ class Migration(migrations.Migration): - initial = True dependencies = [ @@ -15,23 +14,45 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='NotificationTemplate', + name="NotificationTemplate", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('title_key', models.CharField(max_length=255)), - ('description_key', models.CharField(max_length=511)), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("title_key", models.CharField(max_length=255)), + ("description_key", models.CharField(max_length=511)), ], ), migrations.CreateModel( - name='Notification', + name="Notification", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('arguments', models.JSONField(default=dict)), - ('is_read', models.BooleanField(default=False)), - ('is_sent', models.BooleanField(default=False)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ('template_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='notifications.notificationtemplate')), + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("arguments", models.JSONField(default=dict)), + ("is_read", models.BooleanField(default=False)), + ("is_sent", models.BooleanField(default=False)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "template_id", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="notifications.notificationtemplate", + ), + ), ], ), ] diff --git a/backend/ypovoli/asgi.py b/backend/ypovoli/asgi.py index 70cd7d09..ac8466f7 100644 --- a/backend/ypovoli/asgi.py +++ b/backend/ypovoli/asgi.py @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") application = get_asgi_application() diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py index c912fa45..32355200 100644 --- a/backend/ypovoli/settings.py +++ b/backend/ypovoli/settings.py @@ -36,13 +36,12 @@ "django.contrib.contenttypes", "django.contrib.sessions", "django.contrib.messages", - + "django.contrib.staticfiles", # Third party "rest_framework_swagger", # Swagger "rest_framework", # Django rest framework "drf_yasg", # Yet Another Swagger generator "sslserver", # Used for local SSL support (needed by CAS) - # First party "authentication", # Ypovoli authentication "api", # Ypovoli logic of the base application @@ -65,8 +64,12 @@ "rest_framework.renderers.JSONRenderer", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework_simplejwt.authentication.JWTAuthentication" + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication" ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated' + ] } SIMPLE_JWT = { @@ -77,15 +80,13 @@ } AUTH_USER_MODEL = "authentication.User" - ROOT_URLCONF = "ypovoli.urls" - WSGI_APPLICATION = "ypovoli.wsgi.application" # Application endpoints CAS_ENDPOINT = "https://login.ugent.be" -CAS_RESPONSE = "https://localhost:8080/auth/echo" +CAS_RESPONSE = "https://localhost:8080/auth/cas/echo" API_ENDPOINT = "https://localhost:8080" # Database @@ -109,3 +110,23 @@ USE_I18N = True USE_L10N = False USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ +STATIC_URL = "static/" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py index 6f4771a1..25e30a72 100644 --- a/backend/ypovoli/urls.py +++ b/backend/ypovoli/urls.py @@ -15,6 +15,19 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.urls import path, include +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="Ypovoli API", + default_version="v1", + ), + public=True, + permission_classes=[permissions.AllowAny,], +) + urlpatterns = [ # Base API endpoints. @@ -22,4 +35,7 @@ # Authentication endpoints. path("auth/", include("authentication.urls")), path("notifications/", include("notifications.urls")), + # Swagger documentation. + path("swagger/", schema_view.with_ui("swagger", cache_timeout=0), name="schema-swagger-ui"), + path("swagger/", schema_view.without_ui(cache_timeout=0), name="schema-json"), ] diff --git a/backend/ypovoli/wsgi.py b/backend/ypovoli/wsgi.py index c617cd31..0495fc95 100644 --- a/backend/ypovoli/wsgi.py +++ b/backend/ypovoli/wsgi.py @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'ypovoli.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") application = get_wsgi_application()