diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..10d45d48 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,22 @@ +name: Backend test CI + +on: + - pull_request + +jobs: + backend-test: + runs-on: self-hosted + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: "3.11" + - name: Install dependencies + working-directory: ./ + run: | + python -m pip install --upgrade pip + pip install -r ./backend/requirements.txt + - name: Running Django tests + run: | + sh ./backend/runtests.sh \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3ed07993..e4642805 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .env django/db.sqlite3 backend/db.sqlite3 +backend/uploads venv/ .venv/ diff --git a/Makefile b/Makefile index 01c1abef..e6e04f4a 100644 --- a/Makefile +++ b/Makefile @@ -6,4 +6,17 @@ stop: lint: docker exec pigeonhole-backend flake8 . - docker exec pigeonhole-frontend npm run lint \ No newline at end of file + docker exec pigeonhole-frontend npm run lint + +superuser: + docker exec -it pigeonhole-backend python manage.py createsuperuser + +reset: + docker image prune -af + docker system prune + +backendtest: + docker exec -it pigeonhole-backend sh /usr/src/app/backend/runtests.sh + +backendshell: + docker exec -it pigeonhole-backend sh diff --git a/backend/pigeonhole/apps/courses/migrations/0001_initial.py b/backend/pigeonhole/apps/courses/migrations/0001_initial.py index 9d548232..a62b1faf 100644 --- a/backend/pigeonhole/apps/courses/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/courses/migrations/0001_initial.py @@ -1,9 +1,10 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py new file mode 100644 index 00000000..54b4ca11 --- /dev/null +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -0,0 +1,26 @@ +from rest_framework import permissions + +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.courses.models import Course + + +class CourseUserPermissions(permissions.BasePermission): + def has_permission(self, request, view): + if request.user.is_admin or request.user.is_superuser: + return True + + if request.user.is_teacher: + if view.action in ['create', 'list', 'retrieve']: + return True + elif view.action in ['update', 'partial_update', 'destroy', 'get_projects'] and User.objects.filter( + id=request.user.id, + course=view.kwargs[ + 'pk']).exists(): + return True + return + + if request.user.is_student: + if view.action == 'get_projects': + return Course.objects.filter(course_id=view.kwargs['pk'], user=request.user).exists() + return view.action in ['list', 'retrieve'] + return False diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py new file mode 100644 index 00000000..ba7f84be --- /dev/null +++ b/backend/pigeonhole/apps/courses/views.py @@ -0,0 +1,42 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.pigeonhole.apps.courses.models import CourseSerializer +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.projects.models import ProjectSerializer +from .models import Course +from .permissions import CourseUserPermissions + + +class CourseViewSet(viewsets.ModelViewSet): + queryset = Course.objects.all() + serializer_class = CourseSerializer + permission_classes = [IsAuthenticated, CourseUserPermissions] + + def perform_create(self, serializer): + course = serializer.save() + user = self.request.user + user.course.add(course) + + @action(detail=True, methods=['post']) + def join_course(self, request, *args, **kwargs): + course = self.get_object() + user = request.user + + user.course.add(course) + return Response(status=status.HTTP_200_OK) + + @action(detail=False, methods=['GET']) + def get_selected_courses(self, request, *args, **kwargs): + user = request.user + courses = user.course.all() + serializer = CourseSerializer(courses, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @action(detail=True, methods=['GET']) + def get_projects(self, request, *args, **kwargs): + course = self.get_object() + projects = Project.objects.filter(course_id=course) + return Response(ProjectSerializer(projects, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/groups/migrations/0001_initial.py b/backend/pigeonhole/apps/groups/migrations/0001_initial.py index fc8925aa..f7a3b64b 100644 --- a/backend/pigeonhole/apps/groups/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0001_initial.py @@ -1,10 +1,11 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.db.models.deletion from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ @@ -16,9 +17,10 @@ class Migration(migrations.Migration): name='Group', fields=[ ('group_id', models.BigAutoField(primary_key=True, serialize=False)), - ('group_nr', models.IntegerField()), + ('group_nr', models.IntegerField(blank=True, null=True)), ('feedback', models.TextField(null=True)), - ('final_score', models.IntegerField()), + ('final_score', models.IntegerField(blank=True, null=True)), + ('visible', models.BooleanField(default=True)), ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), ], ), diff --git a/backend/pigeonhole/apps/groups/migrations/0002_initial.py b/backend/pigeonhole/apps/groups/migrations/0002_initial.py index 354b0401..c90a35bb 100644 --- a/backend/pigeonhole/apps/groups/migrations/0002_initial.py +++ b/backend/pigeonhole/apps/groups/migrations/0002_initial.py @@ -1,20 +1,22 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 +from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): + initial = True dependencies = [ ('groups', '0001_initial'), - ('users', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ migrations.AddField( model_name='group', - name='student', - field=models.ManyToManyField(to='users.student'), + name='user', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), ), ] diff --git a/backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py b/backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py similarity index 63% rename from backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py rename to backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py index 51d6180a..a0ac7211 100644 --- a/backend/pigeonhole/apps/groups/migrations/0003_alter_group_group_nr.py +++ b/backend/pigeonhole/apps/groups/migrations/0003_alter_group_visible.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:30 +# Generated by Django 5.0.3 on 2024-03-13 11:04 from django.db import migrations, models @@ -12,7 +12,7 @@ class Migration(migrations.Migration): operations = [ migrations.AlterField( model_name='group', - name='group_nr', - field=models.IntegerField(blank=True, null=True), + name='visible', + field=models.BooleanField(default=False), ), ] diff --git a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py deleted file mode 100644 index 1a72c57e..00000000 --- a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_final_score.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:36 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0003_alter_group_group_nr'), - ] - - operations = [ - migrations.AlterField( - model_name='group', - name='final_score', - field=models.IntegerField(blank=True, null=True), - ), - ] diff --git a/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py new file mode 100644 index 00000000..fadc38cb --- /dev/null +++ b/backend/pigeonhole/apps/groups/migrations/0004_alter_group_user.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.3 on 2024-03-13 18:34 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0003_alter_group_visible'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='group', + name='user', + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/pigeonhole/apps/groups/models.py b/backend/pigeonhole/apps/groups/models.py index 9f57317e..0d299876 100644 --- a/backend/pigeonhole/apps/groups/models.py +++ b/backend/pigeonhole/apps/groups/models.py @@ -1,26 +1,41 @@ +from django.core.exceptions import ValidationError from django.db import models from rest_framework import serializers from backend.pigeonhole.apps.projects.models import Project -from backend.pigeonhole.apps.users.models import Student +from backend.pigeonhole.apps.users.models import User class Group(models.Model): group_id = models.BigAutoField(primary_key=True) group_nr = models.IntegerField(blank=True, null=True) project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - student = models.ManyToManyField(Student) + user = models.ManyToManyField(User, blank=True) feedback = models.TextField(null=True) final_score = models.IntegerField(null=True, blank=True) + visible = models.BooleanField(null=False, default=False) objects = models.Manager() + # a student can only be in one group per project + def clean(self): + if self.user.exists(): # Only validate if there are users + for student in self.user.all(): + existing_groups = Group.objects.filter( + project_id=self.project_id, user=student).exclude( + group_id=self.group_id) + if existing_groups.exists(): + raise ValidationError(f"Student {student} is already part of " + "another group in this project.") + + # a student can only be in one group per project, group_nr is + # automatically assigned and unique per project def save(self, *args, **kwargs): if not self.group_id: if self.group_nr is None: max_group_nr = Group.objects.filter( project_id=self.project_id).aggregate( - models.Max('group_nr'))['group_nr__max'] or 0 + models.Max('group_nr'))['group_nr__max'] or 0 self.group_nr = max_group_nr + 1 super().save(*args, **kwargs) @@ -28,4 +43,26 @@ def save(self, *args, **kwargs): class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ["group_id", "group_nr", "final_score", "project_id", "student"] + fields = ["group_id", "group_nr", "final_score", "project_id", "user", "feedback", "visible"] + + def to_representation(self, instance): + data = super().to_representation(instance) + request = self.context.get('request') + + # if student not in group always hide final_score and feedback + if request and request.user.is_student and not instance.user.filter( + pk=request.user.pk).exists(): + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data + + # Check if the user is a student and the group is not visible + if request and request.user.is_student and not instance.visible: + # Hide sensitive information for students + if 'final_score' in data: + del data['final_score'] + if 'feedback' in data: + del data['feedback'] + return data diff --git a/backend/pigeonhole/apps/groups/permission.py b/backend/pigeonhole/apps/groups/permission.py new file mode 100644 index 00000000..0efe3c24 --- /dev/null +++ b/backend/pigeonhole/apps/groups/permission.py @@ -0,0 +1,49 @@ +from rest_framework.response import Response +from rest_framework import permissions, status + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project + + +class CanAccessGroup(permissions.BasePermission): + # Custom user class to check if the user can join a group. + def has_permission(self, request, view): + if not request.user.is_authenticated: + # If user is not authenticated, deny permission + return False + + if view.action in ['create', 'list']: + return False + + user = request.user + group_id = int(view.kwargs.get('pk')) + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + project_id = Group.objects.get(group_id=group_id).project_id.project_id + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + + course_id = Project.objects.get(project_id=project_id).course_id.course_id + if user.is_admin or user.is_superuser: + return view.action not in ['join', 'leave'] + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return view.action in ['retrieve', 'get_submissions', 'update', 'partial_update'] + elif user.is_student: + if user.course.filter(course_id=course_id).exists(): + # check if the user is already in the group + if Group.objects.get(group_id=group_id).user.filter(id=user.id).exists(): + return view.action in ['retrieve', 'get_submissions', 'leave'] + elif Group.objects.get(group_id=group_id).user.count() < Project.objects.get( + project_id=project_id).group_size: + return view.action in ['retrieve', 'get_submissions', 'join'] + elif view.action in ['join']: + return Response({'message': 'Group is full'}, status=status.HTTP_400_BAD_REQUEST) + elif view.action in ['leave']: + return Response({'message': 'User is not in the group'}, status=status.HTTP_400_BAD_REQUEST) + return False diff --git a/backend/pigeonhole/apps/groups/views.py b/backend/pigeonhole/apps/groups/views.py new file mode 100644 index 00000000..e61cd3d5 --- /dev/null +++ b/backend/pigeonhole/apps/groups/views.py @@ -0,0 +1,44 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.pigeonhole.apps.groups.models import Group, GroupSerializer +from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer +from .permission import CanAccessGroup +from ..projects.models import Project + + +class GroupViewSet(viewsets.ModelViewSet): + queryset = Group.objects.all() + serializer_class = GroupSerializer + permission_classes = [IsAuthenticated and CanAccessGroup] + + @action(detail=True, methods=['post']) + def join(self, request, pk=None): + group_id = pk + group = Group.objects.get(group_id=group_id) + user = request.user + project = Project.objects.get(project_id=group.project_id.project_id) + if group.user.count() < project.group_size: + group.user.add(user) + group.save() + return Response({'message': 'User joined group'}, status=status.HTTP_200_OK) + else: + return Response({'message': 'Group is full'}, status=status.HTTP_400_BAD_REQUEST) + + # leave a group + @action(detail=True, methods=['post']) + def leave(self, request, pk=None): + group = self.get_object() + user = request.user + group.user.remove(user) + group.save() + return Response({'message': 'User left group'}, status=status.HTTP_200_OK) + + # get all submissions for a group + @action(detail=True, methods=['get']) + def get_submissions(self, request, pk=None): + group = self.get_object() + submissions = Submissions.objects.filter(group_id=group.group_id) + return Response(SubmissionsSerializer(submissions, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/projects/migrations/0001_initial.py b/backend/pigeonhole/apps/projects/migrations/0001_initial.py index c4390c94..f2fef4f4 100644 --- a/backend/pigeonhole/apps/projects/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/projects/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.db.models.deletion from django.db import migrations, models @@ -18,36 +18,26 @@ class Migration(migrations.Migration): ('project_id', models.BigAutoField(primary_key=True, serialize=False)), ('name', models.CharField(max_length=256)), ('description', models.TextField()), + ('deadline', models.DateTimeField(blank=True, null=True)), ('visible', models.BooleanField(default=False)), + ('number_of_groups', models.IntegerField(default=5)), + ('group_size', models.IntegerField(default=1)), + ('file_structure', models.CharField(max_length=1024, null=True)), + ('max_score', models.IntegerField(blank=True, null=True)), ('course_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='courses.course')), ], ), migrations.CreateModel( - name='ForbiddenExtension', + name='Test', fields=[ - ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.IntegerField()), - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), - ], - ), - migrations.CreateModel( - name='Conditions', - fields=[ - ('condition_id', models.BigAutoField(primary_key=True, serialize=False)), - ('condition', models.CharField(max_length=256)), - ('deadline', models.DateTimeField()), - ('test_file_location', models.CharField(max_length=512, null=True)), - ('test_file_type', models.CharField(max_length=256, null=True)), - ('submission_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, - to='projects.project')), - ], - ), - migrations.CreateModel( - name='AllowedExtension', - fields=[ - ('extension_id', models.BigAutoField(primary_key=True, serialize=False)), - ('extension', models.IntegerField()), - ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='projects.project')), + ('project_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, + serialize=False, to='projects.project')), + ('test_nr', models.IntegerField()), + ('test_file_type', models.FileField(max_length=255, null=True, upload_to='uploads/projects//')), ], ), ] diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index 1183fdcc..b533214e 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -4,51 +4,33 @@ from backend.pigeonhole.apps.courses.models import Course -# Create your models here. class Project(models.Model): + objects = models.Manager() project_id = models.BigAutoField(primary_key=True) course_id = models.ForeignKey(Course, on_delete=models.CASCADE) name = models.CharField(max_length=256) description = models.TextField() + deadline = models.DateTimeField(null=True, blank=True) visible = models.BooleanField(default=False) + max_score = models.IntegerField(null=True, blank=True) + number_of_groups = models.IntegerField(default=5) + group_size = models.IntegerField(default=1) + file_structure = models.CharField(max_length=1024, null=True) + max_score = models.IntegerField(null=True, blank=True) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project - fields = ['project_id', 'course_id', 'name', 'description', 'deadline', 'visible'] - - -class Conditions(models.Model): - condition_id = models.BigAutoField(primary_key=True) - submission_id = models.ForeignKey(Project, on_delete=models.CASCADE) - condition = models.CharField(max_length=256) - deadline = models.DateTimeField() - test_file_location = models.CharField(max_length=512, null=True) - test_file_type = models.CharField(max_length=256, null=True) - - objects = models.Manager() - - @property - def get_forbidden_extensions(self): - return ForbiddenExtension.objects.filter(project_id=self.submission_id) - - @property - def get_allowed_extensions(self): - return AllowedExtension.objects.filter(project_id=self.submission_id) - - -class AllowedExtension(models.Model): - extension_id = models.BigAutoField(primary_key=True) - project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.IntegerField() - - objects = models.Manager() + fields = ["project_id", "course_id", "name", "description", "deadline", "visible", "number_of_groups", + "group_size", "max_score", "file_structure"] -class ForbiddenExtension(models.Model): - extension_id = models.BigAutoField(primary_key=True) - project_id = models.ForeignKey(Project, on_delete=models.CASCADE) - extension = models.IntegerField() +class Test(models.Model): + project_id = models.ForeignKey(Project, primary_key=True, on_delete=models.CASCADE) + test_nr = models.IntegerField() + test_file_type = models.FileField(upload_to='uploads/projects/' + + str(project_id) + '/' + str(test_nr), null=True, blank=False, + max_length=255) objects = models.Manager() diff --git a/backend/pigeonhole/apps/projects/permissions.py b/backend/pigeonhole/apps/projects/permissions.py new file mode 100644 index 00000000..e125789f --- /dev/null +++ b/backend/pigeonhole/apps/projects/permissions.py @@ -0,0 +1,42 @@ +from rest_framework import permissions +from rest_framework import status +from rest_framework.response import Response + +from .models import Project + + +class CanAccessProject(permissions.BasePermission): + # Custom permission class to determine if the currect user has access + # to the project data. + def has_permission(self, request, view): + user = request.user + if view.action in ['create']: + course_id = request.data.get('course_id') + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + return False + elif view.action in ['list']: + if user.is_admin or user.is_superuser: + return True + return False + else: + project_id = int(view.kwargs.get('pk')) + if not Project.objects.filter(project_id=project_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + course_id = Project.objects.get(project_id=project_id).course_id.course_id + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + if user.course.filter(course_id=course_id).exists(): + return True + elif user.is_student: + if not Project.objects.get(project_id=project_id).visible: + return False + if user.course.filter(course_id=course_id).exists(): + return view.action in ['retrieve', 'get_my_groups', 'get_groups'] + return False diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py new file mode 100644 index 00000000..35014a26 --- /dev/null +++ b/backend/pigeonhole/apps/projects/views.py @@ -0,0 +1,52 @@ +from django.db import transaction +from rest_framework import status +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.groups.models import GroupSerializer +from .models import Project, ProjectSerializer +from .permissions import CanAccessProject + + +class ProjectViewSet(viewsets.ModelViewSet): + queryset = Project.objects.all() + serializer_class = ProjectSerializer + permission_classes = [IsAuthenticated & CanAccessProject] + + @transaction.atomic + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + number_of_groups = serializer.validated_data.get('number_of_groups', 0) + project = serializer.save() + + groups = [] + for i in range(number_of_groups): + group_data = { + 'project_id': project.project_id, + 'user': [], # You may add users here if needed + 'feedback': None, + 'final_score': None, + 'visible': False, # Adjust visibility as needed + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + + # You may return the newly created groups if needed + groups_data = GroupSerializer(groups, many=True).data + response_data = serializer.data + response_data['groups'] = groups_data + + headers = self.get_success_headers(serializer.data) + return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + + @action(detail=True, methods=['get']) + def get_groups(self, request, *args, **kwargs): + project = self.get_object() + groups = Group.objects.filter(project_id=project) + return Response(GroupSerializer(groups, many=True).data, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py index f393bd58..42638ec9 100644 --- a/backend/pigeonhole/apps/submissions/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/submissions/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 +import backend.pigeonhole.apps.submissions.models import django.db.models.deletion from django.db import migrations, models @@ -15,16 +16,26 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Submissions', fields=[ - ('submission_id', models.BigAutoField(primary_key=True, serialize=False)), - ('submission_nr', models.IntegerField()), - ('file', models.FileField( - upload_to='uploads///')), + ('submission_id', + models.BigAutoField(primary_key=True, serialize=False)), + ('submission_nr', models.IntegerField(blank=True)), + ('file', + models.FileField(max_length=255, null=True, + upload_to=backend.pigeonhole.apps. + submissions.models.get_upload_to)), ('timestamp', models.DateTimeField(auto_now_add=True)), - ('output_test', models.FileField( - upload_to='uploads///output_test/')), - ('group_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='groups.group')), + ('output_test', + models.FileField(blank=True, + max_length=255, + null=True, + upload_to='uploads/submissions/outputs/' + '//output_test/')), + ('group_id', + models.ForeignKey(blank=True, + on_delete=django.db.models.deletion.CASCADE, + to='groups.group')), ], ), ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index 56752ac0..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0002_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('submissions', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField(null=True, - upload_to='uploads///'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(null=True, - upload_to='uploads///output_test/'), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index 48eb7b6e..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0003_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-05 11:02 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('submissions', - '0002_alter_submissions_file_alter_submissions_output_test'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField(max_length=255, null=True, - upload_to='uploads///',), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField(max_length=255, null=True, - upload_to='uploads///output_test/',), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py b/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py deleted file mode 100644 index c2aa722a..00000000 --- a/backend/pigeonhole/apps/submissions/migrations/0004_alter_submissions_file_alter_submissions_output_test.py +++ /dev/null @@ -1,32 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 20:26 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('submissions', - '0003_alter_submissions_file_alter_submissions_output_test'), - ] - - operations = [ - migrations.AlterField( - model_name='submissions', - name='file', - field=models.FileField( - max_length=255, - null=True, - upload_to='uploads/submissions/files///'), - ), - migrations.AlterField( - model_name='submissions', - name='output_test', - field=models.FileField( - max_length=255, - null=True, - upload_to='uploads/submissions/outputs//' - '/output_test/'), - ), - ] diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 7025fb02..03e5c6e2 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,27 +1,51 @@ +import os + from django.db import models from rest_framework import serializers from backend.pigeonhole.apps.groups.models import Group +def get_upload_to(self, filename): + return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input' + \ + os.path.splitext(filename)[1] + + +def get_upload_to_test(self, filename): + return None # TODO implement this + + # Create your models here. class Submissions(models.Model): submission_id = models.BigAutoField(primary_key=True) - group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=False) - submission_nr = models.IntegerField() - file = models.FileField(upload_to='uploads/submissions/files/' + - str(group_id) + '/' + str(submission_nr) + '/', + group_id = models.ForeignKey(Group, on_delete=models.CASCADE, blank=True) + submission_nr = models.IntegerField(blank=True) + file = models.FileField(upload_to=get_upload_to, null=True, blank=False, max_length=255) - timestamp = models.DateTimeField(auto_now_add=True) + timestamp = models.DateTimeField(auto_now_add=True, blank=True) output_test = models.FileField(upload_to='uploads/submissions/outputs/' + - str(group_id) + '/' + str(submission_nr) + - '/output_test/', null=True, blank=False, + str(group_id) + '/' + str(submission_nr) + + '/output_test/', null=True, blank=True, max_length=255) objects = models.Manager() + # submission_nr is automatically assigned and unique per group, and + # increments + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + if not self.submission_id: + max_submission_nr = Submissions.objects.filter( + group_id=self.group_id).aggregate( + models.Max('submission_nr'))['submission_nr__max'] or 0 + self.submission_nr = max_submission_nr + 1 + super().save(force_insert=force_insert, force_update=force_update, using=using, update_fields=update_fields) + class SubmissionsSerializer(serializers.ModelSerializer): + submission_nr = serializers.IntegerField(read_only=True) + output_test = serializers.FileField(read_only=True) + group_id = serializers.PrimaryKeyRelatedField(queryset=Group.objects.all()) + class Meta: model = Submissions - fields = ['submission_id', 'group_id', 'file', 'timestamp', 'submission_nr'] + fields = ['submission_id', 'file', 'timestamp', 'submission_nr', 'output_test', 'group_id'] diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py new file mode 100644 index 00000000..cb485a04 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -0,0 +1,54 @@ +from rest_framework import permissions, status +from rest_framework.response import Response + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.projects.models import Project + + +class CanAccessSubmission(permissions.BasePermission): + # Custom permission class to determine if the currect user has access + # to the submission data. + def has_permission(self, request, view): + user = request.user + if view.action in ['list']: + return False + elif view.action in ['create']: + if user.is_student: + group_id = request.data.get('group_id') + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + return False + group = Group.objects.get(group_id=group_id) + if group.user.filter(id=user.id).exists(): + return True + else: + return False + elif user.is_admin or user.is_superuser: + return True + else: + return False + else: + submission = Submissions.objects.get(submission_id=view.kwargs['pk']) + group_id = submission.group_id.group_id + if not Group.objects.filter(group_id=group_id).exists(): + if user.is_admin or user.is_superuser: + return Response(status=status.HTTP_404_NOT_FOUND) + elif user.is_teacher: + return True + return False + group = Group.objects.get(group_id=group_id) + if user.is_admin or user.is_superuser: + return True + elif user.is_teacher: + group = Group.objects.get(group_id=group_id) + project = Project.objects.get(project_id=group.project_id.project_id) + course = Course.objects.get(course_id=project.course_id.course_id) + if user.course.filter(course_id=course.course_id).exists(): + return True + elif user.is_student: + if group.user.filter(id=user.id).exists(): + return view.action in ['retrieve', 'create'] + return False diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py new file mode 100644 index 00000000..eb77e335 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/views.py @@ -0,0 +1,46 @@ +from datetime import datetime + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer +from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission + + +# TODO test timestamp, file, output_test + + +class SubmissionsViewset(viewsets.ModelViewSet): + queryset = Submissions.objects.all() + serializer_class = SubmissionsSerializer + permission_classes = [IsAuthenticated & CanAccessSubmission] + + @action(detail=False, methods=['POST']) + def submit(self, request, *args, **kwargs): + submission = self.get_submission() + serializer = SubmissionsSerializer(submission, data=request.data) + serializer.is_valid(raise_exception=True) + + group = Group.objects.get(id=serializer.group_id) + if not group: + return Response({"message": "Group not found"}, status=status.HTTP_404_NOT_FOUND) + + project = Project.objects.get(id=group.project_id) + if not project: + return Response({"message": "Project not found"}, status=status.HTTP_404_NOT_FOUND) + + if datetime.now() > project.deadline: + return Response({"message": "Deadline expired"}, status=status.HTTP_410_GONE) + + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def update(self, request, *args, **kwargs): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) + + def destroy(self, request, *args, **kwargs): + return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/backend/pigeonhole/apps/users/admin.py b/backend/pigeonhole/apps/users/admin.py new file mode 100644 index 00000000..ec1d1884 --- /dev/null +++ b/backend/pigeonhole/apps/users/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from backend.pigeonhole.apps.users.models import User + + +class UserAdmin(BaseUserAdmin): + list_display = ('username', 'id', 'email', 'first_name', 'last_name',) + search_fields = ('username', 'id', 'email', 'first_name', 'last_name',) + ordering = ('username',) + filter_horizontal = () + fieldsets = ( + (None, {'fields': ( + 'username', + 'email', + 'password', + 'first_name', + 'last_name', + )}), + ) + + +admin.site.register(User, UserAdmin) diff --git a/backend/pigeonhole/apps/users/migrations/0001_initial.py b/backend/pigeonhole/apps/users/migrations/0001_initial.py index 42953dcd..6a79a812 100644 --- a/backend/pigeonhole/apps/users/migrations/0001_initial.py +++ b/backend/pigeonhole/apps/users/migrations/0001_initial.py @@ -1,10 +1,8 @@ -# Generated by Django 5.0.2 on 2024-03-02 21:03 +# Generated by Django 5.0.3 on 2024-03-12 22:52 import django.contrib.auth.models import django.contrib.auth.validators -import django.db.models.deletion import django.utils.timezone -from django.conf import settings from django.db import migrations, models @@ -20,35 +18,35 @@ class Migration(migrations.Migration): migrations.CreateModel( name='User', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('password', models.CharField(max_length=128, verbose_name='password')), ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('is_superuser', models.BooleanField(default=False, - help_text='Designates that this user has all permissions ' - 'without explicitly assigning them.', + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all ' + 'permissions without explicitly ' + 'assigning them.', verbose_name='superuser status')), ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, - help_text='Required. 150 characters or fewer. Letters, digits ' - 'and @/./+/-/_ only.', - max_length=150, unique=True, + help_text='Required. 150 characters or fewer. Letters, digits and ' + '@/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), - ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), - ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), - ('is_staff', models.BooleanField(default=False, - help_text='Designates whether the user can log into this admin site.', + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into ' + 'this admin site.', verbose_name='staff status')), - ('is_active', models.BooleanField(default=True, - help_text='Designates whether this user should be treated as active. ' - 'Unselect this instead of deleting accounts.', + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be ' + 'treated as active. Unselect this instead ' + 'of deleting accounts.', verbose_name='active')), ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), - ('groups', models.ManyToManyField(blank=True, - help_text='The groups this user belongs to. A user will get all ' - 'permissions granted to each of their groups.', - related_name='user_set', related_query_name='user', to='auth.group', - verbose_name='groups')), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254, unique=True)), + ('first_name', models.CharField(max_length=30)), + ('last_name', models.CharField(max_length=150)), + ('role', models.IntegerField(choices=[(1, 'Admin'), (2, 'Teacher'), (3, 'Student')], default=1)), + ('course', models.ManyToManyField(to='courses.course')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will ' + 'get all permissions granted to each of their ' + 'groups.', related_name='user_set', + related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), @@ -63,23 +61,4 @@ class Migration(migrations.Migration): ('objects', django.contrib.auth.models.UserManager()), ], ), - migrations.CreateModel( - name='Student', - fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), - ('number', models.IntegerField()), - ('course', models.ManyToManyField(to='courses.course')), - ], - ), - migrations.CreateModel( - name='Teacher', - fields=[ - ('id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, - to=settings.AUTH_USER_MODEL)), - ('is_admin', models.BooleanField(default=False)), - ('is_assistent', models.BooleanField(default=False)), - ('course', models.ManyToManyField(to='courses.course')), - ], - ), ] diff --git a/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py b/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py deleted file mode 100644 index a9cf87be..00000000 --- a/backend/pigeonhole/apps/users/migrations/0002_rename_is_assistent_teacher_is_assistant.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.2 on 2024-03-07 18:53 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0001_initial'), - ] - - operations = [ - migrations.RenameField( - model_name='teacher', - old_name='is_assistent', - new_name='is_assistant', - ), - ] diff --git a/backend/pigeonhole/apps/users/models.py b/backend/pigeonhole/apps/users/models.py index 9a0f36f4..6994d287 100644 --- a/backend/pigeonhole/apps/users/models.py +++ b/backend/pigeonhole/apps/users/models.py @@ -1,11 +1,26 @@ -from django.contrib.auth.models import AbstractUser +from django.contrib.auth.models import AbstractUser, UserManager from django.db import models from rest_framework import serializers from backend.pigeonhole.apps.courses.models import Course +class Roles(models.IntegerChoices): + ADMIN = 1 + TEACHER = 2 + STUDENT = 3 + + class User(AbstractUser): + id = models.BigAutoField(primary_key=True) + email = models.EmailField(unique=True) + first_name = models.CharField(max_length=30) + last_name = models.CharField(max_length=150) + course = models.ManyToManyField(Course) # TODO: Add blank=True + role = models.IntegerField(choices=Roles.choices, default=Roles.ADMIN) + + objects = UserManager() + class Meta(AbstractUser.Meta): db_table = "auth_user" @@ -13,37 +28,20 @@ class Meta(AbstractUser.Meta): def name(self): return f"{self.first_name.strip()} {self.last_name.strip()}" + @property + def is_admin(self): + return self.role == Roles.ADMIN -class UserSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ['id', 'e_mail', 'first_name', 'last_name'] - - -class Student(models.Model): - id = models.ForeignKey(User, on_delete=models.CASCADE, primary_key=True) - number = models.IntegerField() - course = models.ManyToManyField(Course) - - objects = models.Manager() - - -class StudentSerializer(serializers.ModelSerializer): - class Meta: - model = Student - fields = ['number', 'course', 'id'] - - -class Teacher(models.Model): - id = models.ForeignKey(User, on_delete=models.CASCADE, primary_key=True) - course = models.ManyToManyField(Course) - is_admin = models.BooleanField(default=False) - is_assistant = models.BooleanField(default=False) + @property + def is_teacher(self): + return self.role == Roles.TEACHER - objects = models.Manager() + @property + def is_student(self): + return self.role == Roles.STUDENT -class TeacherSerializer(serializers.ModelSerializer): +class UserSerializer(serializers.ModelSerializer): class Meta: - model = Teacher - fields = ['course', 'id', 'is_admin', 'is_assistent'] + model = User + fields = ['id', 'email', 'first_name', 'last_name', 'course', 'role'] diff --git a/backend/pigeonhole/apps/users/permissions.py b/backend/pigeonhole/apps/users/permissions.py new file mode 100644 index 00000000..a04b0c5c --- /dev/null +++ b/backend/pigeonhole/apps/users/permissions.py @@ -0,0 +1,16 @@ +from rest_framework import permissions + + +class UserPermissions(permissions.BasePermission): + def has_permission(self, request, view): + if request.user.is_admin or request.user.is_superuser: + return True # TODO can admins destroy each other? + + if request.user.is_teacher or request.user.is_student: + if view.action in ['list', 'retrieve']: # TODO: can teachers create and destroy users? + return True + # user can only update their own user + elif view.action in ['update', 'partial_update'] and request.user.pk == int(view.kwargs['pk']): + return True + + return False diff --git a/backend/pigeonhole/apps/users/views.py b/backend/pigeonhole/apps/users/views.py new file mode 100644 index 00000000..264e3e80 --- /dev/null +++ b/backend/pigeonhole/apps/users/views.py @@ -0,0 +1,11 @@ +from rest_framework import viewsets +from rest_framework.permissions import IsAuthenticated + +from backend.pigeonhole.apps.users.models import User, UserSerializer +from .permissions import UserPermissions + + +class UserViewSet(viewsets.ModelViewSet): + queryset = User.objects.all() + serializer_class = UserSerializer + permission_classes = [IsAuthenticated, UserPermissions] diff --git a/backend/pigeonhole/settings.py b/backend/pigeonhole/settings.py index 3c7a0902..f06af906 100644 --- a/backend/pigeonhole/settings.py +++ b/backend/pigeonhole/settings.py @@ -48,6 +48,7 @@ 'backend.pigeonhole.apps.projects', 'backend.pigeonhole.apps.submissions', 'backend.pigeonhole.apps.groups', + 'drf_yasg', ] MIDDLEWARE = [ @@ -162,3 +163,11 @@ # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +SWAGGER_SETTINGS = { + 'SECURITY_DEFINITIONS': { + 'Basic': { + 'type': 'basic' + } + } +} diff --git a/backend/pigeonhole/tests/test_models/test_course.py b/backend/pigeonhole/tests/test_models/test_course.py index 2498a217..40d2e12c 100644 --- a/backend/pigeonhole/tests/test_models/test_course.py +++ b/backend/pigeonhole/tests/test_models/test_course.py @@ -1,7 +1,8 @@ +from django.db.utils import DataError from django.test import TestCase + from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.users.models import User, Student, Teacher -from django.db.utils import DataError +from backend.pigeonhole.apps.users.models import User # python3 manage.py test backend/ @@ -10,31 +11,31 @@ class CourseTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) student.course.add(course) def test_course_teacher_relationship(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id=1) course = Course.objects.get(name="Math") self.assertIn(course, teacher.course.all()) course_alter_ego = teacher.course.get(name="Math") @@ -42,7 +43,7 @@ def test_course_teacher_relationship(self): self.assertTrue(course_alter_ego, "Mathematics") def test_course_students_relationship(self): - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id=2) course = Course.objects.get(name="Math") self.assertIn(course, student.course.all()) course_alter_ego = student.course.get(name="Math") diff --git a/backend/pigeonhole/tests/test_models/test_groups.py b/backend/pigeonhole/tests/test_models/test_groups.py index 0ece53b9..bf76c974 100644 --- a/backend/pigeonhole/tests/test_models/test_groups.py +++ b/backend/pigeonhole/tests/test_models/test_groups.py @@ -1,8 +1,9 @@ from django.test import TestCase + from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.users.models import User, Student, Teacher from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User # python3 manage.py test backend/ @@ -11,54 +12,57 @@ class GroupTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) + # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) # Create a second student user - student_user2 = User.objects.create_user( + student2 = User.objects.create( + id=3, username="student_username2", email="student2@gmail.com", first_name="Fozzie", - last_name="Bear" + last_name="Bear", + role=3 ) - # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - student2 = Student.objects.create(id=student_user2, number=5678) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) student.course.add(course) # Create project - project = Project.objects.create( + Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", + number_of_groups=2, + group_size=2 ) - - # Create group + # get group with id 1 group = Group.objects.create( group_nr=1, - project_id=project, - final_score=0, + project_id=Project.objects.get(name="Project"), ) # Add students to the group - group.student.set([student, student2]) + group.user.set([student, student2]) def test_group_project_relation(self): group = Group.objects.get(group_nr=1) @@ -67,14 +71,14 @@ def test_group_project_relation(self): def test_group_student_relation(self): group = Group.objects.get(group_nr=1) - student = Student.objects.get(id__email="student@gmail.com") - student2 = Student.objects.get(id__email="student2@gmail.com") - self.assertIn(student, group.student.all()) - self.assertIn(student2, group.student.all()) + student = User.objects.get(id=2) + student2 = User.objects.get(id=3) + self.assertIn(student, group.user.all()) + self.assertIn(student2, group.user.all()) def test_group_final_score(self): group = Group.objects.get(group_nr=1) - self.assertEqual(group.final_score, 0) + self.assertEqual(group.final_score, None) def test_group_group_nr(self): group = Group.objects.get(group_nr=1) diff --git a/backend/pigeonhole/tests/test_models/test_project.py b/backend/pigeonhole/tests/test_models/test_project.py index 11285c91..7a84141d 100644 --- a/backend/pigeonhole/tests/test_models/test_project.py +++ b/backend/pigeonhole/tests/test_models/test_project.py @@ -1,30 +1,31 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher + from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User class ProjectTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) @@ -34,6 +35,7 @@ def setUp(self): self.project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", ) @@ -41,11 +43,11 @@ def test_project_course_relation(self): self.assertEqual(self.project.course_id.name, "Math") def test_project_teacher_relation(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") + teacher = User.objects.get(id=1) self.assertIn(self.project.course_id, teacher.course.all()) def test_project_student_relation(self): - student = Student.objects.get(id__email="student@gmail.com") + student = User.objects.get(id=2) self.assertIn(self.project.course_id, student.course.all()) def test_course_name_length_validation(self): diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index c6efed08..cf757485 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -1,33 +1,32 @@ +from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher + from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.submissions.models import Submissions -from backend.pigeonhole.apps.groups.models import Group -from django.core.files.uploadedfile import SimpleUploadedFile +from backend.pigeonhole.apps.users.models import User class SubmissionTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) @@ -37,6 +36,7 @@ def setUp(self): project = Project.objects.create( name="Project", course_id=course, + deadline="2021-12-12 12:12:12", description="Project Description", ) @@ -46,37 +46,25 @@ def setUp(self): ) # Add student to the group - group.student.set([student]) + group.user.set([student]) # Create submission Submissions.objects.create( group_id=group, - submission_nr=1, ) def test_submission_student_relation(self): submission = Submissions.objects.get(submission_nr=1) - student = submission.group_id.student.first() - self.assertEqual(submission.group_id.student.count(), 1) - self.assertEqual(submission.group_id.student.first(), student) - self.assertEqual(submission.group_id.student.first().id, student.id) + student = submission.group_id.user.first() + self.assertEqual(submission.group_id.user.count(), 1) + self.assertEqual(submission.group_id.user.first(), student) + self.assertEqual(submission.group_id.user.first().id, student.id) def test_submission_project_relation(self): submission = Submissions.objects.get(submission_nr=1) project = submission.group_id.project_id self.assertEqual(submission.group_id.project_id, project) - def test_update_and_delete_submission(self): - submission = Submissions.objects.get(submission_nr=1) - submission.submission_nr = 2 - submission.save() - updated_submission = Submissions.objects.get(submission_nr=2) - self.assertEqual(updated_submission.submission_nr, 2) - - submission.delete() - with self.assertRaises(Submissions.DoesNotExist): - Submissions.objects.get(submission_nr=2) - def test_submission_file_upload_and_retrieval(self): submission = Submissions.objects.get(submission_nr=1) diff --git a/backend/pigeonhole/tests/test_models/test_conditions.py b/backend/pigeonhole/tests/test_models/test_tests.txt similarity index 75% rename from backend/pigeonhole/tests/test_models/test_conditions.py rename to backend/pigeonhole/tests/test_models/test_tests.txt index 37307ab7..c6054165 100644 --- a/backend/pigeonhole/tests/test_models/test_conditions.py +++ b/backend/pigeonhole/tests/test_models/test_tests.txt @@ -1,30 +1,30 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.apps.courses.models import Course -from backend.pigeonhole.apps.projects.models import Project, Conditions, AllowedExtension, ForbiddenExtension +from backend.pigeonhole.apps.projects.models import Project, Test class ConditionsTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + teacher = User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) # Create student user - student_user = User.objects.create_user( + student = User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - teacher = Teacher.objects.create(id=teacher_user) - student = Student.objects.create(id=student_user, number=1234) - # Create course course = Course.objects.create(name="Math", description="Mathematics") teacher.course.add(course) @@ -38,7 +38,7 @@ def setUp(self): ) # Create conditions - self.conditions = Conditions.objects.create( + self.conditions = Test.objects.create( submission_id=project, condition="Condition 1", deadline="2021-12-12 12:12:12", @@ -46,17 +46,6 @@ def setUp(self): test_file_type="txt" ) - # Create allowed extension - AllowedExtension.objects.create( - project_id=project, - extension=123 - ) - - # Create forbidden extension - ForbiddenExtension.objects.create( - project_id=project, - extension=456 - ) def test_conditions_submission_relation(self): self.assertEqual(self.conditions.submission_id, Project.objects.get(name="Project")) @@ -69,7 +58,7 @@ def test_conditions_allowed_extensions(self): def test_create_conditions_without_submission(self): with self.assertRaises(Exception): - Conditions.objects.create( + Test.objects.create( condition="Condition 2", deadline="2021-12-12 12:12:12", test_file_location="path/to/test", @@ -103,4 +92,4 @@ def test_update_and_delete_conditions(self): AllowedExtension.objects.get(extension=123) with self.assertRaises(ForbiddenExtension.DoesNotExist): - ForbiddenExtension.objects.get(extension=456) + ForbiddenExtension.objects.get(extension=456) \ No newline at end of file diff --git a/backend/pigeonhole/tests/test_models/test_user.py b/backend/pigeonhole/tests/test_models/test_user.py index 043197e5..0e1f9f6c 100644 --- a/backend/pigeonhole/tests/test_models/test_user.py +++ b/backend/pigeonhole/tests/test_models/test_user.py @@ -1,5 +1,6 @@ from django.test import TestCase -from backend.pigeonhole.apps.users.models import User, Student, Teacher + +from backend.pigeonhole.apps.users.models import User # python3 manage.py test backend/ @@ -7,66 +8,68 @@ class UserTestCase(TestCase): def setUp(self): # Create teacher user - teacher_user = User.objects.create_user( + User.objects.create( + id=1, username="teacher_username", email="teacher@gmail.com", first_name="Kermit", - last_name="The Frog" + last_name="The Frog", + role=2 ) + # Create student user - student_user = User.objects.create_user( + User.objects.create( + id=2, username="student_username", email="student@gmail.com", first_name="Miss", - last_name="Piggy" + last_name="Piggy", + role=3 ) - # Create teacher and student using the created users - Teacher.objects.create(id=teacher_user) - Student.objects.create(id=student_user, number=1234) - - def test_student(self): - student = Student.objects.get(id__email="student@gmail.com") - self.assertEqual(student.id.email, "student@gmail.com") - self.assertEqual(student.number, 1234) - - # update student number - student.number = 5678 - student.save() - student = Student.objects.get(id__email="student@gmail.com") - self.assertEqual(student.number, 5678) + def test_student_fields(self): + student = User.objects.get(id=1), + self.assertEqual(student[0].username, "teacher_username") + self.assertEqual(student[0].email, "teacher@gmail.com") + self.assertEqual(student[0].first_name, "Kermit") + self.assertEqual(student[0].last_name, "The Frog") + self.assertEqual(student[0].role, 2) - # delete student - student.delete() - with self.assertRaises(Student.DoesNotExist): - Student.objects.get(id__email="student@gmail.com") + def test_teacher_fields(self): + teacher = User.objects.get(id=2), + self.assertEqual(teacher[0].username, "student_username") + self.assertEqual(teacher[0].email, "student@gmail.com"), + self.assertEqual(teacher[0].first_name, "Miss") + self.assertEqual(teacher[0].last_name, "Piggy") + self.assertEqual(teacher[0].role, 3) - def test_teacher(self): - teacher = Teacher.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.id.email, "teacher@gmail.com") - self.assertEqual(teacher.is_admin, False) - - # update teacher is_admin - teacher.is_admin = True - teacher.save() - teacher = Teacher.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.is_admin, True) - - self.assertEqual(teacher.is_assistant, False) - # update teacher is_assistent - teacher.is_assistant = True - teacher.save() - teacher = Teacher.objects.get(id__email="teacher@gmail.com") - self.assertEqual(teacher.is_assistant, True) - # delete teacher - teacher.delete() - with self.assertRaises(Teacher.DoesNotExist): - Teacher.objects.get(id__email="teacher@gmail.com") + def test_user_name_length_validation(self): + with self.assertRaises(Exception): + User.objects.create( + username="A" * 300, + email="student@gmail.com", + first_name="Miss", + last_name="Piggy", + role=3 + ) - def test_create_student_without_user(self): + # TODO + def test_user_correct_email(self): with self.assertRaises(Exception): - Student.objects.create(number=1234) + User.objects.create( + username="student_username", + email="studentgmail.com", + first_name="Miss", + last_name="Piggy", + role=3 + ) - def test_create_teacher_without_user(self): + def test_user_role_validation(self): with self.assertRaises(Exception): - Teacher.objects.create(is_admin=True, is_assistent=True) + User.objects.create( + username="student_username", + email="student@gmail.com", + first_name="Miss", + last_name="Piggy", + role=4 + ) diff --git a/backend/pigeonhole/tests/test_views/__init__.py b/backend/pigeonhole/tests/test_views/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_complete/admin.py b/backend/pigeonhole/tests/test_views/test_complete/admin.py new file mode 100644 index 00000000..29c88505 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_complete/admin.py @@ -0,0 +1,88 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submission +from backend.pigeonhole.apps.users.models import User + +ROUTES_PREFIX = '/courses/' + + +class CompleteTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a teacher user + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="Teacher", + last_name="LastName", + role=2 # Teacher role + ) + + # Create a student user + self.student = User.objects.create( + username="student_username", + email="student@gmail.com", + first_name="Student", + last_name="LastName", + role=3 # Student role + ) + + # Authenticate the teacher user + self.client.force_authenticate(self.teacher) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + def test_create_course(self): + # Use the teacher to create the course + response = self.client.post( + ROUTES_PREFIX, + { + "name": "Test Course 2", + "description": "Test Course 2 Description", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 2) + self.assertEqual(Course.objects.get(course_id=2).name, "Test Course 2") + + def test_create_project(self): + # Use the teacher to create the project for the course + response = self.client.post( + ROUTES_PREFIX + f'{self.course.course_id}/projects/', + { + "name": "Test Project", + "description": "Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Project.objects.get(project_id=1).name, "Test Project") + + def test_create_submission(self): + # Authenticate the student user + self.client.force_authenticate(self.student) + + # Use the student to create the submission for the project + response = self.client.post( + ROUTES_PREFIX + f'{self.course.course_id}/projects/1/submissions/', + { + "project_id": 1, + "student_id": self.student.id, # Use the student's id + "submission": "Test Submission", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submission.objects.count(), 1) + self.assertEqual(Submission.objects.get(submission_id=1).submission, "Test Submission") diff --git a/backend/pigeonhole/tests/test_views/test_course/__init__.py b/backend/pigeonhole/tests/test_views/test_course/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_course/test_admin.py b/backend/pigeonhole/tests/test_views/test_course/test_admin.py new file mode 100644 index 00000000..d4361de0 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_admin.py @@ -0,0 +1,127 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project + +API_ENDPOINT = '/courses/' + + +class CourseTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") + + self.course = Course.objects.create(**self.course_data) + + # Create a regular user (teacher) + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1, + ) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.teacher.course.set([self.course]) + self.client.force_authenticate(user=self.teacher) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + self.assertEqual(self.teacher.course.count(), 2) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course_not_of_teacher.refresh_from_db() + self.assertEqual(self.course_not_of_teacher.name, updated_data['name']) + self.assertEqual(self.course_not_of_teacher.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.course.name) + self.assertEqual(response.data['description'], self.course.description) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_course_not_exist(self): + response = self.client.delete(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py new file mode 100644 index 00000000..d3b40ec9 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -0,0 +1,121 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project + +API_ENDPOINT = '/courses/' + + +class CourseTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + # Create a regular user (teacher) + self.teacher = User.objects.create_user( + username="teacher", + email="teacher@gmail.com", + first_name="teacher", + last_name="teacher" + ) + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + self.course_not_of_student = Course.objects.create(name="Not of Student", + description="This is not of the student") + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + # Provide a value for the "number" field when creating the Student instance + self.student = User.objects.create( + username="student", + email="student@gmail.com", + first_name="student", + last_name="student", + role=3 + ) + self.student.course.set([self.course]) + + # Authenticate the test client with the regular user + self.client.force_authenticate(user=self.student) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 2) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 2) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["name"], "Test Course") + self.assertEqual(content_json["description"], "This is a test course.") + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + def get_project_of_course_not_of_student(self): + response = self.client.get(f'{API_ENDPOINT}{self.course_not_of_student.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 0) + self.assertEqual(response.data, []) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_teacher.py b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py new file mode 100644 index 00000000..7340f57f --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_teacher.py @@ -0,0 +1,125 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.projects.models import Project + +API_ENDPOINT = '/courses/' + + +class CourseTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + self.course_not_of_teacher = Course.objects.create(name="Not of Teacher", + description="This is not of the teacher") + + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.teacher.course.set([self.course]) + + self.client.force_authenticate(user=self.teacher) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Course.objects.count(), 3) + self.assertEqual(self.teacher.course.count(), 2) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.course.refresh_from_db() + self.assertEqual(self.course.name, updated_data['name']) + self.assertEqual(self.course.description, updated_data['description']) + + def test_update_course_not_of_teacher(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/', updated_data, + format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Course.objects.count(), 1) + + def test_delete_course_not_of_teacher(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], self.course.name) + self.assertEqual(response.data['description'], self.course.description) + + def test_retrieve_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/') + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]['name'], self.project.name) + self.assertEqual(response.data[0]['course_id'], self.course.course_id) + + def get_projects_of_course_not_of_teacher(self): + response = self.client.get(f'{API_ENDPOINT}{self.course_not_of_teacher.course_id}/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 0) + self.assertEqual(response.data, []) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py new file mode 100644 index 00000000..4b0c94bd --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_course/test_unauthorized.py @@ -0,0 +1,70 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course + +API_ENDPOINT = '/courses/' + + +class CourseTestUnauthorized(TestCase): + def setUp(self): + self.client = APIClient() + + self.course_data = { + 'name': 'Test Course', + 'description': 'This is a test course.' + } + + self.course = Course.objects.create(**self.course_data) + + def test_create_course(self): + response = self.client.post(API_ENDPOINT, self.course_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_update_course(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}{self.course.course_id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.course.refresh_from_db() + self.assertNotEqual(self.course.name, updated_data['name']) + self.assertNotEqual(self.course.description, updated_data['description']) + + def test_delete_course(self): + response = self.client.delete(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Course.objects.count(), 1) + + def test_retrieve_course(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_courses(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_projects(self): + response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/get_projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid course + + def get_projects_invalid_course(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_projects_invalid_course_not_exist(self): + response = self.client.get(f'{API_ENDPOINT}100/projects/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_course_not_exist(self): + updated_data = { + 'name': 'Updated Course', + 'description': 'This course has been updated.' + } + response = self.client.put(f'{API_ENDPOINT}100/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_group/__init__.py b/backend/pigeonhole/tests/test_views/test_group/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_group/test_admin.py b/backend/pigeonhole/tests/test_views/test_group/test_admin.py new file mode 100644 index 00000000..2b40a38f --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_group/test_admin.py @@ -0,0 +1,152 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + self.client.force_authenticate(self.admin) + + def test_admin_create_group(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Group 1", + "description": "Test Group 1 Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_list_groups(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Group.objects.count(), 2) + + def test_group_count(self): + self.assertEqual(Group.objects.count(), 3) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/join/' + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_group_submissions(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_student.py b/backend/pigeonhole/tests/test_views/test_group/test_student.py new file mode 100644 index 00000000..8b4ae9fc --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_group/test_student.py @@ -0,0 +1,169 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + self.group2.user.add(self.student) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + self.client.force_authenticate(self.student) + + def test_student_create_group(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Group 1", + "description": "Test Group 1 Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group2.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # test whether feedback and final_score are visbile + self.assertEqual(response.data['feedback'], "Test Feedback") + self.assertEqual(response.data['final_score'], 0) + + def test_score_not_visible_in_other_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + # test whether feedback and final_score are not vin response + self.assertNotIn('feedback', response.data) + self.assertNotIn('final_score', response.data) + + def test_list_groups(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Group.objects.count(), 3) # Count should remain the same + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_submissions(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group2.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_leave_group_not_joined(self): + response = self.client.post( + API_ENDPOINT + f'{self.group_not_visible.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + {'feedback': 'Updated Feedback'}, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + {'feedback': 'Updated Feedback'}, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + + def test_leave_and_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group2.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.group2.user.count(), 0) + response = self.client.post( + API_ENDPOINT + f'{self.group2.group_id}/join/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.group2.user.count(), 1) + diff --git a/backend/pigeonhole/tests/test_views/test_group/test_teacher.py b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py new file mode 100644 index 00000000..7b950104 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_group/test_teacher.py @@ -0,0 +1,152 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + self.client.force_authenticate(self.teacher) + + def test_student_create_group(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Group 1", + "description": "Test Group 1 Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_group(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # + def test_list_groups(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete( + API_ENDPOINT + f'{self.group1.group_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_group_count(self): + self.assertEqual(Group.objects.count(), 3) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.get(group_id=self.group1.group_id).feedback, 'Updated Feedback') + + def test_retrieve_invalid_group(self): + response = self.client.get( + API_ENDPOINT + '9999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_group(self): + response = self.client.patch( + API_ENDPOINT + '999/', + { + 'feedback': 'Updated Feedback' + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_group(self): + response = self.client.delete( + API_ENDPOINT + '999/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_join_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/join/' + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_leave_group(self): + response = self.client.post( + API_ENDPOINT + f'{self.group1.group_id}/leave/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_group_submission(self): + response = self.client.get( + API_ENDPOINT + f'{self.group1.group_id}/get_submissions/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py new file mode 100644 index 00000000..4bc591d4 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_group/test_unauthenticated.py @@ -0,0 +1,103 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/groups/' + + +class GroupTestUnauthorized(TestCase): + + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="teacher@gmail.com", + first_name="teacher", + last_name="lastname", + role=2, + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course, + number_of_groups=3, + group_size=2, + ) + + self.group1 = Group.objects.create( + group_id=0, + group_nr=1, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group2 = Group.objects.create( + group_id=1, + group_nr=2, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=True, + ) + + self.group_not_visible = Group.objects.create( + group_id=2, + group_nr=3, + final_score=0, + project_id=self.project, + feedback="Test Feedback", + visible=False, + ) + + def test_retrieve_group(self): + response = self.client.get(API_ENDPOINT + f'{self.group1.group_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_group(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + "name": "Updated Test Group", + "description": "Updated Test Group Description", + "project_id": self.project.project_id, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_group(self): + response = self.client.delete(API_ENDPOINT + f'{self.group1.group_id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_group(self): + response = self.client.patch( + API_ENDPOINT + f'{self.group1.group_id}/', + { + "name": "Updated Test Group", + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_group_submissions(self): + response = self.client.get(API_ENDPOINT + f'{self.group1.group_id}/get_submissions/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_project/__init__.py b/backend/pigeonhole/tests/test_views/test_project/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py new file mode 100644 index 00000000..1aa3a611 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -0,0 +1,144 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/projects/' + + +class ProjectTestAdminTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.client.force_authenticate(self.admin) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id, + "number_of_groups": 4, + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 2) + self.assertEqual(Group.objects.count(), 4) + new_project = Project.objects.get(name="Test Project 2") + self.assertEqual(new_project.name, "Test Project 2") + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 1) + + def test_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(Project.objects.count(), 0) + + def test_partial_update_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Project.objects.get(project_id=self.project.project_id).name, "Updated Test Project") + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + '100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + '100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_partial_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + '100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + '100/' + ) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_student.py b/backend/pigeonhole/tests/test_views/test_project/test_student.py new file mode 100644 index 00000000..455ee398 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_student.py @@ -0,0 +1,219 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/projects/' + + +class ProjectTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_student = Course.objects.create( + name="Test Course 2", + ) + + self.student.course.set([self.course]) + + self.invisible_project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.visible_project = Project.objects.create( + name="Test Project 2", + course_id=self.course, + visible=True + ) + + self.project_not_of_student = Project.objects.create( + name="Test Project", + course_id=self.course_not_of_student + ) + + self.client.force_authenticate(self.student) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 3) + + def test_retrieve_visible_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.visible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['name'], "Test Project 2") + + def test_retrieve_invisible_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 1) + + def test_update_visible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.visible_project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") + + def test_update_invisible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.invisible_project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") + + def test_delete_visible_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.visible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 3) + + def test_delete_invisible_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 3) + + def test_partial_update_visible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.visible_project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.visible_project.project_id).name, "Test Project 2") + + def test_partial_update_invisible_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.invisible_project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") + + # tests with a course not of the student + + def test_create_project_course_not_of_student(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 3) + + def test_retrieve_project_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + f'{self.project_not_of_student.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_course_not_of_student(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_course_not_of_student(self): + response = self.client.patch( + API_ENDPOINT + f'{self.invisible_project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course_not_of_student.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.get(project_id=self.invisible_project.project_id).name, "Test Project") + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.invisible_project.project_id}6165498/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.invisible_project.project_id}6841684/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.invisible_project.project_id}681854/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py new file mode 100644 index 00000000..a8dce414 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -0,0 +1,162 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/projects/' + + +class ProjectTestStudent(TestCase): + def setUp(self): + self.client = APIClient() + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.project_not_of_teacher = Project.objects.create( + name="Test Project", + course_id=self.course_not_of_teacher + ) + + self.client.force_authenticate(self.teacher) + + def test_create_project(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Project.objects.count(), 3) + + def test_retrieve_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get('name'), self.project.name) + + def test_list_projects(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(len(response.data), 1) + + def test_update_project(self): + response = self.client.put( + API_ENDPOINT + f'{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + + def test_retrieve_invisible_project(self): + invisible_project = Project.objects.create( + name="Test Project", + course_id=self.course, + visible=False + ) + response = self.client.get( + API_ENDPOINT + f'{invisible_project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # test with invalid project + + def test_retrieve_invalid_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}5654168944/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_invalid_project(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project.project_id}5615491/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_invalid_project(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project.project_id}651689/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with project not of teacher + + def test_retrieve_project_not_of_teacher(self): + response = self.client.get( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_not_of_teacher(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_not_of_teacher(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project_not_of_teacher.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def get_groups_of_project(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 0) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py new file mode 100644 index 00000000..ebcc1728 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_project/test_unauthenticated.py @@ -0,0 +1,128 @@ +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/projects/' + + +class ProjectTestUnauthenticated(TestCase): + def setUp(self): + self.client = APIClient() + self.student = User.objects.create( + username="student_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + def test_create_project_unauthenticated(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Project 2", + "description": "Test Project 2 Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(Project.objects.count(), 1) + + def test_retrieve_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_list_projects_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project.project_id}/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + f'{self.project.project_id}/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + f'{self.project.project_id}/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + # test with invalid project + + def test_retrieve_invalid_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + '100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + '100/', + { + "name": "Updated Test Project", + "description": "Updated Test Project Description", + "course_id": self.course.course_id + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_invalid_project_unauthenticated(self): + response = self.client.delete( + API_ENDPOINT + '100/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_partial_update_invalid_project_unauthenticated(self): + response = self.client.patch( + API_ENDPOINT + '100/', + { + "name": "Updated Test Project" + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_get_groups_of_project_unauthenticated(self): + response = self.client.get( + API_ENDPOINT + f'{self.project.project_id}/get_groups/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_submission/__init__.py b/backend/pigeonhole/tests/test_views/test_submission/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py new file mode 100644 index 00000000..eb68b1b9 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -0,0 +1,154 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="admin_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=1 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.admin.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group_not_of_admin = Group.objects.create( + group_nr=2, + project_id=self.project + ) + + self.group.user.set([self.admin]) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.client.force_authenticate(self.admin) + + def check_setup(self): + self.assertEqual(User.objects.count(), 1) + self.assertEqual(Course.objects.count(), 1) + self.assertEqual(Project.objects.count(), 1) + self.assertEqual(Group.objects.count(), 1) + self.assertEqual(Submissions.objects.count(), 1) + + def test_submit_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submissions.objects.count(), 2) + + def test_submit_submission_in_different_group(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group_not_of_admin.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Submissions.objects.count(), 2) + + def test_retrieve_submission(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + # tests with an invalid submission + + def test_create_submission_invalid_group(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": 95955351, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_update_not_possible(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_update_not_possible_invalid(self): + with self.assertRaises(Exception): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + def test_delete_submission_not_possible(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_delete_submission_invalid(self): + with self.assertRaises(Exception): + self.client.delete( + API_ENDPOINT + '4563153/' + ) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_student.py b/backend/pigeonhole/tests/test_views/test_submission/test_student.py new file mode 100644 index 00000000..877d2848 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_student.py @@ -0,0 +1,177 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group_not_of_student = Group.objects.create( + group_nr=2, + project_id=self.project + ) + + self.group.user.set([self.student]) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_student = Submissions.objects.create( + group_id=self.group_not_of_student, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.client.force_authenticate(self.student) + + def test_can_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + def test_retriev_invalid_submissions(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.get( + API_ENDPOINT + str(489454134561) + '/' + ) + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_other_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_other_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.delete( + API_ENDPOINT + '4561313516/', + ) + self.client.delete( + API_ENDPOINT + '4563153/', + ) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py new file mode 100644 index 00000000..2ec7afac --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py @@ -0,0 +1,159 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="teacher_username", + email="test@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=2 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.course_not_of_teacher = Course.objects.create( + name="Test Course 2", + ) + + self.teacher.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.project_not_of_teacher = Project.objects.create( + name="Test Project 2", + course_id=self.course_not_of_teacher + ) + + self.group_not_of_teacher = Group.objects.create( + group_nr=2, + project_id=self.project_not_of_teacher + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_teacher = Submissions.objects.create( + group_id=self.group_not_of_teacher, + file=SimpleUploadedFile("test_file2.txt", b"file_content2") + ) + + self.client.force_authenticate(self.teacher) + + def test_cant_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data.get("submission_id"), self.submission.submission_id) + + def test_retriev_invalid_submissions(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.get( + API_ENDPOINT + str(489454134561) + '/' + ) + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_teacher.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_cant_update_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.put( + API_ENDPOINT + '4561313516/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + self.client.patch( + API_ENDPOINT + '4563153/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) + + def test_cant_delete_invalid_submission(self): + with self.assertRaises(Submissions.DoesNotExist): + self.client.delete( + API_ENDPOINT + '4561313516/', + ) + self.client.delete( + API_ENDPOINT + '4563153/', + ) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py new file mode 100644 index 00000000..c94a894c --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py @@ -0,0 +1,141 @@ +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import Submissions +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/submissions/' + + +class SubmissionTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.student = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + + self.course = Course.objects.create( + name="Test Course", + description="Test Course Description", + ) + + self.student.course.set([self.course]) + + self.project = Project.objects.create( + name="Test Project", + course_id=self.course + ) + + self.group = Group.objects.create( + group_nr=1, + project_id=self.project + ) + + self.group_not_of_student = Group.objects.create( + group_nr=2, + project_id=self.project + ) + + self.group.user.set([self.student]) + + self.submission = Submissions.objects.create( + group_id=self.group, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + self.submission_not_of_student = Submissions.objects.create( + group_id=self.group_not_of_student, + file=SimpleUploadedFile("test_file.txt", b"file_content") + ) + + def test_cant_create_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": self.group.group_id + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_create_invalid_submission(self): + test_file = SimpleUploadedFile("test_file.txt", b"file_content") + response = self.client.post(API_ENDPOINT, + { + "file": test_file, + "group_id": 489454134561 + } + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_retrieve_submissions(self): + response = self.client.get( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_retreive_submissions_of_different_course(self): + response = self.client.get( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission.submission_id) + '/', + { + "group_id": self.group.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_update_other_submission(self): + response = self.client.put( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + response = self.client.patch( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + { + "group_id": self.group_not_of_student.group_id, + "file": SimpleUploadedFile("test_file.txt", b"file_content") + }, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_cant_delete_other_submission(self): + response = self.client.delete( + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/pigeonhole/tests/test_views/test_user/__init__.py b/backend/pigeonhole/tests/test_views/test_user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/pigeonhole/tests/test_views/test_user/test_admin.py b/backend/pigeonhole/tests/test_views/test_user/test_admin.py new file mode 100644 index 00000000..5b482ff2 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_user/test_admin.py @@ -0,0 +1,82 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/users/' + + +class UserTestAdmin(TestCase): + def setUp(self): + self.client = APIClient() + + self.admin = User.objects.create( + username="user1", + email="user1@gmail.com", + first_name="user1", + last_name="user1", + role=1, + ) + + self.student = User.objects.create( + username="user2", + email="user2@gmail.com", + first_name="user2", + last_name="user2", + role=3, + ) + + self.course = Course.objects.create(name="Test Course", description="This is a test course.") + + self.client.force_authenticate(user=self.admin) + + def test_create_user(self): + response = self.client.post( + API_ENDPOINT, + { + "username": "user5", + "email": "user5@gmail.com", + "first_name": "user5", + "last_name": "user5", + "role": 2, + "course": [self.course.course_id] + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(User.objects.count(), 3) + + def test_update_user(self): + updated_data = { + 'username': 'user2', + 'first_name': 'user6', + 'last_name': 'user2', + 'email': 'user2@gmail.com', + 'role': 3, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.student.id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.student.refresh_from_db() + self.assertEqual(self.student.first_name, updated_data['first_name']) + + def test_delete_user(self): + user_id = User.objects.get(username="user2").id + response = self.client.delete(f'{API_ENDPOINT}{user_id}/') + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(User.objects.count(), 1) + + def test_list_users(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) + + def test_retrieve_user(self): + response = self.client.get(f'{API_ENDPOINT}{self.admin.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.admin.first_name) diff --git a/backend/pigeonhole/tests/test_views/test_user/test_teacher.py b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py new file mode 100644 index 00000000..a6239158 --- /dev/null +++ b/backend/pigeonhole/tests/test_views/test_user/test_teacher.py @@ -0,0 +1,97 @@ +import json + +from django.test import TestCase +from rest_framework import status +from rest_framework.test import APIClient + +from backend.pigeonhole.apps.courses.models import Course +from backend.pigeonhole.apps.users.models import User + +API_ENDPOINT = '/users/' + + +class UserTestTeacher(TestCase): + def setUp(self): + self.client = APIClient() + + self.teacher = User.objects.create( + username="user1", + email="user1@gmail.com", + first_name="user1", + last_name="user1", + role=2, + ) + + self.student = User.objects.create( + username="user2", + email="user2@gmail.com", + first_name="user2", + last_name="user2", + role=3, + ) + + self.course = Course.objects.create(name="Test Course", description="This is a test course.") + + self.client.force_authenticate(user=self.teacher) + + def test_create_user(self): + response = self.client.post( + API_ENDPOINT, + { + "username": "user5", + "email": "user5@gmail.com", + "first_name": "user5", + "last_name": "user5", + "role": 2, + "course": [self.course.course_id] + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(User.objects.count(), 2) + + def test_update_self(self): + updated_data = { + 'username': 'user1', + 'first_name': 'user6', + 'last_name': 'user1', + 'email': 'user1@gmail.com', + 'role': 2, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.teacher.id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], updated_data['first_name']) + + def test_delete_self(self): + response = self.client.delete(f'{API_ENDPOINT}{self.teacher.id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(User.objects.count(), 2) + + def test_update_other(self): + updated_data = { + 'username': 'user2', + 'first_name': 'user6', + 'last_name': 'user2', + 'email': 'user2@gmail.com', + 'role': 3, + 'course': [self.course.course_id] + } + response = self.client.put(f'{API_ENDPOINT}{self.student.id}/', updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_delete_other(self): + response = self.client.delete(f'{API_ENDPOINT}{self.student.id}/') + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(User.objects.count(), 2) + + def test_list_users(self): + response = self.client.get(API_ENDPOINT) + self.assertEqual(response.status_code, status.HTTP_200_OK) + content_json = json.loads(response.content.decode("utf-8")) + self.assertEqual(content_json["count"], 2) + + def test_retrieve_user(self): + response = self.client.get(f'{API_ENDPOINT}{self.teacher.id}/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['first_name'], self.teacher.first_name) diff --git a/backend/pigeonhole/urls.py b/backend/pigeonhole/urls.py index 2cb677dd..34374b3e 100644 --- a/backend/pigeonhole/urls.py +++ b/backend/pigeonhole/urls.py @@ -1,17 +1,48 @@ +from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin from django.urls import include, path -from rest_framework import routers +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import routers, permissions +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from backend.testapi import views +from backend.pigeonhole.apps.courses.views import CourseViewSet +from backend.pigeonhole.apps.groups.views import GroupViewSet +from backend.pigeonhole.apps.projects.views import ProjectViewSet +from backend.pigeonhole.apps.submissions.views import SubmissionsViewset +from backend.pigeonhole.apps.users.views import UserViewSet + +schema_view = get_schema_view( + openapi.Info( + title="My API", + default_version='v1', + description="My API description", + terms_of_service="https://www.example.com/terms/", + contact=openapi.Contact(email="contact@example.com"), + license=openapi.License(name="Awesome License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) router = routers.DefaultRouter() -router.register(r'users', views.UserViewSet) -router.register(r'groups', views.GroupViewSet) +router.register(r'users', UserViewSet) +router.register(r'courses', CourseViewSet) +router.register(r'projects', ProjectViewSet) +router.register(r'groups', GroupViewSet) +router.register(r'submissions', SubmissionsViewset) # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browsable API. urlpatterns = [ - path('', include(router.urls)), - path('api-auth/', include('rest_framework.urls', namespace='rest_framework')) -] + path('', include(router.urls)), + path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + path('redoc/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path("admin/", admin.site.urls), + path('auth/login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('auth/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += router.urls diff --git a/backend/requirements.txt b/backend/requirements.txt index df59d6c2..22eb5e0d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,7 @@ djangorestframework-simplejwt~=5.2.2 djangorestframework~=3.14.0 flake8==7.0.0 psycopg2-binary~=2.9.5 -pytz~=2022.7.1 \ No newline at end of file +pytz~=2022.7.1 +pyyaml==6.0.1 +uritemplate==4.1.1 +drf-yasg==1.21.7 diff --git a/backend/runtests.sh b/backend/runtests.sh new file mode 100644 index 00000000..65d41fa8 --- /dev/null +++ b/backend/runtests.sh @@ -0,0 +1 @@ +coverage run manage.py test backend/pigeonhole/tests/test_views \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index c4033664..08e12e04 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -16,7 +16,7 @@ bun dev Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +You can start editing the page by modifying `app/index.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d72dd96b..b7efaa1c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,9 +8,22 @@ "name": "pigeonhole", "version": "0.1.0", "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.12", + "@mui/icons-material": "^5.15.12", + "@mui/material": "^5.15.12", + "@mui/material-nextjs": "^5.15.11", + "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.344.0", "next": "14.1.0", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { "@types/node": "^20", @@ -37,7 +50,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, "engines": { "node": ">=10" }, @@ -45,11 +57,190 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@babel/code-frame": { + "version": "7.23.5", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", + "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "dependencies": { + "@babel/highlight": "^7.23.4", + "chalk": "^2.4.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/code-frame/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/code-frame/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/code-frame/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/code-frame/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.22.15", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", + "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "dependencies": { + "@babel/types": "^7.22.15" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", + "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.22.20", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", + "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.23.4", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", + "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.22.20", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/@babel/highlight/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + }, + "node_modules/@babel/highlight/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@babel/highlight/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/highlight/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/@babel/runtime": { "version": "7.23.9", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.9.tgz", "integrity": "sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -57,6 +248,152 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/types": { + "version": "7.24.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.0.tgz", + "integrity": "sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==", + "dependencies": { + "@babel/helper-string-parser": "^7.23.4", + "@babel/helper-validator-identifier": "^7.22.20", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", + "integrity": "sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/serialize": "^1.1.2", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.11.0.tgz", + "integrity": "sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==", + "dependencies": { + "@emotion/memoize": "^0.8.1", + "@emotion/sheet": "^1.2.2", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.1.tgz", + "integrity": "sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.2.tgz", + "integrity": "sha512-uNsoYd37AFmaCdXlg6EYD1KaPOaRWRByMCYzbKUX4+hhMfrxdVSelShywL4JVaAeM/eHUOSprYBQls+/neX3pw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/react": { + "version": "11.11.4", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.11.4.tgz", + "integrity": "sha512-t8AjMlF0gHpvvxk5mAtCqR4vmxiGHCeJBaQO6gncUSdklELOgtwjerNY2yuJNfwnc6vi16U/+uMF+afIawJ9iw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/cache": "^11.11.0", + "@emotion/serialize": "^1.1.3", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1", + "@emotion/weak-memoize": "^0.3.1", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.3.tgz", + "integrity": "sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==", + "dependencies": { + "@emotion/hash": "^0.9.1", + "@emotion/memoize": "^0.8.1", + "@emotion/unitless": "^0.8.1", + "@emotion/utils": "^1.2.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.2.tgz", + "integrity": "sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==" + }, + "node_modules/@emotion/styled": { + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.11.0.tgz", + "integrity": "sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/is-prop-valid": "^1.2.1", + "@emotion/serialize": "^1.1.2", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.1", + "@emotion/utils": "^1.2.1" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz", + "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.1.tgz", + "integrity": "sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.1.tgz", + "integrity": "sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz", + "integrity": "sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -113,6 +450,45 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.0.tgz", + "integrity": "sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g==", + "dependencies": { + "@floating-ui/utils": "^0.2.1" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.3.tgz", + "integrity": "sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.8.tgz", + "integrity": "sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw==", + "dependencies": { + "@floating-ui/dom": "^1.6.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" + }, + "node_modules/@fontsource/roboto": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.0.12.tgz", + "integrity": "sha512-x0o17jvgoSSbS9OZnUX2+xJmVRvVCfeaYJjkS7w62iN7CuJWtMf5vJj8LqgC7ibqIkitOHVW+XssRjgrcHn62g==" + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.14", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", @@ -150,7 +526,6 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", @@ -167,7 +542,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -179,7 +553,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -194,7 +567,6 @@ "version": "0.3.3", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", - "dev": true, "dependencies": { "@jridgewell/set-array": "^1.0.1", "@jridgewell/sourcemap-codec": "^1.4.10", @@ -208,7 +580,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -217,7 +588,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, "engines": { "node": ">=6.0.0" } @@ -225,19 +595,306 @@ "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==", - "dev": true + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.22", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", - "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.38.tgz", + "integrity": "sha512-AsjD6Y1X5A1qndxz8xCcR8LDqv31aiwlgWMPxFAX/kCKiIGKlK65yMeVZ62iQr/6LBz+9hSKLiD1i4TZdAHKcQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.12.tgz", + "integrity": "sha512-brRO+tMFLpGyjEYHrX97bzqeF6jZmKpqqe1rY0LyIHAwP6xRVzh++zSecOQorDOCaZJg4XkGT9xfD+RWOWxZBA==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.12.tgz", + "integrity": "sha512-3BXiDlOd3AexZoEXa/VqpIpVIvosCzjLHsdMWzKMXbZdnBiJjmb9ECdqfjn5SpTClO49qvkKLhkTqdBH3fSFGw==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.12.tgz", + "integrity": "sha512-vXJGg6KNKucsvbW6l7w9zafnpOp0CWc0Wx4mDykuABTpQ5QQBnZxP7+oB4yAS1hDZQ1WobbeIl0CjxK4EEahkA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.38", + "@mui/core-downloads-tracker": "^5.15.12", + "@mui/system": "^5.15.12", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "@types/react-transition-group": "^4.4.10", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^18.2.0", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material-nextjs": { + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/material-nextjs/-/material-nextjs-5.15.11.tgz", + "integrity": "sha512-cp5RWYbBngyi7NKP91R9QITllfxumCVPFjqe4AKzNROVuCot0VpgkafxXqfbv0uFsyUU0ROs0O2M3r17q604Aw==", + "dependencies": { + "@babel/runtime": "^7.23.9" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/cache": "^11.11.0", + "@emotion/server": "^11.11.0", + "@mui/material": "^5.0.0", + "@types/react": "^17.0.0 || ^18.0.0", + "next": "^13.0.0 || ^14.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/cache": { + "optional": true + }, + "@emotion/server": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "node_modules/@mui/private-theming": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.12.tgz", + "integrity": "sha512-cqoSo9sgA5HE+8vZClbLrq9EkyOnYysooepi5eKaKvJ41lReT2c5wOZAeDDM1+xknrMDos+0mT2zr3sZmUiRRA==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.12", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "5.15.11", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.11.tgz", + "integrity": "sha512-So21AhAngqo07ces4S/JpX5UaMU2RHXpEA6hNzI6IQjd/1usMPxpgK8wkGgTe3JKmC2KDmH8cvoycq5H3Ii7/w==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@emotion/cache": "^11.11.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.12.tgz", + "integrity": "sha512-/pq+GO6yN3X7r3hAwFTrzkAh7K1bTF5r8IzS79B9eyKJg7v6B/t4/zZYMR6OT9qEPtwf6rYN2Utg1e6Z7F1OgQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.12", + "@mui/styled-engine": "^5.15.11", + "@mui/types": "^7.2.13", + "@mui/utils": "^5.15.12", + "clsx": "^2.1.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.13", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.13.tgz", + "integrity": "sha512-qP9OgacN62s+l8rdDhSFRe05HWtLLJ5TGclC9I1+tQngbssu0m2dmFZs+Px53AcOs9fD7TbYd4gc9AXzVqO/+g==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.15.12", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.12.tgz", + "integrity": "sha512-8SDGCnO2DY9Yy+5bGzu00NZowSDtuyHP4H8gunhHGQoIlhlY2Z3w64wBzAOLpYw/ZhJNzksDTnS/i8qdJvxuow==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@types/prop-types": "^15.7.11", + "prop-types": "^15.8.1", + "react-is": "^18.2.0" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils/node_modules/react-is": { + "version": "18.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, "node_modules/@next/env": { "version": "14.1.0", "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", @@ -391,7 +1048,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -404,7 +1060,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "engines": { "node": ">= 8" } @@ -413,7 +1068,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -426,12 +1080,55 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, "optional": true, "engines": { "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "dependencies": { + "@babel/runtime": "^7.13.10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.7.2.tgz", @@ -461,17 +1158,20 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/parse-json": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", + "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" + }, "node_modules/@types/prop-types": { "version": "15.7.11", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", - "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", - "dev": true + "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" }, "node_modules/@types/react": { "version": "18.2.57", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.57.tgz", "integrity": "sha512-ZvQsktJgSYrQiMirAN60y4O/LRevIV8hUzSOSNB6gfR3/o3wCBFQx3sPwIYtuDMeiVgsSS3UzCV26tEzgnfvQw==", - "dev": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -487,11 +1187,18 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.10", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", + "integrity": "sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.8", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", - "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==", - "dev": true + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@typescript-eslint/parser": { "version": "6.21.0", @@ -667,7 +1374,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, "engines": { "node": ">=8" } @@ -676,7 +1382,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -690,14 +1395,12 @@ "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -709,8 +1412,7 @@ "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" }, "node_modules/argparse": { "version": "2.0.1", @@ -895,6 +1597,11 @@ "has-symbols": "^1.0.3" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/autoprefixer": { "version": "10.4.17", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", @@ -956,6 +1663,16 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz", + "integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==", + "dependencies": { + "follow-redirects": "^1.15.4", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/axobject-query": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", @@ -965,17 +1682,29 @@ "dequal": "^2.0.3" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, "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 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, "node_modules/binary-extensions": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, "engines": { "node": ">=8" } @@ -994,7 +1723,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -1068,7 +1796,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, "engines": { "node": ">=6" } @@ -1077,7 +1804,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, "engines": { "node": ">= 6" } @@ -1121,7 +1847,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1145,7 +1870,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -1153,16 +1877,42 @@ "node": ">= 6" } }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "engines": { + "node": ">=6" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==" }, + "node_modules/clsx": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", + "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -1173,14 +1923,23 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, "engines": { "node": ">= 6" } @@ -1191,11 +1950,38 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cosmiconfig/node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", - "dev": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -1209,7 +1995,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, "bin": { "cssesc": "bin/cssesc" }, @@ -1220,8 +2005,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -1286,6 +2070,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1298,8 +2090,7 @@ "node_modules/didyoumean": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, "node_modules/dir-glob": { "version": "3.0.1", @@ -1316,8 +2107,7 @@ "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" }, "node_modules/doctrine": { "version": "3.0.0", @@ -1331,11 +2121,19 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, "node_modules/electron-to-chromium": { "version": "1.4.676", @@ -1346,8 +2144,7 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" }, "node_modules/enhanced-resolve": { "version": "5.15.0", @@ -1362,6 +2159,14 @@ "node": ">=10.13.0" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.22.4", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.4.tgz", @@ -1523,7 +2328,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "engines": { "node": ">=10" }, @@ -1951,7 +2755,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -1967,7 +2770,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -1991,7 +2793,6 @@ "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -2012,7 +2813,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2020,6 +2820,11 @@ "node": ">=8" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -2056,6 +2861,25 @@ "integrity": "sha512-noqGuLw158+DuD9UPRKHpJ2hGxpFyDlYYrfM0mWt4XhT4n0lwzTLh70Tkdyy4kyTmyTT9Bv7bWAJqw7cgkEXDg==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.5", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", + "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -2069,7 +2893,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", - "dev": true, "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -2081,6 +2904,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -2104,7 +2940,6 @@ "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": [ @@ -2118,7 +2953,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2202,7 +3036,6 @@ "version": "10.3.10", "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", - "dev": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^2.3.5", @@ -2224,7 +3057,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -2236,7 +3068,6 @@ "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" } @@ -2245,7 +3076,6 @@ "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" }, @@ -2402,7 +3232,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.1.tgz", "integrity": "sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==", - "dev": true, "dependencies": { "function-bind": "^1.1.2" }, @@ -2410,6 +3239,14 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2423,7 +3260,6 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -2490,6 +3326,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, "node_modules/is-async-function": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", @@ -2521,7 +3362,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, "dependencies": { "binary-extensions": "^2.0.0" }, @@ -2561,7 +3401,6 @@ "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, "dependencies": { "hasown": "^2.0.0" }, @@ -2588,7 +3427,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -2609,7 +3447,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "engines": { "node": ">=8" } @@ -2633,7 +3470,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -2666,7 +3502,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -2820,8 +3655,7 @@ "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, "node_modules/iterator.prototype": { "version": "1.1.2", @@ -2840,7 +3674,6 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", - "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -2858,7 +3691,6 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", - "dev": true, "bin": { "jiti": "bin/jiti.js" } @@ -2886,6 +3718,11 @@ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", "dev": true }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -2969,7 +3806,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", - "dev": true, "engines": { "node": ">=10" } @@ -2977,8 +3813,7 @@ "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/locate-path": { "version": "6.0.0", @@ -3016,16 +3851,22 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "dev": true, "engines": { "node": "14 || >=16.14" } }, + "node_modules/lucide-react": { + "version": "0.344.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.344.0.tgz", + "integrity": "sha512-6YyBnn91GB45VuVT96bYCOKElbJzUHqp65vX8cDcu55MQL9T969v4dhGClpljamuI/+KMO9P6w9Acq1CVQGvIQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -3034,7 +3875,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -3043,6 +3883,25 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3068,7 +3927,6 @@ "version": "7.0.4", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", - "dev": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -3083,7 +3941,6 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", @@ -3195,7 +4052,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3213,7 +4069,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3222,7 +4077,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, "engines": { "node": ">= 6" } @@ -3397,7 +4251,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, "dependencies": { "callsites": "^3.0.0" }, @@ -3405,6 +4258,23 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -3427,7 +4297,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "engines": { "node": ">=8" } @@ -3435,14 +4304,12 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", - "dev": true, "dependencies": { "lru-cache": "^9.1.1 || ^10.0.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" @@ -3458,7 +4325,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", - "dev": true, "engines": { "node": ">=8" } @@ -3472,7 +4338,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -3484,7 +4349,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3493,7 +4357,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", - "dev": true, "engines": { "node": ">= 6" } @@ -3511,7 +4374,6 @@ "version": "8.4.35", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz", "integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3539,7 +4401,6 @@ "version": "15.1.0", "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", @@ -3556,7 +4417,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", - "dev": true, "dependencies": { "camelcase-css": "^2.0.1" }, @@ -3575,7 +4435,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -3610,7 +4469,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.1.tgz", "integrity": "sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==", - "dev": true, "engines": { "node": ">=14" }, @@ -3622,7 +4480,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", - "dev": true, "dependencies": { "postcss-selector-parser": "^6.0.11" }, @@ -3641,7 +4498,6 @@ "version": "6.0.15", "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", - "dev": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -3653,8 +4509,7 @@ "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, "node_modules/prelude-ls": { "version": "1.2.1", @@ -3669,13 +4524,17 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3689,7 +4548,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -3731,14 +4589,27 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, "dependencies": { "pify": "^2.3.0" } @@ -3747,7 +4618,6 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, "dependencies": { "picomatch": "^2.2.1" }, @@ -3779,8 +4649,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", @@ -3804,7 +4673,6 @@ "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -3821,7 +4689,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { "node": ">=4" } @@ -3839,7 +4706,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -3884,7 +4750,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -4009,7 +4874,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -4021,7 +4885,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "engines": { "node": ">=8" } @@ -4048,7 +4911,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, "engines": { "node": ">=14" }, @@ -4065,6 +4927,14 @@ "node": ">=8" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", @@ -4085,7 +4955,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", @@ -4103,7 +4972,6 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4116,14 +4984,12 @@ "node_modules/string-width-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/string-width/node_modules/ansi-regex": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -4135,7 +5001,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4215,7 +5080,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4228,7 +5092,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, "dependencies": { "ansi-regex": "^5.0.1" }, @@ -4279,11 +5142,15 @@ } } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", - "dev": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", @@ -4317,7 +5184,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -4325,11 +5191,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tailwind-merge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.2.1.tgz", + "integrity": "sha512-o+2GTLkthfa5YUt4JxPfzMIpQzZ3adD1vLVkvKE1Twl9UAhGsEbIZhHHZVRttyW177S8PDJI3bTQNaebyofK3Q==", + "dependencies": { + "@babel/runtime": "^7.23.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", - "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", @@ -4362,6 +5239,14 @@ "node": ">=14.0.0" } }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, "node_modules/tapable": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", @@ -4381,7 +5266,6 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, "dependencies": { "any-promise": "^1.0.0" } @@ -4390,7 +5274,6 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, "dependencies": { "thenify": ">= 3.1.0 < 4" }, @@ -4398,11 +5281,18 @@ "node": ">=0.8" } }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "engines": { + "node": ">=4" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -4425,8 +5315,7 @@ "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, "node_modules/tsconfig-paths": { "version": "3.15.0", @@ -4611,14 +5500,12 @@ "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "dependencies": { "isexe": "^2.0.0" }, @@ -4709,7 +5596,6 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", @@ -4727,7 +5613,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", @@ -4743,14 +5628,12 @@ "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/wrap-ansi-cjs/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", @@ -4764,7 +5647,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, "engines": { "node": ">=12" }, @@ -4776,7 +5658,6 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, "engines": { "node": ">=12" }, @@ -4788,7 +5669,6 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, "dependencies": { "ansi-regex": "^6.0.1" }, @@ -4815,7 +5695,6 @@ "version": "2.3.4", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", - "dev": true, "engines": { "node": ">= 14" } diff --git a/frontend/package.json b/frontend/package.json index 47c304b2..3ed1fbde 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,19 +9,32 @@ "lint": "next lint" }, "dependencies": { + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.0", + "@fontsource/roboto": "^5.0.12", + "@mui/icons-material": "^5.15.12", + "@mui/material": "^5.15.12", + "@mui/material-nextjs": "^5.15.11", + "@radix-ui/react-slot": "^1.0.2", + "axios": "^1.6.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "lucide-react": "^0.344.0", + "next": "14.1.0", "react": "^18", "react-dom": "^18", - "next": "14.1.0" + "tailwind-merge": "^2.2.1", + "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", + "eslint": "^8", + "eslint-config-next": "14.1.0", "postcss": "^8", "tailwindcss": "^3.3.0", - "eslint": "^8", - "eslint-config-next": "14.1.0" + "typescript": "^5" } } diff --git a/frontend/pages/_document.js b/frontend/pages/_document.js new file mode 100644 index 00000000..c732e018 --- /dev/null +++ b/frontend/pages/_document.js @@ -0,0 +1,26 @@ +import Document, {Head, Html, Main, NextScript} from 'next/document'; +import {PRIMARY_COLOR} from "../src/lib/utils"; + +class MyDocument extends Document { + render() { + return ( + + + {/* PWA primary color */} + + + {/* Add other head elements here as needed */} + + +
+ + + + ); + } +} + +export default MyDocument; diff --git a/frontend/public/logo.png b/frontend/public/logo.png new file mode 100644 index 00000000..1fbb1c1d Binary files /dev/null and b/frontend/public/logo.png differ diff --git a/frontend/public/logo_old.png b/frontend/public/logo_old.png new file mode 100644 index 00000000..47868d7b Binary files /dev/null and b/frontend/public/logo_old.png differ diff --git a/frontend/src/app/components/CASButton.tsx b/frontend/src/app/components/CASButton.tsx new file mode 100644 index 00000000..6af7e5c7 --- /dev/null +++ b/frontend/src/app/components/CASButton.tsx @@ -0,0 +1,27 @@ +"use client"; +import React from 'react' +import SchoolIcon from "@mui/icons-material/School"; +import {Button} from "@mui/material"; + +const CASButton = () => { + const handleCASLogin = (): void => { + // Implement CAS login logic here + console.log('Login with CAS'); + }; + + return ( +
+ +
+ ) +} + +export default CASButton diff --git a/frontend/src/app/components/LoginCard.tsx b/frontend/src/app/components/LoginCard.tsx new file mode 100644 index 00000000..98fd6511 --- /dev/null +++ b/frontend/src/app/components/LoginCard.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import {Box, Container, CssBaseline, Typography} from '@mui/material'; +import CASButton from "@/app/components/CASButton"; +import LoginForm from "@/app/components/LoginForm"; + +const LoginCard: React.FC = () => { + return ( + + + + + Pigeonhole + +
+ + + + OR + + + +
+
+
+ ); +}; + +export default LoginCard; diff --git a/frontend/src/app/components/LoginForm.tsx b/frontend/src/app/components/LoginForm.tsx new file mode 100644 index 00000000..8e652e3d --- /dev/null +++ b/frontend/src/app/components/LoginForm.tsx @@ -0,0 +1,67 @@ +"use client"; +import React, {useState} from 'react'; +import {Button, IconButton, InputAdornment, TextField} from "@mui/material"; +import Visibility from "@mui/icons-material/Visibility"; +import VisibilityOff from "@mui/icons-material/VisibilityOff"; +import AuthAgent from "../../auth/auth-agent"; + +const LoginForm = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + const handleLogin = (): void => { + AuthAgent.login(username, password).then((data) => { + console.log("Logged in") + console.log(data) + }) + }; + + const handleClickShowPassword = () => { + setShowPassword(!showPassword); + }; + + const handleMouseDownPassword = (event: React.MouseEvent) => { + event.preventDefault(); + }; + + return ( +
+ setUsername(e.target.value)} + fullWidth + /> + setPassword(e.target.value)} + fullWidth + InputProps={{ + endAdornment: ( + + + {showPassword ? ( + // Set the icon to small + ) : ( + // Set the icon to small + )} + + + ), + }} + /> + +
+ ); +} + +export default LoginForm; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css deleted file mode 100644 index 875c01e8..00000000 --- a/frontend/src/app/globals.css +++ /dev/null @@ -1,33 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} - -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; - } -} - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -@layer utilities { - .text-balance { - text-wrap: balance; - } -} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 3314e478..caf37ade 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,22 +1,24 @@ -import type { Metadata } from "next"; -import { Inter } from "next/font/google"; -import "./globals.css"; +import React from 'react' +import {AppRouterCacheProvider} from '@mui/material-nextjs/v13-appRouter'; +import {ThemeProvider} from '@mui/material/styles'; +import loginTheme from '@/styles/theme'; -const inter = Inter({ subsets: ["latin"] }); - -export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", -}; +export const metadata = { + title: 'Next.js', + description: 'Generated by Next.js', +} -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - {children} - - ); +export default function RootLayout(props: React.PropsWithChildren<{}>) { + const {children} = props; + return ( + + + + + {children} + + + + + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ee07830a..66d47abf 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,37 +1,13 @@ -"use client" +import React from 'react'; +import LoginCard from '@/app/components/LoginCard'; -import Image from "next/image"; -import React, { useState, useEffect } from 'react'; - -export default function Home() { - - - const [data, setData] = useState(null); - - useEffect(() => { - const fetchData = async () => { - try { - const response = await fetch('http://127.0.0.1:8000/groups/'); - if (response.ok) { - const json = await response.json(); - setData(json); - } else { - console.error('Failed to fetch data'); - } - } catch (error) { - console.error('Error:', error); - } - }; - - fetchData(); - }, []); - - - return ( -
-

