diff --git a/.dev.env b/.dev.env new file mode 100644 index 00000000..624d2c4b --- /dev/null +++ b/.dev.env @@ -0,0 +1,24 @@ +# Default values +PUID=1000 +PGID=1000 +TZ=Europe/Brussels + +# File directories +DATA_DIR="./data" +BACKEND_DIR="./backend" +FRONTEND_DIR="./frontend" +SSL_DIR="./data/nginx/ssl" + +# Redis +REDIS_IP=192.168.90.10 +REDIS_PORT=6379 + +# Django +DJANGO_SECRET_KEY="" # Set to a random string +DJANGO_DEBUG=True # Django debug mode +DJANGO_DOMAIN_NAME=localhost # Domain name for the Django server +DJANGO_CAS_URL_PREFIX="" # URL prefix for the CAS server. Should be empty for development +DJANGO_CAS_PORT=8080 # Port for the CAS server. Should be 8080 if DJANGO_DOMAIN_NAME is localhost +DJANGO_DB_ENGINE=django.db.backends.sqlite3 # Database engine +DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration +DJANGO_REDIS_PORT=${REDIS_PORT} \ No newline at end of file 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..0d56f627 --- /dev/null +++ b/.github/workflows/backend-tests.yaml @@ -0,0 +1,27 @@ +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: Compile translations + run: django-admin compilemessages + - name: Execute tests + run: cd backend; python manage.py test diff --git a/.github/workflows/deployement.yml b/.github/workflows/deployement.yml new file mode 100644 index 00000000..ac9d3110 --- /dev/null +++ b/.github/workflows/deployement.yml @@ -0,0 +1,23 @@ +name: Deploy +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: Deploy + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + key: ${{ secrets.SSH_KEY }} + port: ${{ secrets.PORT }} + script: | + cd UGent-7 + docker-compose -f production.yml down + ${{ secrets.PULL_SCRIPT }} + docker-compose -f production.yml build --no-cache + docker-compose -f production.yml up -d diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..ef08eecc --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +.tool-versions +.env +.venv +.idea +.vscode +data/* +data/nginx/ssl/* +data/postres* +data/redis/* +backend/data/production/* + +/node_modules +backend/staticfiles/* + +!data/nginx/ssl/.gitkeep diff --git a/.prod.env b/.prod.env new file mode 100644 index 00000000..f7a8f530 --- /dev/null +++ b/.prod.env @@ -0,0 +1,36 @@ +# Default values +PUID=1000 +PGID=1000 +TZ=Europe/Brussels + +# File directories +DATA_DIR="./data" +BACKEND_DIR="./backend" +FRONTEND_DIR="./frontend" +SSL_DIR="" + +# Postgress DB +POSTGRES_IP=192.168.90.9 +POSTGRES_PORT=5432 +POSTGRES_DB=selab +POSTGRES_USER=selab_user +POSTGRES_PASSWORD="" # Set to desired password + +# Redis +REDIS_IP=192.168.90.10 +REDIS_PORT=6379 + +# Django +DJANGO_SECRET_KEY="" # Set to a random string +DJANGO_DEBUG=False # Django debug mode +DJANGO_DOMAIN_NAME="" # Domain name for the Django server +DJANGO_CAS_URL_PREFIX="" # URL prefix for the CAS server +DJANGO_CAS_PORT="" # Port for the CAS server +DJANGO_DB_ENGINE=django.db.backends.postgresql # Database engine configuration +DJANGO_DB_NAME=${POSTGRES_DB} +DJANGO_DB_USER=${POSTGRES_USER} +DJANGO_DB_PASSWORD=${POSTGRES_PASSWORD} +DJANGO_DB_HOST=${POSTGRES_IP} +DJANGO_DB_PORT=${POSTGRES_PORT} +DJANGO_REDIS_HOST=${REDIS_IP} # Redis configuration +DJANGO_REDIS_PORT=${REDIS_PORT} \ No newline at end of file diff --git a/README.md b/README.md index fd6335f8..4c81c019 100644 --- a/README.md +++ b/README.md @@ -1 +1,10 @@ -# UGent-7 \ No newline at end of file +# Ypovoli + +![backend linting](https://github.com/SELab-2/UGent-7/actions/workflows/backend-linting.yaml/badge.svg) +![backend tests](https://github.com/SELab-2/UGent-7/actions/workflows/backend-tests.yaml/badge.svg) + +This application was developed within the framework of the course "Software Engineering Lab 2" within the Computer Science program at Ghent University. + +## Documentation + +See our wiki at [https://github.com/SELab-2/UGent-7/wiki](https://github.com/SELab-2/UGent-7/wiki) for more detailed information on the project's architecture. 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..91d939c2 --- /dev/null +++ b/backend/.flake8 @@ -0,0 +1,15 @@ +[flake8] + +# Ignore unused imports +ignore = F401 + +max-line-length = 125 + +max-complexity = 10 + +exclude = .git, + __pycache__, + .venv, + venv, + migrations + diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 00000000..4c4ced1e --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +.venv +.idea +db.sqlite3 +__pycache__ +*.mo \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..0365d5f9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /code/ +RUN pip install -r requirements.txt + +COPY . /code/ diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 00000000..75a7bf99 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,16 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt /code/ +RUN pip install -r requirements.txt + +COPY . /code/ + +RUN ./setup.sh + diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod new file mode 100644 index 00000000..1377e32d --- /dev/null +++ b/backend/Dockerfile.prod @@ -0,0 +1,15 @@ +FROM python:3.11.4 + +RUN apt update && apt install -y gettext libgettextpo-dev && pip install --upgrade pip + +WORKDIR /code + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN ./setup.sh diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 00000000..c870a0eb --- /dev/null +++ b/backend/README.md @@ -0,0 +1,15 @@ +# Prerequisites + +- python 3.11.8 + +__Django doesn't support python 3.12__ + +# Install instructions + +- Create a virtual environment `python -m venv .venv` (make sure to use the right python version) + +- Activate the virtual environment `source .venv/bin/activate` + +- Run `setup.sh` + +- Run the server `python manage.py runsslserver localhost:8080` \ No newline at end of file diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/apps.py b/backend/api/apps.py new file mode 100644 index 00000000..55a607c6 --- /dev/null +++ b/backend/api/apps.py @@ -0,0 +1,12 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "api" + + def ready(self): + 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..134302e7 --- /dev/null +++ b/backend/api/fixtures/checks.yaml @@ -0,0 +1,22 @@ +- model: api.extracheck + pk: 1 + fields: + project: 123456 + run_script: 'scripts/run.sh' + +- model: api.fileextension + pk: 1 + fields: + extension: 'class' +- model: api.fileextension + pk: 2 + fields: + extension: 'png' +- model: api.fileextension + pk: 3 + fields: + extension: 'java' +- model: api.fileextension + pk: 4 + fields: + extension: 'py' 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..40657f3f --- /dev/null +++ b/backend/api/fixtures/groups.yaml @@ -0,0 +1,20 @@ +- model: api.group + pk: 1 + fields: + project: 123456 + score: 7 + students: + - '1' + - '2' +- model: api.group + pk: 3 + fields: + project: 123456 + score: 8 + students: [] +- model: api.group + pk: 2 + fields: + project: 1 + score: 8 + students: [] diff --git a/backend/api/fixtures/projects.yaml b/backend/api/fixtures/projects.yaml new file mode 100644 index 00000000..6e16ad73 --- /dev/null +++ b/backend/api/fixtures/projects.yaml @@ -0,0 +1,24 @@ +- 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 + group_size: 3 + max_score: 20 + course: 2 +- model: api.project + pk: 1 + fields: + name: sel3 + 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 + group_size: 3 + max_score: 20 + course: 1 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..af3627eb --- /dev/null +++ b/backend/api/fixtures/submissions.yaml @@ -0,0 +1,38 @@ +- model: api.submission + pk: 1 + fields: + group: 1 + submission_number: 1 + submission_time: '2021-01-01T00:00:00Z' + structure_checks_passed: True +- model: api.submission + pk: 2 + fields: + group: 1 + submission_number: 2 + submission_time: '2021-01-02T00:00:00Z' + structure_checks_passed: True + +- 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' + +- model: api.extrachecksresult + pk: 1 + fields: + submission: 1 + extra_check: 1 + passed: False +- model: api.extrachecksresult + pk: 2 + fields: + submission: 2 + extra_check: 1 + passed: True 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/helpers/__init__.py b/backend/api/helpers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/helpers/check_folder_structure.py b/backend/api/helpers/check_folder_structure.py new file mode 100644 index 00000000..bfac3e4b --- /dev/null +++ b/backend/api/helpers/check_folder_structure.py @@ -0,0 +1,196 @@ +import zipfile +import os +from api.models.checks import StructureCheck +from api.models.extension import FileExtension +from django.utils.translation import gettext +from django.conf import settings + + +def parse_zip_file(project, dir_path): # TODO block paths that start with .. + dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) + struct = get_zip_structure(dir_path) + + sorted_keys = sorted(struct.keys()) + + for key in sorted_keys: + value = struct[key] + check = StructureCheck.objects.create( + name=key, + project=project + ) + for ext in value["obligated_extensions"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + check.obligated_extensions.add(extensie.id) + project.structure_checks.add(check) + + +# TODO block paths that start with .. +def check_zip_file(project, dir_path, restrict_extra_folders=False): + dir_path = os.path.normpath(os.path.join(settings.MEDIA_ROOT, dir_path)) + project_structure_checks = StructureCheck.objects.filter(project=project.id) + structuur = {} + for struct in project_structure_checks: + structuur[struct.name] = { + 'obligated_extensions': set(), + 'blocked_extensions': set() + } + for ext in struct.obligated_extensions.all(): + structuur[struct.name]["obligated_extensions"].add(ext.extension) + for ext in struct.blocked_extensions.all(): + structuur[struct.name]["blocked_extensions"].add(ext.extension) + return check_zip_structure( + structuur, dir_path, restrict_extra_folders=restrict_extra_folders) + + +def get_parent_directories(dir_path): + components = dir_path.split('/') + parents = set() + current_path = "" + for component in components: + if current_path != "": + current_path = current_path + "/" + component + else: + current_path = component + parents.add(current_path) + return parents + + +def list_zip_directories(zip_file_path): + """ + List all directories in a zip file. + :param zip_file_path: Path where the zip file is located. + :return: List of directory names. + """ + dirs = set() + with zipfile.ZipFile(zip_file_path, 'r') as zip_ref: + info_list = zip_ref.infolist() + for file in info_list: + if file.is_dir(): + dir_path = file.filename[:-1] + parent_dirs = get_parent_directories(dir_path) + dirs.update(parent_dirs) + return dirs + + +def get_zip_structure(root_path): + directory_structure = {} + base, _ = os.path.splitext(root_path) + inhoud = list_zip_directories(root_path) + inhoud.add(".") + for inh in inhoud: + directory_structure[inh] = { + 'obligated_extensions': set(), + 'blocked_extensions': set() + } + with zipfile.ZipFile(root_path, 'r') as zip_file: + file_names = zip_file.namelist() + for file_name in file_names: + parts = file_name.rsplit('/', 1) + if len(parts) == 2: + map, file = parts + _, file_extension = os.path.splitext(file) + file_extension = file_extension[1:] + if not file_extension == "": + directory_structure[map]["obligated_extensions"].add(file_extension) + else: + _, file_extension = os.path.splitext(file_name) + file_extension = file_extension[1:] + directory_structure["."]["obligated_extensions"].add(file_extension) + return directory_structure + + +def check_zip_content( + root_path, + dir_path, + obligated_extensions, + blocked_extensions): + """ + Check the content of a directory without traversing subdirectories. + parameters: + dir_path: The path of the zip we need to check. + obligated_extensions: The file extensions that are obligated to be present. + blocked_extensions: The file extensions that are forbidden to be present. + :return: + True: All checks pass. + False: At least 1 blocked extension is found + or 1 obligated extension is not found. + """ + dir_path = dir_path.replace('\\', '/') + with zipfile.ZipFile(root_path, 'r') as zip_file: + zip_contents = set(zip_file.namelist()) + found_obligated = set() # To track found obligated extensions + if dir_path == ".": + files_in_subdirectory = [file for file in zip_contents if "/" not in file] + else: + files_in_subdirectory = [ + file[len(dir_path) + 1:] for file in zip_contents + if file.startswith(dir_path) and '/' not in file[len(dir_path) + 1:] and bool(file[len(dir_path) + 1:])] + + for file in files_in_subdirectory: + _, file_extension = os.path.splitext(file) + # file_extension[1:] to remove the . + file_extension = file_extension[1:] + + if file_extension in blocked_extensions: + # print( + # f"Error: {file_extension} found in + # '{dir_path}' is not allowed.") TODO + return False, gettext( + 'zip.errors.invalid_structure.blocked_extension_found') + elif file_extension in obligated_extensions: + found_obligated.add(file_extension) + if set(obligated_extensions) <= found_obligated: + return True, gettext('zip.success') + else: + return False, gettext( + 'zip.errors.invalid_structure.obligated_extension_not_found') + + +def check_zip_structure( + folder_structure, + zip_file_path, + restrict_extra_folders=False): + # print(f"Checking folder_structure: {folder_structure}") + # print(f"Checking zip_file_path: {zip_file_path}") + """ + Check the structure of a zip file. + + parameters: + zip_file_path: Path to the zip file. + folder_structure: Dictionary representing the expected folder structure. + :return: + True: Zip file structure matches the expected structure. + False: Zip file structure does not match the expected structure. + """ + base, _ = os.path.splitext(zip_file_path) + struc = [f for f in folder_structure.keys() if not f == "."] + + dirs = list_zip_directories(zip_file_path) + for dir in struc: + if dir not in dirs: + # print(f"Error: Directory '{dir}' not defined.") TODO + return False, gettext( + 'zip.errors.invalid_structure.directory_not_defined') + + for directory, info in folder_structure.items(): + obligated_extensions = info.get('obligated_extensions', set()) + blocked_extensions = info.get('blocked_extensions', set()) + + result, message = check_zip_content( + zip_file_path, + directory, + obligated_extensions, + blocked_extensions) + if not result: + return result, message + # Check for any directories not present in the folder structure dictionary + if restrict_extra_folders: + for actual_directory in dirs: + if actual_directory not in struc: + # print(f"Error: Directory '{actual_directory}' + # not defined in the folder structure dictionary.") TODO + return False, gettext( + 'zip.errors.invalid_structure.directory_not_found_in_template') + return True, gettext('zip.success') diff --git a/backend/api/locale/en/LC_MESSAGES/django.po b/backend/api/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..ac864a0a --- /dev/null +++ b/backend/api/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,148 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-13 23:12+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "The submitted zip file contains a file with a non-allowed extension." + +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "The submitted zip file succeeds in all checks." + +#: helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "" +"The submitted zip file doesn't have any file with a certain file extension " +"that's obligated." + +#: helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "An obligated directory was not found in the submitted zip file." + +#: helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "" +"There was a directory found in the submitted zip file, which was not asked " +"for." + +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "The course is not supplied in the context." + +#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: tests/test_locale.py:38 +msgid "courses.error.students.already_present" +msgstr "The student is already present in the course." + +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "The course is from a past year, thus cannot be manipulated." + +#: serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "The student is not present in the course." + +#: serializers/group_serializer.py:47 +msgid "group.errors.score_exceeds_max" +msgstr "The score exceeds the group's max score." + +#: serializers/group_serializer.py:57 serializers/group_serializer.py:87 +msgid "group.error.context" +msgstr "The group is not supplied in the context." + +#: serializers/group_serializer.py:65 serializers/group_serializer.py:99 +msgid "group.errors.locked" +msgstr "The group is currently locked." + +#: serializers/group_serializer.py:69 +msgid "group.errors.full" +msgstr "The group is already full." + +#: serializers/group_serializer.py:73 +msgid "group.errors.not_in_course" +msgstr "The student is not present in the related course." + +#: serializers/group_serializer.py:77 +msgid "group.errors.already_in_group" +msgstr "The student is already in the group." + +#: serializers/group_serializer.py:95 +msgid "group.errors.not_present" +msgstr "The student is currently not in the group." + +#: serializers/project_serializer.py:56 +msgid "project.errors.context" +msgstr "The project is not supplied in the context." + +#: serializers/project_serializer.py:60 +msgid "project.errors.start_date_in_past" +msgstr "The start date of the project lies in the past." + +#: serializers/project_serializer.py:64 +msgid "project.errors.deadline_before_start_date" +msgstr "The deadline of the project lies before the start date of the project." + +#: serializers/project_serializer.py:89 +msgid "project.error.submissions.past_project" +msgstr "The deadline of the project has already passed." + +#: serializers/project_serializer.py:92 +msgid "project.error.submissions.non_visible_project" +msgstr "The project is currently in a non-visible state." + +#: serializers/project_serializer.py:95 +msgid "project.error.submissions.archived_project" +msgstr "The project is archived." + +#: views/course_view.py:58 +msgid "courses.success.assistants.add" +msgstr "The assistant was successfully added to the course." + +#: views/course_view.py:77 +msgid "courses.success.assistants.remove" +msgstr "The assistant was successfully removed from the course." + +#: views/course_view.py:111 +msgid "courses.success.students.add" +msgstr "The student was successfully added to the course." + +#: views/course_view.py:131 +msgid "courses.success.students.remove" +msgstr "The student was successfully removed from the course." + +#: views/course_view.py:186 +msgid "course.success.project.add" +msgstr "The project was successfully added to the course." + +#: views/group_view.py:73 +msgid "group.success.students.add" +msgstr "The student was successfully added to the group." + +#: views/group_view.py:92 +msgid "group.success.students.remove" +msgstr "The student was successfully removed from the group." + +#: views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "The submission was successfully added to the group." + +#: views/project_view.py:80 +msgid "project.success.groups.created" +msgstr "A group was successfully created for the project." diff --git a/backend/api/locale/nl/LC_MESSAGES/django.po b/backend/api/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..320b5cb0 --- /dev/null +++ b/backend/api/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,149 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-13 23:07+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: helpers/check_folder_structure.py:141 +msgid "zip.errors.invalid_structure.blocked_extension_found" +msgstr "" +"Bestanden met een verboden extensie zijn gevonden in het ingediende zip-" +"bestand." + +#: helpers/check_folder_structure.py:145 helpers/check_folder_structure.py:196 +msgid "zip.success" +msgstr "Het zip-bestand van de indiening bevat alle benodigde bestanden." + +#: helpers/check_folder_structure.py:148 +msgid "zip.errors.invalid_structure.obligated_extension_not_found" +msgstr "" +"Er is geen enkel bestand met een bepaalde extensie die verplicht is in het " +"ingediende zip-bestand." + +#: helpers/check_folder_structure.py:175 +msgid "zip.errors.invalid_structure.directory_not_defined" +msgstr "Een verplichte map is niet aanwezig in het ingediende zip-bestand." + +#: helpers/check_folder_structure.py:195 +msgid "zip.errors.invalid_structure.directory_not_found_in_template" +msgstr "Het ingediende zip-bestand bevat een map die niet gevraagd is." + +#: serializers/course_serializer.py:58 serializers/course_serializer.py:77 +msgid "courses.error.context" +msgstr "De opleiding is niet meegeleverd als context." + +#: serializers/course_serializer.py:64 tests/test_locale.py:28 +#: tests/test_locale.py:38 +msgid "courses.error.students.already_present" +msgstr "De student bevindt zich al in de opleiding." + +#: serializers/course_serializer.py:68 serializers/course_serializer.py:87 +msgid "courses.error.students.past_course" +msgstr "De opleiding die men probeert te manipuleren is van een vorig jaar." + +#: serializers/course_serializer.py:83 +msgid "courses.error.students.not_present" +msgstr "De student bevindt zich niet in de opleiding." + +#: serializers/group_serializer.py:47 +msgid "group.errors.score_exceeds_max" +msgstr "De score van de groep is groter dan de maximum score." + +#: serializers/group_serializer.py:57 serializers/group_serializer.py:87 +msgid "group.error.context" +msgstr "De groep is niet meegegeven als context waar dat nodig is." + +#: serializers/group_serializer.py:65 serializers/group_serializer.py:99 +msgid "group.errors.locked" +msgstr "De groep is momenteel vergrendeld." + +#: serializers/group_serializer.py:69 +msgid "group.errors.full" +msgstr "De groep is al vol." + +#: serializers/group_serializer.py:73 +msgid "group.errors.not_in_course" +msgstr "" +"De student bevindt zich niet in de opleiding waartoe het project hoort." + +#: serializers/group_serializer.py:77 +msgid "group.errors.already_in_group" +msgstr "De student bevindt zich al in de groep." + +#: serializers/group_serializer.py:95 +msgid "group.errors.not_present" +msgstr "De student bevindt zich niet in de groep." + +#: serializers/project_serializer.py:56 +msgid "project.errors.context" +msgstr "Het project is niet meegegeven als context waar dat nodig is." + +#: serializers/project_serializer.py:60 +msgid "project.errors.start_date_in_past" +msgstr "De startdatum van het project ligt in het verleden." + +#: serializers/project_serializer.py:64 +msgid "project.errors.deadline_before_start_date" +msgstr "De uiterste inleverdatum voor het project ligt voor de startdatum." + +#: serializers/project_serializer.py:89 +msgid "project.error.submissions.past_project" +msgstr "De uiterste inleverdatum voor het project is gepasseerd." + +#: serializers/project_serializer.py:92 +msgid "project.error.submissions.non_visible_project" +msgstr "Het project is niet zichtbaar." + +#: serializers/project_serializer.py:95 +msgid "project.error.submissions.archived_project" +msgstr "Het project is gearchiveerd." + +#: views/course_view.py:58 +msgid "courses.success.assistants.add" +msgstr "De assistent is succesvol toegevoegd aan de opleiding." + +#: views/course_view.py:77 +msgid "courses.success.assistants.remove" +msgstr "De assistent is succesvol verwijderd uit de opleiding." + +#: views/course_view.py:111 +msgid "courses.success.students.add" +msgstr "De student is succesvol toegevoegd aan de opleiding." + +#: views/course_view.py:131 +msgid "courses.success.students.remove" +msgstr "De student is succesvol verwijderd uit de opleiding." + +#: views/course_view.py:186 +msgid "course.success.project.add" +msgstr "Het project is succesvol toegevoegd aan de opleiding." + +#: views/group_view.py:73 +msgid "group.success.students.add" +msgstr "De student is succesvol toegevoegd aan de groep." + +#: views/group_view.py:92 +msgid "group.success.students.remove" +msgstr "De student is succesvol verwijderd uit de groep." + +#: views/group_view.py:111 +msgid "group.success.submissions.add" +msgstr "De indiening is succesvol toegevoegd aan de groep." + +#: views/project_view.py:80 +msgid "project.success.groups.created" +msgstr "De groep is succesvol toegevoegd aan het project." diff --git a/backend/api/migrations/0001_initial.py b/backend/api/migrations/0001_initial.py new file mode 100644 index 00000000..1254180c --- /dev/null +++ b/backend/api/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.0.2 on 2024-03-05 14:25 + +import datetime +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='FileExtension', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extension', models.CharField(max_length=10, unique=True)), + ], + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('academic_startyear', models.IntegerField()), + ('description', models.TextField(blank=True, null=True)), + ('parent_course', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_course', to='api.course')), + ], + ), + migrations.CreateModel( + name='Assistant', + 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)), + ('courses', models.ManyToManyField(blank=True, related_name='assistants', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + migrations.CreateModel( + name='Checks', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('dockerfile', models.FileField(blank=True, null=True, upload_to='')), + ('allowed_file_extensions', models.ManyToManyField(blank=True, related_name='checks_allowed', to='api.fileextension')), + ('forbidden_file_extensions', models.ManyToManyField(blank=True, related_name='checks_forbidden', to='api.fileextension')), + ], + ), + migrations.CreateModel( + name='Project', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, null=True)), + ('visible', models.BooleanField(default=True)), + ('archived', models.BooleanField(default=False)), + ('start_date', models.DateTimeField(blank=True, default=datetime.datetime.now)), + ('deadline', models.DateTimeField()), + ('checks', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='api.checks')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='api.course')), + ], + ), + migrations.CreateModel( + name='Student', + 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)), + ('student_id', models.CharField(max_length=8, null=True, unique=True)), + ('courses', models.ManyToManyField(blank=True, related_name='students', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.FloatField(blank=True, null=True)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', to='api.project')), + ('students', models.ManyToManyField(related_name='groups', to='api.student')), + ], + ), + migrations.CreateModel( + name='Submission', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submission_number', models.PositiveIntegerField()), + ('submission_time', models.DateTimeField(auto_now_add=True)), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='submissions', to='api.group')), + ], + options={ + 'unique_together': {('group', 'submission_number')}, + }, + ), + migrations.CreateModel( + name='SubmissionFile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to='')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='api.submission')), + ], + ), + migrations.CreateModel( + name='Teacher', + 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)), + ('courses', models.ManyToManyField(blank=True, related_name='teachers', to='api.course')), + ], + options={ + 'abstract': False, + }, + bases=('authentication.user',), + ), + ] diff --git a/backend/api/migrations/0002_project_group_size_project_max_score.py b/backend/api/migrations/0002_project_group_size_project_max_score.py new file mode 100644 index 00000000..17da27d0 --- /dev/null +++ b/backend/api/migrations/0002_project_group_size_project_max_score.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-03-05 10:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='group_size', + field=models.PositiveSmallIntegerField(default=1), + ), + migrations.AddField( + model_name='project', + name='max_score', + field=models.PositiveSmallIntegerField(default=100), + ), + ] diff --git a/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py b/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py new file mode 100644 index 00000000..8d5426a7 --- /dev/null +++ b/backend/api/migrations/0003_remove_project_checks_extracheck_structurecheck_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.2 on 2024-03-06 09:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0002_project_group_size_project_max_score'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='checks', + ), + migrations.CreateModel( + name='ExtraCheck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('run_script', models.FileField(upload_to='')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extra_checks', to='api.project')), + ], + ), + migrations.CreateModel( + name='StructureCheck', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('blocked_extensions', models.ManyToManyField(blank=True, related_name='blocked_extensions', to='api.fileextension')), + ('obligated_extensions', models.ManyToManyField(blank=True, related_name='obligated_extensions', to='api.fileextension')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='structure_checks', to='api.project')), + ], + ), + migrations.DeleteModel( + name='Checks', + ), + ] diff --git a/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py b/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py new file mode 100644 index 00000000..5c1abdab --- /dev/null +++ b/backend/api/migrations/0004_submission_structure_checks_passed_extrachecksresult.py @@ -0,0 +1,28 @@ +# Generated by Django 5.0.2 on 2024-03-06 11:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0003_remove_project_checks_extracheck_structurecheck_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='submission', + name='structure_checks_passed', + field=models.BooleanField(default=False), + ), + migrations.CreateModel( + name='ExtraChecksResult', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('passed', models.BooleanField(default=False)), + ('extra_check', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='results', to='api.extracheck')), + ('submission', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extra_checks_results', to='api.submission')), + ], + ), + ] diff --git a/backend/api/migrations/0005_alter_project_max_score.py b/backend/api/migrations/0005_alter_project_max_score.py new file mode 100644 index 00000000..faec3a5f --- /dev/null +++ b/backend/api/migrations/0005_alter_project_max_score.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-03-06 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0004_submission_structure_checks_passed_extrachecksresult'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='max_score', + field=models.PositiveSmallIntegerField(default=20), + ), + ] diff --git a/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py new file mode 100644 index 00000000..af36053b --- /dev/null +++ b/backend/api/migrations/0006_project_locked_groups_alter_project_start_date_and_more.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-03-12 20:25 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0005_alter_project_max_score"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="locked_groups", + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py b/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py new file mode 100644 index 00000000..dd13125d --- /dev/null +++ b/backend/api/migrations/0006_project_score_visible_alter_project_start_date_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.0.2 on 2024-03-12 09:49 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0005_alter_project_max_score'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='score_visible', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='project', + name='start_date', + field=models.DateTimeField(blank=True, default=django.utils.timezone.now), + ), + migrations.AlterField( + model_name='submission', + name='submission_number', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/api/migrations/0007_merge_20240313_0639.py b/backend/api/migrations/0007_merge_20240313_0639.py new file mode 100644 index 00000000..d95bfa44 --- /dev/null +++ b/backend/api/migrations/0007_merge_20240313_0639.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.2 on 2024-03-13 06:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("api", "0006_project_locked_groups_alter_project_start_date_and_more"), + ("api", "0006_project_score_visible_alter_project_start_date_and_more"), + ] + + operations = [] diff --git a/backend/api/migrations/__init__.py b/backend/api/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/models/assistant.py b/backend/api/models/assistant.py new file mode 100644 index 00000000..4c6d9f19 --- /dev/null +++ b/backend/api/models/assistant.py @@ -0,0 +1,18 @@ +from django.db import models +from authentication.models import User +from api.models.course import Course + + +class Assistant(User): + """This model represents a single assistant. + It extends the User model from the authentication app with + assistant-specific attributes. + """ + + # All the courses the assistant is assisting in + courses = models.ManyToManyField( + Course, + # Allows us to access the assistants from the course + related_name="assistants", + blank=True, + ) diff --git a/backend/api/models/checks.py b/backend/api/models/checks.py new file mode 100644 index 00000000..b27c3bdc --- /dev/null +++ b/backend/api/models/checks.py @@ -0,0 +1,63 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from api.models.project import Project +from api.models.extension import FileExtension + + +class StructureCheck(models.Model): + """Model that represents a structure check for a project. + This means that the structure of a submission is checked. + These checks are obligated to pass.""" + + # ID should be generated automatically + + # Name of the structure check + name = models.CharField( + max_length=100, + blank=False, + null=False + ) + + # Link to the project + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="structure_checks" + ) + + # Obligated extensions + obligated_extensions = models.ManyToManyField( + FileExtension, + related_name="obligated_extensions", + blank=True + ) + + # Blocked extensions + blocked_extensions = models.ManyToManyField( + FileExtension, + related_name="blocked_extensions", + blank=True + ) + + # ID check should be generated automatically + + +class ExtraCheck(models.Model): + """Model that represents an extra check for a project. + These checks are not obligated to pass.""" + + # ID should be generated automatically + + # Link to the project + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="extra_checks" + ) + + # Run script + # TODO set upload_to + run_script = models.FileField( + blank=False, + null=False + ) diff --git a/backend/api/models/course.py b/backend/api/models/course.py new file mode 100644 index 00000000..02f346e8 --- /dev/null +++ b/backend/api/models/course.py @@ -0,0 +1,56 @@ +from datetime import datetime +from typing import Self +from django.db import models + + +class Course(models.Model): + """This model represents a single course. + It contains all the information about a course.""" + + # ID of the course should automatically be generated + + name = models.CharField(max_length=100, blank=False, null=False) + + # Begin year of the academic year + academic_startyear = models.IntegerField(blank=False, null=False) + + 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", + # 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", + blank=True, + null=True, + ) + + def __str__(self) -> str: + """The string representation of the course.""" + return str(self.name) + + def is_past(self) -> bool: + """Returns whether the course is from a past academic year""" + return datetime(self.academic_startyear + 1, 10, 1) < datetime.now() + + def clone(self, clone_assistants=True) -> Self: + """Clone the course to the next academic start year""" + course = Course( + name=self.name, + description=self.description, + academic_startyear=self.academic_startyear + 1, + parent_course=self + ) + + if clone_assistants: + course.assistants.add(self.assistants) + + return course + + @property + def academic_year(self) -> str: + """The academic year of the course.""" + return f"{self.academic_startyear}-{self.academic_startyear + 1}" diff --git a/backend/api/models/extension.py b/backend/api/models/extension.py new file mode 100644 index 00000000..08238c0c --- /dev/null +++ b/backend/api/models/extension.py @@ -0,0 +1,12 @@ +from django.db import models + + +class FileExtension(models.Model): + """Model that represents a file extension.""" + + # ID should be generated automatically + + extension = models.CharField( + max_length=10, + unique=True + ) diff --git a/backend/api/models/group.py b/backend/api/models/group.py new file mode 100644 index 00000000..a03ab0a8 --- /dev/null +++ b/backend/api/models/group.py @@ -0,0 +1,34 @@ +from django.db import models +from api.models.project import Project +from api.models.student import Student + + +class Group(models.Model): + """Model for group of students that work together on a project.""" + + # ID should be generated automatically + + project = models.ForeignKey( + 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", + blank=False, + null=False, + ) + + # Students that are part of the group + students = models.ManyToManyField( + Student, + # This is how we can access groups from a student + related_name="groups", + blank=False, + ) + + # Score of the group + score = models.FloatField(blank=True, null=True) + + def is_full(self) -> bool: + """Check if the group is full.""" + return self.students.count() >= self.project.group_size diff --git a/backend/api/models/project.py b/backend/api/models/project.py new file mode 100644 index 00000000..d6d48a72 --- /dev/null +++ b/backend/api/models/project.py @@ -0,0 +1,101 @@ +from django.db import models +from datetime import timedelta +from django.utils import timezone +from api.models.course import Course + + +# TODO max submission size +class Project(models.Model): + """Model that represents a project.""" + + # ID should be generated automatically + + name = models.CharField(max_length=100, blank=False, null=False) + + description = models.TextField(blank=True, null=True) + + # Project already visible to students + visible = models.BooleanField(default=True) + + # Project archived + archived = models.BooleanField(default=False) + + # Locked groups + locked_groups = models.BooleanField(default=False) + + start_date = models.DateTimeField( + # The default value is the current date and time + default=timezone.now, + blank=True, + ) + + deadline = models.DateTimeField(blank=False, null=False) + + # Max score that can be achieved in the project + max_score = models.PositiveSmallIntegerField( + blank=False, + null=False, + default=20 + ) + + # Score already visible to students + score_visible = models.BooleanField(default=False) + + # Size of the groups than can be formed + group_size = models.PositiveSmallIntegerField( + blank=False, + null=False, + default=1 + ) + + # Course that the project belongs to + course = models.ForeignKey( + Course, + # If the course is deleted, the project should be deleted as well + on_delete=models.CASCADE, + related_name="projects", + blank=False, + null=False, + ) + + def deadline_approaching_in(self, days=7): + """Returns True if the deadline is approaching in the next days.""" + now = timezone.now() + approaching_date = now + timezone.timedelta(days=days) + return now <= self.deadline <= approaching_date + + def deadline_passed(self): + """Returns True if the deadline has passed.""" + now = timezone.now() + return now > self.deadline + + def is_archived(self): + """Returns True if a project is archived.""" + return self.archived + + def is_visible(self): + """Returns True if a project is visible.""" + return self.visible + + def toggle_visible(self): + """Toggles the visibility of the project.""" + self.visible = not self.visible + self.save() + + def toggle_archived(self): + """Toggles the archived status of the project.""" + self.archived = not self.archived + self.save() + + def is_groups_locked(self): + """Returns True if participating groups are locked.""" + return self.locked_groups + + def toggle_groups_locked(self): + """Toggles the locked state of the groups related to the project.""" + self.locked_groups = not self.locked_groups + self.save() + + def increase_deadline(self, days): + self.deadline = self.deadline + timedelta(days=days) + self.save() diff --git a/backend/api/models/student.py b/backend/api/models/student.py new file mode 100644 index 00000000..fea5cf73 --- /dev/null +++ b/backend/api/models/student.py @@ -0,0 +1,25 @@ +from django.db import models +from authentication.models import User +from api.models.course import Course + + +class Student(User): + """This model represents a single student. + 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) + + # All the courses the student is enrolled in + courses = models.ManyToManyField( + Course, + # Allows us to access the students from the course + related_name="students", + blank=True, + ) + + def is_enrolled_in_group(self, project): + """Check if the student is enrolled in a group for the given project.""" + return self.groups.filter(project=project).exists() diff --git a/backend/api/models/submission.py b/backend/api/models/submission.py new file mode 100644 index 00000000..174f5265 --- /dev/null +++ b/backend/api/models/submission.py @@ -0,0 +1,82 @@ +from django.db import models +from api.models.group import Group +from api.models.checks import ExtraCheck + + +class Submission(models.Model): + """Model for submission of a project by a group of students.""" + + # Submission ID should be generated automatically + + group = models.ForeignKey( + Group, + # If the group is deleted, the submission should be deleted as well + on_delete=models.CASCADE, + related_name="submissions", + blank=False, + null=False, + ) + + # Multiple submissions can be made by a group + submission_number = models.PositiveIntegerField(blank=True, null=True) + + # Automatically set the submission time to the current time + submission_time = models.DateTimeField(auto_now_add=True) + + # True if submission passed the structure checks + structure_checks_passed = models.BooleanField( + blank=False, + null=False, + default=False + ) + + class Meta: + # A group can only have one submission with a specific 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, + ) + + file = models.FileField(blank=False, null=False) + + +class ExtraChecksResult(models.Model): + """Model for the result of extra checks on a submission.""" + + # Result ID should be generated automatically + + submission = models.ForeignKey( + Submission, + on_delete=models.CASCADE, + related_name="extra_checks_results", + blank=False, + null=False, + ) + + # Link to the extra checks that were performed + extra_check = models.ForeignKey( + ExtraCheck, + on_delete=models.CASCADE, + related_name="results", + blank=False, + null=False, + ) + + # True if the submission passed the extra checks + passed = models.BooleanField( + blank=False, + null=False, + default=False + ) diff --git a/backend/api/models/teacher.py b/backend/api/models/teacher.py new file mode 100644 index 00000000..6866dd7b --- /dev/null +++ b/backend/api/models/teacher.py @@ -0,0 +1,22 @@ +from django.db import models +from api.models.course import Course +from authentication.models import User + + +class Teacher(User): + """This model represents a single teacher. + It extends the User model from the authentication app with + teacher-specific attributes. + """ + + # All the courses the teacher is teaching + courses = models.ManyToManyField( + Course, + # Allows us to access the teachers from the course + related_name="teachers", + blank=True, + ) + + def has_course(self, course: Course) -> bool: + """Checks if the teacher has the given course.""" + return self.courses.contains(course) diff --git a/backend/api/permissions/__init__.py b/backend/api/permissions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/permissions/assistant_permissions.py b/backend/api/permissions/assistant_permissions.py new file mode 100644 index 00000000..5cb76fc1 --- /dev/null +++ b/backend/api/permissions/assistant_permissions.py @@ -0,0 +1,23 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from api.permissions.role_permissions import is_teacher, is_assistant +from api.models.assistant import Assistant + + +class AssistantPermission(BasePermission): + """Permission class used as default policy for assistant endpoint.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general assistant endpoint.""" + user = request.user + + if view.action == "list": + # Only teachers can query the assistant list. + return user.is_authenticated and is_teacher(user) + + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, assistant: Assistant) -> bool: + # Teachers can view the details of all assistants. + # Users can view their own assistant object. + return is_teacher(request.user) or request.user.id == assistant.id diff --git a/backend/api/permissions/course_permissions.py b/backend/api/permissions/course_permissions.py new file mode 100644 index 00000000..a6c5ef19 --- /dev/null +++ b/backend/api/permissions/course_permissions.py @@ -0,0 +1,83 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher +from api.models.course import Course + + +class CoursePermission(BasePermission): + """Permission class used as default policy for course endpoints.""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general course endpoint.""" + user: User = request.user + + # Logged-in users can fetch course information. + if request.method in SAFE_METHODS: + return request.user.is_authenticated + + # Only teachers can create courses. + return is_teacher(user) + + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + """Check if user has permission to view a detailed course endpoint""" + user = request.user + + # Logged-in users can fetch course details. + if request.method in SAFE_METHODS: + return is_student(user) or is_teacher(user) or is_assistant(user) + + # We only allow teachers and assistants to modify their own courses. + return is_teacher(user) and user.teacher.courses.contains(course) or \ + is_assistant(user) and user.assistant.courses.contains(course) + + +class CourseAssistantPermission(CoursePermission): + """Permission class for assistant related endpoints.""" + + def has_object_permission(self, request: Request, view: ViewSet, course: Course) -> bool: + user: User = request.user + + # Logged-in users can fetch course assistants. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only teachers can modify assistants of their own courses. + return is_teacher(user) and user.teacher.has_course(course) + + +class CourseStudentPermission(CoursePermission): + """Permission class for student related endpoints.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + return request.user and request.user.is_authenticated + + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course students. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Only students can add or remove themselves from a course. + if is_student(user) and request.data.get("student_id") == user.id: + return True + + # Teachers and assistants can add and remove any student. + return super().has_object_permission(request, view, course) + + +class CourseProjectPermission(CoursePermission): + """Permission class for project related endpoints.""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + return request.user and request.user.is_authenticated + + def has_object_permission(self, request: Request, view: ViewSet, course: Course): + user: User = request.user + + # Logged-in users can fetch course projects. + if request.method in SAFE_METHODS: + return user.is_authenticated + + # Teachers and assistants can modify projects. + return super().has_object_permission(request, view, course) diff --git a/backend/api/permissions/faculty_permissions.py b/backend/api/permissions/faculty_permissions.py new file mode 100644 index 00000000..5c63a5c5 --- /dev/null +++ b/backend/api/permissions/faculty_permissions.py @@ -0,0 +1,17 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class FacultyPermission(BasePermission): + """Permission class for faculty related endpoints""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general faculty endpoint.""" + + # The general faculty endpoint that lists all faculties is accessible for any role. + if request.method in SAFE_METHODS: + return True + + # We only allow admins to create, update, and delete faculties. + return False diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py new file mode 100644 index 00000000..64eea5b1 --- /dev/null +++ b/backend/api/permissions/group_permissions.py @@ -0,0 +1,55 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher + + +class GroupPermission(BasePermission): + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general group endpoint.""" + user: User = request.user + + # The general group endpoint that lists all groups is not accessible for any role. + if request.method in SAFE_METHODS: + return True + + # We only allow teachers and assistants to create new groups. + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + """Check if user has permission to view a detailed group endpoint""" + user: User = request.user + course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the group. + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + + # We only allow teachers and assistants to modify specified groups. + return teacher_or_assitant + + +class GroupStudentPermission(BasePermission): + """Permission class for student related group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, group) -> bool: + user: User = request.user + course = group.project.course + teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + if request.method in SAFE_METHODS: + # Users related to the course can view the students of the group. + return teacher_or_assitant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + + # Students can only add and remove themselves from a group. + if is_student(user) and request.data.get("student_id") == user.id: + # Make sure the student is actually part of the course. + return user.student.courses.filter(id=course.id).exists() + + # Teachers and assistants can add and remove any student from a group + return teacher_or_assitant diff --git a/backend/api/permissions/notification_permissions.py b/backend/api/permissions/notification_permissions.py new file mode 100644 index 00000000..b9443fb6 --- /dev/null +++ b/backend/api/permissions/notification_permissions.py @@ -0,0 +1,10 @@ +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet + + +class NotificationPermission(BasePermission): + # The user can only access their own notifications + # An admin can access all notifications + def has_permission(self, request: Request, view: ViewSet) -> bool: + return view.kwargs.get("pk") == request.user.id or request.user.is_staff diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py new file mode 100644 index 00000000..aca5aee1 --- /dev/null +++ b/backend/api/permissions/project_permissions.py @@ -0,0 +1,51 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.permissions.role_permissions import is_student, is_assistant, is_teacher + + +class ProjectPermission(BasePermission): + """Permission class for project related endpoints""" + + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general project endpoint.""" + user: User = request.user + + # The general project endpoint that lists all projects is not accessible for any role. + if request.method in SAFE_METHODS: + return True + + # We only allow teachers and assistants to create new projects. + return is_teacher(user) or is_assistant(user) + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + """Check if user has permission to view a detailed project endpoint""" + user: User = request.user + course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the project. + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + + # We only allow teachers and assistants to modify specified projects. + return teacher_or_assistant + + +class ProjectGroupPermission(BasePermission): + """Permission class for project related group endpoints""" + + def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: + user: User = request.user + course = project.course + teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ + is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + + if request.method in SAFE_METHODS: + # Users that are linked to the course can view the group. + return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + + # We only allow teachers and assistants to create new groups. + return teacher_or_assistant diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py new file mode 100644 index 00000000..2c50aa5b --- /dev/null +++ b/backend/api/permissions/role_permissions.py @@ -0,0 +1,49 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.request import Request +from rest_framework.viewsets import ViewSet +from authentication.models import User +from api.models.student import Student +from api.models.assistant import Assistant +from api.models.teacher import Teacher + + +def is_student(user: User): + return Student.objects.filter(id=user.id).exists() + + +def is_assistant(user: User): + return Assistant.objects.filter(id=user.id).exists() + + +def is_teacher(user: User): + return Teacher.objects.filter(id=user.id).exists() + + +class IsStudent(IsAuthenticated): + def has_permission(self, request: Request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and is_student(request.user) + + +class IsTeacher(IsAuthenticated): + def has_permission(self, request: Request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and is_teacher(request.user) + + +class IsAssistant(IsAuthenticated): + def has_permission(self, request, view): + """Returns true if the request contains a user, + with said user being a student""" + return super().has_permission(request, view) and is_assistant(request.user) + + +class IsSameUser(IsAuthenticated): + def has_permission(self, request, view): + return False + + def has_object_permission(self, request: Request, view: ViewSet, user: User): + """Returns true if the request's user is the same as the given user""" + return super().has_permission(request, view) and user.id == request.user.id diff --git a/backend/api/serializers/__init__.py b/backend/api/serializers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/serializers/assistant_serializer.py b/backend/api/serializers/assistant_serializer.py new file mode 100644 index 00000000..c5569924 --- /dev/null +++ b/backend/api/serializers/assistant_serializer.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from ..models.assistant import Assistant + + +class AssistantSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( + 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", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] + + +class AssistantIDSerializer(serializers.Serializer): + assistant = serializers.PrimaryKeyRelatedField( + queryset=Assistant.objects.all() + ) diff --git a/backend/api/serializers/checks_serializer.py b/backend/api/serializers/checks_serializer.py new file mode 100644 index 00000000..082a7da9 --- /dev/null +++ b/backend/api/serializers/checks_serializer.py @@ -0,0 +1,47 @@ +from rest_framework import serializers +from ..models.extension import FileExtension +from ..models.checks import StructureCheck, ExtraCheck + + +class FileExtensionSerializer(serializers.ModelSerializer): + class Meta: + model = FileExtension + fields = ["extension"] + + +class StructureCheckSerializer(serializers.ModelSerializer): + + project = serializers.HyperlinkedRelatedField( + view_name="project-detail", + read_only=True + ) + + obligated_extensions = FileExtensionSerializer(many=True, required=False, default=[], read_only=True) + + blocked_extensions = FileExtensionSerializer(many=True, required=False, default=[], read_only=True) + + class Meta: + model = StructureCheck + fields = [ + "id", + "name", + "project", + "obligated_extensions", + "blocked_extensions" + ] + + +class ExtraCheckSerializer(serializers.ModelSerializer): + + project = serializers.HyperlinkedRelatedField( + view_name="project-detail", + read_only=True + ) + + class Meta: + model = ExtraCheck + fields = [ + "id", + "project", + "run_script" + ] diff --git a/backend/api/serializers/course_serializer.py b/backend/api/serializers/course_serializer.py new file mode 100644 index 00000000..de7d0b70 --- /dev/null +++ b/backend/api/serializers/course_serializer.py @@ -0,0 +1,89 @@ +from django.utils.translation import gettext +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from api.serializers.student_serializer import StudentIDSerializer +from api.models.course import Course + + +class CourseSerializer(serializers.ModelSerializer): + teachers = serializers.HyperlinkedIdentityField( + view_name="course-teachers", + read_only=True, + ) + + assistants = serializers.HyperlinkedIdentityField( + view_name="course-assistants", + read_only=True, + ) + + students = serializers.HyperlinkedIdentityField( + view_name="course-students", + read_only=True, + ) + + projects = serializers.HyperlinkedIdentityField( + view_name="course-projects", + read_only=True, + ) + + parent_course = serializers.HyperlinkedRelatedField( + 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", + ] + + +class CourseIDSerializer(serializers.Serializer): + student_id = serializers.PrimaryKeyRelatedField( + queryset=Course.objects.all() + ) + + +class StudentJoinSerializer(StudentIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Check if the student isn't already enrolled. + if course.students.contains(data["student_id"]): + raise ValidationError(gettext("courses.error.students.already_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.students.past_course")) + + return data + + +class StudentLeaveSerializer(StudentIDSerializer): + def validate(self, data): + # The validator needs the course context. + if "course" not in self.context: + raise ValidationError(gettext("courses.error.context")) + + course: Course = self.context["course"] + + # Check if the student isn't already enrolled. + if not course.students.contains(data["student_id"]): + raise ValidationError(gettext("courses.error.students.not_present")) + + # Check if the course is not from a past academic year. + if course.is_past(): + raise ValidationError(gettext("courses.error.students.past_course")) + + return data diff --git a/backend/api/serializers/faculty_serializer.py b/backend/api/serializers/faculty_serializer.py new file mode 100644 index 00000000..eab4a48d --- /dev/null +++ b/backend/api/serializers/faculty_serializer.py @@ -0,0 +1,8 @@ +from rest_framework import serializers +from authentication.models import Faculty + + +class facultySerializer(serializers.ModelSerializer): + class Meta: + model = Faculty + fields = ["name"] diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py new file mode 100644 index 00000000..2529d721 --- /dev/null +++ b/backend/api/serializers/group_serializer.py @@ -0,0 +1,101 @@ +from django.utils.translation import gettext +from rest_framework import serializers +from rest_framework.exceptions import ValidationError +from api.permissions.role_permissions import is_student +from api.models.group import Group +from api.models.student import Student +from api.serializers.student_serializer import StudentIDSerializer + + +class GroupSerializer(serializers.ModelSerializer): + project = serializers.HyperlinkedRelatedField( + many=False, read_only=True, view_name="project-detail" + ) + + students = serializers.HyperlinkedIdentityField( + view_name="group-students", + read_only=True, + ) + + submissions = serializers.HyperlinkedIdentityField( + view_name="group-submissions", + read_only=True, + ) + + class Meta: + model = Group + fields = ["id", "project", "students", "score", "submissions"] + + def to_representation(self, instance): + data = super().to_representation(instance) + + # If you are not a student, you can always see the score + if is_student(self.context["request"].user): + # Student can not see the score if they are not part of the group, or it is not visible yet + if not instance.students.filter(id=self.context["request"].user.student.id).exists() or\ + not instance.project.score_visible: + + data.pop("score") + + return data + + def validate(self, data): + # Make sure the score of the group is lower or equal to the maximum score + group: Group = self.instance + + if "score" in data and data["score"] > group.project.max_score: + raise ValidationError(gettext("group.errors.score_exceeds_max")) + + return data + + +class StudentJoinGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure a student can't join if groups are locked + if group.project.is_groups_locked(): + raise ValidationError(gettext("group.errors.locked")) + + # Make sure the group is not already full + if group.is_full(): + raise ValidationError(gettext("group.errors.full")) + + # Make sure the student is part of the course + if not group.project.course.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_in_course")) + + # Make sure the student is not already in a group + if student.is_enrolled_in_group(group.project): + raise ValidationError(gettext("group.errors.already_in_group")) + + return data + + +class StudentLeaveGroupSerializer(StudentIDSerializer): + + def validate(self, data): + # The validator needs the group context. + if "group" not in self.context: + raise ValidationError(gettext("group.error.context")) + + # Get the group and student + group: Group = self.context["group"] + student: Student = data["student_id"] + + # Make sure the student was in the group + if not group.students.filter(id=student.id).exists(): + raise ValidationError(gettext("group.errors.not_present")) + + # Make sure a student can't leave if groups are locked + if group.project.is_groups_locked(): + raise ValidationError(gettext("group.errors.locked")) + + return data diff --git a/backend/api/serializers/project_serializer.py b/backend/api/serializers/project_serializer.py new file mode 100644 index 00000000..47af0ed5 --- /dev/null +++ b/backend/api/serializers/project_serializer.py @@ -0,0 +1,128 @@ +from django.utils.translation import gettext +from rest_framework import serializers +from api.models.project import Project +from api.models.group import Group +from rest_framework.exceptions import ValidationError +from django.utils import timezone +from api.models.submission import Submission, SubmissionFile +from api.models.checks import FileExtension, StructureCheck +from api.serializers.submission_serializer import SubmissionSerializer +from api.serializers.checks_serializer import StructureCheckSerializer +from rest_framework.request import Request + + +class ProjectSerializer(serializers.ModelSerializer): + course = serializers.HyperlinkedRelatedField( + many=False, + view_name="course-detail", + read_only=True + ) + + structure_checks = serializers.HyperlinkedIdentityField( + view_name="project-structure-checks", + read_only=True + ) + + extra_checks = serializers.HyperlinkedIdentityField( + view_name="project-extra-checks", + read_only=True + ) + + groups = serializers.HyperlinkedIdentityField( + view_name="project-groups", + read_only=True + ) + + class Meta: + model = Project + fields = [ + "id", + "name", + "description", + "visible", + "archived", + "start_date", + "deadline", + "max_score", + "score_visible", + "group_size", + "structure_checks", + "extra_checks", + "course", + "groups" + ] + + def validate(self, data): + if "course" in self.context: + data["course_id"] = self.context["course"].id + else: + raise ValidationError(gettext("project.errors.context")) + + # Check if start date of the project is not in the past + if data["start_date"] < timezone.now().replace(hour=0, minute=0, second=0): + raise ValidationError(gettext("project.errors.start_date_in_past")) + + # Check if deadline of the project is before the start date + if data["deadline"] < data["start_date"]: + raise ValidationError(gettext("project.errors.deadline_before_start_date")) + + return data + + +class TeacherCreateGroupSerializer(serializers.Serializer): + number_groups = serializers.IntegerField(min_value=1) + + def validate(self, data): + return data + + +class SubmissionStatusSerializer(serializers.Serializer): + non_empty_groups = serializers.IntegerField(read_only=True) + groups_submitted = serializers.IntegerField(read_only=True) + submissions_passed = serializers.IntegerField(read_only=True) + + +class SubmissionAddSerializer(SubmissionSerializer): + def validate(self, data): + + group: Group = self.context["group"] + project: Project = group.project + + # Check if the project's deadline is not passed. + if project.deadline_passed(): + raise ValidationError(gettext("project.error.submissions.past_project")) + + if not project.is_visible(): + raise ValidationError(gettext("project.error.submissions.non_visible_project")) + + if project.is_archived(): + raise ValidationError(gettext("project.error.submissions.archived_project")) + + return data + + +class StructureCheckAddSerializer(StructureCheckSerializer): + def validate(self, data): + project: Project = self.context["project"] + if project.structure_checks.filter(name=data["name"]).count(): + raise ValidationError(gettext("project.error.structure_checks.already_existing")) + + obl_ext = set() + for ext in self.context["obligated"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + obl_ext.add(extensie) + data["obligated_extensions"] = obl_ext + + block_ext = set() + for ext in self.context["blocked"]: + extensie, _ = FileExtension.objects.get_or_create( + extension=ext + ) + if extensie in obl_ext: + raise ValidationError(gettext("project.error.structure_checks.extension_blocked_and_obligated")) + block_ext.add(extensie) + data["blocked_extensions"] = block_ext + + return data diff --git a/backend/api/serializers/student_serializer.py b/backend/api/serializers/student_serializer.py new file mode 100644 index 00000000..2c46593f --- /dev/null +++ b/backend/api/serializers/student_serializer.py @@ -0,0 +1,28 @@ +from rest_framework import serializers +from api.models.student import Student + + +class StudentSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( + view_name="student-courses", + read_only=True, + ) + + groups = serializers.HyperlinkedIdentityField( + view_name="student-groups", + read_only=True, + ) + + faculties = serializers.HyperlinkedRelatedField( + many=True, read_only=True, view_name="faculty-detail" + ) + + class Meta: + model = Student + fields = '__all__' + + +class StudentIDSerializer(serializers.Serializer): + student_id = serializers.PrimaryKeyRelatedField( + queryset=Student.objects.all() + ) diff --git a/backend/api/serializers/submission_serializer.py b/backend/api/serializers/submission_serializer.py new file mode 100644 index 00000000..6059e96d --- /dev/null +++ b/backend/api/serializers/submission_serializer.py @@ -0,0 +1,90 @@ +from rest_framework import serializers +from ..models.submission import Submission, SubmissionFile, ExtraChecksResult +from api.helpers.check_folder_structure import check_zip_file # , parse_zip_file +from django.db.models import Max + + +class SubmissionFileSerializer(serializers.ModelSerializer): + class Meta: + model = SubmissionFile + fields = ["file"] + + +class ExtraChecksResultSerializer(serializers.ModelSerializer): + + extra_check = serializers.HyperlinkedRelatedField( + many=False, + read_only=True, + view_name="extra-check-detail" + ) + + class Meta: + model = ExtraChecksResult + fields = [ + "extra_check", + "passed" + ] + + +class SubmissionSerializer(serializers.ModelSerializer): + + group = serializers.HyperlinkedRelatedField( + many=False, read_only=True, view_name="group-detail" + ) + + files = SubmissionFileSerializer(many=True, read_only=True) + + extra_checks_results = ExtraChecksResultSerializer(many=True, read_only=True) + + class Meta: + model = Submission + fields = [ + "id", + "group", + "submission_number", + "submission_time", + "files", + "structure_checks_passed", + "extra_checks_results" + ] + extra_kwargs = { + "submission_number": { + "required": False, + "default": 0, + } + } + + def create(self, validated_data): + # Extract files from the request + request = self.context.get('request') + files_data = request.FILES.getlist('files') + + # Get the group for the submission + group = validated_data['group'] + + # Get the project associated with the group + project = group.project + + # Get the maximum submission number for the group's project + max_submission_number = Submission.objects.filter( + group__project=project + ).aggregate(Max('submission_number'))['submission_number__max'] or 0 + + # Set the new submission number to the maximum value plus 1 + validated_data['submission_number'] = max_submission_number + 1 + + # Create the Submission instance without the files + submission = Submission.objects.create(**validated_data) + + pas: bool = True + # Create SubmissionFile instances for each file and check if none fail structure checks + for file in files_data: + SubmissionFile.objects.create(submission=submission, file=file) + status, _ = check_zip_file(submission.group.project, file.name) + if not status: + pas = False + + # Set structure_checks_passed + submission.structure_checks_passed = pas + submission.save() + return submission diff --git a/backend/api/serializers/teacher_serializer.py b/backend/api/serializers/teacher_serializer.py new file mode 100644 index 00000000..fcfa35e1 --- /dev/null +++ b/backend/api/serializers/teacher_serializer.py @@ -0,0 +1,26 @@ +from rest_framework import serializers +from ..models.teacher import Teacher + + +class TeacherSerializer(serializers.ModelSerializer): + courses = serializers.HyperlinkedIdentityField( + 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", + "faculties", + "last_enrolled", + "create_time", + "courses", + ] diff --git a/backend/api/signals.py b/backend/api/signals.py new file mode 100644 index 00000000..6395ea75 --- /dev/null +++ b/backend/api/signals.py @@ -0,0 +1,10 @@ +from authentication.models import User +from api.models.student import Student + + +def user_creation(user: User, attributes: dict, **_): + """Upon user creation, auto-populate additional properties""" + student_id: str = attributes.get("ugentStudentID") + + if student_id is not None: + Student(user_ptr=user, student_id=student_id).save_base(raw=True) diff --git a/backend/api/tests/__init__.py b/backend/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b 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..d1c66580 --- /dev/null +++ b/backend/api/tests/test_checks.py @@ -0,0 +1,304 @@ +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.checks import StructureCheck, ExtraCheck +from api.models.extension import FileExtension +from api.models.project import Project +from api.models.course import Course +from django.conf import settings + + +def create_fileExtension(id, extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create(id=id, extension=extension) + + +def create_structure_check(id, name, project, obligated_extensions, blocked_extensions): + """ + Create a StructureCheck with the given arguments. + """ + check = StructureCheck.objects.create(id=id, name=name, project=project) + + for ext in obligated_extensions: + check.obligated_extensions.add(ext) + for ext in blocked_extensions: + check.blocked_extensions.add(ext) + + return check + + +def create_extra_check(id, project, run_script): + """ + Create an ExtraCheck with the given arguments. + """ + return ExtraCheck.objects.create(id=id, project=project, run_script=run_script) + + +def create_project(id, name, description, visible, archived, days, course, max_score, group_size): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timezone.timedelta(days=days) + + return Project.objects.create( + id=id, + name=name, + description=description, + visible=visible, + archived=archived, + deadline=deadline, + course=course, + max_score=max_score, + group_size=group_size, + ) + + +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 get_project(): + course = create_course(id=1, name="Course", academic_startyear=2021) + project = create_project( + id=1, + name="Project", + description="Description", + visible=True, + archived=False, + days=5, + course=course, + max_score=10, + group_size=2, + ) + return project + + +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("file-extension-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("file-extension-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("file-extension-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("file-extension-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 StructureCheckModelTests(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("structure-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_structure_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_structure_check( + id=1, + name=".", + project=get_project(), + obligated_extensions=[fileExtension1, fileExtension4], + blocked_extensions=[fileExtension2, fileExtension3], + ) + + # Make a GET request to retrieve the Checks + response = self.client.get(reverse("structure-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_obligated_file_extensions = retrieved_checks["obligated_extensions"] + + self.assertEqual(len(retrieved_obligated_file_extensions), 2) + self.assertEqual( + retrieved_obligated_file_extensions[0]["extension"], fileExtension1.extension + ) + self.assertEqual( + retrieved_obligated_file_extensions[1]["extension"], fileExtension4.extension + ) + + retrieved_blocked_file_extensions = retrieved_checks[ + "blocked_extensions" + ] + self.assertEqual(len(retrieved_blocked_file_extensions), 2) + self.assertEqual( + retrieved_blocked_file_extensions[0]["extension"], + fileExtension2.extension, + ) + self.assertEqual( + retrieved_blocked_file_extensions[1]["extension"], + fileExtension3.extension, + ) + + +class ExtraCheckModelTests(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("extra-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_extra_checks_exists(self): + """ + Able to retrieve a single Checks after creating it. + """ + checks = create_extra_check( + id=1, project=get_project(), run_script="test.sh" + ) + + # Make a GET request to retrieve the Checks + response = self.client.get(reverse("extra-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) + self.assertEqual(retrieved_checks["run_script"], settings.TESTING_BASE_LINK + checks.run_script.url) 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_file_structure.py b/backend/api/tests/test_file_structure.py new file mode 100644 index 00000000..f29f6a17 --- /dev/null +++ b/backend/api/tests/test_file_structure.py @@ -0,0 +1,215 @@ +import os +import json +from django.utils import timezone +from django.urls import reverse +from rest_framework.test import APITestCase +from api.helpers.check_folder_structure import check_zip_file, parse_zip_file +from api.models.checks import StructureCheck +from api.models.extension import FileExtension +from api.models.course import Course +from api.models.project import Project +from authentication.models import User +from django.conf import settings + + +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_file_extension(extension): + """ + Create a FileExtension with the given arguments. + """ + return FileExtension.objects.create(extension=extension) + + +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_structure_check(name, project, obligated, blocked): + """ + Create a StructureCheck with the given arguments. + """ + structure_check = StructureCheck.objects.create( + name=name, + project=project, + ) + for ch in obligated: + structure_check.obligated_extensions.add(ch) + for ch in blocked: + structure_check.blocked_extensions.add(ch) + + return structure_check + + +class FileTestsTests(APITestCase): + def setUp(self): + self.client.force_authenticate( + User.get_dummy_admin() + ) + # Set up a temporary directory for MEDIA_ROOT during tests + self.old_media_root = settings.MEDIA_ROOT + settings.MEDIA_ROOT = os.path.normpath(os.path.join(settings.MEDIA_ROOT, '../testing')) + + def tearDown(self): + # Restore the original MEDIA_ROOT after tests + settings.MEDIA_ROOT = self.old_media_root + + def test_your_parsing(self): + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=100, + course=course, + ) + parse_zip_file(project=project, dir_path="structures/zip_struct1.zip") + + response = self.client.get( + reverse("project-detail", args=[str(project.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")) + + response = self.client.get( + content_json["structure_checks"], 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), 7) + + expected_project_url = settings.TESTING_BASE_LINK + reverse( + "project-detail", args=[str(project.id)] + ) + + content = content_json[0] + self.assertEqual(content["name"], ".") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 0) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[1] + self.assertEqual(content["name"], "folder_struct1") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[2] + self.assertEqual(content["name"], "folder_struct1/submap1") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 2) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[3] + self.assertEqual(content["name"], "folder_struct1/submap1/templates") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[4] + self.assertEqual(content["name"], "folder_struct1/submap2") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 1) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[5] + self.assertEqual(content["name"], "folder_struct1/submap2/src") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 3) + self.assertEqual(len(content["blocked_extensions"]), 0) + + content = content_json[6] + self.assertEqual(content["name"], "folder_struct1/submap3") + self.assertEqual(content["project"], expected_project_url) + self.assertEqual(len(content["obligated_extensions"]), 2) + self.assertEqual(len(content["blocked_extensions"]), 0) + + def test_your_checking(self): + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=100, + course=course, + ) + + fileExtensionHS = create_file_extension(extension="hs") + fileExtensionPDF = create_file_extension(extension="pdf") + fileExtensionDOCX = create_file_extension(extension="docx") + fileExtensionLATEX = create_file_extension(extension="latex") + fileExtensionMD = create_file_extension(extension="md") + fileExtensionPY = create_file_extension(extension="py") + fileExtensionHPP = create_file_extension(extension="hpp") + fileExtensionCPP = create_file_extension(extension="cpp") + fileExtensionTS = create_file_extension(extension="ts") + fileExtensionTSX = create_file_extension(extension="tsx") + + create_structure_check( + name=".", + project=project, + obligated=[], + blocked=[]) + + create_structure_check( + name="folder_struct1", + project=project, + obligated=[fileExtensionHS], + blocked=[]) + + create_structure_check( + name="folder_struct1/submap1", + project=project, + obligated=[fileExtensionPDF, fileExtensionDOCX], + blocked=[]) + + create_structure_check( + name="folder_struct1/submap1/templates", + project=project, + obligated=[fileExtensionLATEX], + blocked=[]) + + create_structure_check( + name="folder_struct1/submap2", + project=project, + obligated=[fileExtensionMD], + blocked=[]) + + create_structure_check( + name="folder_struct1/submap2/src", + project=project, + obligated=[fileExtensionPY, fileExtensionHPP, fileExtensionCPP], + blocked=[]) + + create_structure_check( + name="folder_struct1/submap3", + project=project, + obligated=[fileExtensionTS, fileExtensionTSX], + blocked=[]) + + self.assertTrue(check_zip_file(project=project, dir_path="structures/zip_struct1.zip")[0]) diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py new file mode 100644 index 00000000..63017054 --- /dev/null +++ b/backend/api/tests/test_group.py @@ -0,0 +1,553 @@ +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 +from django.conf import settings +from api.models.teacher import 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, + ) + + +def create_project(name, description, days, course, group_size=2, max_score=20, score_visible=True): + """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, + group_size=group_size, max_score=max_score, score_visible=score_visible + ) + + +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_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 = settings.TESTING_BASE_LINK + 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 = settings.TESTING_BASE_LINK + 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) + + +class GroupModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_assign_student_to_group(self): + """Able to assign a student to a 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@gmail.com" + ) + + # Add this teacher and student to the course + course.teachers.add(self.user) + course.students.add(student) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=student.id).exists()) + + def test_remove_student_from_group(self): + """Able to remove a student from a 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@gmail.com" + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=student.id).exists()) + + def test_update_score_of_group(self): + """Able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, max_score=20 + ) + + # Add this teacher to the course + course.teachers.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + + # Make sure the score of the group is updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + + # Try to update the score of a group to a score higher than the maximum score + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 30}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + # Make sure the score of the group is not updated + group.refresh_from_db() + self.assertEqual(group.score, 20) + + +class GroupModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com" + ) + + self.client.force_authenticate( + self.user + ) + + def test_join_group(self): + """Able to join a group as a student.""" + 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) + + # Try to join a group that is part of a course the student is not enrolled in + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + # Make sure that you can not join a group if you are not enrolled in the course + self.assertEqual(response.status_code, 403) + + # Add the student to the course + course.students.add(self.user) + + # Join the group now that the student is enrolled in the course + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Try to join a second group + group2 = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group2.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + # Make sure you can only be in one group at a time + self.assertEqual(response.status_code, 400) + + def test_join_full_group(self): + """Not able to join a full group as a student.""" + course = create_course(name="sel2", academic_startyear=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, group_size=1 + ) + group = create_group(project=project, score=10) + student = create_student( + id=5, first_name="Bernard", last_name="Doe", email="Bernard.Doe@gmail.com" + ) + group.students.add(student) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + + def test_leave_group(self): + """Able to leave a group as a student.""" + 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) + + # Add the student to the course + course.students.add(self.user) + + # Join the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is in the group now + self.assertTrue(group.students.filter(id=self.user.id).exists()) + + # Leave the group + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": self.user.id}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Make sure the student is not in the group anymore + self.assertFalse(group.students.filter(id=self.user.id).exists()) + + def test_try_to_assign_other_student_to_group(self): + """Not able to assign another student to a 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@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to assign another student to a group + self.assertEqual(response.status_code, 403) + + def test_try_to_delete_other_student_from_group(self): + """Not able to remove another student from a 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@gmail.com" + ) + + # Add this student to the course + course.students.add(student) + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(student) + group.students.add(self.user) + + response = self.client.delete( + reverse("group-students", args=[str(group.id)]), + {"student_id": student.id}, + follow=True, + ) + + # Make sure that you are not able to remove another student from a group + self.assertEqual(response.status_code, 403) + + def test_try_to_update_score_of_group(self): + """Not able to update the score of a group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + # Add this student to the course + course.students.add(self.user) + + group = create_group(project=project, score=10) + group.students.add(self.user) + + response = self.client.patch( + reverse("group-detail", args=[str(group.id)]), + {"score": 20}, + follow=True, + ) + + # Make sure that you are not able to update the score of a group + self.assertEqual(response.status_code, 403) + + group.refresh_from_db() + self.assertEqual(group.score, 10) + + def test_group_score_visibility(self): + """Only able to retrieve the score of a group if it is visible, and the student is part of the group.""" + course = create_course(name="sel2", academic_startyear=2023) + + project = create_project( + name="Project 1", description="Description 1", days=7, course=course, score_visible=True + ) + group = create_group(project=project, score=10) + course.students.add(self.user) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure that score is not included, because the student is not part of the group + self.assertNotIn("score", content_json) + + # Add the student to the group + group.students.add(self.user) + + # Set the visibility of the score to False, to make sure the score is not included if it is not visible + project.score_visible = False + project.save() + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure that score is not included, because the teacher has set the visibility of the score to False + self.assertNotIn("score", content_json) + + # Update that the score is visible + project.score_visible = True + project.save() + + self.assertEqual(response.status_code, 200) + + response = self.client.get( + reverse("group-detail", args=[str(group.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + content_json = json.loads(response.content.decode("utf-8")) + + # Make sure the score is included now + self.assertIn("score", content_json) diff --git a/backend/api/tests/test_locale.py b/backend/api/tests/test_locale.py new file mode 100644 index 00000000..43403caa --- /dev/null +++ b/backend/api/tests/test_locale.py @@ -0,0 +1,38 @@ +import json +from django.urls import reverse +from django.utils.translation import activate +from django.utils.translation import gettext as _ +from rest_framework.test import APITestCase + +from api.models.course import Course +from api.models.student import Student +from authentication.models import User + + +class TestLocaleAddAlreadyPresentStudentToCourse(APITestCase): + def setUp(self) -> None: + self.client.force_authenticate(User.get_dummy_admin()) + + course = Course.objects.create(id=1, name="Test Course", academic_startyear=2024) + student = Student.objects.create(id=1, first_name="John", last_name="Doe", email="john.doe@example.com") + + student.courses.add(course) + + def test_default_locale(self): + response = self.client.post(reverse("course-students", args=["1"]), + {"student_id": 1}) + + self.assertEqual(response.status_code, 400) + body = json.loads(response.content.decode('utf-8')) + activate("en") + self.assertEqual(body["non_field_errors"][0], _("courses.error.students.already_present")) + + def test_nl_locale(self): + response = self.client.post(reverse("course-students", args=["1"]), + {"student_id": 1}, + headers={"accept-language": "nl"}) + + self.assertEqual(response.status_code, 400) + body = json.loads(response.content.decode('utf-8')) + activate("nl") + self.assertEqual(body["non_field_errors"][0], _("courses.error.students.already_present")) diff --git a/backend/api/tests/test_project.py b/backend/api/tests/test_project.py new file mode 100644 index 00000000..bf17f5ec --- /dev/null +++ b/backend/api/tests/test_project.py @@ -0,0 +1,795 @@ +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.group import Group +from api.models.submission import Submission +from api.models.teacher import Teacher +from api.models.student import Student +from api.models.checks import StructureCheck, ExtraCheck +from api.models.extension import FileExtension +from django.conf import settings + + +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_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_group(project): + """Create a Group with the given arguments.""" + + return Group.objects.create( + project=project, + ) + + +def create_submission(submission_number, group, structure_checks_passed): + """Create a Submission with the given arguments.""" + + return Submission.objects.create( + submission_number=submission_number, + group=group, + structure_checks_passed=structure_checks_passed, + ) + + +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_structure_check(id, name, project, obligated_extensions, blocked_extensions): + """ + Create a StructureCheck with the given arguments. + """ + check = StructureCheck.objects.create(id=id, name=name, project=project) + + for ext in obligated_extensions: + check.obligated_extensions.add(ext) + for ext in blocked_extensions: + check.blocked_extensions.add(ext) + + return check + + +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) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + 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) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=True, + days=-10, + 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_toggle_locked_groups(self): + """ + toggle the locked state of the project groups. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + course=course, + ) + self.assertIs(past_project.locked_groups, False) + past_project.toggle_groups_locked() + self.assertIs(past_project.locked_groups, True) + past_project.toggle_groups_locked() + self.assertIs(past_project.locked_groups, False) + + def test_automatically_create_groups_when_creating_project(self): + """ + creating a project as a teacher should open the same amount of groups as students enrolled in the project. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + student1.courses.add(course) + student2.courses.add(course) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": timezone.now(), + "deadline": timezone.now() + timezone.timedelta(days=1), + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), data=project_data, follow=True + ) + + # Creating a group as a teacher should work + self.assertEqual(response.status_code, 200) + + project = Project.objects.get( + name="Test Project", + description="Test project description", + visible=True, + archived=False, + start_date=project_data["start_date"], + deadline=project_data["deadline"], + course=course, + ) + + groups_count = project.groups.count() + # The amount of students participating in the corresponding course + expected_groups_count = 2 + + # We expect the amount of groups to be the same as the amount of students in the course + self.assertEqual(groups_count, expected_groups_count) + + def test_start_date_Project_not_in_past(self): + """ + unable to create a project as a teacher/admin if the start date lies within the past. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + start_date = timezone.now() - timezone.timedelta(days=1) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": start_date, + "deadline": timezone.now() + timezone.timedelta(days=1), + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), data=project_data, follow=True + ) + + # Should not work since the start date lies in the past + self.assertEqual(response.status_code, 400) + + def test_deadline_Project_before_start_date(self): + """ + unable to create a project as a teacher/admin if the deadline lies before the start date. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + deadline = timezone.now() + timezone.timedelta(days=1) + start_date = timezone.now() + timezone.timedelta(days=2) + + project_data = { + "name": "Test Project", + "description": "Test project description", + "visible": True, + "archived": False, + "start_date": start_date, + "deadline": deadline, + } + + response = self.client.post( + reverse("course-projects", args=[course.id]), data=project_data, follow=True + ) + + # Should not work since deadline is before the start date + self.assertEqual(response.status_code, 400) + + 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) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-10, + 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) + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=6, + 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) + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=8, + 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) + future_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=1, + 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) + past_project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=-1, + course=course, + ) + self.assertIs(past_project.deadline_passed(), True) + + 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) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-detail", args=[str(project.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")) + + retrieved_project = content_json + + expected_course_url = settings.TESTING_BASE_LINK + 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) + + 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) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-detail", args=[str(project.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")) + + retrieved_project = content_json + + 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) + + 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_structure_checks(self): + """ + Able to retrieve a structure 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") + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + checks = create_structure_check( + id=5, + name=".", + project=project, + obligated_extensions=[fileExtension1, fileExtension4], + blocked_extensions=[fileExtension2, fileExtension3], + ) + + response = self.client.get( + reverse("project-detail", args=[str(project.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")) + + retrieved_project = content_json + + expected_course_url = settings.TESTING_BASE_LINK + 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["structure_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"))[0] + + self.assertEqual(int(content_json["id"]), checks.id) + + # Assert the file extensions of the retrieved + # Checks match the created file extensions + retrieved_obligated_extensions = content_json["obligated_extensions"] + + self.assertEqual(len(retrieved_obligated_extensions), 2) + self.assertEqual( + retrieved_obligated_extensions[0]["extension"], fileExtension1.extension + ) + self.assertEqual( + retrieved_obligated_extensions[1]["extension"], fileExtension4.extension + ) + + retrieved_blocked_file_extensions = content_json["blocked_extensions"] + self.assertEqual(len(retrieved_blocked_file_extensions), 2) + self.assertEqual( + retrieved_blocked_file_extensions[0]["extension"], + fileExtension2.extension, + ) + self.assertEqual( + retrieved_blocked_file_extensions[1]["extension"], + fileExtension3.extension, + ) + + def test_project_extra_checks(self): + """ + Able to retrieve an extra check of a project after creating it. + """ + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + checks = ExtraCheck.objects.create( + id=5, + project=project, + run_script="testscript.sh", + ) + + response = self.client.get( + reverse("project-detail", args=[str(project.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")) + + retrieved_project = content_json + + response = self.client.get(retrieved_project["extra_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"))[0] + + self.assertEqual(int(content_json["id"]), checks.id) + self.assertEqual( + content_json["project"], + settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]), + ) + self.assertEqual( + content_json["run_script"], + settings.TESTING_BASE_LINK + checks.run_script.url, + ) + + def test_cant_join_locked_groups(self): + """Should not be able to add a student to a group if the groups are locked.""" + course = create_course(id=3, name="sel2", academic_startyear=2023) + + project = create_project( + name="test project", + description="test description", + visible=True, + archived=False, + days=7, + course=course, + ) + + # Create example students + student1 = create_student( + id=5, first_name="John", last_name="Doe", email="John.Doe@gmail.com" + ) + student2 = create_student( + id=7, first_name="Jane", last_name="Doe", email="Jane.Doe@gmail.com" + ) + + # Add these student to the course + course.students.add(student1) + course.students.add(student2) + + # Create an exmample group + group = create_group(project=project) + + # Already add one student to the group + group.students.add(student1) + + # Lock the groups + project.locked_groups = True + project.save() + + # Try to add a student to the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student2.id}, + follow=True, + ) + + # Should not work since the groups are locked + self.assertEqual(response.status_code, 400) + + # Make sure the student is not in the group now + self.assertFalse(group.students.filter(id=student2.id).exists()) + + # Try to remove a student from the group + response = self.client.post( + reverse("group-students", args=[str(group.id)]), + {"student_id": student1.id}, + follow=True, + ) + + # Make sure the student is still in the group now + self.assertTrue(group.students.filter(id=student1.id).exists()) + + +class ProjectModelTestsAsTeacher(APITestCase): + def setUp(self) -> None: + self.user = Teacher.objects.create( + id="teacher", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Test@gmail.com", + ) + + self.client.force_authenticate(self.user) + + def test_create_groups(self): + """Able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you cannot make groups for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + + # Assert that the groups were created + self.assertEqual(project.groups.count(), 3) + + def test_submission_status_non_empty_groups(self): + """Submission status returns the correct amount of non empty groups participating in the project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-groups", args=[str(project.id)]), follow=True + ) + + # Make sure you cannot retrieve the submission status for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + # Create example students + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + + # Create example groups + group1 = create_group(project=project) + group2 = create_group(project=project) + group3 = create_group(project=project) # noqa: F841 + + # Add the students to some of the groups + group1.students.add(student1) + group2.students.add(student2) + + response = self.client.get( + reverse("project-submission-status", args=[str(project.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + + # Only two of the three created groups contain at least one student + self.assertEqual( + response.data, + {"non_empty_groups": 2, "groups_submitted": 0, "submissions_passed": 0}, + ) + + def test_submission_status_groups_submitted_and_passed_checks(self): + """Retrieve the submission status for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + + response = self.client.get( + reverse("project-groups", args=[str(project.id)]), follow=True + ) + + # Make sure you cannot retrieve the submission status for a project that is not yours + self.assertEqual(response.status_code, 403) + + # Add the teacher to the course + course.teachers.add(self.user) + + # Create example students + student1 = create_student( + id=1, first_name="John", last_name="Doe", email="john.doe@example.com" + ) + student2 = create_student( + id=2, first_name="Jane", last_name="Doe", email="jane.doe@example.com" + ) + student3 = create_student( + id=3, first_name="Joe", last_name="Doe", email="Joe.doe@example.com" + ) + + # Create example groups + group1 = create_group(project=project) + group2 = create_group(project=project) + group3 = create_group(project=project) + + # Add students to the groups + group1.students.add(student1) + group2.students.add(student2) + group3.students.add(student3) + + # Create submissions for certain groups + create_submission( + submission_number=1, group=group1, structure_checks_passed=True + ) + create_submission( + submission_number=2, group=group3, structure_checks_passed=False + ) + + response = self.client.get( + reverse("project-submission-status", args=[str(project.id)]), follow=True + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"non_empty_groups": 3, "groups_submitted": 2, "submissions_passed": 1}, + ) + + +class ProjectModelTestsAsStudent(APITestCase): + def setUp(self) -> None: + self.user = Student.objects.create( + id="student", + first_name="Bobke", + last_name="Peeters", + username="bpeeters", + email="Bobke.Peeters@gmail.com", + ) + + self.client.force_authenticate(self.user) + + def test_try_to_create_groups(self): + """Not able to create groups for a project.""" + course = create_course(id=3, name="test course", academic_startyear=2024) + project = create_project( + name="test", + description="descr", + visible=True, + archived=False, + days=7, + course=course, + ) + course.students.add(self.user) + + response = self.client.post( + reverse("project-groups", args=[str(project.id)]), + data={"number_groups": 3}, + follow=True, + ) + + # Make sure you cannot make groups as a student + self.assertEqual(response.status_code, 403) 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..cc2d58ca --- /dev/null +++ b/backend/api/tests/test_submission.py @@ -0,0 +1,458 @@ +import json +from django.utils.translation import gettext +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, ExtraChecksResult +from api.models.project import Project +from api.models.group import Group +from api.models.course import Course +from api.models.checks import ExtraCheck +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.db.models import Max + + +def create_course(name, academic_start_year, description=None, parent_course=None): + """ + Create a Course with the given arguments. + """ + return Course.objects.create( + name=name, + academic_startyear=academic_start_year, + 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, score_visible=True + ) + + +def create_past_project(name, description, days, course, days_start_date): + """Create a Project with the given arguments.""" + deadline = timezone.now() + timedelta(days=days) + startDate = timezone.now() + timedelta(days=days_start_date) + return Project.objects.create( + name=name, description=description, deadline=deadline, course=course, score_visible=True, start_date=startDate + ) + + +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(), structure_checks_passed=True + ) + + +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_start_year=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 = settings.TESTING_BASE_LINK + 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) + self.assertEqual(retrieved_submission["structure_checks_passed"], submission.structure_checks_passed) + + def test_multiple_submission_exists(self): + """ + Able to retrieve multiple submissions after creating them. + """ + course = create_course(name="sel2", academic_start_year=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 = settings.TESTING_BASE_LINK + 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 = settings.TESTING_BASE_LINK + 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_start_year=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 = settings.TESTING_BASE_LINK + 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_start_year=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 = settings.TESTING_BASE_LINK + 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_submission_extra_checks(self): + """ + Able to retrieve extra checks of a single submission. + """ + course = create_course(name="sel2", academic_start_year=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) + extra_check = ExtraCheck.objects.create( + project=project, run_script="test.py" + ) + extra_check_result = ExtraChecksResult.objects.create( + submission=submission, extra_check=extra_check, passed=True + ) + + # 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) + + # Extra check that is part of the project + retrieved_extra_check = content_json["extra_checks_results"][0] + + self.assertEqual( + retrieved_extra_check["passed"], extra_check_result.passed + ) + + def test_submission_before_deadline(self): + """ + Able to subbmit to a project before the deadline. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as file: + files = {'files': SimpleUploadedFile('mixed.zip', file.read())} + course = create_course(name="sel2", academic_start_year=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + + def test_submission_after_deadline(self): + """ + Not able to subbmit to a project after the deadline. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_start_year=2023) + project = create_past_project( + name="Project 1", description="Description 1", days=-7, course=course, days_start_date=-84 + ) + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.past_project")]}) + + def test_submission_number_increases_by_1(self): + """ + When submiting a submission the submission number should be the prev one + 1 + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_start_year=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + group = create_group(project=project, score=10) + + max_submission_number_before = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + + if max_submission_number_before is None: + max_submission_number_before = 0 + + old_submissions = group.submissions.count() + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + group.refresh_from_db() + new_submissions = group.submissions.count() + + max_submission_number_after = group.submissions.aggregate(Max('submission_number'))['submission_number__max'] + + if max_submission_number_after is None: + max_submission_number_after = 0 + self.assertEqual(max_submission_number_after - max_submission_number_before, 1) + self.assertEqual(new_submissions - old_submissions, 1) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), {"message": gettext("group.success.submissions.add")}) + + def test_submission_invisible_project(self): + """ + Not able to subbmit to a project if its not visible. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_start_year=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + project.toggle_visible() + project.save() + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.non_visible_project")]}) + + def test_submission_archived_project(self): + """ + Not able to subbmit to a project if its archived. + """ + zip_file_path = "data/testing/tests/mixed.zip" + + with open(zip_file_path, 'rb') as f: + files = {'files': SimpleUploadedFile('mixed.zip', f.read())} + + course = create_course(name="sel2", academic_start_year=2023) + project = create_project( + name="Project 1", description="Description 1", days=7, course=course + ) + + project.toggle_archived() + project.save() + + group = create_group(project=project, score=10) + + response = self.client.post( + reverse("group-submissions", args=[str(group.id)]), + files, + follow=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.accepted_media_type, "application/json") + self.assertEqual(json.loads(response.content), { + 'non_field_errors': [gettext("project.error.submissions.archived_project")]}) 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 new file mode 100644 index 00000000..094e2fce --- /dev/null +++ b/backend/api/urls.py @@ -0,0 +1,35 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from api.views.user_view import UserViewSet +from api.views.teacher_view import TeacherViewSet +from api.views.admin_view import AdminViewSet +from api.views.assistant_view import AssistantViewSet +from api.views.student_view import StudentViewSet +from api.views.project_view import ProjectViewSet +from api.views.group_view import GroupViewSet +from api.views.course_view import CourseViewSet +from api.views.submission_view import SubmissionViewSet +from api.views.faculty_view import FacultyViewSet +from api.views.checks_view import ( + ExtraCheckViewSet, FileExtensionViewSet, StructureCheckViewSet +) + + +router = DefaultRouter() +router.register(r"users", UserViewSet, basename="user") +router.register(r"teachers", TeacherViewSet, basename="teacher") +router.register(r"admins", AdminViewSet, basename="admin") +router.register(r"assistants", AssistantViewSet, basename="assistant") +router.register(r"students", StudentViewSet, basename="student") +router.register(r"projects", ProjectViewSet, basename="project") +router.register(r"groups", GroupViewSet, basename="group") +router.register(r"courses", CourseViewSet, basename="course") +router.register(r"submissions", SubmissionViewSet, basename="submission") +router.register(r"structure-checks", StructureCheckViewSet, basename="structure-check") +router.register(r"extra-checks", ExtraCheckViewSet, basename="extra-check") +router.register(r"file-extensions", FileExtensionViewSet, basename="file-extension") +router.register(r"faculties", FacultyViewSet, basename="faculty") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/views/admin_view.py b/backend/api/views/admin_view.py new file mode 100644 index 00000000..a1292c88 --- /dev/null +++ b/backend/api/views/admin_view.py @@ -0,0 +1,28 @@ +from django.utils.translation import gettext +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.response import Response +from rest_framework.request import Request +from rest_framework.permissions import IsAdminUser +from authentication.serializers import UserSerializer, UserIDSerializer +from authentication.models import User + + +class AdminViewSet(ReadOnlyModelViewSet): + queryset = User.objects.filter(is_staff=True) + serializer_class = UserSerializer + permission_classes = [IsAdminUser] + + def create(self, request: Request) -> Response: + """ + Make the provided user admin by setting `is_staff` = true. + """ + serializer = UserIDSerializer( + data=request.data + ) + + if serializer.is_valid(raise_exception=True): + serializer.validated_data["user"].make_admin() + + return Response({ + "message": gettext("admins.success.add") + }) diff --git a/backend/api/views/assistant_view.py b/backend/api/views/assistant_view.py new file mode 100644 index 00000000..e20bec81 --- /dev/null +++ b/backend/api/views/assistant_view.py @@ -0,0 +1,28 @@ +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser +from api.permissions.assistant_permissions import AssistantPermission +from ..models.assistant import Assistant +from ..serializers.assistant_serializer import AssistantSerializer +from ..serializers.course_serializer import CourseSerializer + + +class AssistantViewSet(ReadOnlyModelViewSet): + + queryset = Assistant.objects.all() + serializer_class = AssistantSerializer + permission_classes = [IsAdminUser | AssistantPermission] + + @action(detail=True, methods=["get"]) + def courses(self, request, **_): + """Returns a list of courses for the given assistant""" + assistant = self.get_object() + courses = assistant.courses + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) + + return Response(serializer.data) diff --git a/backend/api/views/checks_view.py b/backend/api/views/checks_view.py new file mode 100644 index 00000000..eaf18757 --- /dev/null +++ b/backend/api/views/checks_view.py @@ -0,0 +1,21 @@ +from rest_framework import viewsets +from ..models.extension import FileExtension +from ..models.checks import StructureCheck, ExtraCheck +from ..serializers.checks_serializer import ( + StructureCheckSerializer, ExtraCheckSerializer, FileExtensionSerializer +) + + +class StructureCheckViewSet(viewsets.ModelViewSet): + queryset = StructureCheck.objects.all() + serializer_class = StructureCheckSerializer + + +class ExtraCheckViewSet(viewsets.ModelViewSet): + queryset = ExtraCheck.objects.all() + serializer_class = ExtraCheckSerializer + + +class FileExtensionViewSet(viewsets.ModelViewSet): + queryset = FileExtension.objects.all() + serializer_class = FileExtensionSerializer diff --git a/backend/api/views/course_view.py b/backend/api/views/course_view.py new file mode 100644 index 00000000..cc16a1f5 --- /dev/null +++ b/backend/api/views/course_view.py @@ -0,0 +1,209 @@ +from django.utils.translation import gettext +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.request import Request +from api.models.course import Course +from api.models.group import Group +from api.permissions.course_permissions import ( + CoursePermission, + CourseAssistantPermission, + CourseStudentPermission +) +from api.permissions.role_permissions import IsTeacher +from api.serializers.course_serializer import CourseSerializer, StudentJoinSerializer, StudentLeaveSerializer +from api.serializers.teacher_serializer import TeacherSerializer +from api.serializers.assistant_serializer import AssistantSerializer, AssistantIDSerializer +from api.serializers.student_serializer import StudentSerializer +from api.serializers.project_serializer import ProjectSerializer + + +class CourseViewSet(viewsets.ModelViewSet): + """Actions for general course logic""" + queryset = Course.objects.all() + serializer_class = CourseSerializer + permission_classes = [IsAdminUser | CoursePermission] + + @action(detail=True, permission_classes=[IsAdminUser | CourseAssistantPermission]) + def assistants(self, request: Request, **_): + """Returns a list of assistants for the given course""" + course = self.get_object() + assistants = course.assistants.all() + + # Serialize assistants + serializer = AssistantSerializer( + assistants, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @assistants.mapping.post + @assistants.mapping.put + def _add_assistant(self, request: Request, **_): + """Add an assistant to the course""" + course = self.get_object() + + # Add assistant to course + serializer = AssistantIDSerializer( + data=request.data + ) + + if serializer.is_valid(raise_exception=True): + course.assistants.add( + serializer.validated_data["assistant"] + ) + + return Response({ + "message": gettext("courses.success.assistants.add") + }) + + @assistants.mapping.delete + def _remove_assistant(self, request: Request, **_): + """Remove an assistant from the course""" + course = self.get_object() + + # Remove assistant from course + serializer = AssistantIDSerializer( + data=request.data + ) + + if serializer.is_valid(raise_exception=True): + course.assistants.remove( + serializer.validated_data["assistant"] + ) + + return Response({ + "message": gettext("courses.success.assistants.remove") + }) + + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | CourseStudentPermission]) + def students(self, request, **_): + """Returns a list of students for the given course""" + course = self.get_object() + students = course.students.all() + + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @students.mapping.post + @students.mapping.put + def _add_student(self, request: Request, **_): + """Add a student to the course""" + # Get the course + course = self.get_object() + + # Add student to course + serializer = StudentJoinSerializer(data=request.data, context={ + "course": course + }) + + if serializer.is_valid(raise_exception=True): + course.students.add( + serializer.validated_data["student_id"] + ) + + return Response({ + "message": gettext("courses.success.students.add") + }) + + @students.mapping.delete + def _remove_student(self, request: Request, **_): + """Remove a student from the course""" + # Get the course + course = self.get_object() + + # Add student to course + serializer = StudentLeaveSerializer(data=request.data, context={ + "course": course + }) + + if serializer.is_valid(raise_exception=True): + course.students.remove( + serializer.validated_data["student_id"] + ) + + return Response({ + "message": gettext("courses.success.students.remove") + }) + + @action(detail=True, methods=["get"]) + def teachers(self, request, **_): + """Returns a list of teachers for the given course""" + course = self.get_object() + teachers = course.teachers.all() + + # Serialize the teacher objects + serializer = TeacherSerializer( + teachers, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action(detail=True, methods=["get"]) + def projects(self, request, **_): + """Returns a list of projects for the given course""" + course = self.get_object() + projects = course.projects.all() + + # Serialize the project objects + serializer = ProjectSerializer( + projects, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @projects.mapping.post + @projects.mapping.put + def _add_project(self, request, **_): + """Add a project to the course""" + course = self.get_object() + + serializer = ProjectSerializer( + data=request.data, context={ + "request": request, + "course": course + } + ) + + project = None + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + project = serializer.save() + course.projects.add(project) + + # Create groups for the project + students_count = course.students.count() + for _ in range(students_count): + Group.objects.create(project=project) + + return Response({ + "message": gettext("course.success.project.add"), + }) + + @action(detail=True, methods=["post"], permission_classes=[IsAdminUser | IsTeacher]) + def clone(self, request: Request, **__): + """Copy the course to a new course with the same fields""" + course: Course = self.get_object() + + try: + # We should return the already cloned course, if present + course = course.child_course + + except Course.DoesNotExist: + # Else, we clone the course + course = course.clone( + clone_assistants=request.data.get("clone_assistants") + ) + + course.save() + + # Return serialized cloned course + course_serializer = CourseSerializer(course, context={"request": request}) + + return Response(course_serializer.data) diff --git a/backend/api/views/faculty_view.py b/backend/api/views/faculty_view.py new file mode 100644 index 00000000..fc8c71ca --- /dev/null +++ b/backend/api/views/faculty_view.py @@ -0,0 +1,11 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAdminUser +from authentication.models import Faculty +from api.permissions.faculty_permissions import FacultyPermission +from ..serializers.faculty_serializer import facultySerializer + + +class FacultyViewSet(viewsets.ModelViewSet): + queryset = Faculty.objects.all() + serializer_class = facultySerializer + permission_classes = [IsAdminUser | FacultyPermission] diff --git a/backend/api/views/group_view.py b/backend/api/views/group_view.py new file mode 100644 index 00000000..98550fdb --- /dev/null +++ b/backend/api/views/group_view.py @@ -0,0 +1,111 @@ +from django.utils.translation import gettext +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin +from rest_framework.viewsets import GenericViewSet +from rest_framework.permissions import IsAdminUser +from rest_framework.decorators import action +from rest_framework.response import Response +from api.models.group import Group +from api.permissions.group_permissions import GroupPermission +from api.permissions.group_permissions import GroupStudentPermission +from api.serializers.group_serializer import GroupSerializer +from api.serializers.student_serializer import StudentSerializer +from api.serializers.group_serializer import StudentJoinGroupSerializer, StudentLeaveGroupSerializer +from api.serializers.project_serializer import SubmissionAddSerializer +from api.serializers.submission_serializer import SubmissionSerializer +from rest_framework.request import Request + + +class GroupViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + GenericViewSet): + + queryset = Group.objects.all() + serializer_class = GroupSerializer + permission_classes = [IsAdminUser | GroupPermission] + + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | GroupStudentPermission]) + def students(self, request, **_): + """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + students = group.students.all() + + # Serialize the student objects + serializer = StudentSerializer( + students, many=True, context={"request": request} + ) + return Response(serializer.data) + + @action(detail=True, permission_classes=[IsAdminUser]) + def submissions(self, request, **_): + """Returns a list of students for the given group""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + group = self.get_object() + submissions = group.submissions.all() + + # Serialize the student objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + return Response(serializer.data) + + @students.mapping.post + @students.mapping.put + def _add_student(self, request, **_): + """Add a student to the group""" + group = self.get_object() + + serializer = StudentJoinGroupSerializer( + data=request.data, context={"group": group} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.add( + serializer.validated_data["student_id"] + ) + + return Response({ + "message": gettext("group.success.students.add"), + }) + + @students.mapping.delete + def _remove_student(self, request, **_): + """Removes a student from the group""" + group = self.get_object() + + serializer = StudentLeaveGroupSerializer( + data=request.data, context={"group": group} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + group.students.remove( + serializer.validated_data["student_id"] + ) + + return Response({ + "message": gettext("group.success.students.remove"), + }) + + @submissions.mapping.post + @submissions.mapping.put + def _add_submission(self, request: Request, **_): + """Add a submission to the group""" + group: Group = self.get_object() + + # Add submission to course + serializer = SubmissionAddSerializer( + data=request.data, context={"group": group, "request": request} + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(group=group) + + return Response({ + "message": gettext("group.success.submissions.add") + }) diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py new file mode 100644 index 00000000..16d4aa7d --- /dev/null +++ b/backend/api/views/project_view.py @@ -0,0 +1,153 @@ +from django.utils.translation import gettext +from rest_framework.mixins import CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin +from rest_framework.permissions import IsAdminUser +from rest_framework.viewsets import GenericViewSet +from rest_framework.decorators import action +from rest_framework.response import Response +from api.permissions.project_permissions import ProjectGroupPermission, ProjectPermission +from api.models.group import Group +from api.models.submission import Submission +from api.models.project import Project +from api.models.checks import StructureCheck +from api.serializers.checks_serializer import StructureCheckSerializer, ExtraCheckSerializer +from api.serializers.project_serializer import ( + StructureCheckAddSerializer, SubmissionStatusSerializer, + ProjectSerializer, TeacherCreateGroupSerializer +) + +from api.serializers.group_serializer import GroupSerializer +from api.serializers.submission_serializer import SubmissionSerializer +from rest_framework.request import Request + + +class ProjectViewSet(CreateModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + DestroyModelMixin, + GenericViewSet): + + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project + + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) + def groups(self, request, **_): + """Returns a list of groups for the given project""" + # This automatically fetches the group from the URL. + # It automatically gives back a 404 HTTP response in case of not found. + project = self.get_object() + groups = project.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action(detail=True) + def submissions(self, request, **_): + """Returns a list of submissions for the given project""" + project = self.get_object() + submissions = project.submissions.all() + + # Serialize the group objects + serializer = SubmissionSerializer( + submissions, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @groups.mapping.post + def _create_groups(self, request, **_): + """Create a number of groups for the project""" + project = self.get_object() + + serializer = TeacherCreateGroupSerializer( + data=request.data, context={"project": project} + ) + + # Validate the serializer + if serializer.is_valid(raise_exception=True): + # Get the number of groups to create + num_groups = serializer.validated_data["number_groups"] + + # Create the groups + for _ in range(num_groups): + Group.objects.create( + project=project + ) + + return Response({ + "message": gettext("project.success.groups.created"), + }) + + @action(detail=True, methods=["get"]) + def structure_checks(self, request, **_): + """Returns the structure checks for the given project""" + project = self.get_object() + checks = project.structure_checks.all() + + # Serialize the check objects + serializer = StructureCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) + + @structure_checks.mapping.post + @structure_checks.mapping.put + def _add_structure_check(self, request: Request, **_): + """Add an structure_check to the project""" + + project: Project = self.get_object() + + # Add submission to course + serializer = StructureCheckAddSerializer( + data=request.data, + context={ + "project": project, + "request": request, + "obligated": request.data.getlist('obligated_extensions'), + "blocked": request.data.getlist('blocked_extensions') + } + ) + + if serializer.is_valid(raise_exception=True): + serializer.save(project=project) + + return Response({ + "message": gettext("project.success.structure_check.add") + }) + + @action(detail=True, methods=["get"]) + def extra_checks(self, request, **_): + """Returns the extra checks for the given project""" + project = self.get_object() + checks = project.extra_checks.all() + + # Serialize the check objects + serializer = ExtraCheckSerializer( + checks, many=True, context={"request": request} + ) + return Response(serializer.data) + + @action(detail=True, methods=["get"], permission_classes=[IsAdminUser | ProjectGroupPermission]) + def submission_status(self, request, **_): + """Returns the current submission status for the given project + This includes: + - The total amount of groups that contain at least one student + - The amount of groups that have uploaded a submission + - The amount of submissions that passed the basic tests + """ + project = self.get_object() + non_empty_groups = project.groups.filter(students__isnull=False).count() + groups_submitted = Submission.objects.filter(group__project=project).count() + submissions_passed = Submission.objects.filter(group__project=project, structure_checks_passed=True).count() + + serializer = SubmissionStatusSerializer({ + "non_empty_groups": non_empty_groups, + "groups_submitted": groups_submitted, + "submissions_passed": submissions_passed, + }) + + return Response(serializer.data) diff --git a/backend/api/views/student_view.py b/backend/api/views/student_view.py new file mode 100644 index 00000000..8517a0b8 --- /dev/null +++ b/backend/api/views/student_view.py @@ -0,0 +1,40 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAdminUser +from api.permissions.role_permissions import IsSameUser, IsTeacher +from api.models.student import Student +from api.serializers.student_serializer import StudentSerializer +from api.serializers.course_serializer import CourseSerializer +from api.serializers.group_serializer import GroupSerializer + + +class StudentViewSet(viewsets.ModelViewSet): + queryset = Student.objects.all() + serializer_class = StudentSerializer + permission_classes = [IsAdminUser | IsTeacher | IsSameUser] + + @action(detail=True) + def courses(self, request, **_): + """Returns a list of courses for the given student""" + student = self.get_object() + courses = student.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action(detail=True) + def groups(self, request, **_): + """Returns a list of groups for the given student""" + student = self.get_object() + groups = student.groups.all() + + # Serialize the group objects + serializer = GroupSerializer( + groups, many=True, context={"request": request} + ) + return Response(serializer.data) diff --git a/backend/api/views/submission_view.py b/backend/api/views/submission_view.py new file mode 100644 index 00000000..279f105b --- /dev/null +++ b/backend/api/views/submission_view.py @@ -0,0 +1,13 @@ +from rest_framework import viewsets +from ..models.submission import Submission, SubmissionFile +from ..serializers.submission_serializer import SubmissionSerializer, SubmissionFileSerializer + + +class SubmissionFileViewSet(viewsets.ModelViewSet): + queryset = SubmissionFile.objects.all() + serializer_class = SubmissionFileSerializer + + +class SubmissionViewSet(viewsets.ModelViewSet): + queryset = Submission.objects.all() + serializer_class = SubmissionSerializer diff --git a/backend/api/views/teacher_view.py b/backend/api/views/teacher_view.py new file mode 100644 index 00000000..c16d4167 --- /dev/null +++ b/backend/api/views/teacher_view.py @@ -0,0 +1,30 @@ +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser + +from api.models.course import Course +from api.models.teacher import Teacher +from api.serializers.teacher_serializer import TeacherSerializer +from api.serializers.course_serializer import CourseSerializer +from api.permissions.role_permissions import IsSameUser + + +class TeacherViewSet(ReadOnlyModelViewSet): + queryset = Teacher.objects.all() + serializer_class = TeacherSerializer + permission_classes = [IsAdminUser | IsSameUser] + + @action(detail=True, methods=["get"]) + def courses(self, request, pk=None): + """Returns a list of courses for the given teacher""" + teacher = self.get_object() + courses = teacher.courses.all() + + # Serialize the course objects + serializer = CourseSerializer( + courses, many=True, context={"request": request} + ) + + return Response(serializer.data) diff --git a/backend/api/views/user_view.py b/backend/api/views/user_view.py new file mode 100644 index 00000000..f584f6fe --- /dev/null +++ b/backend/api/views/user_view.py @@ -0,0 +1,39 @@ +from api.permissions.notification_permissions import NotificationPermission +from api.permissions.role_permissions import IsSameUser +from authentication.models import User +from authentication.serializers import UserSerializer +from notifications.models import Notification +from notifications.serializers import NotificationSerializer +from rest_framework.decorators import action +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.status import HTTP_200_OK +from rest_framework.viewsets import ReadOnlyModelViewSet +from rest_framework.permissions import IsAdminUser + + +class UserViewSet(ReadOnlyModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAdminUser | IsSameUser] + + @action(detail=True, methods=["get"], permission_classes=[NotificationPermission]) + def notifications(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + serializer = NotificationSerializer( + notifications, many=True, context={"request": request} + ) + + return Response(serializer.data) + + @action( + detail=True, + methods=["post"], + permission_classes=[NotificationPermission], + url_path="notifications/read", + ) + def read(self, request: Request, pk: str): + notifications = Notification.objects.filter(user=pk) + notifications.update(is_read=True) + + return Response(status=HTTP_200_OK) diff --git a/backend/authentication/__init__.py b/backend/authentication/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/apps.py b/backend/authentication/apps.py new file mode 100644 index 00000000..c65f1d28 --- /dev/null +++ b/backend/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "authentication" diff --git a/backend/authentication/cas/__init__.py b/backend/authentication/cas/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/cas/client.py b/backend/authentication/cas/client.py new file mode 100644 index 00000000..8388c978 --- /dev/null +++ b/backend/authentication/cas/client.py @@ -0,0 +1,6 @@ +from cas_client import CASClient +from ypovoli import settings + +client = CASClient( + 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 new file mode 100644 index 00000000..3265f487 --- /dev/null +++ b/backend/authentication/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.0.2 on 2024-03-05 14:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + 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_add=True)), + ('faculties', models.ManyToManyField(blank=True, related_name='users', to='authentication.faculty')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/authentication/migrations/__init__.py b/backend/authentication/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/models.py b/backend/authentication/models.py new file mode 100644 index 00000000..066d6fbb --- /dev/null +++ b/backend/authentication/models.py @@ -0,0 +1,53 @@ +from datetime import MINYEAR +from django.db import models +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. + It extends the built-in Django user model with CAS-specific attributes. + """ + + """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) + + 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" + + @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) 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 new file mode 100644 index 00000000..86c44cf7 --- /dev/null +++ b/backend/authentication/serializers.py @@ -0,0 +1,117 @@ +from typing import Tuple + +from authentication.cas.client import client +from authentication.models import User +from authentication.signals import user_created, user_login +from django.contrib.auth import login +from django.contrib.auth.models import update_last_login +from rest_framework.serializers import ( + CharField, + EmailField, + HyperlinkedRelatedField, + ModelSerializer, + Serializer, + ValidationError, + PrimaryKeyRelatedField +) +from rest_framework_simplejwt.settings import api_settings +from rest_framework_simplejwt.tokens import AccessToken, RefreshToken + + +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. + """ + + ticket = CharField(required=True, min_length=49, max_length=49) + + def validate(self, data): + """Validate a ticket using CAS client""" + # Validate the ticket and get CAS attributes. + attributes = self._validate_ticket(data["ticket"]) + + # 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) + + # Login and send authentication signals. + if "request" in self.context: + login(self.context["request"], user) + + user_login.send(sender=self, user=user) + + if created: + user_created.send(sender=self, attributes=attributes, user=user) + + # Return access tokens for the now logged-in user. + return { + "attributes": attributes, + "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 = HyperlinkedRelatedField( + view_name="notification-detail", + read_only=True, + ) + + class Meta: + model = 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) + + +class UserIDSerializer(Serializer): + user = PrimaryKeyRelatedField( + queryset=User.objects.all() + ) diff --git a/backend/authentication/signals.py b/backend/authentication/signals.py new file mode 100644 index 00000000..40c89941 --- /dev/null +++ b/backend/authentication/signals.py @@ -0,0 +1,5 @@ +from django.dispatch import Signal + +user_created = Signal() +user_login = Signal() +user_logout = Signal() diff --git a/backend/authentication/tests/__init__.py b/backend/authentication/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/authentication/tests/test_authentication_serializer.py b/backend/authentication/tests/test_authentication_serializer.py new file mode 100644 index 00000000..c931970e --- /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 = "dackers" +EMAIL = "dummy@dummy.be" +FIRST_NAME = "Dummy" +LAST_NAME = "Ackers" + + +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="duid") + 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="duid") + 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="duid") + 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..cb5734c8 --- /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": "dackers", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "Ackers", + }) + + 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": "dackers", + "email": "dummy@dummy.com", + "first_name": "dummy", + "last_name": "Ackers", + } + 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 new file mode 100644 index 00000000..2214cc67 --- /dev/null +++ b/backend/authentication/urls.py @@ -0,0 +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 import CASViewSet + +router = DefaultRouter() +router.register("cas", CASViewSet, "cas") + +urlpatterns = [ + # AUTH endpoints. + path("", include(router.urls)), + # TOKEN endpoints. + 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/checks/__init__.py b/backend/checks/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/checks/admin.py b/backend/checks/admin.py new file mode 100644 index 00000000..4185d360 --- /dev/null +++ b/backend/checks/admin.py @@ -0,0 +1,3 @@ +# from django.contrib import admin + +# Register your models here. diff --git a/backend/checks/apps.py b/backend/checks/apps.py new file mode 100644 index 00000000..5fa5cda6 --- /dev/null +++ b/backend/checks/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChecksConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "checks" diff --git a/backend/checks/migrations/__init__.py b/backend/checks/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/checks/models.py b/backend/checks/models.py new file mode 100644 index 00000000..0b4331b3 --- /dev/null +++ b/backend/checks/models.py @@ -0,0 +1,3 @@ +# from django.db import models + +# Create your models here. diff --git a/backend/checks/tests.py b/backend/checks/tests.py new file mode 100644 index 00000000..a79ca8be --- /dev/null +++ b/backend/checks/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/backend/checks/views.py b/backend/checks/views.py new file mode 100644 index 00000000..fd0e0449 --- /dev/null +++ b/backend/checks/views.py @@ -0,0 +1,3 @@ +# from django.shortcuts import render + +# Create your views here. diff --git a/backend/data/testing/structures/mixed_template.zip b/backend/data/testing/structures/mixed_template.zip new file mode 100644 index 00000000..456c258b Binary files /dev/null and b/backend/data/testing/structures/mixed_template.zip differ diff --git a/backend/data/testing/structures/only_files_template.zip b/backend/data/testing/structures/only_files_template.zip new file mode 100644 index 00000000..f135233a Binary files /dev/null and b/backend/data/testing/structures/only_files_template.zip differ diff --git a/backend/data/testing/structures/only_folders_template.zip b/backend/data/testing/structures/only_folders_template.zip new file mode 100644 index 00000000..e15d7f53 Binary files /dev/null and b/backend/data/testing/structures/only_folders_template.zip differ diff --git a/backend/data/testing/structures/remake.zip b/backend/data/testing/structures/remake.zip new file mode 100644 index 00000000..7969b3c0 Binary files /dev/null and b/backend/data/testing/structures/remake.zip differ diff --git a/backend/data/testing/structures/root.zip b/backend/data/testing/structures/root.zip new file mode 100644 index 00000000..4e703157 Binary files /dev/null and b/backend/data/testing/structures/root.zip differ diff --git a/backend/data/testing/structures/zip_struct1.zip b/backend/data/testing/structures/zip_struct1.zip new file mode 100644 index 00000000..bd156cd2 Binary files /dev/null and b/backend/data/testing/structures/zip_struct1.zip differ diff --git a/backend/data/testing/tests/mixed.zip b/backend/data/testing/tests/mixed.zip new file mode 100644 index 00000000..50991100 Binary files /dev/null and b/backend/data/testing/tests/mixed.zip differ diff --git a/backend/data/testing/tests/only_files.zip b/backend/data/testing/tests/only_files.zip new file mode 100644 index 00000000..55a7402c Binary files /dev/null and b/backend/data/testing/tests/only_files.zip differ diff --git a/backend/data/testing/tests/only_folders.zip b/backend/data/testing/tests/only_folders.zip new file mode 100644 index 00000000..012a0304 Binary files /dev/null and b/backend/data/testing/tests/only_folders.zip differ diff --git a/backend/data/testing/tests/test_zip1struct1.zip b/backend/data/testing/tests/test_zip1struct1.zip new file mode 100644 index 00000000..c6a73671 Binary files /dev/null and b/backend/data/testing/tests/test_zip1struct1.zip differ diff --git a/backend/data/testing/tests/test_zip2struct1.zip b/backend/data/testing/tests/test_zip2struct1.zip new file mode 100644 index 00000000..fbce9306 Binary files /dev/null and b/backend/data/testing/tests/test_zip2struct1.zip differ diff --git a/backend/data/testing/tests/test_zip3struct1.zip b/backend/data/testing/tests/test_zip3struct1.zip new file mode 100644 index 00000000..315fd52e Binary files /dev/null and b/backend/data/testing/tests/test_zip3struct1.zip differ diff --git a/backend/data/testing/tests/test_zip4struct1.zip b/backend/data/testing/tests/test_zip4struct1.zip new file mode 100644 index 00000000..9acfea02 Binary files /dev/null and b/backend/data/testing/tests/test_zip4struct1.zip differ diff --git a/backend/gunicorn_config.py b/backend/gunicorn_config.py new file mode 100644 index 00000000..6ec33776 --- /dev/null +++ b/backend/gunicorn_config.py @@ -0,0 +1,4 @@ +workers = 4 +bind = "0.0.0.0:8080" +chdir = "/code/" +module = "ypovoli.wsgi:application" diff --git a/backend/manage.py b/backend/manage.py new file mode 100755 index 00000000..75478bbb --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/backend/notifications/__init__.py b/backend/notifications/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/notifications/apps.py b/backend/notifications/apps.py new file mode 100644 index 00000000..3a084766 --- /dev/null +++ b/backend/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "notifications" diff --git a/backend/notifications/fixtures/notification_template.yaml b/backend/notifications/fixtures/notification_template.yaml new file mode 100644 index 00000000..baaf6c2d --- /dev/null +++ b/backend/notifications/fixtures/notification_template.yaml @@ -0,0 +1,11 @@ +- model: notifications.notificationtemplate + pk: 1 + fields: + title_key: "Title: Score added" + description_key: "Description: Score added %(score)s" +- model: notifications.notificationtemplate + pk: 2 + fields: + title_key: "Title: Score updated" + description_key: "Description: Score updated %(score)s" + \ No newline at end of file diff --git a/backend/notifications/locale/en/LC_MESSAGES/django.po b/backend/notifications/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..465520e9 --- /dev/null +++ b/backend/notifications/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,32 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-27 15:52+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Dear %(name)s\nYou have a new notification.\n%(title)s\n%(description)s\n\n- Ypovoli" +# Score Added +msgid "Title: Score added" +msgstr "Score Added" +msgid "Description: Score added %(score)s" +msgstr "You received a score: %(score)s" +# Score Updated +msgid "Title: Score updated" +msgstr "New score" +msgid "Description: Score updated %(score)s" +msgstr "Your score has been updated.\nNew score: %(score)s" diff --git a/backend/notifications/locale/nl/LC_MESSAGES/django.po b/backend/notifications/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..5a854108 --- /dev/null +++ b/backend/notifications/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,32 @@ +# Translations for notification. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-02-27 15:50+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +# Email Template +msgid "Email %(name)s %(title)s %(description)s" +msgstr "Beste %(name)s\nU heeft een nieuwe notificatie.\n%(title)s\n%(description)s\n\n- Ypovoli" +# Score Added +msgid "Title: Score added" +msgstr "Score toegevoegd" +msgid "Description: Score added %(score)s" +msgstr "Je hebt een score ontvangen: %(score)s" +# Score Updated +msgid "Title: Score updated" +msgstr "Nieuwe score" +msgid "Description: Score updated %(score)s" +msgstr "Je score is geupdate.\nNieuwe score: %(score)s" diff --git a/backend/notifications/logic.py b/backend/notifications/logic.py new file mode 100644 index 00000000..b5df6d18 --- /dev/null +++ b/backend/notifications/logic.py @@ -0,0 +1,105 @@ +import threading +from collections import defaultdict +from os import error +from smtplib import SMTPException +from typing import DefaultDict, Dict, List + +from celery import shared_task +from django.core import mail +from django.core.cache import cache +from django.utils.translation import gettext as _ +from notifications.models import Notification +from ypovoli.settings import EMAIL_CUSTOM + + +# Returns a dictionary with the title and description of the notification +def get_message_dict(notification: Notification) -> Dict[str, str]: + return { + "title": _(notification.template_id.title_key), + "description": _(notification.template_id.description_key) + % notification.arguments, + } + + +# Call the function after 60 seconds and no more than once in that period +def schedule_send_mails(): + if not cache.get("notifications_send_mails"): + cache.set("notifications_send_mails", True) + _send_mails.apply_async(countdown=60) + + +# Try to send one email and set the result +def _send_mail(mail: mail.EmailMessage, result: List[bool]): + try: + mail.send(fail_silently=False) + result[0] = True + except SMTPException: + result[0] = False + + +# Send all unsent emails +@shared_task +def _send_mails(): + # All notifications that need to be sent + notifications = Notification.objects.filter(is_sent=False) + # Dictionary with the number of errors for each email + errors: DefaultDict[str, int] = cache.get( + "notifications_send_mails_errors", defaultdict(int) + ) + + # No notifications to send + if notifications.count() == 0: + return + + # Connection with the mail server + connection = mail.get_connection() + + for notification in notifications: + message = get_message_dict(notification) + content = _("Email %(name)s %(title)s %(description)s") % { + "name": notification.user.username, + "title": message["title"], + "description": message["description"], + } + + # Construct the email + email = mail.EmailMessage( + subject=EMAIL_CUSTOM["subject"], + body=content, + from_email=EMAIL_CUSTOM["from"], + to=[notification.user.email], + connection=connection, + ) + + # Send the email with a timeout + result: List[bool] = [False] + thread = threading.Thread(target=_send_mail, args=(email, result)) + thread.start() + thread.join(timeout=EMAIL_CUSTOM["timeout"]) + + # Email failed to send + if thread.is_alive() or not result[0]: + # Increase the number of errors for the email + errors[notification.user.email] += 1 + # Mark notification as sent if the maximum number of errors is reached + if errors[notification.user.email] >= EMAIL_CUSTOM["max_errors"]: + errors.pop(notification.user.email) + notification.sent() + + continue + + # Email sent successfully + if notification.user.email in errors: + errors.pop(notification.user.email) + + # Mark the notification as sent + notification.sent() + + # Save the number of errors for each email + cache.set("notifications_send_mails_errors", errors) + + # Restart the process if there are any notifications left that were not sent + unsent_notifications = Notification.objects.filter(is_sent=False) + cache.set("notifications_send_mails", False) + if unsent_notifications.count() > 0: + schedule_send_mails() diff --git a/backend/notifications/migrations/0001_initial.py b/backend/notifications/migrations/0001_initial.py new file mode 100644 index 00000000..c0a67c04 --- /dev/null +++ b/backend/notifications/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 5.0.2 on 2024-02-28 21:55 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + 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)), + ], + ), + migrations.CreateModel( + 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", + ), + ), + ], + ), + ] diff --git a/backend/notifications/migrations/__init__.py b/backend/notifications/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/notifications/models.py b/backend/notifications/models.py new file mode 100644 index 00000000..c9c2fbde --- /dev/null +++ b/backend/notifications/models.py @@ -0,0 +1,29 @@ +from authentication.models import User +from django.db import models + + +class NotificationTemplate(models.Model): + id = models.AutoField(auto_created=True, primary_key=True) + title_key = models.CharField(max_length=255) # Key used to get translated title + description_key = models.CharField( + max_length=511 + ) # Key used to get translated description + + +class Notification(models.Model): + id = models.AutoField(auto_created=True, primary_key=True) + user = models.ForeignKey(User, on_delete=models.CASCADE) + template_id = models.ForeignKey(NotificationTemplate, on_delete=models.CASCADE) + created_at = models.DateTimeField(auto_now_add=True) + arguments = models.JSONField(default=dict) # Arguments to be used in the template + is_read = models.BooleanField( + default=False + ) # Whether the notification has been read + is_sent = models.BooleanField( + default=False + ) # Whether the notification has been sent (email) + + # Mark the notification as read + def sent(self): + self.is_sent = True + self.save() diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py new file mode 100644 index 00000000..d4c488ba --- /dev/null +++ b/backend/notifications/serializers.py @@ -0,0 +1,73 @@ +import re +from typing import Dict, List + +from authentication.models import User +from notifications.logic import get_message_dict +from notifications.models import Notification, NotificationTemplate +from rest_framework import serializers + + +class NotificationTemplateSerializer(serializers.ModelSerializer): + class Meta: + model = NotificationTemplate + fields = "__all__" + + +class NotificationSerializer(serializers.ModelSerializer): + # Hyper linked user field + user = serializers.HyperlinkedRelatedField( + view_name="user-detail", queryset=User.objects.all() + ) + + # Translate template and arguments into a message + message = serializers.SerializerMethodField() + + # Check if the required arguments are present + def _get_missing_keys(self, string: str, arguments: Dict[str, str]) -> List[str]: + required_keys: List[str] = re.findall(r"%\((\w+)\)", string) + missing_keys = [key for key in required_keys if key not in arguments] + + return missing_keys + + def validate(self, data: Dict[str, str]) -> Dict[str, str]: + data: Dict[str, str] = super().validate(data) + + # Validate the arguments + if "arguments" not in data: + data["arguments"] = {} + + title_missing = self._get_missing_keys( + data["template_id"].title_key, data["arguments"] + ) + description_missing = self._get_missing_keys( + data["template_id"].description_key, data["arguments"] + ) + + if title_missing or description_missing: + raise serializers.ValidationError( + { + "missing arguments": { + "title": title_missing, + "description": description_missing, + } + } + ) + + return data + + # Get the message from the template and arguments + def get_message(self, obj: Notification) -> Dict[str, str]: + return get_message_dict(obj) + + class Meta: + model = Notification + fields = [ + "id", + "user", + "template_id", + "arguments", + "message", + "created_at", + "is_read", + "is_sent", + ] diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py new file mode 100644 index 00000000..f8203f39 --- /dev/null +++ b/backend/notifications/signals.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from enum import Enum +from typing import Dict, List, Union + +from authentication.models import User +from django.db.models.query import QuerySet +from django.dispatch import Signal, receiver +from django.urls import reverse +from notifications.logic import schedule_send_mails +from notifications.serializers import NotificationSerializer + +notification_create = Signal() + + +@receiver(notification_create) +def notification_creation( + type: NotificationType, + queryset: QuerySet[User], + arguments: Dict[str, str], + **kwargs, # Required by django +) -> bool: + data: List[Dict[str, Union[str, int, Dict[str, str]]]] = [] + + for user in queryset: + data.append( + { + "template_id": type.value, + "user": reverse("user-detail", kwargs={"pk": user.id}), + "arguments": arguments, + } + ) + + serializer = NotificationSerializer(data=data, many=True) + + if not serializer.is_valid(): + return False + + serializer.save() + + schedule_send_mails() + + return True + + +class NotificationType(Enum): + SCORE_ADDED = 1 # Arguments: {"score": int} + SCORE_UPDATED = 2 # Arguments: {"score": int} diff --git a/backend/notifications/tests.py b/backend/notifications/tests.py new file mode 100644 index 00000000..a79ca8be --- /dev/null +++ b/backend/notifications/tests.py @@ -0,0 +1,3 @@ +# from django.test import TestCase + +# Create your tests here. diff --git a/backend/notifications/urls.py b/backend/notifications/urls.py new file mode 100644 index 00000000..637600f5 --- /dev/null +++ b/backend/notifications/urls.py @@ -0,0 +1 @@ +urlpatterns = [] diff --git a/backend/notifications/views.py b/backend/notifications/views.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 00000000..7ea85a77 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,13 @@ +Django==5.0.2 +django-sslserver==0.22 +djangorestframework==3.14.0 +django-rest-swagger==2.2.0 +drf-yasg==1.21.7 +requests==2.31.0 +cas-client==1.0.0 +psycopg2-binary==2.9.9 +djangorestframework-simplejwt==5.3.1 +celery[redis]==5.3.6 +django-redis==5.4.0 +gunicorn==21.2.0 +whitenoise==5.3.0 \ No newline at end of file diff --git a/backend/setup.sh b/backend/setup.sh new file mode 100755 index 00000000..1f6fa4c8 --- /dev/null +++ b/backend/setup.sh @@ -0,0 +1,16 @@ +echo "Installing requirements..." +pip install -r requirements.txt > /dev/null + +echo "Migrating database..." +python manage.py migrate > /dev/null + +echo "Populating database..." +python manage.py loaddata */fixtures/* > /dev/null + +echo "Compiling translations..." +django-admin compilemessages > /dev/null + +echo "Generating Swagger documentation..." +echo "yes" | python manage.py collectstatic > /dev/null + +echo "Done" \ No newline at end of file diff --git a/backend/ypovoli/__init__.py b/backend/ypovoli/__init__.py new file mode 100644 index 00000000..5568b6d7 --- /dev/null +++ b/backend/ypovoli/__init__.py @@ -0,0 +1,5 @@ +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ("celery_app",) diff --git a/backend/ypovoli/asgi.py b/backend/ypovoli/asgi.py new file mode 100644 index 00000000..ac8466f7 --- /dev/null +++ b/backend/ypovoli/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for ypovoli project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + +application = get_asgi_application() diff --git a/backend/ypovoli/celery.py b/backend/ypovoli/celery.py new file mode 100644 index 00000000..5f2cac39 --- /dev/null +++ b/backend/ypovoli/celery.py @@ -0,0 +1,17 @@ +import os + +from celery import Celery + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + +app = Celery("ypovoli") + +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object("django.conf:settings", namespace="CELERY") + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() diff --git a/backend/ypovoli/handlers.py b/backend/ypovoli/handlers.py new file mode 100644 index 00000000..1a027b7a --- /dev/null +++ b/backend/ypovoli/handlers.py @@ -0,0 +1,14 @@ +from rest_framework.views import exception_handler +from django.utils.translation import gettext_lazy as _ + + +def translate_exception_handler(exc, context): + response = exception_handler(exc, context) + + if response.status_code == 401: + response.data['detail'] = _('Given token not valid for any token type') + + if response.status_code == 404: + response.data['detail'] = _('Not found.') + + return response diff --git a/backend/ypovoli/locale/en/LC_MESSAGES/django.po b/backend/ypovoli/locale/en/LC_MESSAGES/django.po new file mode 100644 index 00000000..b14e202b --- /dev/null +++ b/backend/ypovoli/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "English" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Dutch" diff --git a/backend/ypovoli/locale/nl/LC_MESSAGES/django.po b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 00000000..513be77b --- /dev/null +++ b/backend/ypovoli/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,26 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-03-12 17:05+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: settings.py:115 +msgid "languages.en" +msgstr "Engels" + +#: settings.py:115 +msgid "languages.nl" +msgstr "Nederlands" diff --git a/backend/ypovoli/settings.py b/backend/ypovoli/settings.py new file mode 100644 index 00000000..5a3f7c23 --- /dev/null +++ b/backend/ypovoli/settings.py @@ -0,0 +1,176 @@ +""" +Django settings for ypovoli project. + +Generated by 'django-admin startproject' using Django 5.0.2. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" + +from django.utils.translation import gettext_lazy as _ +import os +from datetime import timedelta +from os import environ +from pathlib import Path + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent +MEDIA_ROOT = os.path.normpath(os.path.join(BASE_DIR, "data/production")) + +TESTING_BASE_LINK = "http://testserver" + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = environ.get("DJANGO_SECRET_KEY", "lnZZ2xHc6HjU5D85GDE3Nnu4CJsBnm") + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = environ.get("DJANGO_DEBUG", "False").lower() in ["true", "1", "t"] +DOMAIN_NAME = environ.get("DJANGO_DOMAIN_NAME", "localhost") +ALLOWED_HOSTS = [DOMAIN_NAME] + + +# Application definition + +INSTALLED_APPS = [ + # Built-ins + "django.contrib.auth", + "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 + "notifications", # Ypovoli notifications +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", +] + +REST_FRAMEWORK = { + "DEFAULT_RENDERER_CLASSES": [ + "rest_framework.renderers.JSONRenderer", + ], + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework_simplejwt.authentication.JWTAuthentication", + "rest_framework.authentication.SessionAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], +} + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(days=365), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "UPDATE_LAST_LOGIN": True, + "TOKEN_OBTAIN_SERIALIZER": "authentication.serializers.CASTokenObtainSerializer", +} + +AUTH_USER_MODEL = "authentication.User" +ROOT_URLCONF = "ypovoli.urls" +WSGI_APPLICATION = "ypovoli.wsgi.application" + +# Application endpoints + +URL_PREFIX = environ.get("DJANGO_CAS_URL_PREFIX", "") +PORT = environ.get("DJANGO_CAS_PORT", "8080") +CAS_ENDPOINT = "https://login.ugent.be" +CAS_RESPONSE = f"https://{DOMAIN_NAME}:{PORT}{'/' + URL_PREFIX if URL_PREFIX else ''}/api/auth/cas/echo" +API_ENDPOINT = f"https://{DOMAIN_NAME}/api" + +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": environ.get("DJANGO_DB_ENGINE", "django.db.backends.sqlite3"), + "NAME": environ.get("DJANGO_DB_NAME", BASE_DIR / "db.sqlite3"), + "USER": environ.get("DJANGO_DB_USER", ""), + "PASSWORD": environ.get("DJANGO_DB_PASSWORD", ""), + "HOST": environ.get("DJANGO_DB_HOST", ""), + "PORT": environ.get("DJANGO_DB_PORT", ""), + }, +} + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Internationalization + +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +LANGUAGES = [("en", _("languages.en")), ("nl", _("languages.nl"))] +USE_L10N = False +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ +STATIC_URL = "api/static/" +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") +STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage" + +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", + ], + }, + }, +] + +EMAIL_HOST = "smtprelay.UGent.be" +EMAIL_PORT = 25 + +EMAIL_CUSTOM = { + "from": "ypovoli@ugent.be", + "subject": "[Ypovoli] New Notification", + "timeout": 2, + "max_errors": 3, +} + +REDIS_CUSTOM = { + "host": environ.get("DJANGO_REDIS_HOST", "localhost"), + "port": environ.get("DJANGO_REDIS_PORT", 6379), + "db_django": 0, + "db_celery": 1, +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_django']}", + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } +} + +CELERY_BROKER_URL = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" +CELERY_RESULT_BACKEND = f"redis://@{REDIS_CUSTOM['host']}:{REDIS_CUSTOM['port']}/{REDIS_CUSTOM['db_celery']}" diff --git a/backend/ypovoli/urls.py b/backend/ypovoli/urls.py new file mode 100644 index 00000000..0d786c39 --- /dev/null +++ b/backend/ypovoli/urls.py @@ -0,0 +1,63 @@ +""" +URL configuration for ypovoli project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.urls import include, path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + +schema_view = get_schema_view( + openapi.Info( + title="Ypovoli API", + default_version="v1", + ), + public=True, + permission_classes=[ + permissions.AllowAny, + ], +) + + +urlpatterns = [ + path( + "api/", + include( + [ + # Base API endpoints. + path("", include("api.urls")), + # Authentication endpoints. + path("auth/", include("authentication.urls")), + path( + "notifications/", + include("notifications.urls"), + name="notifications", + ), + # 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 new file mode 100644 index 00000000..0495fc95 --- /dev/null +++ b/backend/ypovoli/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for ypovoli project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "ypovoli.settings") + +application = get_wsgi_application() diff --git a/data/nginx/nginx.dev.conf b/data/nginx/nginx.dev.conf new file mode 100644 index 00000000..7475f718 --- /dev/null +++ b/data/nginx/nginx.dev.conf @@ -0,0 +1,57 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:8080; + } + + upstream frontend { + server frontend:5173; + } + + server { + listen 80; + listen [::]:80; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 8080 ssl; + listen [::]:8080 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location /api/ { + proxy_pass https://backend$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + + location / { + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } +} \ No newline at end of file diff --git a/data/nginx/nginx.prod.conf b/data/nginx/nginx.prod.conf new file mode 100644 index 00000000..042f9859 --- /dev/null +++ b/data/nginx/nginx.prod.conf @@ -0,0 +1,52 @@ +events { + worker_connections 1024; +} + +http { + upstream backend { + server backend:8080; + } + + upstream frontend { + server frontend:3000; + } + + server { + listen 80; + listen [::]:80; + + location / { + return 301 https://$host$request_uri; + } + } + + server { + listen 443 ssl; + listen [::]:443 ssl; + + ssl_certificate ssl/certificate.crt; + ssl_certificate_key ssl/private.key; + + location /api/ { + proxy_pass http://backend$request_uri; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location /auth/ { + rewrite ^/auth/(.*)$ /$1 break; + proxy_pass http://backend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + + location / { + proxy_pass http://frontend; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header Host $host; + proxy_redirect off; + } + } +} \ No newline at end of file diff --git a/data/nginx/ssl/.gitkeep b/data/nginx/ssl/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/development.sh b/development.sh new file mode 100755 index 00000000..fdf0e030 --- /dev/null +++ b/development.sh @@ -0,0 +1,75 @@ +#!/bin/bash + +backend=false +frontend=false +build=false + +while getopts ":bfc" opt; do + case ${opt} in + b ) + backend=true + ;; + f ) + frontend=true + ;; + c ) + build=true + ;; + \? ) + echo "Usage: $0 [-b] [-f] [-c]" + exit 1 + ;; + esac +done + +echo "Checking environment file..." + +if ! [ -f .env ]; then + cp .dev.env .env + read -s -p "Enter a random string for the django secret (just smash keyboard): " new_secret + sed -i "s/^DJANGO_SECRET_KEY=.*/DJANGO_SECRET_KEY=$new_secret/" .env + echo "Created environment file" +fi + +echo "Checking for existing SSL certificates..." + +if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then + echo "Generating SSL certificates..." + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout data/nginx/ssl/private.key \ + -out data/nginx/ssl/certificate.crt \ + -subj "/C=BE/ST=/L=/O=/OU=/CN=" > /dev/null + echo "SSL certificates generated." +else + echo "SSL certificates already exist, skipping generation." +fi + +if [ "$build" = true ]; then + echo "Building Docker images..." + echo "This can take a while..." + docker-compose -f development.yml build --no-cache +else + echo "$build" +fi + +echo "Starting services..." +docker-compose -f development.yml up -d + +echo "-------------------------------------" +echo "Following logs..." +echo "Press CTRL + C to stop all containers" +echo "-------------------------------------" + +if [ "$backend" = true ] && [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 backend frontend +elif [ "$frontend" = true ]; then + docker-compose -f development.yml logs --follow --tail 50 frontend +else + docker-compose -f development.yml logs --follow --tail 50 backend +fi + +echo "Cleaning up..." + +docker-compose -f development.yml down + +echo "Done." diff --git a/development.yml b/development.yml new file mode 100644 index 00000000..7b096836 --- /dev/null +++ b/development.yml @@ -0,0 +1,98 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + ports: + - 80:80 + - 443:443 + - 8080:8080 + volumes: + - ${DATA_DIR}/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" + expose: + - 8080 + volumes: + - $BACKEND_DIR:/code + + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: celery -A ypovoli worker -l DEBUG + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile.dev + command: bash -c "npm install && npm run host" + expose: + - 5173 + volumes: + - $FRONTEND_DIR:/app + depends_on: + - backend + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - ${DATA_DIR}/redis:/data + \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.gitkeep b/frontend/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 00000000..c0a6e5a4 --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] +} diff --git a/frontend/Dockerfile.dev b/frontend/Dockerfile.dev new file mode 100644 index 00000000..5792f9fc --- /dev/null +++ b/frontend/Dockerfile.dev @@ -0,0 +1,9 @@ +FROM node:16 + +WORKDIR /app + +COPY package*.json ./ + +RUN npm install + +COPY . /app/ diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod new file mode 100644 index 00000000..5254aa37 --- /dev/null +++ b/frontend/Dockerfile.prod @@ -0,0 +1,12 @@ +FROM node:16 as build-stage +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY ./ . +RUN npm run build + +FROM nginx as production-stage +EXPOSE 3000 +RUN mkdir /app +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build-stage /app/dist /app \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 00000000..ef72fd52 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,18 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 00000000..0ae8c83e --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 3000; + + location / { + root /app; + index index.html index.htm; + try_files $uri $uri/ /index.html; + } +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..d375df59 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1767 @@ +{ + "name": "vite-vue-typescript-starter", + "version": "0.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "vite-vue-typescript-starter", + "version": "0.0.0", + "dependencies": { + "vue": "^3.4.18" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.2.2", + "vite": "^5.1.1", + "vue-tsc": "^1.8.27" + } + }, + "node_modules/@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz", + "integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", + "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.19", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", + "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", + "dependencies": { + "@vue/compiler-core": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", + "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.19", + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.6", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", + "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", + "dependencies": { + "@vue/compiler-dom": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", + "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", + "dependencies": { + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", + "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", + "dependencies": { + "@vue/reactivity": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", + "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", + "dependencies": { + "@vue/runtime-core": "3.4.19", + "@vue/shared": "3.4.19", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", + "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", + "dependencies": { + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19" + }, + "peerDependencies": { + "vue": "3.4.19" + } + }, + "node_modules/@vue/shared": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", + "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "bin": { + "he": "bin/he" + } + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/magic-string": { + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "dev": true, + "dependencies": { + "esbuild": "^0.19.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", + "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", + "dependencies": { + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-sfc": "3.4.19", + "@vue/runtime-dom": "3.4.19", + "@vue/server-renderer": "3.4.19", + "@vue/shared": "3.4.19" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + }, + "dependencies": { + "@babel/parser": { + "version": "7.23.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", + "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==" + }, + "@esbuild/aix-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", + "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", + "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", + "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", + "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", + "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", + "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", + "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", + "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", + "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", + "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", + "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", + "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", + "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", + "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", + "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", + "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", + "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", + "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", + "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", + "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", + "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", + "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", + "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "dev": true, + "optional": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "@rollup/rollup-android-arm-eabi": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz", + "integrity": "sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==", + "dev": true, + "optional": true + }, + "@rollup/rollup-android-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz", + "integrity": "sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-arm64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz", + "integrity": "sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-darwin-x64": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz", + "integrity": "sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz", + "integrity": "sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz", + "integrity": "sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-arm64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz", + "integrity": "sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-riscv64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz", + "integrity": "sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-gnu": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz", + "integrity": "sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-linux-x64-musl": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz", + "integrity": "sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-arm64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz", + "integrity": "sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-ia32-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz", + "integrity": "sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==", + "dev": true, + "optional": true + }, + "@rollup/rollup-win32-x64-msvc": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz", + "integrity": "sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==", + "dev": true, + "optional": true + }, + "@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, + "@vitejs/plugin-vue": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz", + "integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==", + "dev": true, + "requires": {} + }, + "@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "requires": { + "@volar/source-map": "1.11.1" + } + }, + "@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "requires": { + "muggle-string": "^0.3.1" + } + }, + "@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "requires": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "@vue/compiler-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.19.tgz", + "integrity": "sha512-gj81785z0JNzRcU0Mq98E56e4ltO1yf8k5PQ+tV/7YHnbZkrM0fyFyuttnN8ngJZjbpofWE/m4qjKBiLl8Ju4w==", + "requires": { + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.19", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.19.tgz", + "integrity": "sha512-vm6+cogWrshjqEHTzIDCp72DKtea8Ry/QVpQRYoyTIg9k7QZDX6D8+HGURjtmatfgM8xgCFtJJaOlCaRYRK3QA==", + "requires": { + "@vue/compiler-core": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/compiler-sfc": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.19.tgz", + "integrity": "sha512-LQ3U4SN0DlvV0xhr1lUsgLCYlwQfUfetyPxkKYu7dkfvx7g3ojrGAkw0AERLOKYXuAGnqFsEuytkdcComei3Yg==", + "requires": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.19", + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.6", + "postcss": "^8.4.33", + "source-map-js": "^1.0.2" + } + }, + "@vue/compiler-ssr": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.19.tgz", + "integrity": "sha512-P0PLKC4+u4OMJ8sinba/5Z/iDT84uMRRlrWzadgLA69opCpI1gG4N55qDSC+dedwq2fJtzmGald05LWR5TFfLw==", + "requires": { + "@vue/compiler-dom": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "requires": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + } + }, + "@vue/reactivity": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.19.tgz", + "integrity": "sha512-+VcwrQvLZgEclGZRHx4O2XhyEEcKaBi50WbxdVItEezUf4fqRh838Ix6amWTdX0CNb/b6t3Gkz3eOebfcSt+UA==", + "requires": { + "@vue/shared": "3.4.19" + } + }, + "@vue/runtime-core": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.19.tgz", + "integrity": "sha512-/Z3tFwOrerJB/oyutmJGoYbuoadphDcJAd5jOuJE86THNZji9pYjZroQ2NFsZkTxOq0GJbb+s2kxTYToDiyZzw==", + "requires": { + "@vue/reactivity": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/runtime-dom": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.19.tgz", + "integrity": "sha512-IyZzIDqfNCF0OyZOauL+F4yzjMPN2rPd8nhqPP2N1lBn3kYqJpPHHru+83Rkvo2lHz5mW+rEeIMEF9qY3PB94g==", + "requires": { + "@vue/runtime-core": "3.4.19", + "@vue/shared": "3.4.19", + "csstype": "^3.1.3" + } + }, + "@vue/server-renderer": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.19.tgz", + "integrity": "sha512-eAj2p0c429RZyyhtMRnttjcSToch+kTWxFPHlzGMkR28ZbF1PDlTcmGmlDxccBuqNd9iOQ7xPRPAGgPVj+YpQw==", + "requires": { + "@vue/compiler-ssr": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "@vue/shared": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.19.tgz", + "integrity": "sha512-/KliRRHMF6LoiThEy+4c1Z4KB/gbPrGjWwJR+crg2otgrf/egKzRaCPvJ51S5oetgsgXLfc4Rm5ZgrKHZrtMSw==" + }, + "balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true + }, + "csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true + }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" + }, + "esbuild": { + "version": "0.19.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", + "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "dev": true, + "requires": { + "@esbuild/aix-ppc64": "0.19.12", + "@esbuild/android-arm": "0.19.12", + "@esbuild/android-arm64": "0.19.12", + "@esbuild/android-x64": "0.19.12", + "@esbuild/darwin-arm64": "0.19.12", + "@esbuild/darwin-x64": "0.19.12", + "@esbuild/freebsd-arm64": "0.19.12", + "@esbuild/freebsd-x64": "0.19.12", + "@esbuild/linux-arm": "0.19.12", + "@esbuild/linux-arm64": "0.19.12", + "@esbuild/linux-ia32": "0.19.12", + "@esbuild/linux-loong64": "0.19.12", + "@esbuild/linux-mips64el": "0.19.12", + "@esbuild/linux-ppc64": "0.19.12", + "@esbuild/linux-riscv64": "0.19.12", + "@esbuild/linux-s390x": "0.19.12", + "@esbuild/linux-x64": "0.19.12", + "@esbuild/netbsd-x64": "0.19.12", + "@esbuild/openbsd-x64": "0.19.12", + "@esbuild/sunos-x64": "0.19.12", + "@esbuild/win32-arm64": "0.19.12", + "@esbuild/win32-ia32": "0.19.12", + "@esbuild/win32-x64": "0.19.12" + } + }, + "estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "optional": true + }, + "he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "magic-string": { + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", + "requires": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, + "minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + }, + "muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true + }, + "nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==" + }, + "path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true + }, + "picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "postcss": { + "version": "8.4.35", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", + "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", + "requires": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + } + }, + "rollup": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.12.0.tgz", + "integrity": "sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==", + "dev": true, + "requires": { + "@rollup/rollup-android-arm-eabi": "4.12.0", + "@rollup/rollup-android-arm64": "4.12.0", + "@rollup/rollup-darwin-arm64": "4.12.0", + "@rollup/rollup-darwin-x64": "4.12.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.12.0", + "@rollup/rollup-linux-arm64-gnu": "4.12.0", + "@rollup/rollup-linux-arm64-musl": "4.12.0", + "@rollup/rollup-linux-riscv64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-gnu": "4.12.0", + "@rollup/rollup-linux-x64-musl": "4.12.0", + "@rollup/rollup-win32-arm64-msvc": "4.12.0", + "@rollup/rollup-win32-ia32-msvc": "4.12.0", + "@rollup/rollup-win32-x64-msvc": "4.12.0", + "@types/estree": "1.0.5", + "fsevents": "~2.3.2" + } + }, + "semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + }, + "source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==" + }, + "typescript": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", + "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "devOptional": true + }, + "vite": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.1.3.tgz", + "integrity": "sha512-UfmUD36DKkqhi/F75RrxvPpry+9+tTkrXfMNZD+SboZqBCMsxKtO52XeGzzuh7ioz+Eo/SYDBbdb0Z7vgcDJew==", + "dev": true, + "requires": { + "esbuild": "^0.19.3", + "fsevents": "~2.3.3", + "postcss": "^8.4.35", + "rollup": "^4.2.0" + } + }, + "vue": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.4.19.tgz", + "integrity": "sha512-W/7Fc9KUkajFU8dBeDluM4sRGc/aa4YJnOYck8dkjgZoXtVsn3OeTGni66FV1l3+nvPA7VBFYtPioaGKUmEADw==", + "requires": { + "@vue/compiler-dom": "3.4.19", + "@vue/compiler-sfc": "3.4.19", + "@vue/runtime-dom": "3.4.19", + "@vue/server-renderer": "3.4.19", + "@vue/shared": "3.4.19" + } + }, + "vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "requires": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "requires": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + } + }, + "yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..60994926 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "vite-vue-typescript-starter", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "host": "vite --host", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.18" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "typescript": "^5.2.2", + "vite": "^5.1.1", + "vue-tsc": "^1.8.27" + } +} diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 00000000..bb666a8d --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/assets/vue.svg b/frontend/src/assets/vue.svg new file mode 100644 index 00000000..770e9d33 --- /dev/null +++ b/frontend/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/HelloWorld.vue b/frontend/src/components/HelloWorld.vue new file mode 100644 index 00000000..7b25f3f2 --- /dev/null +++ b/frontend/src/components/HelloWorld.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 00000000..2425c0f7 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 00000000..bb131d6b --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 00000000..9e03e604 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 00000000..97ede7ee --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 00000000..05c17402 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], +}) diff --git a/production.yml b/production.yml new file mode 100644 index 00000000..40f93d81 --- /dev/null +++ b/production.yml @@ -0,0 +1,109 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + ports: + - 80:80 + - 443:443 + volumes: + - ${DATA_DIR}/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + + postgres: + <<: *common-keys-selab + image: postgres:15.2 + container_name: postgres + networks: + selab_network: + ipv4_address: $POSTGRES_IP + environment: + POSTGRES_DB: $POSTGRES_DB + POSTGRES_USER: $POSTGRES_USER + POSTGRES_PASSWORD: $POSTGRES_PASSWORD + expose: + - $POSTGRES_PORT + volumes: + - ${DATA_DIR}/postgres:/var/lib/postgresql/data + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.prod + command: bash -c "./setup.sh && gunicorn --config gunicorn_config.py ypovoli.wsgi:application" + expose: + - 8080 + depends_on: + - postgres + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - ${DATA_DIR}/redis:/data + + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.prod + command: celery -A ypovoli worker -l ERROR + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile.prod + expose: + - 3000 + depends_on: + - backend \ No newline at end of file diff --git a/test.sh b/test.sh new file mode 100755 index 00000000..a70f382a --- /dev/null +++ b/test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +backend=false +frontend=false +build=false + +while getopts ":bfc" opt; do + case ${opt} in + b ) + backend=true + ;; + f ) + frontend=true + ;; + c ) + build=true + ;; + \? ) + echo "Usage: $0 [-b] [-f] [-c]" + exit 1 + ;; + esac +done + +echo "Checking environment file..." + +if ! [ -f .env ]; then + cp .dev.env .env + sed -i "s/^DJANGO_SECRET_KEY=.*/DJANGO_SECRET_KEY=totally_random_key_string/" .env + echo "Created environment file" +fi + +echo "Checking for existing SSL certificates..." + +if [ ! -f "data/nginx/ssl/private.key" ] || [ ! -f "data/nginx/ssl/certificate.crt" ]; then + echo "Generating SSL certificates..." + sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ + -keyout data/nginx/ssl/private.key \ + -out data/nginx/ssl/certificate.crt \ + -subj "/C=BE/ST=/L=/O=/OU=/CN=" > /dev/null + echo "SSL certificates generated." +else + echo "SSL certificates already exist, skipping generation." +fi + +if [ "$build" = true ]; then + echo "Building Docker images..." + echo "This can take a while..." + docker-compose -f testing.yml build --no-cache +else + echo "$build" +fi + +echo "Starting services..." +docker-compose -f testing.yml up -d + +if [ "$frontend" = true ]; then + echo "Running frontend tests..." + echo "Not implemented yet" +fi + +if [ "$backend" = true ]; then + echo "Running backend tests..." + docker-compose -f testing.yml exec backend python manage.py test +fi + +echo "Cleaning up..." + +docker-compose -f testing.yml down + +echo "Done." diff --git a/testing.yml b/testing.yml new file mode 100644 index 00000000..1c6dbf00 --- /dev/null +++ b/testing.yml @@ -0,0 +1,98 @@ +version: "3.9" + +############################# NETWORKS + +networks: + selab_network: + name: selab_network + driver: bridge + ipam: + config: + - subnet: 192.168.90.0/24 + +############################# EXTENSIONS + +x-common-keys-selab: &common-keys-selab + networks: + - selab_network + security_opt: + - no-new-privileges:true + restart: unless-stopped + environment: + TZ: $TZ + PUID: $PUID + PGID: $PGID + env_file: + - .env + +############################# SERVICES + +services: + + nginx: + <<: *common-keys-selab + image: nginx:latest + container_name: nginx + expose: + - 80 + - 443 + - 8080 + volumes: + - ${DATA_DIR}/nginx/nginx.dev.conf:/etc/nginx/nginx.conf:ro + - ${SSL_DIR}:/etc/nginx/ssl:ro + depends_on: + - backend + - frontend + + backend: + <<: *common-keys-selab + container_name: backend + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: /bin/bash -c "./setup.sh && python manage.py runsslserver 192.168.90.2:8080" + expose: + - 8080 + volumes: + - $BACKEND_DIR:/code + + celery: + <<: *common-keys-selab + container_name: celery + build: + context: $BACKEND_DIR + dockerfile: Dockerfile.dev + command: celery -A ypovoli worker -l DEBUG + volumes: + - $BACKEND_DIR:/code + depends_on: + - backend + - redis + + frontend: + <<: *common-keys-selab + container_name: frontend + build: + context: $FRONTEND_DIR + dockerfile: Dockerfile.dev + command: bash -c "npm install && npm run host" + expose: + - 5173 + volumes: + - $FRONTEND_DIR:/app + depends_on: + - backend + + redis: + <<: *common-keys-selab + container_name: redis + image: redis:latest + networks: + selab_network: + ipv4_address: $REDIS_IP + expose: + - $REDIS_PORT + entrypoint: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru + volumes: + - ${DATA_DIR}/redis:/data + \ No newline at end of file