Deployment test:

-

{JSON.stringify(data)}

-
- ); +const Login = () => { + return ( +
+ +
+ ) } + +export default Login diff --git a/frontend/src/auth/auth-agent.js b/frontend/src/auth/auth-agent.js new file mode 100644 index 00000000..279b33a2 --- /dev/null +++ b/frontend/src/auth/auth-agent.js @@ -0,0 +1,26 @@ +import axios from "axios"; + +class AuthAgent { + + login(username, password) { + return axios + .post("http://127.0.0.1:8000/auth/login/", { + username, + password + }) + .then(response => { + console.log(response) + if (response.data.access) { + localStorage.setItem("user", JSON.stringify(response.data.user)); + } + return response.data; + }); + } + + getCurrentUser() { + return JSON.parse(localStorage.getItem('user')); + } + +} + +export default new AuthAgent(); \ No newline at end of file diff --git a/frontend/src/auth/auth-header.js b/frontend/src/auth/auth-header.js new file mode 100644 index 00000000..bee5b57b --- /dev/null +++ b/frontend/src/auth/auth-header.js @@ -0,0 +1,10 @@ +function authHeader() { + const user = JSON.parse(localStorage.getItem('user')); + if (user && user.accessToken) { + return {Authorization: 'Bearer ' + user.accessToken}; + } else { + return {}; + } +} + +export default authHeader; \ No newline at end of file diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 00000000..c8ec6dab --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,8 @@ +import {type ClassValue, clsx} from "clsx" +import {twMerge} from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export const PRIMARY_COLOR = "#1976d2"; diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 00000000..6a757250 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,76 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/frontend/src/styles/theme.ts b/frontend/src/styles/theme.ts new file mode 100644 index 00000000..b607229d --- /dev/null +++ b/frontend/src/styles/theme.ts @@ -0,0 +1,83 @@ +"use client"; +import {createTheme, PaletteOptions} from '@mui/material/styles'; +import {Palette} from '@mui/material/styles/createPalette'; + +declare module '@mui/material/styles/createPalette' { + interface Palette { + failure?: Palette['primary']; + } + + interface PaletteOptions { + failure?: PaletteOptions['primary']; + } +} + +const loginTheme = createTheme({ + palette: { + background: { + default: '#f4f5fd' + }, + primary: { + main: '#1E64C8', + contrastText: '#FFFFFF' + }, + secondary: { + main: '#D0E4FF', + contrastText: '#001D36' + }, + failure: { + main: '#E15E5E' + }, + success: { + main: '#7DB47C' + } + }, + typography: { + fontFamily: 'Quicksand, sans-serif', + h4: { + fontWeight: 700, + }, + }, + components: { + MuiTextField: { + defaultProps: { + InputLabelProps: { + shrink: true, + }, + margin: 'normal', + required: true, + fullWidth: true, + }, + }, + MuiButton: { + defaultProps: { + variant: 'contained', + color: 'primary', + fullWidth: true, + style: {margin: '10px 0'}, + }, + }, + }, +}); + +export const theme = createTheme({ + palette: { + primary: { + main: '#1E64C8', + contrastText: '#FFFFFF' + }, + secondary: { + main: '#D0E4FF', + contrastText: '#001D36' + }, + background: { + default: '#f4f5fd', + }, + text: { + primary: '#001D36', + secondary: '#FFFFFF' + }, + }, +}); + +export default loginTheme; diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts index e9a0944e..84287e82 100644 --- a/frontend/tailwind.config.ts +++ b/frontend/tailwind.config.ts @@ -1,20 +1,80 @@ -import type { Config } from "tailwindcss"; +import type { Config } from "tailwindcss" -const config: Config = { +const config = { + darkMode: ["class"], content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], + './pages/**/*.{ts,tsx}', + './components/**/*.{ts,tsx}', + './app/**/*.{ts,tsx}', + './src/**/*.{ts,tsx}', + ], + prefix: "", theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: "0" }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: "0" }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", }, }, }, - plugins: [], -}; -export default config; + plugins: [require("tailwindcss-animate")], +} satisfies Config + +export default config \ No newline at end of file