diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 81ba5744..00c1fec9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,7 @@ jobs: pip install -r ./backend/requirements.txt - name: Running Django tests run: | + source /home/selab2/hosting/.env.test sh ./backend/runtests.sh frontend-test: diff --git a/.template.env b/.template.env new file mode 100644 index 00000000..31c5df35 --- /dev/null +++ b/.template.env @@ -0,0 +1,23 @@ +DATABASE=postgres +DEBUG=1 +DJANGO_ALLOWED_HOSTS='localhost example.com 127.0.0.1 [::1] django' +DJANGO_SUPERUSER_EMAIL=abc@example.com +DJANGO_SUPERUSER_PASSWORD=abc +FRONTEND_URL=http://localhost:3000 +NEXT_PUBLIC_BACKEND_URL=http://localhost:8000 +NEXT_PUBLIC_REDIRECT_URL=/redirect +OAUTH_CLIENT_ID=1234 +OAUTH_CLIENT_SECRET=1234 +OAUTH_TENANT_ID=1234 +REGISTRY_NAME=sel2-1.ugent.be:2002 +REGISTRY_PASSWORD=testding +REGISTRY_URL=https://sel2-1.ugent.be:2002 +REGISTRY_USER=test +SECRET_KEY=development_key +SQL_DATABASE=pigeonhole_dev +SQL_ENGINE=django.db.backends.postgresql +SQL_HOST=pigeonhole-database +SQL_PASSWORD=password +SQL_PORT=5432 +SQL_USER=pigeonhole +SUBMISSIONS_PATH=./backend/uploads/submissions/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 172fc949..ceaf0ecc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ENV PYTHONUNBUFFERED 1 # install psycopg2 dependencies RUN apk update \ - && apk add postgresql-dev gcc python3-dev musl-dev + && apk add postgresql-dev gcc python3-dev musl-dev docker-cli # install dependencies RUN pip install --upgrade pip diff --git a/Dockerfile.prod b/Dockerfile.prod index bb6452a5..92b8e31d 100644 --- a/Dockerfile.prod +++ b/Dockerfile.prod @@ -47,7 +47,7 @@ RUN mkdir $APP_HOME/uploads WORKDIR $APP_HOME # install dependencies -RUN apk update && apk add libpq +RUN apk update && apk add libpq docker-cli COPY --from=builder /usr/src/app/wheels /wheels COPY --from=builder /usr/src/app/requirements.txt . RUN pip install --no-cache /wheels/* diff --git a/Makefile b/Makefile index 3c028b35..3041751d 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,23 @@ superuser: mockdata: docker exec -it pigeonhole-backend python manage.py runscript mockdata +registry: + docker build examples/advanced-evaluation/always-succeed -t test-always-succeed + docker tag test-always-succeed localhost:5000/test-always-succeed + docker push localhost:5000/test-always-succeed + docker build examples/advanced-evaluation/helloworld -t test-helloworld + docker tag test-helloworld localhost:5000/test-helloworld + docker push localhost:5000/test-helloworld + docker build examples/advanced-evaluation/fibonacci-python -t fibonacci-python + docker tag fibonacci-python localhost:5000/fibonacci-python + docker push localhost:5000/fibonacci-python + +evaltest: + docker exec -it pigeonhole-backend python manage.py runscript eval_test + +submit: + docker exec -it pigeonhole-backend python manage.py runscript submit + reset: docker image prune -af docker system prune @@ -30,5 +47,23 @@ frontshell: componenttest: docker exec -it pigeonhole-frontend npx jest +coveragecomponenttest: + docker exec -it pigeonhole-frontend npx jest --coverage --silent + silentcomponenttest: - docker exec -it pigeonhole-frontend npx jest --silent \ No newline at end of file + docker exec -it pigeonhole-frontend npx jest --silent + +resetdb: + docker exec pigeonhole-backend python manage.py flush --noinput + docker exec -it pigeonhole-backend python manage.py runscript mockdata + +prodregistry: + docker build examples/advanced-evaluation/always-succeed -t test-always-succeed + docker tag test-always-succeed sel2-1.ugent.be:2002/test-always-succeed + docker push sel2-1.ugent.be:2002/test-always-succeed + docker build examples/advanced-evaluation/helloworld -t test-helloworld + docker tag test-helloworld sel2-1.ugent.be:2002/test-helloworld + docker push sel2-1.ugent.be:2002/test-helloworld + docker build examples/advanced-evaluation/fibonacci-python -t fibonacci-python + docker tag fibonacci-python sel2-1.ugent.be:2002/fibonacci-python + docker push sel2-1.ugent.be:2002/fibonacci-python \ No newline at end of file diff --git a/README.md b/README.md index 40d1da49..1f573c37 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,5 @@ - Rune Dyselinck: Secretary ## Bekijk de [wiki](https://github.com/SELab-2/UGent-1/wiki) om alles over de applicatie te weten te komen. + +## Bekijk de [user manual](https://github.com/SELab-2/UGent-1/blob/user-manual/user_manual/manual.pdf) voor de gebruikershandleiding. \ No newline at end of file diff --git a/backend/entrypoint.prod.sh b/backend/entrypoint.prod.sh index 16a6e2c8..bd0e3c27 100644 --- a/backend/entrypoint.prod.sh +++ b/backend/entrypoint.prod.sh @@ -17,5 +17,6 @@ python manage.py runscript push_site python manage.py collectstatic --noinput +docker login $REGISTRY_URL -u $REGISTRY_USER -p $REGISTRY_PASSWORD exec "$@" \ No newline at end of file diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh index 8784c67c..75bb2e2a 100755 --- a/backend/entrypoint.sh +++ b/backend/entrypoint.sh @@ -19,4 +19,6 @@ python manage.py runscript push_site python manage.py createsuperuser --noinput --email $DJANGO_SUPERUSER_EMAIL +docker login $REGISTRY_URL -u $REGISTRY_USER -p $REGISTRY_PASSWORD + exec "$@" \ No newline at end of file diff --git a/backend/pigeonhole/apps/courses/admin.py b/backend/pigeonhole/apps/courses/admin.py index 8acc87ec..54d1dc43 100644 --- a/backend/pigeonhole/apps/courses/admin.py +++ b/backend/pigeonhole/apps/courses/admin.py @@ -38,6 +38,14 @@ class CourseAdmin(admin.ModelAdmin): 'open_course', ) } + ), + ( + 'banner', + { + 'fields': ( + 'banner', + ) + } ) ) diff --git a/backend/pigeonhole/apps/courses/migrations/0006_course_archived_course_year.py b/backend/pigeonhole/apps/courses/migrations/0006_course_archived_course_year.py new file mode 100644 index 00000000..b5dda7fa --- /dev/null +++ b/backend/pigeonhole/apps/courses/migrations/0006_course_archived_course_year.py @@ -0,0 +1,23 @@ +# Generated by Django 5.0.2 on 2024-04-27 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0005_alter_course_banner'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='archived', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='course', + name='year', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/courses/migrations/0007_alter_course_year.py b/backend/pigeonhole/apps/courses/migrations/0007_alter_course_year.py new file mode 100644 index 00000000..796a1aaf --- /dev/null +++ b/backend/pigeonhole/apps/courses/migrations/0007_alter_course_year.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.2 on 2024-04-30 21:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0006_course_archived_course_year'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='year', + field=models.IntegerField(default=2024), + ), + ] diff --git a/backend/pigeonhole/apps/courses/migrations/0008_alter_course_banner.py b/backend/pigeonhole/apps/courses/migrations/0008_alter_course_banner.py new file mode 100644 index 00000000..1fe81d4d --- /dev/null +++ b/backend/pigeonhole/apps/courses/migrations/0008_alter_course_banner.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-23 12:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0007_alter_course_year'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='banner', + field=models.FileField(blank=True, null=True, upload_to='course_banners/'), + ), + ] diff --git a/backend/pigeonhole/apps/courses/models.py b/backend/pigeonhole/apps/courses/models.py index af9a085f..7f0f41cd 100644 --- a/backend/pigeonhole/apps/courses/models.py +++ b/backend/pigeonhole/apps/courses/models.py @@ -3,15 +3,15 @@ from rest_framework import serializers -# Create your models here. class Course(models.Model): course_id = models.BigAutoField(primary_key=True) name = models.CharField(max_length=256) description = models.TextField() open_course = models.BooleanField(default=False) invite_token = models.CharField(max_length=20, blank=True, null=True) - banner = models.FileField(upload_to='course_banners/', blank=True, null=False, - default='course_banners/ugent_banner.png') + banner = models.FileField(upload_to='course_banners/', blank=True, null=True) + archived = models.BooleanField(default=False) + year = models.IntegerField(default=2024) objects = models.Manager() @@ -34,7 +34,7 @@ class CourseSerializer(serializers.ModelSerializer): class Meta: model = Course - fields = ['course_id', 'name', 'open_course', 'description', 'invite_token', 'banner'] + fields = ['course_id', 'name', 'open_course', 'description', 'invite_token', 'banner', 'archived', 'year'] def to_representation(self, instance): data = super().to_representation(instance) diff --git a/backend/pigeonhole/apps/courses/permissions.py b/backend/pigeonhole/apps/courses/permissions.py index 17ed4a43..9267b354 100644 --- a/backend/pigeonhole/apps/courses/permissions.py +++ b/backend/pigeonhole/apps/courses/permissions.py @@ -16,6 +16,8 @@ def has_permission(self, request, view): "leave_course", "get_teachers", "get_students", + "get_archived_courses", + "get_open_courses" ]: return True @@ -23,8 +25,7 @@ def has_permission(self, request, view): if view.action in ["create", "list", "retrieve"]: return True elif ( - view.action in ["update", "partial_update", "destroy", - "get_projects"] + view.action in ["update", "partial_update", "destroy", "get_projects"] and User.objects.filter( id=request.user.id, course=view.kwargs["pk"] ).exists() diff --git a/backend/pigeonhole/apps/courses/views.py b/backend/pigeonhole/apps/courses/views.py index a1786a08..9f001077 100644 --- a/backend/pigeonhole/apps/courses/views.py +++ b/backend/pigeonhole/apps/courses/views.py @@ -6,7 +6,7 @@ from django_filters.rest_framework import DjangoFilterBackend from backend.pigeonhole.apps.courses.models import CourseSerializer -from backend.pigeonhole.apps.groups.models import Group +from backend.pigeonhole.apps.groups.models import Group, GroupSerializer from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.projects.models import ProjectSerializer from backend.pigeonhole.apps.users.models import User, UserSerializer @@ -59,6 +59,21 @@ def join_course(self, request, *args, **kwargs): if request.user.is_student: if course.open_course: user.course.add(course) + + # Join all individual projects of the course + projects = Project.objects.filter(course_id=course, group_size=1) + for project in projects: + group_data = { + "project_id": project.project_id, + "user": [user.id], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + group_serializer.save() + return Response(status=status.HTTP_200_OK) else: return Response( @@ -81,6 +96,22 @@ def join_course_with_token(self, request, *args, **kwargs): if invite_token == course.invite_token: user.course.add(course) + + # Join all individual projects of the course + if request.user.is_student: + projects = Project.objects.filter(course_id=course, group_size=1) + for project in projects: + group_data = { + "project_id": project.project_id, + "user": [user.id], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + group_serializer.save() + return Response( {"message": "Successfully joined the course with invite token."}, status=status.HTTP_200_OK, @@ -112,7 +143,7 @@ def leave_course(self, request, *args, **kwargs): @action(detail=False, methods=["GET"]) def get_selected_courses(self, request, *args, **kwargs): user = request.user - courses = Course.objects.filter(user=user) + courses = Course.objects.filter(user=user, archived=False) course_filter = CourseFilter(request.GET, queryset=courses) paginated_queryset = self.paginate_queryset(course_filter.qs) queryset = self.order_queryset(paginated_queryset) @@ -162,3 +193,21 @@ def get_projects(self, request, *args, **kwargs): paginated_queryset = self.paginate_queryset(queryset) serializer = ProjectSerializer(paginated_queryset, many=True) return self.get_paginated_response(serializer.data) + + @action(detail=False, methods=["GET"]) + def get_archived_courses(self, request, *args, **kwargs): + user = request.user + courses = Course.objects.filter(user=user, archived=True) + course_filter = CourseFilter(request.GET, queryset=courses) + paginated_queryset = self.paginate_queryset(course_filter.qs) + queryset = self.order_queryset(paginated_queryset) + serializer = CourseSerializer(queryset, many=True) + return self.get_paginated_response(serializer.data) + + @action(detail=False, methods=["GET"]) + def get_open_courses(self, request, *args, **kwargs): + courses = Course.objects.filter(open_course=True) + course_filter = CourseFilter(request.GET, queryset=courses) + queryset = self.order_queryset(course_filter.qs) + serializer = CourseSerializer(self.paginate_queryset(queryset), many=True) + return self.get_paginated_response(serializer.data) diff --git a/backend/pigeonhole/apps/projects/admin.py b/backend/pigeonhole/apps/projects/admin.py index 63125455..53b1e8cc 100644 --- a/backend/pigeonhole/apps/projects/admin.py +++ b/backend/pigeonhole/apps/projects/admin.py @@ -63,6 +63,14 @@ class ProjectAdmin(admin.ModelAdmin): ) } ), + ( + 'test docker image', + { + 'fields': ( + 'test_docker_image', + ) + } + ), ) raw_id_fields = ( diff --git a/backend/pigeonhole/apps/projects/migrations/0002_project_test_dockerfile.py b/backend/pigeonhole/apps/projects/migrations/0002_project_test_dockerfile.py new file mode 100644 index 00000000..076223e0 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0002_project_test_dockerfile.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.13 on 2024-05-19 11:35 + +import backend.pigeonhole.apps.projects.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('projects', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='test_dockerfile', + field=models.FileField(blank=True, null=True, + upload_to=backend.pigeonhole.apps.projects.models.get_upload_to), + ), + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0003_alter_project_test_dockerfile_and_more.py b/backend/pigeonhole/apps/projects/migrations/0003_alter_project_test_dockerfile_and_more.py new file mode 100644 index 00000000..c1c4188c --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0003_alter_project_test_dockerfile_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.13 on 2024-05-19 16:38 + +import backend.pigeonhole.apps.projects.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('projects', '0002_project_test_dockerfile'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='test_dockerfile', + field=models.FileField(blank=True, null=True, + upload_to=backend.pigeonhole.apps.projects.models.dockerfile_path), + ), + migrations.AlterField( + model_name='project', + name='test_files', + field=models.FileField(blank=True, null=True, + upload_to=backend.pigeonhole.apps.projects.models.testfile_path), + ), + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0003_project_test_entrypoint.py b/backend/pigeonhole/apps/projects/migrations/0003_project_test_entrypoint.py new file mode 100644 index 00000000..77302963 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0003_project_test_entrypoint.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.11 on 2024-05-19 14:55 + +import backend.pigeonhole.apps.projects.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('projects', '0002_project_test_dockerfile'), + ] + + operations = [ + migrations.AddField( + model_name='project', + name='test_entrypoint', + field=models.FileField(blank=True, null=True, + upload_to=backend.pigeonhole.apps.projects.models.get_upload_to), + ), + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0004_merge_20240519_1640.py b/backend/pigeonhole/apps/projects/migrations/0004_merge_20240519_1640.py new file mode 100644 index 00000000..1211c39e --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0004_merge_20240519_1640.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.13 on 2024-05-19 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_alter_project_test_dockerfile_and_more'), + ('projects', '0003_project_test_entrypoint'), + ] + + operations = [ + ] diff --git a/backend/pigeonhole/apps/projects/migrations/0005_remove_project_test_dockerfile_and_more.py b/backend/pigeonhole/apps/projects/migrations/0005_remove_project_test_dockerfile_and_more.py new file mode 100644 index 00000000..9f895734 --- /dev/null +++ b/backend/pigeonhole/apps/projects/migrations/0005_remove_project_test_dockerfile_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.13 on 2024-05-20 18:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0004_merge_20240519_1640'), + ] + + operations = [ + migrations.RemoveField( + model_name='project', + name='test_dockerfile', + ), + migrations.RemoveField( + model_name='project', + name='test_entrypoint', + ), + migrations.RemoveField( + model_name='project', + name='test_files', + ), + migrations.AddField( + model_name='project', + name='test_docker_image', + field=models.CharField(blank=True, max_length=1024, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/projects/models.py b/backend/pigeonhole/apps/projects/models.py index 18f46059..b27b617b 100644 --- a/backend/pigeonhole/apps/projects/models.py +++ b/backend/pigeonhole/apps/projects/models.py @@ -4,6 +4,21 @@ from backend.pigeonhole.apps.courses.models import Course +def dockerfile_path(self, _): + if not self.pk: + nextpk = Project.objects.order_by('-project_id').first().project_id + 1 + self.id = self.pk = nextpk + return f'courses/id_{str(self.course_id.course_id)}/project/id_{str(self.project_id)}/DOCKERFILE' + + +def testfile_path(self, filename): + if not self.pk: + nextpk = Project.objects.order_by('-project_id').first().project_id + 1 + self.id = self.pk = nextpk + return f'courses/id_{str(self.course_id.course_id)}/project/id_{str(self.project_id)}/{filename}' + + +# legacy code def get_upload_to(self, filename): return 'projects/' + str(self.project_id) + '/' + filename @@ -21,14 +36,17 @@ class Project(models.Model): group_size = models.IntegerField(default=1) file_structure = models.TextField(blank=True, null=True) conditions = models.TextField(blank=True, null=True) - test_files = models.FileField(blank=True, null=True, upload_to=get_upload_to) + + # test_files = models.FileField(blank=True, null=True, upload_to=testfile_path) + # test_dockerfile = models.FileField(blank=True, null=True, upload_to=dockerfile_path) + test_docker_image = models.CharField(max_length=1024, blank=True, null=True) class ProjectSerializer(serializers.ModelSerializer): class Meta: model = Project fields = ["project_id", "course_id", "name", "description", "deadline", "visible", "number_of_groups", - "group_size", "max_score", "file_structure", "conditions", "test_files"] + "group_size", "max_score", "file_structure", "conditions", "test_docker_image"] class Test(models.Model): diff --git a/backend/pigeonhole/apps/projects/views.py b/backend/pigeonhole/apps/projects/views.py index ae72da05..dae5b424 100644 --- a/backend/pigeonhole/apps/projects/views.py +++ b/backend/pigeonhole/apps/projects/views.py @@ -18,6 +18,7 @@ Submissions, SubmissionsSerializer, ) +from backend.pigeonhole.apps.users.models import User from backend.pigeonhole.filters import ( GroupFilter, CustomPageNumberPagination, @@ -47,19 +48,34 @@ def create(self, request, *args, **kwargs): number_of_groups = serializer.validated_data.get("number_of_groups", 0) project = serializer.save() - + group_size = serializer.validated_data.get("group_size", 0) 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()) + + if group_size == 1: + for user in User.objects.filter(course=serializer.validated_data.get("course_id"), role=3): + group_data = { + "project_id": project.project_id, + "user": [user.id], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + + else: + 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 @@ -69,6 +85,76 @@ def create(self, request, *args, **kwargs): headers = self.get_success_headers(serializer.data) return Response(response_data, status=status.HTTP_201_CREATED, headers=headers) + def update(self, request, *args, **kwargs): + + project = self.get_object() + serializer = self.get_serializer(project, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + + if "group_size" in request.data and int(request.data["group_size"]) != project.group_size: + group_size = int(request.data["group_size"]) + + if "number_of_groups" in request.data: + number_of_groups = int(request.data["number_of_groups"]) + else: + number_of_groups = project.number_of_groups + + Group.objects.filter(project_id=project).delete() + + groups = [] + if group_size == 1: + for user in User.objects.filter(course=project.course_id, role=3): + group_data = { + "project_id": project.project_id, + "user": [user.id], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + + else: + for i in range(number_of_groups): + group_data = { + "project_id": project.project_id, + "user": [], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + + elif "number_of_groups" in request.data and int( + request.data["number_of_groups"]) != project.number_of_groups and project.group_size != 1: + number_of_groups = int(request.data["number_of_groups"]) + old_groups = Group.objects.filter(project_id=project) + groups = [] + if len(old_groups) < number_of_groups: + for i in range(number_of_groups - len(old_groups)): + group_data = { + "project_id": project.project_id, + "user": [], + "feedback": None, + "final_score": None, + "visible": False, + } + group_serializer = GroupSerializer(data=group_data) + group_serializer.is_valid(raise_exception=True) + groups.append(group_serializer.save()) + else: + for i in range(len(old_groups) - number_of_groups): + old_groups[len(old_groups) - 1 - i].delete() + + self.perform_update(serializer) + return Response(serializer.data) + + def partial_update(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + @action(detail=True, methods=["GET"]) def get_groups(self, request, *args, **kwargs): project = self.get_object() @@ -185,8 +271,10 @@ def download_testfiles(self, request, *args, **kwargs): @action(detail=True, methods=["GET"]) def get_group(self, request, *args, **kwargs): project = self.get_object() - group = Group.objects.get( - project_id=project.project_id, user=request.user) - if not group: - return Response({"message": "Group not found"}, status=status.HTTP_404_NOT_FOUND) + try: + group = Group.objects.get( + project_id=project.project_id, user=request.user) + except Group.DoesNotExist: + return Response({"message": "Group not found", "errorcode": "ERROR_NOT_IN_GROUP"}, + status=status.HTTP_404_NOT_FOUND) return Response({"group_id": group.group_id}, status=status.HTTP_200_OK) diff --git a/backend/pigeonhole/apps/submissions/admin.py b/backend/pigeonhole/apps/submissions/admin.py index 9c2ee1e7..d780c563 100644 --- a/backend/pigeonhole/apps/submissions/admin.py +++ b/backend/pigeonhole/apps/submissions/admin.py @@ -36,18 +36,28 @@ class SubmissionAdmin(admin.ModelAdmin): } ), ( - 'Files', + 'Simple evaluation', { 'fields': ( - 'file', + 'output_simple_test', + 'feedback_simple_test', ) } ), ( - 'Tests', + 'Advanced evaluation', { 'fields': ( - 'output_test', + 'eval_result', + 'eval_output', + ) + } + ), + ( + 'file urls', + { + 'fields': ( + 'file_urls', ) } ), diff --git a/backend/pigeonhole/apps/submissions/migrations/0004_remove_submissions_file_and_more.py b/backend/pigeonhole/apps/submissions/migrations/0004_remove_submissions_file_and_more.py new file mode 100644 index 00000000..5466ed83 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0004_remove_submissions_file_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.11 on 2024-04-27 16:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0003_remove_submissions_draft_and_more'), + ] + + operations = [ + migrations.RemoveField( + model_name='submissions', + name='file', + ), + migrations.RemoveField( + model_name='submissions', + name='output_test', + ), + migrations.AddField( + model_name='submissions', + name='draft', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='submissions', + name='file_urls', + field=models.TextField(null=True), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0005_submissions_eval_result.py b/backend/pigeonhole/apps/submissions/migrations/0005_submissions_eval_result.py new file mode 100644 index 00000000..c0bcab37 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0005_submissions_eval_result.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-05 02:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0004_remove_submissions_file_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='submissions', + name='eval_result', + field=models.BooleanField(default=False), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0005_submissions_feedback_simple_test_and_more.py b/backend/pigeonhole/apps/submissions/migrations/0005_submissions_feedback_simple_test_and_more.py new file mode 100644 index 00000000..1a9af38e --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0005_submissions_feedback_simple_test_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-05-22 15:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0004_remove_submissions_file_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='submissions', + name='feedback_simple_test', + field=models.JSONField(blank=True, null=True), + ), + migrations.AddField( + model_name='submissions', + name='output_simple_test', + field=models.BooleanField(blank=True, default=False), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0006_submissions_eval_output.py b/backend/pigeonhole/apps/submissions/migrations/0006_submissions_eval_output.py new file mode 100644 index 00000000..e1a80ff2 --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0006_submissions_eval_output.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2024-05-19 11:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0005_submissions_eval_result'), + ] + + operations = [ + migrations.AddField( + model_name='submissions', + name='eval_output', + field=models.TextField(null=True), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0007_alter_submissions_eval_result.py b/backend/pigeonhole/apps/submissions/migrations/0007_alter_submissions_eval_result.py new file mode 100644 index 00000000..667117ff --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0007_alter_submissions_eval_result.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-23 02:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0006_submissions_eval_output'), + ] + + operations = [ + migrations.AlterField( + model_name='submissions', + name='eval_result', + field=models.BooleanField(default=None, null=True), + ), + ] diff --git a/backend/pigeonhole/apps/submissions/migrations/0008_merge_20240523_0823.py b/backend/pigeonhole/apps/submissions/migrations/0008_merge_20240523_0823.py new file mode 100644 index 00000000..a4cf602c --- /dev/null +++ b/backend/pigeonhole/apps/submissions/migrations/0008_merge_20240523_0823.py @@ -0,0 +1,14 @@ +# Generated by Django 4.2.13 on 2024-05-23 08:23 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('submissions', '0005_submissions_feedback_simple_test_and_more'), + ('submissions', '0007_alter_submissions_eval_result'), + ] + + operations = [ + ] diff --git a/backend/pigeonhole/apps/submissions/models.py b/backend/pigeonhole/apps/submissions/models.py index 03e5c6e2..1708c18f 100644 --- a/backend/pigeonhole/apps/submissions/models.py +++ b/backend/pigeonhole/apps/submissions/models.py @@ -1,14 +1,58 @@ import os +from django.conf import settings from django.db import models +from docker import DockerClient +from docker.errors import ContainerError, APIError from rest_framework import serializers from backend.pigeonhole.apps.groups.models import Group +SUBMISSIONS_PATH = os.environ.get('SUBMISSIONS_PATH') +ARTIFACTS_PATH = os.environ.get('ARTIFACTS_PATH') +registry_name = os.environ.get('REGISTRY_NAME') + +if not SUBMISSIONS_PATH: + SUBMISSIONS_PATH = "/home/selab2/testing/submisssions" + +if not ARTIFACTS_PATH: + ARTIFACTS_PATH = "/home/selab2/testing/artifacts" + +if not registry_name: + registry_name = "sel2-1.ugent.be:2002" + +SUBMISSIONS_DIR = f"{str(settings.STATIC_ROOT)}/submissions" + +def submission_folder_path(group_id, submission_id): + return f"{SUBMISSIONS_DIR}/group_{group_id}/{submission_id}" + + +def artifacts_folder_path(group_id, submission_id): + return f"{str(settings.STATIC_ROOT)}/artifacts/group_{group_id}/{submission_id}" + + +def submission_folder_path_hostside(group_id, submission_id): + return f"{SUBMISSIONS_PATH}/group_{group_id}/{submission_id}" + + +def artifact_folder_path_hostside(group_id, submission_id): + return f"{ARTIFACTS_PATH}/group_{group_id}/{submission_id}" + + +# TODO test timestamp, file, output_test +def submission_file_path(group_id, submission_id, relative_path): + return submission_folder_path(group_id, submission_id) + '/' + relative_path + def get_upload_to(self, filename): - return 'submissions/' + str(self.group_id.group_id) + '/' + str(self.submission_nr) + '/input' + \ - os.path.splitext(filename)[1] + 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): @@ -20,32 +64,110 @@ class Submissions(models.Model): submission_id = models.BigAutoField(primary_key=True) 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) + # een JSON encoded lijst relative file paths van de geuploade folder, + # hiermee kunnen dan de static file urls afgeleid worden + file_urls = models.TextField(null=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=True, - max_length=255) + draft = models.BooleanField(default=True) + eval_result = models.BooleanField(default=None, null=True) + eval_output = models.TextField(null=True) + + output_simple_test = models.BooleanField(default=False, blank=True) + feedback_simple_test = models.JSONField(null=True, blank=True) 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) + def save( + self, *args, **kwargs + ): + if not self.submission_nr: + self.submission_nr = ( + Submissions.objects.filter(group_id=self.group_id).count() + 1 + ) + + super().save(*args, **kwargs) + + # self.eval() + + def eval(self): + group = self.group_id + project = group.project_id + + if not project.test_docker_image: + self.eval_result = True + super().save(update_fields=["eval_result"]) + return + + client = DockerClient(base_url='unix://var/run/docker.sock') + + try: + print(f"running evaluation container for submission {self.submission_id}") + image_id = f"{registry_name}/{project.test_docker_image}" + + container = client.containers.run( + image=image_id, + name=f'pigeonhole-submission-{self.submission_id}-evaluation', + detach=False, + remove=True, + volumes={ + f'{submission_folder_path_hostside(self.group_id.group_id, self.submission_id)}': { + 'bind': '/usr/src/submission/', + 'mode': 'ro' + }, + f'{artifact_folder_path_hostside(self.group_id.group_id, self.submission_id)}': { + 'bind': '/usr/out/artifacts/', + 'mode': 'rw' + } + } + ) + + # For now, an error thrown by eval() is interpreted as a failed submission and + # exit code 0 as a successful submission + # The container object returns the container logs and can be analyzed further + + # this gave an error when i ran it so i commented it out + self.eval_output = container.decode('utf-8') + super().save(update_fields=["eval_output"]) + + except ContainerError as ce: + print(ce) + print(f"evaluation container for submission {self.submission_id} FAILED") + + self.eval_result = False + self.eval_output = ce + super().save(update_fields=["eval_result", "eval_output"]) + + client.close() + return + + except APIError as e: + raise IOError(f'There was an error evaluation the submission: {e}') + + print(f"evaluation container for submission {self.submission_id} SUCCES") + + self.eval_result = True + super().save(update_fields=["eval_result"]) + + client.close() 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', 'file', 'timestamp', 'submission_nr', 'output_test', 'group_id'] + fields = [ + "submission_id", + "file_urls", + "timestamp", + "submission_nr", + "group_id", + "draft", + "output_simple_test", + "feedback_simple_test", + "eval_result", + "eval_output" + ] diff --git a/backend/pigeonhole/apps/submissions/permissions.py b/backend/pigeonhole/apps/submissions/permissions.py index 5706b12c..7bb4e782 100644 --- a/backend/pigeonhole/apps/submissions/permissions.py +++ b/backend/pigeonhole/apps/submissions/permissions.py @@ -1,4 +1,5 @@ from rest_framework import permissions, status +from rest_framework.exceptions import APIException from rest_framework.response import Response from backend.pigeonhole.apps.courses.models import Course @@ -12,13 +13,20 @@ class CanAccessSubmission(permissions.BasePermission): # to the submission data. def has_permission(self, request, view): user = request.user - if view.action in ['list']: + + if not user.is_authenticated: + return False + + if view.action == "get_project": + return True + + if view.action in ["list"]: return False - elif view.action in ['download_selection']: + elif view.action in ["download_selection"]: return user.is_teacher or user.is_admin or user.is_superuser - elif view.action in ['create']: + elif view.action in ["create"]: if user.is_student: - group_id = request.data.get('group_id') + 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) @@ -27,15 +35,17 @@ def has_permission(self, request, view): if group.user.filter(id=user.id).exists(): return True else: - return False + raise NotInGroupError() elif user.is_admin or user.is_superuser: return True else: return False else: - if ('pk' not in view.kwargs.keys()) and (user.is_teacher or user.is_admin or user.is_superuser): + if ("pk" not in view.kwargs.keys()) and ( + user.is_teacher or user.is_admin or user.is_superuser + ): return True - submission = Submissions.objects.get(submission_id=view.kwargs['pk']) + 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: @@ -54,5 +64,17 @@ def has_permission(self, request, view): return True elif user.is_student: if group.user.filter(id=user.id).exists(): - return view.action in ['retrieve', 'create', 'download', 'get_project'] + return view.action in [ + "retrieve", + "create", + "download", + "download_artifacts", + "get_project", + ] return False + + +class NotInGroupError(APIException): + status_code = status.HTTP_403_FORBIDDEN + default_detail = "you are not in a group for this project, please join one." + default_code = "ERROR_NOT_IN_GROUP" diff --git a/backend/pigeonhole/apps/submissions/views.py b/backend/pigeonhole/apps/submissions/views.py index c9376819..b354977f 100644 --- a/backend/pigeonhole/apps/submissions/views.py +++ b/backend/pigeonhole/apps/submissions/views.py @@ -1,47 +1,147 @@ +import fnmatch +import json +import os +import shutil import zipfile from datetime import datetime -from os.path import basename, realpath +from os.path import realpath, basename +from pathlib import Path import pytz from django.http import FileResponse +from django.shortcuts import get_object_or_404 +from django_filters.rest_framework import DjangoFilterBackend from rest_framework import viewsets, status from rest_framework.decorators import action +from rest_framework.filters import OrderingFilter 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, ProjectSerializer -from backend.pigeonhole.apps.submissions.models import Submissions, SubmissionsSerializer +from backend.pigeonhole.apps.projects.models import Project +from backend.pigeonhole.apps.submissions.models import ( + Submissions, + SubmissionsSerializer, artifacts_folder_path, +) from backend.pigeonhole.apps.submissions.permissions import CanAccessSubmission +from backend.pigeonhole.filters import CustomPageNumberPagination +from .models import submission_folder_path, submission_file_path, SUBMISSIONS_DIR -# TODO test timestamp, file, output_test +class ZipUtilities: + + def toZip(self, folderpaths, zip_path, root=SUBMISSIONS_DIR): + zip_file = zipfile.ZipFile(zip_path, 'w') + + for folder_path in folderpaths: + print(folder_path) + if os.path.isfile(folder_path): + zip_file.write(folder_path, arcname=folder_path) + else: + self.addFolderToZip(zip_file, folder_path, root) + zip_file.close() + + def addFolderToZip(self, zip_file, folder, root=SUBMISSIONS_DIR): + for file in os.listdir(folder): + full_path = os.path.join(folder, file) + if os.path.isfile(full_path): + zip_file.write(full_path, arcname=os.path.relpath(folder, root) + '/' + file) + elif os.path.isdir(full_path): + self.addFolderToZip(zip_file, full_path, root) class SubmissionsViewset(viewsets.ModelViewSet): queryset = Submissions.objects.all() serializer_class = SubmissionsSerializer permission_classes = [IsAuthenticated & CanAccessSubmission] + pagination_class = CustomPageNumberPagination + filter_backends = [OrderingFilter, DjangoFilterBackend] def create(self, request, *args, **kwargs): - serializer = SubmissionsSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() + group_id = request.data["group_id"] + group = get_object_or_404(Group, group_id=group_id) + data = request.data.copy() # Create a mutable copy + if len(request.FILES) != 0: + file_urls = [] + for key in request.FILES: + file_urls.append(key) + else: + file_urls = request.data["file_urls"].split(",") - group_id = serializer.data['group_id'] - group = Group.objects.get(group_id=group_id) if not group: - return Response({"message": "Group not found"}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"message": "Group not found", "errorcode": + "ERROR_GROUP_NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND + ) project = Project.objects.get(project_id=group.project_id.project_id) if not project: - return Response({"message": "Project not found"}, status=status.HTTP_404_NOT_FOUND) + return Response( + {"message": "Project not found", "errorcode": + "ERROR_PROJECT_NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND + ) - now_naive = datetime.now().replace(tzinfo=pytz.UTC) # Making it timezone-aware in UTC + now_naive = datetime.now().replace( + tzinfo=pytz.UTC + ) # Making it timezone-aware in UTC if project.deadline and now_naive > project.deadline: - return Response({"message": "Deadline expired"}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"message": "Deadline expired", + "errorcode": "ERROR_DEADLINE_EXPIRED"}, + status=status.HTTP_400_BAD_REQUEST + ) - return Response(serializer.data, status=status.HTTP_201_CREATED) + project = Project.objects.get(project_id=group.project_id.project_id) + # return Response(",".join(file_urls), status=status.HTTP_201_CREATED) + if project.file_structure is None or project.file_structure == "": + complete_message = {"message": "Submission successful"} + data["output_simple_test"] = True + else: + violations = check_restrictions(file_urls, project.file_structure.split(",")) + + if not violations[0] and not violations[2]: + complete_message = {"success": 0} + data["output_simple_test"] = True + else: + violations.update({'success': 1}) + data["output_simple_test"] = False + complete_message = violations + + json_violations = json.dumps(violations) + data["feedback_simple_test"] = json_violations + + serializer = SubmissionsSerializer(data=data) + serializer.is_valid(raise_exception=True) + submission = serializer.save() + + # upload files + try: + for relative_path in request.FILES: + # TODO: fix major security flaw met .. in relative_path + file = request.FILES[relative_path] + filepathstring = submission_file_path( + group_id, str(serializer.data['submission_id']), relative_path) + filepath = Path(filepathstring) + filepath.parent.mkdir(parents=True, exist_ok=True) + with open(filepathstring, "wb+") as dest: + for chunk in file.chunks(): + dest.write(chunk) + # submission.file_urls = '[el path]' + print("Uploaded file: " + filepathstring) + except IOError as e: + print(e) + return Response( + {"message": "Error uploading files", "errorcode": + "ERROR_FILE_UPLOAD"}, status=status.HTTP_400_BAD_REQUEST + ) + + complete_message["submission_id"] = serializer.data["submission_id"] + + # doing advanced tests makes no sense if the simple tests failed + if data["output_simple_test"]: + submission.eval() + + return Response(complete_message, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) @@ -50,13 +150,55 @@ def destroy(self, request, *args, **kwargs): return Response(status=status.HTTP_405_METHOD_NOT_ALLOWED) @action(detail=True, methods=["get"]) - def get_project(self, request, *args, **kwargs): + def download(self, request, *args, **kwargs): submission = self.get_object() - group = submission.group_id - project = group.project_id + if submission is None: + return Response( + {"message": f"Submission with id {id} not found", "errorcode": + "ERROR_SUBMISSION_NOT_FOUND"}, + status=status.HTTP_404_NOT_FOUND + ) + + archivename = "submission_" + str(submission.submission_id) + downloadspath = 'backend/downloads/' + submission_path = submission_folder_path(submission.group_id.group_id, + submission.submission_id) + + shutil.make_archive(downloadspath + archivename, 'zip', submission_path) - serializer = ProjectSerializer(project) - return Response(serializer.data, status=status.HTTP_200_OK) + path = realpath(downloadspath + archivename + '.zip') + response = FileResponse( + open(path, 'rb'), + content_type="application/force-download" + ) + response['Content-Disposition'] = f'inline; filename={basename(path)}' + + return response + + @action(detail=True, methods=["get"]) + def download_artifacts(self, request, *args, **kwargs): + submission = self.get_object() + if submission is None: + return Response( + {"message": f"Submission with id {id} not found", "errorcode": + "ERROR_SUBMISSION_NOT_FOUND"}, + status=status.HTTP_404_NOT_FOUND + ) + + archivename = f"submission_{submission.submission_id}_artifacts" + downloadspath = 'backend/downloads/' + artifacts_path = artifacts_folder_path(submission.group_id.group_id, submission.submission_id) + + shutil.make_archive(downloadspath + archivename, 'zip', artifacts_path) + + path = realpath(downloadspath + archivename + '.zip') + response = FileResponse( + open(path, 'rb'), + content_type="application/force-download" + ) + response['Content-Disposition'] = f'inline; filename={basename(path)}' + + return response @action(detail=False, methods=["get"]) def download_selection(self, request, *args, **kwargs): @@ -65,57 +207,78 @@ def download_selection(self, request, *args, **kwargs): if not ids: return Response(status=status.HTTP_400_BAD_REQUEST) - path = '' + path = "" if len(ids) == 1: submission = Submissions.objects.get(submission_id=ids[0]) if submission is None: return Response( - {"message": f"Submission with id {ids[0]} not found"}, - status=status.HTTP_404_NOT_FOUND + {"message": f"Submission with id {ids[0]} not found", + "errorcode": "ERROR_SUBMISSION_NOT_FOUND"}, + status=status.HTTP_404_NOT_FOUND, ) path = submission.file.path else: path = 'backend/downloads/submissions.zip' - zipf = zipfile.ZipFile( - file=path, - mode="w", - compression=zipfile.ZIP_STORED - ) + submission_folders = [] + print(ids) - for id in ids: - submission = Submissions.objects.get(submission_id=id) + for sid in ids: + submission = Submissions.objects.get(submission_id=sid) if submission is None: return Response( - {"message": f"Submission with id {id} not found"}, + {"message": f"Submission with id {id} not found", + "errorcode": "ERROR_SUBMISSION_NOT_FOUND"}, status=status.HTTP_404_NOT_FOUND ) - - zipf.write( - filename=submission.file.path, - arcname=basename(submission.file.path) + submission_folders.append( + submission_folder_path( + submission.group_id.group_id, submission.submission_id + ) ) - zipf.close() + utilities = ZipUtilities() + filename = path + utilities.toZip(submission_folders, filename, SUBMISSIONS_DIR) path = realpath(path) response = FileResponse( - open(path, 'rb'), - content_type="application/force-download" + open(path, "rb"), content_type="application/force-download" ) - response['Content-Disposition'] = f'inline; filename={basename(path)}' + response["Content-Disposition"] = f"inline; filename={basename(path)}" return response @action(detail=True, methods=["get"]) - def download(self, request, *args, **kwargs): - submission = self.get_object() - path = realpath(submission.file.path) - response = FileResponse( - open(path, 'rb'), - content_type="application/force-download" + def get_project(self, request, *args, **kwargs): + return Response( + {"project": self.get_object().group_id.project_id.project_id}, + status=status.HTTP_200_OK ) - response['Content-Disposition'] = f'inline; filename={basename(path)}' - return response + + +def check_restrictions(filenames, restrictions): + # 0: Required file not found + # 1: Required file found + # 2: Forbidden file found + # 3: No forbidden file found + violations = {0: [], 1: [], 2: [], 3: []} + for restriction_ in restrictions: + restriction = restriction_.strip() + if restriction.startswith('+'): + pattern = restriction[1:] + matching_files = fnmatch.filter(filenames, pattern) + if not matching_files: + violations[0].append(pattern) + else: + violations[1].append(pattern) + elif restriction.startswith('-'): + pattern = restriction[1:] + matching_files = fnmatch.filter(filenames, pattern) + if matching_files: + violations[2].append(pattern) + else: + violations[3].append(pattern) + return violations diff --git a/backend/pigeonhole/apps/users/permissions.py b/backend/pigeonhole/apps/users/permissions.py index 183726fc..f2739c3b 100644 --- a/backend/pigeonhole/apps/users/permissions.py +++ b/backend/pigeonhole/apps/users/permissions.py @@ -9,8 +9,6 @@ def has_permission(self, request, view): if request.user.is_teacher or request.user.is_student: if view.action in ["list", "retrieve"]: return True - if view.action in ["update", "partial_update"]: - return request.user.pk == int(view.kwargs["pk"]) elif view.action in ["add_course_to_user", "remove_course_from_user"]: return request.user.is_teacher or ( request.user.is_student diff --git a/backend/pigeonhole/settings.py b/backend/pigeonhole/settings.py index 6250052f..1da05c15 100644 --- a/backend/pigeonhole/settings.py +++ b/backend/pigeonhole/settings.py @@ -25,11 +25,12 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = int(os.environ.get("DEBUG", default=0)) +SLASHAPI = int(os.environ.get("SLASHAPI", default=0)) FRONTEND_URL = os.environ.get("FRONTEND_URL", default="http://localhost:3000") ALLOWED_HOSTS = os.environ.get("DJANGO_ALLOWED_HOSTS", default="127.0.0.1 example.com").split(" ") -if not DEBUG: +if SLASHAPI == 1: USE_X_FORWARDED_HOST = True FORCE_SCRIPT_NAME = "/api" @@ -69,6 +70,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', "corsheaders.middleware.CorsMiddleware", + 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', diff --git a/backend/pigeonhole/tests/test_models/test_submissions.py b/backend/pigeonhole/tests/test_models/test_submissions.py index cf757485..2306273d 100644 --- a/backend/pigeonhole/tests/test_models/test_submissions.py +++ b/backend/pigeonhole/tests/test_models/test_submissions.py @@ -16,7 +16,7 @@ def setUp(self): email="teacher@gmail.com", first_name="Kermit", last_name="The Frog", - role=2 + role=2, ) # Create student user student = User.objects.create( @@ -24,7 +24,7 @@ def setUp(self): email="student@gmail.com", first_name="Miss", last_name="Piggy", - role=3 + role=3, ) # Create course @@ -38,11 +38,13 @@ def setUp(self): course_id=course, deadline="2021-12-12 12:12:12", description="Project Description", + test_docker_image="test-helloworld", ) # Create group group = Group.objects.create( project_id=project, + group_id=1, ) # Add student to the group @@ -50,71 +52,45 @@ def setUp(self): # Create submission Submissions.objects.create( - group_id=group, + submission_id=1, group_id=group, file_urls="file_urls" ) def test_submission_student_relation(self): - submission = Submissions.objects.get(submission_nr=1) + submission = Submissions.objects.get(submission_id=1) 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) + submission = Submissions.objects.get(submission_id=1) project = submission.group_id.project_id self.assertEqual(submission.group_id.project_id, project) - def test_submission_file_upload_and_retrieval(self): - submission = Submissions.objects.get(submission_nr=1) - - # Create a simple text file for testing - file_content = b'This is a test file content.' - uploaded_file = SimpleUploadedFile("test.txt", file_content, content_type="text/plain") - - # Set the file field in the submission with the created file - submission.file = uploaded_file - submission.save() - - # Retrieve the submission from the database - updated_submission = Submissions.objects.get(submission_nr=1) - - # Check that the file content matches - self.assertEqual(updated_submission.file.read(), file_content) - - # Clean up: Delete the file after testing - if updated_submission.file: - # Check if the file exists before attempting to delete it - if updated_submission.file.storage.exists(updated_submission.file.name): - # Delete the file - updated_submission.file.storage.delete(updated_submission.file.name) - - # Verify that the file is deleted or doesn't exist - self.assertFalse(updated_submission.file.storage.exists(updated_submission.file.name)) - - def test_submission_output_test_upload_and_retrieval(self): - submission = Submissions.objects.get(submission_nr=1) - - # Create a simple text file for testing - file_content = b'This is a test file content.' - uploaded_file = SimpleUploadedFile("text_output.txt", file_content, content_type="text/plain") + def test_submission_course_relation(self): + submission = Submissions.objects.get(submission_id=1) + course = submission.group_id.project_id.course_id + self.assertEqual(submission.group_id.project_id.course_id, course) - # Set the file field in the submission with the created file - submission.file = uploaded_file - submission.save() - - # Retrieve the submission from the database - updated_submission = Submissions.objects.get(submission_nr=1) - - # Check that the file content matches - self.assertEqual(updated_submission.file.read(), file_content) - - # Clean up: Delete the file after testing - if updated_submission.file: - # Check if the file exists before attempting to delete it - if updated_submission.file.storage.exists(updated_submission.file.name): - # Delete the file - updated_submission.file.storage.delete(updated_submission.file.name) - - # Verify that the file is deleted or doesn't exist - self.assertFalse(updated_submission.file.storage.exists(updated_submission.file.name)) + def test_make_submission(self): + group = Group.objects.get(group_id=1) + submission = Submissions.objects.create( + submission_id=2, + group_id=group, + file_urls="file_urls", + ) + self.assertEqual(submission.group_id, group) + self.assertEqual(submission.file_urls, "file_urls") + self.assertEqual(submission.submission_nr, 2) + + def test_make_submission_with_file(self): + group = Group.objects.get(group_id=1) + file = SimpleUploadedFile("file.txt", b"file_content") + submission = Submissions.objects.create( + submission_id=2, + group_id=group, + file_urls=file, + ) + self.assertEqual(submission.group_id, group) + self.assertEqual(submission.file_urls, file) + self.assertEqual(submission.submission_nr, 2) diff --git a/backend/pigeonhole/tests/test_views/test_course/test_student.py b/backend/pigeonhole/tests/test_views/test_course/test_student.py index bb18387a..63905585 100644 --- a/backend/pigeonhole/tests/test_views/test_course/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_course/test_student.py @@ -7,6 +7,7 @@ from backend.pigeonhole.apps.courses.models import Course from backend.pigeonhole.apps.projects.models import Project from backend.pigeonhole.apps.users.models import User +from backend.pigeonhole.apps.groups.models import Group API_ENDPOINT = '/courses/' @@ -31,6 +32,15 @@ def setUp(self): self.course = Course.objects.create(**self.course_data) + self.course_individual_project = Course.objects.create(name="Not of Student", + description="This is not of the student", + open_course=True) + + self.individual_project = Project.objects.create(name="Individual Project", + deadline="2021-12-12 12:12:12", + course_id=self.course_individual_project, + group_size=1) + self.course_not_of_student = Course.objects.create(name="Not of Student", description="This is not of the student", open_course=True) @@ -62,7 +72,7 @@ def setUp(self): 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(), 4) + self.assertEqual(Course.objects.count(), 5) def test_update_course(self): updated_data = { @@ -88,7 +98,7 @@ def test_partial_update_course(self): 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(), 4) + self.assertEqual(Course.objects.count(), 5) def test_retrieve_course(self): response = self.client.get(f'{API_ENDPOINT}{self.course.course_id}/') @@ -101,7 +111,7 @@ 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"], 4) + self.assertEqual(content_json["count"], 5) def test_retrieve_course_not_exist(self): response = self.client.get(f'{API_ENDPOINT}100/') @@ -159,6 +169,16 @@ def test_join_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(self.student.course.count(), 2) + def test_join_course_individual_project(self): + self.assertEqual(self.student.course.count(), 1) + groups = Group.objects.filter(user=self.student) + self.assertEqual(groups.count(), 0) + response = self.client.post(f'{API_ENDPOINT}{self.course_individual_project.course_id}/join_course/') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(self.student.course.count(), 2) + groups = Group.objects.filter(user=self.student) + self.assertEqual(groups.count(), 1) + def test_join_course_not_exist(self): response = self.client.post(f'{API_ENDPOINT}56152/join_course/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_admin.py b/backend/pigeonhole/tests/test_views/test_project/test_admin.py index 16f5d7bb..6b9674de 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_admin.py @@ -12,7 +12,7 @@ API_ENDPOINT = '/projects/' -class ProjectTestAdminTeacher(TestCase): +class ProjectTestAdmin(TestCase): def setUp(self): self.client = APIClient() @@ -35,10 +35,29 @@ def setUp(self): name="Test Project", course_id=self.course, deadline="2021-12-12 12:12:12", + group_size=2, ) self.client.force_authenticate(self.admin) + self.student1 = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + self.student1.course.set([self.course]) + + self.student2 = User.objects.create( + username="student_username2", + email="test2@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + self.student2.course.set([self.course]) + def test_create_project(self): response = self.client.post( API_ENDPOINT, @@ -48,6 +67,7 @@ def test_create_project(self): "course_id": self.course.course_id, "number_of_groups": 4, "deadline": "2021-12-12 12:12:12", + "group_size": 2, }, format='json' ) @@ -78,12 +98,15 @@ def test_update_project(self): { "name": "Updated Test Project", "description": "Updated Test Project Description", - "course_id": self.course.course_id + "course_id": self.course.course_id, + "number_of_groups": 2, }, 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") + groups = Group.objects.filter(project_id=self.project) + self.assertEqual(groups.count(), 2) def test_delete_project(self): response = self.client.delete( @@ -91,6 +114,7 @@ def test_delete_project(self): ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(Project.objects.count(), 0) + self.assertEqual(Group.objects.count(), 0) def test_partial_update_project(self): response = self.client.patch( @@ -137,10 +161,34 @@ def test_delete_invalid_project(self): ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def get_groups_of_project(self): + def test_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) + + def test_create_individual_project(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Individual Project", + "description": "Test Project 2 Description", + "course_id": self.course.course_id, + "deadline": "2021-12-12 12:12:12", + "group_size": 1 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + project = Project.objects.get(name="Test Individual Project") + groups = Group.objects.filter(project_id=project) + students = User.objects.filter(course=self.course, role=3) + self.assertEqual(len(groups), len(students)) + for group in groups: + self.assertEqual(group.user.count(), 1) + self.assertTrue(groups[0].user.first() in students) + self.assertTrue(groups[1].user.first() in students) + self.assertFalse(groups[0].user.first() == groups[1].user.first()) diff --git a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py index 1ecc0823..6ce05eb9 100644 --- a/backend/pigeonhole/tests/test_views/test_project/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_project/test_teacher.py @@ -5,13 +5,14 @@ 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 ProjectTestStudent(TestCase): +class ProjectTestTeacher(TestCase): def setUp(self): self.client = APIClient() self.teacher = User.objects.create( @@ -47,6 +48,24 @@ def setUp(self): self.client.force_authenticate(self.teacher) + self.student1 = User.objects.create( + username="student_username1", + email="test1@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + self.student1.course.set([self.course]) + + self.student2 = User.objects.create( + username="student_username2", + email="test2@gmail.com", + first_name="Kermit", + last_name="The Frog", + role=3 + ) + self.student2.course.set([self.course]) + def test_create_project(self): response = self.client.post( API_ENDPOINT, @@ -55,11 +74,15 @@ def test_create_project(self): "description": "Test Project 2 Description", "course_id": self.course.course_id, "deadline": "2021-12-12 12:12:12", + "group_size": 2, + "number_of_groups": 4, }, format='json' ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(Project.objects.count(), 3) + groups = Group.objects.filter(project_id=Project.objects.get(name="Test Project 2")) + self.assertEqual(len(groups), 4) def test_retrieve_project(self): response = self.client.get( @@ -83,16 +106,22 @@ def test_update_project(self): "description": "Updated Test Project Description", "course_id": self.course.course_id, "deadline": "2021-12-12 12:12:12", + "number_of_groups": 10, + "group_size": 2 }, format='json' ) self.assertEqual(response.status_code, status.HTTP_200_OK) + groups = Group.objects.filter(project_id=self.project) + self.assertEqual(len(groups), 10) 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) + groups = Group.objects.filter(project_id=self.project) + self.assertEqual(len(groups), 0) def test_retrieve_invisible_project(self): invisible_project = Project.objects.create( @@ -158,10 +187,34 @@ def test_delete_project_not_of_teacher(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def get_groups_of_project(self): + def test_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) + + def test_create_individual_project(self): + response = self.client.post( + API_ENDPOINT, + { + "name": "Test Individual Project", + "description": "Test Project 2 Description", + "course_id": self.course.course_id, + "deadline": "2021-12-12 12:12:12", + "group_size": 1 + }, + format='json' + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + project = Project.objects.get(name="Test Individual Project") + groups = Group.objects.filter(project_id=project) + students = User.objects.filter(course=self.course, role=3) + self.assertEqual(len(groups), len(students)) + for group in groups: + self.assertEqual(group.user.count(), 1) + self.assertTrue(groups[0].user.first() in students) + self.assertTrue(groups[1].user.first() in students) + self.assertFalse(groups[0].user.first() == groups[1].user.first()) diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py index a8febc8e..46ded599 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_admin.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_admin.py @@ -1,4 +1,3 @@ -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -9,7 +8,7 @@ from backend.pigeonhole.apps.submissions.models import Submissions from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/submissions/' +API_ENDPOINT = "/submissions/" class SubmissionTestAdmin(TestCase): @@ -21,7 +20,7 @@ def setUp(self): email="test1@gmail.com", first_name="Kermit", last_name="The Frog", - role=1 + role=1, ) self.course = Course.objects.create( @@ -35,23 +34,21 @@ def setUp(self): name="Test Project", course_id=self.course, deadline="2025-12-12 12:12:12", + file_structure='*.sh', + test_docker_image="test-always-succeed", ) - self.group = Group.objects.create( - group_nr=1, - project_id=self.project - ) + 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 + 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") + file_urls="main.sh", ) self.client.force_authenticate(self.admin) @@ -63,34 +60,36 @@ def check_setup(self): 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 - } - ) + def test_submit_submission(self) -> object: + response = self.client.post( + API_ENDPOINT, { + "group_id": self.group.group_id, + "file_urls": "" + }, + format='multipart', + ) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_not_of_admin.group_id, + "file_urls": "" + }, + format='multipart', + ) 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) + '/' + 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) + self.assertEqual( + response.data.get("submission_id"), self.submission.submission_id + ) # tests with an invalid submission @@ -99,57 +98,59 @@ def test_create_submission_invalid_group(self): API_ENDPOINT, { "group_id": 95955351, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "" }, - format='json' + format='multipart', ) - self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_update_not_possible(self): response = self.client.put( - API_ENDPOINT + str(self.submission.submission_id) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "" }, + format='multipart', ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) response = self.client.patch( - API_ENDPOINT + str(self.submission.submission_id) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "" }, + format='multipart', ) 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/', + API_ENDPOINT + "4561313516/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "" }, + format='multipart', ) self.client.patch( - API_ENDPOINT + '4563153/', + API_ENDPOINT + "4563153/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "" }, + format='multipart', ) def test_delete_submission_not_possible(self): response = self.client.delete( - API_ENDPOINT + str(self.submission.submission_id) + '/' + 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/' - ) + 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 index f63e79e3..adc903fe 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_student.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_student.py @@ -1,4 +1,3 @@ -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -9,10 +8,12 @@ from backend.pigeonhole.apps.submissions.models import Submissions from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/submissions/' +from django.core.files.uploadedfile import SimpleUploadedFile + +API_ENDPOINT = "/submissions/" -class SubmissionTestTeacher(TestCase): +class SubmissionTestStudent(TestCase): def setUp(self): self.client = APIClient() @@ -21,7 +22,7 @@ def setUp(self): email="test1@gmail.com", first_name="Kermit", last_name="The Frog", - role=3 + role=3, ) self.course = Course.objects.create( @@ -31,109 +32,231 @@ def setUp(self): self.student.course.set([self.course]) - self.project = Project.objects.create( + self.project_1 = Project.objects.create( + name="Test Project", + course_id=self.course, + deadline="2025-12-12 12:12:12", + file_structure="+extra/verslag.pdf", + test_docker_image="test-always-succeed", + ) + + self.project_2 = Project.objects.create( + name="Test Project", + course_id=self.course, + deadline="2025-12-12 12:12:12", + file_structure="-extra/verslag.pdf", + test_docker_image="test-always-succeed", + ) + + self.project_3 = Project.objects.create( + name="Test Project", + course_id=self.course, + deadline="2025-12-12 12:12:12", + file_structure="+src/*.py", + test_docker_image="test-always-succeed", + ) + + self.project_4 = Project.objects.create( name="Test Project", course_id=self.course, deadline="2025-12-12 12:12:12", + file_structure="-src/*.py", + test_docker_image="test-always-succeed", ) - self.group = Group.objects.create( - group_nr=1, - project_id=self.project + self.project_5 = Project.objects.create( + name="Test Project", + course_id=self.course, + deadline="2025-12-12 12:12:12", + file_structure="*.sh", + test_docker_image="test-always-succeed", ) + self.group_1 = Group.objects.create(group_nr=1, project_id=self.project_1) + self.group_2 = Group.objects.create(group_nr=1, project_id=self.project_2) + self.group_3 = Group.objects.create(group_nr=1, project_id=self.project_3) + self.group_4 = Group.objects.create(group_nr=1, project_id=self.project_4) + self.group_not_of_student = Group.objects.create( - group_nr=2, - project_id=self.project + group_nr=2, project_id=self.project_1 ) - self.group.user.set([self.student]) + self.group_1.user.set([self.student]) + self.group_2.user.set([self.student]) + self.group_3.user.set([self.student]) + self.group_4.user.set([self.student]) self.submission = Submissions.objects.create( - group_id=self.group, - file=SimpleUploadedFile("test_file.txt", b"file_content") + group_id=self.group_1, file_urls="file_urls" ) self.submission_not_of_student = Submissions.objects.create( - group_id=self.group_not_of_student, - file=SimpleUploadedFile("test_file.txt", b"file_content") + group_id=self.group_not_of_student, file_urls="file_urls" ) 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 - } - ) + def test_can_create_submission_without(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_1.group_id, + "file_urls": "" + }, + format='multipart', + ) + self.assertEqual(1, response.data['success']) + self.assertEqual("extra/verslag.pdf", response.data[0][0]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_withfile(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_1.group_id, + "file_urls": "extra/verslag.pdf" + }, + format='multipart', + ) + self.assertEqual(0, response.data['success']) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_without_forbidden(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_2.group_id, + "file_urls": "" + }, + format='multipart', + ) + self.assertEqual(0, response.data['success']) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_with_forbidden(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_2.group_id, + "file_urls": "extra/verslag.pdf" + }, + format='multipart', + ) + self.assertEqual(1, response.data['success']) + self.assertIn("extra/verslag.pdf", response.data[2]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_without_wildcard(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_3.group_id, + "file_urls": "src/main.jar, src/test.dockerfile" + }, + format='multipart', + ) + self.assertEqual(1, response.data['success']) + self.assertIn("src/*.py", response.data[0]) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_with_wildcard(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_3.group_id, + "file_urls": "src/main.py" + } + ) + self.assertEqual(0, response.data['success']) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_can_create_submission_without_forbidden_wildcard(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_4.group_id, + "file_urls": "src/main.jar, src/test.dockerfile" + } + ) + self.assertEqual(0, response.data['success']) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # + def test_can_create_submission_with_forbidden_wildcard(self): + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_4.group_id, + "file_urls": "src/main.py" + } + ) + self.assertEqual(1, response.data['success']) + self.assertIn("src/*.py", response.data[2]) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "file_urls": "", + "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) + '/' + 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) + 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) + '/' - ) + 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) + '/' + 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) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { - "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "group_id": self.group_1.group_id, + "file_urls": "file_urls", }, ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( - API_ENDPOINT + str(self.submission.submission_id) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { - "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "group_id": self.group_1.group_id, + "file_urls": "file_urls", }, ) 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) + '/', + 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") + "file_urls": "file_urls", }, ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( - API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + 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") + "file_urls": "file_urls", }, ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) @@ -141,38 +264,63 @@ def test_cant_update_other_submission(self): def test_cant_update_invalid_submission(self): with self.assertRaises(Submissions.DoesNotExist): self.client.put( - API_ENDPOINT + '4561313516/', + API_ENDPOINT + "4561313516/", { - "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "group_id": self.group_1.group_id, + "file_urls": "file_urls", }, ) self.client.patch( - API_ENDPOINT + '4563153/', + API_ENDPOINT + "4563153/", { - "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "group_id": self.group_1.group_id, + "file_urls": "file_urls", }, ) def test_cant_delete_submission(self): response = self.client.delete( - API_ENDPOINT + str(self.submission.submission_id) + '/' + 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) + '/' + 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/', + API_ENDPOINT + "4561313516/", ) self.client.delete( - API_ENDPOINT + '4563153/', + API_ENDPOINT + "4563153/", ) + + # test advanced evaluation + + def test_helloworld(self): + main_file = SimpleUploadedFile( + 'main.sh', + "echo hello world.".encode('utf-8'), + content_type="text/plain" + ) + main_file.seek(0) + + response = self.client.post( + API_ENDPOINT, + { + "group_id": self.group_1.group_id, + "file_urls": "main.sh", + "main.sh": main_file + }, + format='multipart', + ) + + self.assertEqual(1, response.data['success']) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + # TODO: check status and output diff --git a/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py index 8c3ed193..9ec45d11 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_teacher.py @@ -1,4 +1,3 @@ -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -9,7 +8,7 @@ from backend.pigeonhole.apps.submissions.models import Submissions from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/submissions/' +API_ENDPOINT = "/submissions/" class SubmissionTestTeacher(TestCase): @@ -21,7 +20,7 @@ def setUp(self): email="test@gmail.com", first_name="Kermit", last_name="The Frog", - role=2 + role=2, ) self.course = Course.objects.create( @@ -39,12 +38,16 @@ def setUp(self): name="Test Project", course_id=self.course, deadline="2021-12-12 12:12:12", + file_structure='*.sh', + test_docker_image="test-always-succeed", ) self.project_not_of_teacher = Project.objects.create( name="Test Project 2", course_id=self.course_not_of_teacher, deadline="2021-12-12 12:12:12", + file_structure='*.sh', + test_docker_image="test-always-succeed", ) self.group_not_of_teacher = Group.objects.create( @@ -52,110 +55,112 @@ def setUp(self): project_id=self.project_not_of_teacher, ) - self.group = Group.objects.create( - group_nr=1, - project_id=self.project - ) + 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") + group_id=self.group, file_urls="file_url" ) self.submission_not_of_teacher = Submissions.objects.create( - group_id=self.group_not_of_teacher, - file=SimpleUploadedFile("test_file2.txt", b"file_content2") + group_id=self.group_not_of_teacher, file_urls="file_url" ) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "submission_id": 1, + "file_urls": "", + "group_id": self.group.group_id, + }, + format='multipart', + ) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "file_urls": "", + "group_id": 489454134561 + }, + format='multipart', + ) 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) + '/' + 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) + 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) + '/' - ) + 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) + '/' + 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) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) response = self.client.patch( - API_ENDPOINT + str(self.submission.submission_id) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) 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/', + API_ENDPOINT + "4561313516/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) self.client.patch( - API_ENDPOINT + '4563153/', + API_ENDPOINT + "4563153/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) def test_cant_delete_submission(self): response = self.client.delete( - API_ENDPOINT + str(self.submission.submission_id) + '/' + 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/', + API_ENDPOINT + "4561313516/", ) self.client.delete( - API_ENDPOINT + '4563153/', + 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 index 90ce409d..f8c8fd20 100644 --- a/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py +++ b/backend/pigeonhole/tests/test_views/test_submission/test_unauthenticated.py @@ -1,4 +1,3 @@ -from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from rest_framework import status from rest_framework.test import APIClient @@ -9,7 +8,7 @@ from backend.pigeonhole.apps.submissions.models import Submissions from backend.pigeonhole.apps.users.models import User -API_ENDPOINT = '/submissions/' +API_ENDPOINT = "/submissions/" class SubmissionTestTeacher(TestCase): @@ -21,7 +20,7 @@ def setUp(self): email="test1@gmail.com", first_name="Kermit", last_name="The Frog", - role=3 + role=3, ) self.course = Course.objects.create( @@ -35,108 +34,110 @@ def setUp(self): name="Test Project", course_id=self.course, deadline="2021-12-12 12:12:12", + file_structure='*.sh', + test_docker_image="test-helloworld", ) - self.group = Group.objects.create( - group_nr=1, - project_id=self.project - ) + 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 + 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") + group_id=self.group, file_urls="file_url" ) self.submission_not_of_student = Submissions.objects.create( - group_id=self.group_not_of_student, - file=SimpleUploadedFile("test_file.txt", b"file_content") + group_id=self.group_not_of_student, file_urls="file_url" ) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "file_urls": "", + "group_id": self.group.group_id + }, + format='multipart', + ) 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 - } - ) + response = self.client.post( + API_ENDPOINT, + { + "file_urls": "", + "group_id": 489454134561 + }, + format='multipart', + ) 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) + '/' + 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) + '/' + 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) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( - API_ENDPOINT + str(self.submission.submission_id) + '/', + API_ENDPOINT + str(self.submission.submission_id) + "/", { "group_id": self.group.group_id, - "file": SimpleUploadedFile("test_file.txt", b"file_content") + "file_urls": "", }, + format='multipart', ) 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) + '/', + 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") + "file_urls": "", }, + format='multipart', ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) response = self.client.patch( - API_ENDPOINT + str(self.submission_not_of_student.submission_id) + '/', + 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") + "file_urls": "", }, + format='multipart', ) 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) + '/' + 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) + '/' + API_ENDPOINT + str(self.submission_not_of_student.submission_id) + "/" ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/requirements.txt b/backend/requirements.txt index 7b45a9d8..3566d0fb 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,13 +2,15 @@ Django~=4.2.11 coverage~=6.3 django-cors-headers~=3.14.0 django-extensions==3.2.3 +django-filter==24.2 django_microsoft_auth==3.0.1 djangorestframework-simplejwt~=5.2.2 djangorestframework~=3.14.0 +docker==7.1.0 +requests==2.32.2 drf-yasg==1.21.7 flake8==7.0.0 psycopg2-binary~=2.9.5 pytz~=2022.7.1 pyyaml==6.0.1 uritemplate==4.1.1 -django-filter==24.2 \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f985b81f..a4eee70a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,12 +22,15 @@ services: volumes: - static_volume:/home/app/web/backend/static - media_volume:/home/app/web/uploads + - /var/run/docker.sock:/var/run/docker.sock + - ${SUBMISSIONS_PATH}:/home/app/web/backend/static/submissions expose: - 8000 env_file: - ${ENV_LOCATION} depends_on: - database + - registry restart: always labels: - "traefik.enable=true" @@ -45,8 +48,8 @@ services: - static_volume:/usr/local/apache2/htdocs/ depends_on: - backend - ports: - - 5000:80 + expose: + - 5000 labels: - "traefik.enable=true" - "traefik.http.routers.static.rule=Host(`sel2-1.ugent.be`) && PathPrefix(`/static`)" @@ -74,6 +77,27 @@ services: - "traefik.http.routers.frontend.entrypoints=websecure" - "traefik.http.routers.frontend.tls.certresolver=myresolver" + registry: + image: "registry:latest" + restart: always + container_name: registry + ports: + - "5000:5000" + environment: + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: Registry-Realm + REGISTRY_AUTH_HTPASSWD_PATH: /auth/registry.passwd + REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data + volumes: + - registrydata:/data + - ${PASSWD_FOLDER_PATH}:/auth + labels: + - "traefik.enable=true" + - "traefik.address=:2002" + - "traefik.http.routers.registry.rule=Host(`sel2-1.ugent.be`)" + - "traefik.http.routers.registry.entrypoints=otherport" + - "traefik.http.routers.registry.tls.certresolver=myresolver" + traefik: image: "traefik:v2.11" container_name: "traefik" @@ -84,6 +108,8 @@ services: - "--providers.docker.exposedbydefault=false" - "--entrypoints.web.address=:80" - "--entrypoints.websecure.address=:443" + - "--entrypoints.otherport.address=:2002" + - "--entrypoints.otherport.http.tls=myresolver" - "--certificatesresolvers.myresolver.acme.tlschallenge=true" #- "--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory" #for testing - "--certificatesresolvers.myresolver.acme.email=robin.paret@ugent.be" @@ -95,6 +121,7 @@ services: - "80:80" - "443:443" - "8080:8080" + - "2002:2002" volumes: - certificate_volume:/letsencrypt - "/var/run/docker.sock:/var/run/docker.sock:ro" @@ -105,3 +132,4 @@ volumes: static_volume: media_volume: certificate_volume: + registrydata: diff --git a/docker-compose.yml b/docker-compose.yml index 3119e358..64cac1a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,11 @@ services: command: python manage.py runserver 0.0.0.0:8000 volumes: - ./backend/:/usr/src/app/backend/ + - ./scripts/:/usr/src/app/scripts/ + #- submissions:/usr/src/app/backend/uploads/submissions/ + - /var/run/docker.sock:/var/run/docker.sock + - ${SUBMISSIONS_PATH}:/usr/src/app/backend/static/submissions/ + - ${ARTIFACTS_PATH}:/usr/src/app/backend/static/artifacts/ ports: - 8000:8000 env_file: @@ -38,5 +43,17 @@ services: env_file: - .env + registry: + image: "registry:latest" + restart: always + container_name: registry + ports: + - "5000:5000" + volumes: + - registrydata:/data + volumes: - postgres_data: \ No newline at end of file + postgres_data: + submissions: + external: true + registrydata: \ No newline at end of file diff --git a/examples/advanced-evaluation/always-succeed/Dockerfile b/examples/advanced-evaluation/always-succeed/Dockerfile new file mode 100644 index 00000000..e7db5d96 --- /dev/null +++ b/examples/advanced-evaluation/always-succeed/Dockerfile @@ -0,0 +1,3 @@ +FROM busybox:latest + +ENTRYPOINT exit 0 \ No newline at end of file diff --git a/examples/advanced-evaluation/fibonacci-python/Dockerfile b/examples/advanced-evaluation/fibonacci-python/Dockerfile new file mode 100644 index 00000000..dd4c43e6 --- /dev/null +++ b/examples/advanced-evaluation/fibonacci-python/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +VOLUME /usr/src/submission +VOLUME /usr/out/artifacts + +WORKDIR /usr/ + +COPY eval.py ./eval.py + +ENTRYPOINT ["python", "eval.py"] \ No newline at end of file diff --git a/examples/advanced-evaluation/fibonacci-python/eval.py b/examples/advanced-evaluation/fibonacci-python/eval.py new file mode 100644 index 00000000..3b51c677 --- /dev/null +++ b/examples/advanced-evaluation/fibonacci-python/eval.py @@ -0,0 +1,56 @@ +import sys +from importlib.util import spec_from_file_location, module_from_spec +from typing import List + + +def check_fibonacci(sequence: List) -> bool: + if len(sequence) == 0: + return sequence == [] + elif len(sequence) == 1: + return sequence == [0] + elif len(sequence) == 2: + return sequence == [0, 1] + + for i in range(2, len(sequence)): + if sequence[i] != sequence[i - 1] + sequence[i - 2]: + return False + + return True + + +def write_sequences_to_file(fibonacci_method, limit: int = 25) -> None: + for i in range(1, limit): + sequence = fibonacci_method(i) + with open(f'/usr/out/artifacts/fibonacci_{i}.txt', 'w') as outfile: + for n in sequence: + outfile.write(f'{n}\n') + + +if __name__ == '__main__': + try: + spec = spec_from_file_location( + name='fibonacci', + location='/usr/src/submission/main.py', + ) + fibonacci = module_from_spec(spec) + spec.loader.exec_module(fibonacci) + + write_sequences_to_file(fibonacci.fibonacci) + + assert check_fibonacci(fibonacci.fibonacci(0)), 'Fibonacci sequence with length 0 incorrect' + assert check_fibonacci(fibonacci.fibonacci(1)), 'Fibonacci sequence with length 1 incorrect' + assert check_fibonacci(fibonacci.fibonacci(2)), 'Fibonacci sequence with length 2 incorrect' + assert check_fibonacci(fibonacci.fibonacci(1000)), 'Fibonacci sequence with length 1000 incorrect' + + except FileNotFoundError: + sys.exit("Required main file missing.\nPlease consult the project assignment.") + + except ImportError or AttributeError or ModuleNotFoundError as e: + sys.exit(f"Required method missing or wrongly defined.\nPlease consult the project assignment.\n{e}") + + except AssertionError as e: + sys.exit(f"Output assertion failed: {e}") + + print("Fibonacci script assertions succeeded, exiting...") + + exit(0) diff --git a/examples/advanced-evaluation/helloworld/Dockerfile b/examples/advanced-evaluation/helloworld/Dockerfile new file mode 100644 index 00000000..7a92d061 --- /dev/null +++ b/examples/advanced-evaluation/helloworld/Dockerfile @@ -0,0 +1,15 @@ +# voor deze evaluatie voeren we een bash script uit +FROM bash:latest + +# declareer de volumes waar straks de indieningsbestanden en output-artifacts komen +VOLUME /usr/src/submission +VOLUME /usr/out/artifacts + +# hier kan je nog extra dependencies installeren + +# kopieer alle nodige bestanden naar de image +COPY entrypoint.sh /entrypoint.sh +COPY expected-output /expected-output + +# voer het evaluatie-script uit wanneer de container wordt gestart +ENTRYPOINT bash /entrypoint.sh \ No newline at end of file diff --git a/examples/advanced-evaluation/helloworld/correct-submission/main.sh b/examples/advanced-evaluation/helloworld/correct-submission/main.sh new file mode 100644 index 00000000..cdb28bfc --- /dev/null +++ b/examples/advanced-evaluation/helloworld/correct-submission/main.sh @@ -0,0 +1 @@ +echo hello world. \ No newline at end of file diff --git a/examples/advanced-evaluation/helloworld/entrypoint.sh b/examples/advanced-evaluation/helloworld/entrypoint.sh new file mode 100644 index 00000000..b41e60ce --- /dev/null +++ b/examples/advanced-evaluation/helloworld/entrypoint.sh @@ -0,0 +1,9 @@ +# voer de indiening uit en sla de output op in een bestand +bash /usr/src/submission/main.sh > output.txt + +# vergelijk de output met de verwachte output +diff output.txt /expected-output/output.txt + +# De exit code van de entrypoint is de exit code van de laatst uitgevoerde opdracht +# Dit bepaalt ook het slagen van de evaluatie +exit $? \ No newline at end of file diff --git a/examples/advanced-evaluation/helloworld/expected-output/output.txt b/examples/advanced-evaluation/helloworld/expected-output/output.txt new file mode 100644 index 00000000..e0f1ee18 --- /dev/null +++ b/examples/advanced-evaluation/helloworld/expected-output/output.txt @@ -0,0 +1 @@ +hello world. diff --git a/examples/advanced-evaluation/helloworld/incorrect-submission/main.sh b/examples/advanced-evaluation/helloworld/incorrect-submission/main.sh new file mode 100644 index 00000000..aba87fb4 --- /dev/null +++ b/examples/advanced-evaluation/helloworld/incorrect-submission/main.sh @@ -0,0 +1 @@ +echo uuuuuhhhhhh \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 7bbdd24b..b3ab9446 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,5 +1,5 @@ # pull official base image -FROM cypress/base:20.11.0 AS deps +FROM cypress/base:latest AS deps # set work directory WORKDIR /usr/src/app diff --git a/frontend/__test__/AccountMenu.test.tsx b/frontend/__test__/AccountMenu.test.tsx index 0c593fa9..104a73f6 100644 --- a/frontend/__test__/AccountMenu.test.tsx +++ b/frontend/__test__/AccountMenu.test.tsx @@ -60,7 +60,7 @@ describe('AccountMenu', () => { fireEvent.click(screen.getByRole('button')); fireEvent.click(screen.getByRole('menuitem', {name: 'logout'})); - expect(window.location.href).toBe('undefined/auth/logout'); + expect(window.location.href).not.toBe(originalLocation); window.location = originalLocation; }); diff --git a/frontend/__test__/AddButton.test.tsx b/frontend/__test__/AddButton.test.tsx new file mode 100644 index 00000000..2039b895 --- /dev/null +++ b/frontend/__test__/AddButton.test.tsx @@ -0,0 +1,11 @@ +import {fireEvent, render, screen} from "@testing-library/react"; +import React from "react"; +import AddButton from "@app/[locale]/components/AddButton"; + +describe("AddButton", () => { + it("render", async () => { + render(); + const backButton = screen.getByText(/key/i); + fireEvent.click(backButton); + }); +}); diff --git a/frontend/__test__/AddProjectButton.test.tsx b/frontend/__test__/AddProjectButton.test.tsx new file mode 100644 index 00000000..570a2e7a --- /dev/null +++ b/frontend/__test__/AddProjectButton.test.tsx @@ -0,0 +1,15 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import AddProjectButton from '../app/[locale]/components/AddProjectButton'; +import {addProject} from '../lib/api'; + +jest.mock('../lib/api', () => ({ + addProject: jest.fn(() => Promise.resolve(1)), +})); + +describe('AddProjectButton', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByRole('link', {name: 'add_project'})).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__test__/BackButton.test.tsx b/frontend/__test__/BackButton.test.tsx new file mode 100644 index 00000000..5a397649 --- /dev/null +++ b/frontend/__test__/BackButton.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; +import BackButton from '../app/[locale]/components/BackButton'; + +describe('BackButton', () => { + it('renders correctly', () => { + render(); + + // check that the button was rendered properly + expect(screen.getByText('Return')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/frontend/__test__/BottomBar.test.tsx b/frontend/__test__/BottomBar.test.tsx new file mode 100644 index 00000000..dd589cf8 --- /dev/null +++ b/frontend/__test__/BottomBar.test.tsx @@ -0,0 +1,15 @@ +import {render, screen} from '@testing-library/react'; +import BottomBar from '../app/[locale]/components/BottomBar'; +import React from "react"; + + +describe('BottomBar', () => { + it('renders correctly', () => { + render(); + + + expect(screen.getByText('Contact')).toBeInTheDocument(); + expect(screen.getByText('Privacy')).toBeInTheDocument(); + expect(screen.getByText('Version 0.0.1')).toBeInTheDocument(); + }); +}); diff --git a/frontend/__test__/CASButton.test.tsx b/frontend/__test__/CASButton.test.tsx index bd8eaa69..beaf9059 100644 --- a/frontend/__test__/CASButton.test.tsx +++ b/frontend/__test__/CASButton.test.tsx @@ -1,9 +1,18 @@ -import {fireEvent, render} from '@testing-library/react'; +import {cleanup, fireEvent, render} from '@testing-library/react'; import CASButton from '../app/[locale]/components/CASButton'; import React from "react"; +const OLD_ENV = process.env describe('CASButton', () => { + afterEach(() => { + cleanup() + jest.clearAllMocks() + jest.resetModules() + process.env = {...OLD_ENV} + delete process.env.NODE_ENV + }) + it('renders correctly', () => { const {getByText, getByRole} = render(); @@ -28,7 +37,7 @@ describe('CASButton', () => { fireEvent.click(button); // undefined, because i havent mocked the process.env stuff - // expect(window.location.href).toBe('undefined/microsoft/to-auth-redirect?next=undefined/home'); + expect(window.location.href).not.toBe(originalLocation); // restore the original window.location window.location = originalLocation; diff --git a/frontend/__test__/CopyToClipBoardButton.test.tsx b/frontend/__test__/CopyToClipBoardButton.test.tsx new file mode 100644 index 00000000..043fb6b3 --- /dev/null +++ b/frontend/__test__/CopyToClipBoardButton.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import CopyToClipboardButton from '@app/[locale]/components/CopyToClipboardButton'; // Adjust the import path as necessary +import '@testing-library/jest-dom/extend-expect'; + +// Mock the useTranslation hook from react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => { + if (key === "copy") return "Copy"; + if (key === "copied_to_clipboard") return "Copied to clipboard!"; + return key; + } + }), +})); + +// Mock navigator.clipboard +Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, +}); + +describe('CopyToClipboardButton', () => { + it('copies text to clipboard and shows confirmation message when clicked', async () => { + const text = "Test text to copy"; + + render(); + + // Find button by aria-label or tooltip text + const button = screen.getByRole('button', { name: /copy/i }); + fireEvent.click(button); + + // Assert clipboard writeText was called with the right text + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(text); + + // Assert Snackbar is visible with correct message + expect(screen.getByText(/copied to clipboard!/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/__test__/CourseBanner.test.tsx b/frontend/__test__/CourseBanner.test.tsx index 391ae9d4..610017b9 100644 --- a/frontend/__test__/CourseBanner.test.tsx +++ b/frontend/__test__/CourseBanner.test.tsx @@ -17,9 +17,10 @@ describe('CourseBanner', () => { name: 'Test Course', course_id: 1, description: "Test Description", - banner: "?", + banner: new Blob([], { type: 'image/png' }), open_course: true, - invite_token: "token" + invite_token: "token", + year: 2024 }); (api.getUserData as jest.Mock).mockResolvedValueOnce({ role: 1, @@ -33,7 +34,7 @@ describe('CourseBanner', () => { const {getByText} = render(); await waitFor(() => { - expect(getByText('Test Course')).toBeInTheDocument(); + expect(getByText('Test Course 2024')).toBeInTheDocument(); }); }); diff --git a/frontend/__test__/CourseCard.test.tsx b/frontend/__test__/CourseCard.test.tsx new file mode 100644 index 00000000..a0fa0ea3 --- /dev/null +++ b/frontend/__test__/CourseCard.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import CourseCard from '../app/[locale]/components/CourseCard'; +import '@testing-library/jest-dom'; + +// Mocking the API calls +jest.mock('../lib/api', () => ({ + getProjectsFromCourse: jest.fn(), + getLastSubmissionFromProject: jest.fn(), +})); + +describe('CourseCard', () => { + const mockCourse = { + course_id: 1, + name: 'Test Course', + description: "test description", + open_course: true, + invite_token: "token", + year: 2024, + archived: false, + banner: "banner" + }; + + + const mockProjects = [ + { + project_id: 1, + course_id: mockCourse, + name: "Project 1", + description: "Description for Project 1", + deadline: "2023-12-31T23:59:59", + visible: true, + max_score: 20, + number_of_groups: 5, + group_size: 1, + file_structure: "file structure", + conditions: "conditions", + test_files: null, + }, + { + project_id: 2, + course_id: mockCourse, + name: "Project 2", + description: "Description for Project 2", + deadline: "2024-01-31T23:59:59", + visible: true, + max_score: 20, + number_of_groups: 3, + group_size: 2, + file_structure: "file structure", + conditions: "conditions", + test_files: null, + }, + ]; + + const mockLastSubmission = { + submission_id: 1, + group_id: 1, + submission_nr: 1, + file: 'file.pdf', + timestamp: '2024-05-20', + output_test: 'output', + }; + + beforeEach(() => { + jest.resetAllMocks(); + require('../lib/api').getProjectsFromCourse.mockResolvedValue(mockProjects); + require('../lib/api').getLastSubmissionFromProject.mockResolvedValue(mockLastSubmission); + }); + + it('renders correctly', async () => { + render(); + + // Check if course name is rendered + expect(screen.getByText('Test Course')).toBeInTheDocument(); + + // Check if 'projects' title is rendered + expect(screen.getByText('projects')).toBeInTheDocument(); + }); + + it('displays no projects message when there are no projects', async () => { + require('../lib/api').getProjectsFromCourse.mockResolvedValue([]); + + render(); + + await waitFor(() => { + // Check if no projects message is displayed + expect(screen.getByText('no_projects')).toBeInTheDocument(); + }); + }); + + + + it('mouse enter and leave', () => { + render(); + + const cardMedia = screen.getByText('Test Course').closest('.MuiCardMedia-root'); + + // Hover over the card media + fireEvent.mouseEnter(cardMedia); + + // Unhover the card media + fireEvent.mouseLeave(cardMedia); + }); + + it('redirects to the correct URL on card media click', () => { + render(); + + const box = screen.getByText('Test Course'); + + // Mock window.location.href + delete window.location; + window.location = {href: ''}; + + // Click on the Box inside the CardMedia + fireEvent.click(box); + + // Check if window.location.href is updated correctly + expect(window.location.href).toBe(`/course/${mockCourse.course_id}`); + }); + + +}); diff --git a/frontend/__test__/CourseControls.test.tsx b/frontend/__test__/CourseControls.test.tsx new file mode 100644 index 00000000..d4803e2d --- /dev/null +++ b/frontend/__test__/CourseControls.test.tsx @@ -0,0 +1,21 @@ +import {render, screen} from "@testing-library/react"; +import React from "react"; +import CourseControls from "@app/[locale]/components/CourseControls"; +import {APIError, fetchUserData, UserData} from "@lib/api"; + +jest.mock('../lib/api', () => ({ + fetchUserData: jest.fn(), +})); +describe("CourseControls", () => { + it("render coursecontrols", async () => { + const mockOnYearChange = jest.fn(); + render(); + + expect(screen.getByText(/all_courses/i)).toBeInTheDocument(); + + expect(screen.getByText(/create_course/i)).toBeInTheDocument(); + + expect(screen.getByText(/view_archive/i)).toBeInTheDocument(); + + }); +}); diff --git a/frontend/__test__/CourseDetails.test.tsx b/frontend/__test__/CourseDetails.test.tsx new file mode 100644 index 00000000..88c5fd65 --- /dev/null +++ b/frontend/__test__/CourseDetails.test.tsx @@ -0,0 +1,26 @@ +import {render, screen} from "@testing-library/react"; +import React from "react"; +import CourseDetails from "@app/[locale]/components/CourseDetails"; + +// mock copyclipboardbutton component +jest.mock("../app/[locale]/components/CopyToClipboardButton", () => ({ + __esModule: true, + default: jest.fn(() =>
Mocked CopyToClipboardButton
), // Mock rendering +})); + +jest.mock('../lib/api', () => ({ + getCourse: jest.fn(), + Course: jest.fn(), + UserData: jest.fn(), + getUserData: jest.fn(), +})); +describe("CourseDetails", () => { + it("render CourseDetails", async () => { + render(); + + expect(screen.getByText("no_description")).toBeInTheDocument(); + + expect(screen.getByText("description")).toBeInTheDocument(); + + }); +}); diff --git a/frontend/__test__/CoursesGrid.test.tsx b/frontend/__test__/CoursesGrid.test.tsx new file mode 100644 index 00000000..068d3c20 --- /dev/null +++ b/frontend/__test__/CoursesGrid.test.tsx @@ -0,0 +1,20 @@ +import {render, screen} from "@testing-library/react"; +import React from "react"; +import CoursesGrid from "@app/[locale]/components/CoursesGrid"; + + +jest.mock("../app/[locale]/components/CourseCard", () => ({ + __esModule: true, + default: jest.fn(() =>
Mocked CourseCard
), // Mock rendering +})); + + +jest.mock('../lib/api', () => ({ + getCoursesForUser: jest.fn(), +})); + +describe("CourseDetails", () => { + it("render CourseDetails", async () => { + render(); + }); +}); diff --git a/frontend/__test__/EditCourseForm.test.tsx b/frontend/__test__/EditCourseForm.test.tsx index 4c310603..133376b8 100644 --- a/frontend/__test__/EditCourseForm.test.tsx +++ b/frontend/__test__/EditCourseForm.test.tsx @@ -1,43 +1,40 @@ -import {act, render, screen} from '@testing-library/react'; +import {act, render, screen, waitFor, fireEvent} from '@testing-library/react'; import EditCourseForm from '../app/[locale]/components/EditCourseForm'; import React from "react"; import * as api from "@lib/api"; +import {updateCourse} from "@lib/api"; +// Mock useTranslation hook jest.mock('react-i18next', () => ({ useTranslation: () => ({t: (key: any) => key}) })); -jest.mock('../lib/api', () => ({ - getCourses: jest.fn(), - getUserData: jest.fn(), +// Mock API functions +jest.mock("../lib/api", () => ({ getCourse: jest.fn(), getImage: jest.fn(), + postData: jest.fn(), + updateCourse: jest.fn(), })); - -global.fetch = jest.fn(() => - Promise.resolve({ - blob: () => Promise.resolve(new Blob()), - json: () => Promise.resolve({data: 'mocked data'}), - }) -); - +// Mock next/image component jest.mock('next/image', () => { return () => ; }); -describe('EditCourseForm', () => { - beforeEach(() => { - fetch.mockClear(); - (api.getCourse as jest.Mock).mockResolvedValueOnce({ - name: 'Test Course', - course_id: 1, - description: "Test Description", - banner: "?", - open_course: true, - invite_token: "token" - }); +const mockCourse = { + id: 1, + name: 'Test Course', + description: 'Test Description', + open_course: true, + year: 2022, + banner: new Blob([], { type: 'image/png' }), +}; +describe('EditCourseForm', () => { + beforeEach(async () => { + (api.getCourse as jest.Mock).mockResolvedValue(mockCourse); + (api.getImage as jest.Mock).mockResolvedValue(new Blob([], { type: 'image/png' })); }); it('renders correctly', async () => { @@ -49,55 +46,62 @@ describe('EditCourseForm', () => { it('check boxes', async () => { await act(async () => { render(); - }) + }); // check if the name input was rendered properly expect(screen.getByText("course name")).toBeInTheDocument(); // check if the description input was rendered properly expect(screen.getByText("description")).toBeInTheDocument(); - // check if the save button was rendered properly - expect(screen.getByRole('button', {name: /save changes/i})).toBeInTheDocument(); + expect(screen.getByText('save changes')).toBeInTheDocument(); + }); + + it('fills form fields with course data', async () => { + await act(async () => { + render(); + }); + + // wait for the course data to be fetched + await waitFor(() => expect(api.getCourse).toHaveBeenCalled()); + + // check if the name field was filled correctly + expect(screen.getByDisplayValue(mockCourse.name)).toBeInTheDocument(); + + // check if the description field was filled correctly + expect(screen.getByDisplayValue(mockCourse.description)).toBeInTheDocument(); + + // check if the access select field was filled correctly + expect(screen.getByDisplayValue(mockCourse.open_course.toString())).toBeInTheDocument(); }); - // it('fills form fields with course data', async () => { - // render(); - // - // // wait for the course data to be fetched - // await waitFor(() => expect(axios.get).toHaveBeenCalled()); - // - // // check if the name field was filled correctly - // expect(screen.getByLabelText("course name")).toBeInTheDocument(); - // - // // check if the description field was filled correctly - // expect(screen.getByLabelText("description")).toBeInTheDocument(); - // - // // check if the access select field was filled correctly - // expect(screen.getByLabelText('access')).toBeInTheDocument(); - // }); - // - // it('submits the form correctly', async () => { - // const file = new File(['...'], 'test.png', {type: 'image/png'}); - // - // render(); - // - // // wait for the course data to be fetched - // await waitFor(() => expect(axios.get).toHaveBeenCalled()); - // - // // fill in the form fields - // fireEvent.change(screen.getByLabelText(/name/i), {target: {value: 'new name'}}); - // fireEvent.change(screen.getByLabelText(/description/i), {target: {value: 'new description'}}); - // fireEvent.change(screen.getByLabelText(/access/i), {target: {value: 'true'}}); - // fireEvent.change(screen.getByLabelText(/select image/i), {target: {files: [file]}}); - // - // // submit the form - // fireEvent.click(screen.getByRole('button', {name: /save changes/i})); - // - // // wait for the form to be submitted - // await waitFor(() => expect(axios.post).toHaveBeenCalled()); - // await waitFor(() => expect(axios.put).toHaveBeenCalled()); - // - // // check if the form was submitted with the correct data - // expect(axios.put).toHaveBeenCalledWith(expect.stringContaining(String(mockCourse.id)), expect.anything(), expect.anything()); - // }); -}); \ No newline at end of file + + it('submits the form correctly', async () => { + const file = new File(['dummy content'], 'test.png', { type: 'image/png' }); + + await act(async () => { + render(); + }); + + // wait for the course data to be fetched + await waitFor(() => expect(api.getCourse).toHaveBeenCalled()); + + // fill in the form fields + fireEvent.change(screen.getByDisplayValue(mockCourse.name), { target: { value: 'new name' } }); + fireEvent.change(screen.getByDisplayValue(mockCourse.description), { target: { value: 'new description' } }); + fireEvent.change(screen.getByDisplayValue(mockCourse.open_course.toString()), { target: { value: 'true' } }); + + // mock formData and file reader + const formData = new FormData(); + global.FormData = jest.fn(() => formData) as any; + + const mockFileReader = { + readAsArrayBuffer: jest.fn(), + result: new ArrayBuffer(10), + onload: jest.fn(), + }; + global.FileReader = jest.fn(() => mockFileReader) as any; + + // submit the form + await waitFor(() => fireEvent.submit(screen.getByText("save changes"))); + }); +}); diff --git a/frontend/__test__/EditUserForm.test.tsx b/frontend/__test__/EditUserForm.test.tsx new file mode 100644 index 00000000..4d619d56 --- /dev/null +++ b/frontend/__test__/EditUserForm.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import EditUserForm from '../app/[locale]/components/EditUserForm'; +import { getUser, updateUserData } from '@lib/api'; + +jest.mock('../lib/api', () => ({ + getUser: jest.fn(), + updateUserData: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key, + }), +})); + +describe('EditUserForm', () => { + const mockUser = { + first_name: 'John', + last_name: 'Doe', + role: 2, + email: 'john.doe@example.com', + }; + + beforeEach(() => { + jest.resetAllMocks(); + getUser.mockResolvedValue(mockUser); + updateUserData.mockResolvedValue({}); + }); + + it('fetches and displays user data correctly', async () => { + render(); + + await waitFor(() => { + expect(screen.getByText('email')).toBeInTheDocument(); + expect(screen.getByText('john.doe@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('John')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Doe')).toBeInTheDocument(); + expect(screen.getByText('teacher')).toBeInTheDocument(); + }); + }); + + it('updates user data correctly', async () => { + render(); + + // Wait for the user data to be fetched and displayed + await waitFor(() => { + expect(screen.getByDisplayValue('John')).toBeInTheDocument(); + }); + + // Change the first name + fireEvent.change(screen.getByDisplayValue('John'), { + target: { value: 'Jane' }, + }); + + // Submit the form + fireEvent.submit(screen.getByRole('button', { name: 'save changes' })); + + // Check if the updateUserData was called with the updated data + await waitFor(() => { + expect(updateUserData).toHaveBeenCalledWith(1, expect.any(FormData)); + }); + + const formData = updateUserData.mock.calls[0][1]; + expect(formData.get('first_name')).toBe('Jane'); + expect(formData.get('last_name')).toBe('Doe'); + expect(formData.get('role')).toBe('2'); + expect(formData.get('email')).toBe('john.doe@example.com'); + }); +}); diff --git a/frontend/__test__/GroupSubmissionList.test.tsx b/frontend/__test__/GroupSubmissionList.test.tsx new file mode 100644 index 00000000..8d1cbf54 --- /dev/null +++ b/frontend/__test__/GroupSubmissionList.test.tsx @@ -0,0 +1,15 @@ +import {render} from "@testing-library/react"; +import React from "react"; +import GroupSubmissionList from "@app/[locale]/components/GroupSubmissionList"; + + +jest.mock("../app/[locale]/components/ListView", () => ({ + __esModule: true, + default: jest.fn(() =>
Mocked ListView
), // Mock rendering +})); + +describe("CourseDetails", () => { + it("render CourseDetails", async () => { + render(); + }); +}); diff --git a/frontend/__test__/ListView.test.tsx b/frontend/__test__/ListView.test.tsx new file mode 100644 index 00000000..600b4e49 --- /dev/null +++ b/frontend/__test__/ListView.test.tsx @@ -0,0 +1,162 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor, act} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ListView from '../app/[locale]/components/ListView'; +import { + deleteData, + getArchivedCourses, + getCourses, + getGroups_by_project, + getGroupSubmissions, + getProject, + getProjects_by_course, + getProjectSubmissions, + getStudents_by_course, + getTeachers_by_course, + getUser, + getUserData, + getUsers, + postData, + getOpenCourses +} from '@lib/api'; +import NotesIcon from "@mui/icons-material/Notes"; +import MeetingRoomIcon from "@mui/icons-material/MeetingRoom"; + +jest.mock('../lib/api', () => ({ + deleteData: jest.fn(), + getArchivedCourses: jest.fn(), + getCourses: jest.fn(), + getGroups_by_project: jest.fn(), + getGroupSubmissions: jest.fn(), + getProject: jest.fn(), + getProjects_by_course: jest.fn(), + getProjectSubmissions: jest.fn(), + getStudents_by_course: jest.fn(), + getTeachers_by_course: jest.fn(), + getUser: jest.fn(), + getUserData: jest.fn(), + getUsers: jest.fn(), + postData: jest.fn(), + getOpenCourses: jest.fn() +})); + + +const mockUser = { + id: 1, + email: "test@gmail.com", + first_name: "First", + last_name: "Last", + course: [1], + role: 1, + picture: "http://localhost:8000/media/profile_pictures/test.png" +}; + +const mockCourses = [ + { + course_id: 1, + name: "Course 1", + description: "Description for Course 1", + year: 2023, + open_course: true, + banner: null, + }, + { + course_id: 2, + name: "Course 2", + description: "Description for Course 2", + year: 2024, + open_course: false, + banner: null, + }, +]; + +const mockProjects = [ + { + project_id: 1, + course_id: 1, + name: "Project 1", + description: "Description for Project 1", + deadline: "2023-12-31T23:59:59", + visible: true, + max_score: 20, + number_of_groups: 5, + group_size: 1, + file_structure: "file structure", + conditions: "conditions", + test_files: null, + }, + { + project_id: 2, + course_id: 1, + name: "Project 2", + description: "Description for Project 2", + deadline: "2024-01-31T23:59:59", + visible: true, + max_score: 20, + number_of_groups: 3, + group_size: 2, + file_structure: "file structure", + conditions: "conditions", + test_files: null, + }, +]; + +const mockLastSubmission = { + submission_id: 1, + group_id: 1, + submission_nr: 1, + file: 'file.pdf', + timestamp: '2024-05-20', + output_test: 'output', +}; + +const headers1 = ['name', + {" " + 'description'} + , + , 'open', + {" " + 'join_leave'} + ]; +const headers_backend1 = ['name', 'description', 'open', 'join/leave'] + + + + +describe('ListView', () => { + beforeEach(() => { + jest.resetAllMocks(); + + deleteData.mockResolvedValue({}); + getArchivedCourses.mockResolvedValue(mockCourses); + getCourses.mockResolvedValue(mockCourses); + getGroups_by_project.mockResolvedValue([]); + getGroupSubmissions.mockResolvedValue([]); + getProject.mockResolvedValue({}); + getProjects_by_course.mockResolvedValue(mockProjects); + getProjectSubmissions.mockResolvedValue([]); + getStudents_by_course.mockResolvedValue([]); + getTeachers_by_course.mockResolvedValue([]); + getUser.mockResolvedValue(mockUser); + getUserData.mockResolvedValue({}); + getUsers.mockResolvedValue([]); + postData.mockResolvedValue({}); + getOpenCourses.mockResolvedValue(mockCourses); + }); + + + it('renders without crashing', async () => { + act(() => { + render();}); + + }); +}); diff --git a/frontend/__test__/ProfileCard.test.tsx b/frontend/__test__/ProfileCard.test.tsx new file mode 100644 index 00000000..de5b356d --- /dev/null +++ b/frontend/__test__/ProfileCard.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {render, waitFor} from '@testing-library/react'; +import ProfileCard from "@app/[locale]/components/ProfileCard"; +import '@testing-library/jest-dom'; +import * as api from '@lib/api'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: any) => key}) +})); + +jest.mock('../lib/api', () => ({ + getUserData: jest.fn(), + getImage: jest.fn(), + APIError: jest.fn(), +})); + +describe('ProfileCard', () => { + + it('renders user name correctly', async () => { + (api.getUserData as jest.Mock).mockResolvedValueOnce({ + id: 1, + email: "test@gmail.com", + first_name: "First", + last_name: "Last", + course: [1], + role: 2, + picture: "http://localhost:8000/media/profile_pictures/test.png" + }) + + const {getByText} = render(); + + await waitFor(() => { + expect(getByText('First Last')).toBeInTheDocument(); + expect(getByText('test@gmail.com')).toBeInTheDocument(); + }) + }) +}) diff --git a/frontend/__test__/ProfileEditCard.test.tsx b/frontend/__test__/ProfileEditCard.test.tsx new file mode 100644 index 00000000..d9919397 --- /dev/null +++ b/frontend/__test__/ProfileEditCard.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import {fireEvent, render, waitFor} from '@testing-library/react'; +import ProfileEditCard from "@app/[locale]/components/ProfileEditCard"; +import '@testing-library/jest-dom'; +import * as api from '@lib/api'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: any) => key}) +})); + +jest.mock('../lib/api', () => ({ + getUserData: jest.fn(), + updateUserData: jest.fn(), + getImage: jest.fn(), + APIError: jest.fn(), +})); + +describe('ProfileEditCard', () => { + + it('renders user name correctly', async () => { + (api.getUserData as jest.Mock).mockResolvedValueOnce({ + id: 1, + email: "test@gmail.com", + first_name: "First", + last_name: "Last", + course: [1], + role: 1, + picture: "http://localhost:8000/media/profile_pictures/test.png" + }) + + const {getByText} = render(); + + await waitFor(() => { + expect(getByText('First Last')).toBeInTheDocument(); + expect(getByText('test@gmail.com')).toBeInTheDocument(); + }) + }) +}) diff --git a/frontend/__test__/ProjectReturnButton.test.tsx b/frontend/__test__/ProjectReturnButton.test.tsx new file mode 100644 index 00000000..a5509c6f --- /dev/null +++ b/frontend/__test__/ProjectReturnButton.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import ProjectReturnButton from '@app/[locale]/components/ProjectReturnButton'; + +describe('ProjectReturnButton', () => { + it('renders correctly', () => { + render(); + }); +}); \ No newline at end of file diff --git a/frontend/__test__/ProjectTable.test.tsx b/frontend/__test__/ProjectTable.test.tsx new file mode 100644 index 00000000..58ecf075 --- /dev/null +++ b/frontend/__test__/ProjectTable.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import ProjectTable from '@app/[locale]/components/ProjectTable'; +import {getProjectsFromCourse, getUserData} from '@lib/api'; + +jest.mock('../lib/api', () => ({ + getProjectsFromCourse: jest.fn(), + getUserData: jest.fn(), +})); + +describe('ProjectTable', () => { + const projects = [ + { + project_id: 1, + name: 'Project 1', + deadline: '2024-06-01T12:00:00Z', + visible: true, + }, + { + project_id: 2, + name: 'Project 2', + deadline: '2024-06-10T12:00:00Z', + visible: false, + }, + ]; + + const userData = { /* Mock user data */}; + + beforeEach(() => { + getProjectsFromCourse.mockResolvedValue(projects); + getUserData.mockResolvedValue(userData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders project table with correct data', async () => { + render(); + + // Wait for async operations to complete + await waitFor(() => { + expect(screen.getByText('Project 1')).toBeInTheDocument(); + expect(screen.getByText('Project 2')).toBeInTheDocument(); + }); + }); + + it('renders correct project data after sorting', async () => { + render(); + + // Wait for async operations to complete + await waitFor(() => { + expect(screen.getByText('Project 1')).toBeInTheDocument(); + expect(screen.getByText('Project 2')).toBeInTheDocument(); + }); + + // Sort the table by project name (descending) + const projectNameHeader = screen.getByText('Project Name'); + projectNameHeader.click(); + projectNameHeader.click(); + + // Wait for table to re-render with sorted data + await waitFor(() => { + expect(screen.getByText('Project 2')).toBeInTheDocument(); // Now Project 2 should appear first + expect(screen.getByText('Project 1')).toBeInTheDocument(); + }); + }); + +}); diff --git a/frontend/__test__/ProjectTableTeacher.test.tsx b/frontend/__test__/ProjectTableTeacher.test.tsx new file mode 100644 index 00000000..8359e571 --- /dev/null +++ b/frontend/__test__/ProjectTableTeacher.test.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import {render, screen, fireEvent, within} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import ProjectTableTeacher from '@app/[locale]/components/ProjectTable'; +import * as api from '@lib/api'; + +// Mocking the necessary modules +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => key // Returns the key as is, which is fine for testing + }), +})); + +jest.mock('../lib/api', () => ({ + getProjectsForCourse: jest.fn(), + getUserData: jest.fn(), + APIError: class APIError extends Error {}, +})); + +describe('ProjectTableTeacher', () => { + beforeEach(() => { + // api.getProjectsForCourse.mockClear(); + // api.getUserData.mockClear(); + }); + + it('renders checkboxes once projects are loaded', async () => { + // Set up your mock to resolve with data + // api.getProjectsForCourse.mockResolvedValue([{ project_id: 1, name: 'Project 1', deadline: '2022-12-01', visible: true }]); + + render(); + + // Use findByRole to wait for the checkbox to appear + // const checkbox = await screen.findByRole('checkbox'); + // expect(checkbox).toBeInTheDocument(); + }); + + // it('does not show AddProjectButton for a role 3 user', async () => { + // api.getUserData.mockResolvedValue({ role: 3 }); + // api.getProjectsForCourse.mockResolvedValue([]); + // + // render(); + // + // expect(await screen.findByText('no_projects')).toBeInTheDocument(); + // expect(screen.queryByText('Add Project')).toBeNull(); // Assuming "Add Project" is the text in AddProjectButton + // }); +}); diff --git a/frontend/__test__/StatusButton.test.tsx b/frontend/__test__/StatusButton.test.tsx new file mode 100644 index 00000000..efec088f --- /dev/null +++ b/frontend/__test__/StatusButton.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import StatusButton from '@app/[locale]/components/StatusButton'; + +jest.mock('@mui/icons-material/Check', () => () =>
); +jest.mock('@mui/icons-material/HelpOutline', () => () =>
); +describe('StatusButton', () => { + let files: any[]; + let setFiles: jest.Mock; + + beforeEach(() => { + files = ['+', '~', '-']; + setFiles = jest.fn((newFiles) => { + files = newFiles; + }); + }); + + it('renders the initial status correctly', () => { + render(); + expect(screen.getByTestId('check-icon')).toBeInTheDocument(); + }); + + it('cycles through statuses on click', () => { + render(); + + // Click to change status + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('help-icon')).toBeInTheDocument(); + expect(setFiles).toHaveBeenCalledWith(['~', '~', '-']); + + // Click to change status again + fireEvent.click(screen.getByRole('button')); + expect(setFiles).toHaveBeenCalledWith(['-', '~', '-']); + + // Click to change status back to initial + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('check-icon')).toBeInTheDocument(); + expect(setFiles).toHaveBeenCalledWith(['+', '~', '-']); + }); + + it('renders correct status for fileIndex 1', () => { + render(); + expect(screen.getByTestId('help-icon')).toBeInTheDocument(); + }); + + it('renders correct status for fileIndex 2', () => { + render(); + }); + + it('handles an empty file state correctly', () => { + files = ['', '~', '-']; + render(); + + fireEvent.click(screen.getByRole('button')); + expect(screen.getByTestId('check-icon')).toBeInTheDocument(); + expect(setFiles).toHaveBeenCalledWith(['+', '~', '-']); + }); +}); diff --git a/frontend/__test__/StudentCoTeacherButtons.test.tsx b/frontend/__test__/StudentCoTeacherButtons.test.tsx new file mode 100644 index 00000000..c17f4d83 --- /dev/null +++ b/frontend/__test__/StudentCoTeacherButtons.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import StudentCoTeacherButtons from '@app/[locale]/components/StudentCoTeacherButtons'; +import '@testing-library/jest-dom/extend-expect'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key) => { + if (key === "view_students") return "View Students"; + if (key === "view_co_teachers") return "View Co-Teachers"; + return key; + } + }), +})); + +describe('StudentCoTeacherButtons', () => { + it('renders links with correct texts and URLs', () => { + const course_id = 123; // Example course ID + + render(); + + // Assert that the links have the correct text + // const studentsLink = screen.getByRole('link', { name: /view students/i }); + // const coTeachersLink = screen.getByRole('link', { name: /view co-teachers/i }); + // + // // Assert that the links have the correct URLs + // expect(studentsLink).toHaveAttribute('href', `/course/${course_id}/students`); + // expect(coTeachersLink).toHaveAttribute('href', `/course/${course_id}/teachers`); + // + // // Optionally check styles + // expect(studentsLink).toHaveStyle({ width: 'fit-content' }); + // expect(coTeachersLink).toHaveStyle({ width: 'fit-content' }); + }); +}); diff --git a/frontend/__test__/SubmissionDetailsPage.test.tsx b/frontend/__test__/SubmissionDetailsPage.test.tsx new file mode 100644 index 00000000..5fc4527f --- /dev/null +++ b/frontend/__test__/SubmissionDetailsPage.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/react'; +import SubmissionDetailsPage from '@app/[locale]/components/SubmissionDetailsPage'; + +jest.mock('../lib/api', () => ({ + getSubmission: jest.fn().mockResolvedValue({ + submission_nr: 1, + output_simple_test: false, + feedback_simple_test: { + '0': ['Feedback 1'], + '2': ['Feedback 2'] + }, + }), + getProjectFromSubmission: jest.fn().mockResolvedValue(456), +})); + + +describe('SubmissionDetailsPage', () => { + test('renders submission details correctly', async () => { + render(); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()); + + // Ensure submission details are rendered + expect(screen.getByText(/submission #/i)).toBeInTheDocument(); + expect(screen.getByText(/evaluation status/i)).toBeInTheDocument(); + expect(screen.getByText(/uploaded_files/i)).toBeInTheDocument(); + expect(screen.getByText(/feedback_simple_test_0/i)).toBeInTheDocument(); + expect(screen.getByText(/Feedback 1/i)).toBeInTheDocument(); + + // Test the feedback for simple test "2" + expect(screen.getByText(/feedback_simple_test_2/i)).toBeInTheDocument(); + expect(screen.getByText(/Feedback 2/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/__test__/SubmitDetailsPage.test.tsx b/frontend/__test__/SubmitDetailsPage.test.tsx new file mode 100644 index 00000000..329e79a8 --- /dev/null +++ b/frontend/__test__/SubmitDetailsPage.test.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import SubmitDetailsPage from '@app/[locale]/components/SubmitDetailsPage'; // Adjust the import path as needed +import {getProject, fetchUserData, uploadSubmissionFile} from '@lib/api'; +import {ThemeProvider} from '@mui/material'; +import baseTheme from '@styles/theme'; + +// Mock the dependencies +jest.mock('../lib/api', () => ({ + getProject: jest.fn(), + fetchUserData: jest.fn(), + uploadSubmissionFile: jest.fn(), +})); + +jest.mock('../app/[locale]/components/ProjectReturnButton', () => () =>
ProjectReturnButton
); +jest.mock('../app/[locale]/components/Tree', () => () =>
TreeComponent
); + +describe('SubmitDetailsPage', () => { + const projectMock = { + project_id: 1, + name: 'Project 1', + description: 'This is a description for project 1', + course_id: 1, + }; + + const userMock = { + course: [1], + }; + + beforeEach(() => { + getProject.mockResolvedValue(projectMock); + fetchUserData.mockResolvedValue(userMock); + uploadSubmissionFile.mockResolvedValue({result: 'ok', submission_id: 1}); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component correctly', async () => { + render( + + + + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByText('Project 1')).toBeInTheDocument(); + expect(screen.getByText('This is a description for project 1')).toBeInTheDocument(); + expect(screen.getByText('ProjectReturnButton')).toBeInTheDocument(); + }); + }); + + it('handles file and folder uploads', async () => { + render( + + + + ); + + await waitFor(() => screen.getByText('Project 1')); + + const fileInput = screen.getByText('upload_folders'); + const files = [new File(['content'], 'file1.txt')]; + + fireEvent.change(fileInput, { + target: {files}, + }); + }); + + it('submits the form successfully', async () => { + render( + + + + ); + + await waitFor(() => screen.getByText('Project 1')); + + const fileInput = screen.getByText('upload_folders'); + const files = [new File(['content'], 'file1.txt')]; + + fireEvent.change(fileInput, { + target: {files}, + }); + + const submitButton = screen.getByText('submit'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(uploadSubmissionFile).toHaveBeenCalled(); + }); + }); + + it('displays an error message on submission failure', async () => { + uploadSubmissionFile.mockResolvedValue({result: 'error', errorcode: 'submission_failed'}); + + render( + + + + ); + + await waitFor(() => screen.getByText('Project 1')); + + const fileInput = screen.getByText('upload_folders'); + const files = [new File(['content'], 'file1.txt')]; + + fireEvent.change(fileInput, { + target: {files}, + }); + + const submitButton = screen.getByText('submit'); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(uploadSubmissionFile).toHaveBeenCalled(); + }); + }); + + + it('renders project details and handles file input changes', async () => { + render(); + + await waitFor(() => screen.getByText('Project 1')); + + // Simulate folder input change + const folderInput = screen.getByText('upload_folders'); + const folderFiles = [new File(['content'], 'folder/file1.txt', {type: 'text/plain'})]; + + Object.defineProperty(folderInput, 'files', { + value: folderFiles, + }); + + fireEvent.change(folderInput); + + // Simulate file input change + const fileInput = screen.getByText(/files/i); + const files = [new File(['content'], 'file2.txt', {type: 'text/plain'})]; + + Object.defineProperty(fileInput, 'files', { + value: files, + }); + + fireEvent.change(fileInput); + }); +}); diff --git a/frontend/__test__/Tree.test.tsx b/frontend/__test__/Tree.test.tsx new file mode 100644 index 00000000..f2f16199 --- /dev/null +++ b/frontend/__test__/Tree.test.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import Tree from '@app/[locale]/components/Tree'; + +// Mocking the TreeNode component +jest.mock('../app/[locale]/components/TreeNode', () => ({ + __esModule: true, + default: ({ node }: any) =>
{node.name}
, +})); + +describe('Tree', () => { + it('renders correctly with given paths', () => { + const paths = [ + 'root/branch1/leaf1', + 'root/branch1/leaf2', + 'root/branch2/leaf1', + 'root/branch3' + ]; + + render(); + + // Check if the root node is rendered + expect(screen.getByText('root')).toBeInTheDocument();}); +}); diff --git a/frontend/__test__/TreeNode.test.tsx b/frontend/__test__/TreeNode.test.tsx new file mode 100644 index 00000000..df6ef0e4 --- /dev/null +++ b/frontend/__test__/TreeNode.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import TreeNode from '../app/[locale]/components/TreeNode'; // Adjust the import path accordingly + +const mockNode = { + name: 'Parent Node', + level: 0, + isLeaf: false, + children: [ + { + name: 'Child Node 1', + level: 1, + isLeaf: true, + children: [], + }, + { + name: 'Child Node 2', + level: 1, + isLeaf: false, + children: [ + { + name: 'Grandchild Node 1', + level: 2, + isLeaf: true, + children: [], + }, + ], + }, + ], +}; + +const mockPaths = ['path/to/parent', 'path/to/child1', 'path/to/child2', 'path/to/grandchild1']; + +describe('TreeNode', () => { + it('renders correctly', () => { + render(); + + expect(screen.getByText('Parent Node')).toBeInTheDocument(); + expect(screen.queryByText('Child Node 1')).not.toBeInTheDocument(); + expect(screen.queryByText('Child Node 2')).not.toBeInTheDocument(); + }); + + it('toggles the collapse on click', () => { + render(); + + const parentNode = screen.getByText('Parent Node'); + fireEvent.click(parentNode); + + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + + fireEvent.click(parentNode); + }); + + it('renders child nodes correctly', () => { + render(); + + expect(screen.getByText('Child Node 1')).toBeInTheDocument(); + expect(screen.getByText('Child Node 2')).toBeInTheDocument(); + }); +}); diff --git a/frontend/__test__/YearStateComponent.test.tsx b/frontend/__test__/YearStateComponent.test.tsx new file mode 100644 index 00000000..cebb8fcb --- /dev/null +++ b/frontend/__test__/YearStateComponent.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import {render, screen, fireEvent, act} from '@testing-library/react'; +import YearStateComponent from '@app/[locale]/components/YearStateComponent'; +import {getLastSubmissionFromProject} from "@lib/api"; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: any) => key}) +})); + + +jest.mock('../app/[locale]/components/CourseControls', () => { + return jest.fn().mockImplementation(() =>
Mocked CourseControls
); +}); + +jest.mock('../app/[locale]/components/CoursesGrid', () => { + return jest.fn().mockImplementation(() =>
Mocked CoursesGrid
); +}); + +// Mock API functions +jest.mock("../lib/api", () => ({ + getCourse: jest.fn(), + getUserData: jest.fn(), + getCoursesForUser: jest.fn() +})); + +describe('YearStateComponent', () => { + it('renders correctly', () => { + act(()=> + render() + ); + }); +}); \ No newline at end of file diff --git a/frontend/__test__/admin_components/UserList.test.tsx b/frontend/__test__/admin_components/UserList.test.tsx new file mode 100644 index 00000000..b95e6328 --- /dev/null +++ b/frontend/__test__/admin_components/UserList.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import React from "react"; +import UserList from "@app/[locale]/components/admin_components/UserList"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + +describe("UserList", () => { + it("renders with translated headers, BackButton text, and ListView props", async () => { + render(); + + // BackButton + const backButton = screen.getByText("back_to home page"); // Match translated button text partially + + expect(backButton).toBeInTheDocument(); + + // ListView headers + const emailHeader = screen.getByText(/email/i); // Match translated email partially + const roleHeader = screen.getByText(/role/i); // Match translated role partially + + expect(emailHeader).toBeInTheDocument(); + expect(roleHeader).toBeInTheDocument(); + + }); +}); diff --git a/frontend/__test__/course_components/ArchiveButton.test.tsx b/frontend/__test__/course_components/ArchiveButton.test.tsx new file mode 100644 index 00000000..455fc1e9 --- /dev/null +++ b/frontend/__test__/course_components/ArchiveButton.test.tsx @@ -0,0 +1,64 @@ +import {render, screen, fireEvent} from "@testing-library/react"; +import React from "react"; +import ArchiveButton from "@app/[locale]/components/course_components/ArchiveButton"; +import {APIError, archiveCourse} from "@lib/api"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + +const mockCourse = + { + course_id: 1, + name: "Course 1", + description: "Description for Course 1", + year: 2023, + open_course: true, + banner: null, + }; + +jest.mock("../../lib/api", () => ({ + archiveCourse: jest.fn(), +})); + + +describe("ArchiveButton", () => { + beforeEach(() => { + jest.resetAllMocks(); + archiveCourse.mockResolvedValueOnce(mockCourse.course_id); + }); + + it("renders correctly and click the button", async () => { + render(); + + const archiveButton = screen.getByRole("button", {name: /archive course/i}); + + expect(archiveButton).toBeInTheDocument(); + + fireEvent.click(archiveButton); + + await expect(archiveCourse).toHaveBeenCalledWith(1); + }); + + it("api error", async () => { + // for some reason mocking the apierror doesnt work? + const mockAPIError = jest.fn(); + mockAPIError.mockImplementation(() => ({ + message: "API Error", + status: 400, + type: "UNKNOWN", + })); + + archiveCourse.mockResolvedValueOnce(mockAPIError); + + render(); + + const archiveButton = screen.getByRole("button", {name: /archive course/i}); + + fireEvent.click(archiveButton); + + }); + +}); diff --git a/frontend/__test__/course_components/CancelButton.test.tsx b/frontend/__test__/course_components/CancelButton.test.tsx new file mode 100644 index 00000000..e134106e --- /dev/null +++ b/frontend/__test__/course_components/CancelButton.test.tsx @@ -0,0 +1,10 @@ +import {render, screen} from "@testing-library/react"; +import React from "react"; +import CancelButton from "@app/[locale]/components/course_components/CancelButton"; + +describe("CancelButton", () => { + it("renders cancel button and click", async () => { + render(); + screen.getByText(/cancel/i).click(); + }); +}); diff --git a/frontend/__test__/course_components/DeleteButton.test.tsx b/frontend/__test__/course_components/DeleteButton.test.tsx new file mode 100644 index 00000000..e94ff56f --- /dev/null +++ b/frontend/__test__/course_components/DeleteButton.test.tsx @@ -0,0 +1,35 @@ +import {render, screen, fireEvent} from "@testing-library/react"; +import React from "react"; +import DeleteButton from "@app/[locale]/components/course_components/DeleteButton"; +import {deleteCourse} from "@lib/api"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + + +jest.mock("../../lib/api", () => ({ + deleteCourse: jest.fn(() => Promise.resolve()), +})); + +describe("DeleteButton", () => { + it("render and delete", async () => { + render(); + + const deleteButton = screen.getByRole("button", {name: /delete course/i}); + expect(deleteButton).toBeInTheDocument(); + + fireEvent.click(deleteButton); + + const dialogTitle = screen.getByText("Are you sure you want to delete this course?"); + const cancelButton = screen.getByRole("button", {name: /cancel/i}); + const deleteButtonInDialog = screen.getByRole("button", {name: /delete/i}); + + fireEvent.click(cancelButton); + + fireEvent.click(deleteButton); + fireEvent.click(deleteButtonInDialog); + }); +}); diff --git a/frontend/__test__/general/ItemList.test.tsx b/frontend/__test__/general/ItemList.test.tsx new file mode 100644 index 00000000..26983ba7 --- /dev/null +++ b/frontend/__test__/general/ItemList.test.tsx @@ -0,0 +1,33 @@ +import {render, screen, fireEvent} from "@testing-library/react"; +import React from "react"; +import ItemList from "@app/[locale]/components/general/ItemsList"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + +const mockCourse = + { + course_id: 1, + name: "Course 1", + description: "Description for Course 1", + year: 2023, + open_course: true, + banner: null, + }; + + + +describe("ItemList", () => { + + it("renders correctly", async () => { + const mockSetItems = jest.fn(); + render(); + }); +}); diff --git a/frontend/__test__/general/RequiredFilesList.test.tsx b/frontend/__test__/general/RequiredFilesList.test.tsx new file mode 100644 index 00000000..76fef109 --- /dev/null +++ b/frontend/__test__/general/RequiredFilesList.test.tsx @@ -0,0 +1,25 @@ +import {render} from "@testing-library/react"; +import React from "react"; +import RequiredFilesList from "@app/[locale]/components/general/RequiredFilesList"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + + +describe("RequiredFilesList", () => { + + it("renders correctly", async () => { + const mockSetItems = jest.fn(); + render(); + }); +}); diff --git a/frontend/__test__/project/edit/Assignment.test.tsx b/frontend/__test__/project/edit/Assignment.test.tsx index 35e724e9..806f2292 100644 --- a/frontend/__test__/project/edit/Assignment.test.tsx +++ b/frontend/__test__/project/edit/Assignment.test.tsx @@ -22,8 +22,8 @@ describe('Assignment', () => { ); // check that the assignment was rendered properly - expect(getByText_en('Assignment')).toBeInTheDocument(); - + const element = getByRole('heading', { name: 'assignment' }); + expect(element).toBeInTheDocument(); // check that the text field was rendered properly const textField = getByRole('textbox'); expect(textField).toBeInTheDocument(); @@ -50,6 +50,6 @@ describe('Assignment', () => { ); // check that the helper text was rendered properly - expect(getByText('Assignment is required')).toBeInTheDocument(); + expect(getByText('assignment_required')).toBeInTheDocument(); }); }); \ No newline at end of file diff --git a/frontend/__test__/project/edit/BackButton.test.tsx b/frontend/__test__/project/edit/BackButton.test.tsx new file mode 100644 index 00000000..86aab476 --- /dev/null +++ b/frontend/__test__/project/edit/BackButton.test.tsx @@ -0,0 +1,11 @@ +import {fireEvent, render, screen} from "@testing-library/react"; +import React from "react"; +import BackButton from "@app/[locale]/components/project_components/BackButton"; + +describe("BackButton", () => { + it("render", async () => { + render(); + const backButton = screen.getByRole("button", {name: /back/i}); + fireEvent.click(backButton); + }); +}); diff --git a/frontend/__test__/project/edit/Conditions.test.tsx b/frontend/__test__/project/edit/Conditions.test.tsx index 255e0dd6..3458bdf3 100644 --- a/frontend/__test__/project/edit/Conditions.test.tsx +++ b/frontend/__test__/project/edit/Conditions.test.tsx @@ -1,6 +1,6 @@ import {render, screen} from "@testing-library/react"; import React from "react"; -import Condtions from "@app/[locale]/components/project_components/conditions"; +import Conditions from "@app/[locale]/components/project_components/conditions"; import getTranslations from "../../translations"; jest.mock('react-i18next', () => ({ @@ -12,7 +12,7 @@ describe('Conditions', () => { const translations = await getTranslations(); const {getByText: getByText_en, getByDisplayValue, queryAllByRole} = render( - { ); // check that the conditions were rendered properly - expect(screen.getByText('Conditions')).toBeInTheDocument(); - expect(getByDisplayValue('First')).toBeInTheDocument(); - expect(getByDisplayValue('Second')).toBeInTheDocument(); + expect(screen.getByText('conditions')).toBeInTheDocument(); + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); // check that the text field was rendered properly const textField = queryAllByRole('textbox'); - expect(textField.length).toBe(2); + expect(textField.length).toBe(1); }); }); \ No newline at end of file diff --git a/frontend/__test__/project/edit/Finishbuttons.test.tsx b/frontend/__test__/project/edit/Finishbuttons.test.tsx index 35785aeb..54997387 100644 --- a/frontend/__test__/project/edit/Finishbuttons.test.tsx +++ b/frontend/__test__/project/edit/Finishbuttons.test.tsx @@ -11,7 +11,7 @@ describe('Finishbuttons', () => { it('renders correctly', async () => { const translations = await getTranslations(); - const {getByText: getByTestId} = render( + const {getByText: getByTestId} = render( { // check that the buttons were rendered properly expect(screen.getByTestId('AlarmOnIcon')).toBeInTheDocument(); expect(screen.getByTestId('VisibilityIcon')).toBeInTheDocument(); - expect(screen.getByText('Save')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); - expect(screen.getByText('Remove')).toBeInTheDocument(); + expect(screen.getByText('save')).toBeInTheDocument(); + expect(screen.getByText('cancel')).toBeInTheDocument(); + expect(screen.getByText('remove')).toBeInTheDocument(); }); it('Cancels', async () => { const translations = await getTranslations(); - const courseId = 1; + const projectId = 1; delete window.location; - window.location = { href: '' } as any; + window.location = {href: ''} as any; const {getByText} = render( { handleSave={jest.fn()} setConfirmRemove={jest.fn()} translations={translations.en} - course_id={courseId} + project_id={projectId} setHasDeadline={jest.fn()} hasDeadline={true} /> ); - const button = screen.getByText('Cancel'); + const button = screen.getByText('cancel'); fireEvent.click(button); - expect(window.location.href).toBe("/course/" + courseId + "/"); + expect(window.location.href).toBe("/project/" + projectId + "/"); }); it('Saves', async () => { @@ -75,7 +75,7 @@ describe('Finishbuttons', () => { /> ); - const button = screen.getByText('Save'); + const button = screen.getByText('save'); fireEvent.click(button); expect(handleSave).toHaveBeenCalled(); @@ -98,7 +98,7 @@ describe('Finishbuttons', () => { /> ); - const button = screen.getByText('Remove'); + const button = screen.getByText('remove'); fireEvent.click(button); expect(setConfirmRemove).toHaveBeenCalled(); diff --git a/frontend/__test__/project/edit/Groups.test.tsx b/frontend/__test__/project/edit/Groups.test.tsx index 0e658d77..868c8f2c 100644 --- a/frontend/__test__/project/edit/Groups.test.tsx +++ b/frontend/__test__/project/edit/Groups.test.tsx @@ -1,26 +1,65 @@ -import getTranslations from "../../translations"; -import {render, screen} from "@testing-library/react"; -import Groups from "@app/[locale]/components/project_components/groups"; -import React from "react"; +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom/extend-expect'; +import Groups from '@app/[locale]/components/project_components/groups'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({t: (key: any) => key}) +})); describe('Groups', () => { - it('renders correctly', async () => { - const translations = await getTranslations(); - const {getByText: getByText_en} = render( - + let setGroupAmount: jest.Mock; + let setGroupSize: jest.Mock; + + beforeEach(() => { + setGroupAmount = jest.fn(); + setGroupSize = jest.fn(); + }); + + const renderComponent = (props: any) => { + return render( + ); + }; + + it('renders component correctly', () => { + renderComponent({ + groupAmount: 5, + isGroupAmountEmpty: false, + groupSize: 3, + isGroupSizeEmpty: false, + setGroupAmount, + setGroupSize, + }); + + expect(screen.getByText('groups')).toBeInTheDocument(); + expect(screen.getByText('group_amount')).toBeInTheDocument(); + expect(screen.getByText('group_size')).toBeInTheDocument(); + }); + + it('displays error for empty group amount', () => { + renderComponent({ + groupAmount: '', + isGroupAmountEmpty: true, + groupSize: 3, + isGroupSizeEmpty: false, + setGroupAmount, + setGroupSize, + }); + + expect(screen.getByText('group_amount_required')).toBeInTheDocument(); + }); - // check that it was rendered properly - expect(screen.getByText('Amount of groups')).toBeInTheDocument(); - expect(screen.getByText('Group size')).toBeInTheDocument(); + it('displays error for empty group size', () => { + renderComponent({ + groupAmount: 5, + isGroupAmountEmpty: false, + groupSize: '', + isGroupSizeEmpty: true, + setGroupAmount, + setGroupSize, + }); + expect(screen.getByText('group_size_required')).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/__test__/project/edit/Removedialog.test.tsx b/frontend/__test__/project/edit/Removedialog.test.tsx index f8459c50..91e901fc 100644 --- a/frontend/__test__/project/edit/Removedialog.test.tsx +++ b/frontend/__test__/project/edit/Removedialog.test.tsx @@ -14,17 +14,17 @@ describe('Removedialog', () => { const {getByText: getByText_en, getByTestId} = render( ); // check that the text was rendered properly - expect(screen.getByText('Remove project?')).toBeInTheDocument(); - expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument(); - expect(screen.getByText('Remove')).toBeInTheDocument(); - expect(screen.getByText('Cancel')).toBeInTheDocument(); + expect(screen.getByText('remove_dialog')).toBeInTheDocument(); + expect(screen.getByText('action_dialog')).toBeInTheDocument(); + expect(screen.getByText('remove_confirm')).toBeInTheDocument(); + expect(screen.getByText('remove_cancel')).toBeInTheDocument(); }); @@ -35,13 +35,13 @@ describe('Removedialog', () => { const {getByText} = render( ); - const button = screen.getByText('Cancel'); + const button = screen.getByText('remove_cancel'); fireEvent.click(button); expect(setConfirmRemove).toHaveBeenCalled(); @@ -55,13 +55,13 @@ describe('Removedialog', () => { const {getByText} = render( ); - const button = screen.getByText('Remove'); + const button = screen.getByText('remove_confirm'); fireEvent.click(button); // @ts-ignore diff --git a/frontend/__test__/project/edit/Requiredfiles.test.tsx b/frontend/__test__/project/edit/Requiredfiles.test.tsx index bf523de2..b150269a 100644 --- a/frontend/__test__/project/edit/Requiredfiles.test.tsx +++ b/frontend/__test__/project/edit/Requiredfiles.test.tsx @@ -1,27 +1,30 @@ -import getTranslations from "../../translations"; -import {render, screen} from "@testing-library/react"; -import RequiredFiles from "@app/[locale]/components/project_components/requiredFiles"; +import {render, screen, fireEvent} from "@testing-library/react"; import React from "react"; +import RequiredFiles from "@app/[locale]/components/project_components/requiredFiles"; -jest.mock('react-i18next', () => ({ - useTranslation: () => ({t: (key: any) => key}) +// Mock translations with actual translation logic (consider using a mocking library) +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), })); -describe('Requiredfiles', () => { - it('renders correctly', async () => { - const translations = await getTranslations(); - const {getByText: getByText_en, getByDisplayValue} = render( +describe("RequiredFiles", () => { + it("renders required files title and list with translations", async () => { + render( ); - // check that the required files were rendered properly - expect(screen.getByText('Required files')).toBeInTheDocument(); - expect(getByDisplayValue('First')).toBeInTheDocument(); - expect(getByDisplayValue('Second')).toBeInTheDocument(); + const title = screen.getByText(/required_files/i); + const fileList = screen.getByRole("list"); + + expect(title).toBeInTheDocument(); + expect(fileList).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/frontend/__test__/project/edit/Testfiles.test.tsx b/frontend/__test__/project/edit/Testfiles.test.tsx index fee062fd..aad6c516 100644 --- a/frontend/__test__/project/edit/Testfiles.test.tsx +++ b/frontend/__test__/project/edit/Testfiles.test.tsx @@ -21,7 +21,7 @@ describe('Testfiles', () => { ); // check that the buttons were rendered properly - expect(screen.getByText('Test files')).toBeInTheDocument(); + expect(screen.getByText('test_files')).toBeInTheDocument(); expect(screen.getByText('First')).toBeInTheDocument(); expect(screen.getByText('Second')).toBeInTheDocument(); }); diff --git a/frontend/__test__/project/edit/Title.test.tsx b/frontend/__test__/project/edit/Title.test.tsx index 52f98eb9..dd789562 100644 --- a/frontend/__test__/project/edit/Title.test.tsx +++ b/frontend/__test__/project/edit/Title.test.tsx @@ -1,31 +1,114 @@ -import {render, screen} from "@testing-library/react"; +import {render, screen, fireEvent} from "@testing-library/react"; import React from "react"; import Title from "@app/[locale]/components/project_components/title"; -import getTranslations from "../../translations"; -jest.mock('react-i18next', () => ({ - useTranslation: () => ({t: (key: any) => key}) -})); +describe("Title", () => { + it("renders title and max score labels with translations", async () => { + render( + + ); + + // Assert translated labels using actual translation logic + expect(screen.getByRole("heading", {name: /title/i})).toBeInTheDocument(); + expect(screen.getByRole("heading", {name: /max_score/i})).toBeInTheDocument(); + }); + + it("renders title input with placeholder and helper text", () => { + render( + <Title + isTitleEmpty={false} + setTitle={jest.fn()} + title="Test title" + score={1} + isScoreEmpty={false} + setScore={jest.fn()} + /> + ); + + const titleInput = screen.getByLabelText("title"); + expect(titleInput).toBeInTheDocument(); + expect(titleInput).toHaveAttribute("placeholder", "title"); + }); + + it("renders score input with error when empty and sets score on change", () => { + render( + <Title + isTitleEmpty={false} + setTitle={jest.fn()} + title="Test title" + score={1} + isScoreEmpty={true} + setScore={jest.fn()} + /> + ); + + const scoreInput = screen.getByRole("spinbutton"); + expect(scoreInput).toHaveAttribute("aria-invalid", "true"); + + fireEvent.change(scoreInput, {target: {value: 50}}); + }); + + it("limits score input between 1 and 100", () => { + render( + <Title + isTitleEmpty={false} + setTitle={jest.fn()} + title="Test title" + score={1} + isScoreEmpty={false} + setScore={jest.fn()} + /> + ); + + const scoreInput = screen.getByRole("spinbutton"); -describe('Title', () => { - it('renders correctly', async () => { - const translations = await getTranslations(); + fireEvent.change(scoreInput, {target: {value: 0}}); - const {getByText: getByRole} = render( + fireEvent.change(scoreInput, {target: {value: 150}}); + }); + + it("score no input", () => { + render( <Title isTitleEmpty={false} setTitle={jest.fn()} title="Test title" - score={50} + score={1} + isScoreEmpty={true} // Set initial score empty + setScore={jest.fn()} + /> + ); + + const scoreInput = screen.getByRole("spinbutton"); + + fireEvent.change(scoreInput, {target: {value: ''}}); + }); + + it("updates title state on title input change", () => { + const mockSetTitle = jest.fn(); // Mock the setTitle function + + render( + <Title + isTitleEmpty={false} + setTitle={mockSetTitle} + title="Test title" + score={1} isScoreEmpty={false} setScore={jest.fn()} - translations={translations.en} /> ); - // check that the title and score were rendered properly - expect(screen.getByText('Title')).toBeInTheDocument(); - expect(screen.getByText('Maximal score')).toBeInTheDocument(); + const titleInput = screen.getByLabelText("title"); + fireEvent.change(titleInput, {target: {value: "New Title"}}); + expect(mockSetTitle).toHaveBeenCalledWith("New Title"); // Verify setTitle called with new value }); -}); \ No newline at end of file + +}); diff --git a/frontend/__test__/project/edit/Uploadbutton.test.tsx b/frontend/__test__/project/edit/Uploadbutton.test.tsx index d1d8ca77..e09a4964 100644 --- a/frontend/__test__/project/edit/Uploadbutton.test.tsx +++ b/frontend/__test__/project/edit/Uploadbutton.test.tsx @@ -1,26 +1,27 @@ -import {fireEvent, render} from "@testing-library/react"; -import React from "react"; -import UploadTestFile from "@app/[locale]/components/project_components/uploadButton"; -import getTranslations from "../../translations"; +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import UploadTestFile from '@app/[locale]/components/project_components/uploadButton'; -jest.mock('react-i18next', () => ({ - useTranslation: () => ({t: (key: any) => key}) -})); +describe('UploadTestFile', () => { + test('uploads files correctly', async () => { + const setTestfilesName = jest.fn(); + const setTestfilesData = jest.fn(); + const files = [ + new File(['test file 1'], 'testfile1.txt', { type: 'text/plain' }), + new File(['test file 2'], 'testfile2.txt', { type: 'text/plain' }) + ]; -describe('Uploadbutton', () => { - it('renders correctly', async () => { - const translations = await getTranslations(); - const {getByText: getByText_en, getByDisplayValue} = render( - <UploadTestFile - testfilesName={[]} - setTestfilesName={jest.fn()} - testfilesData={[]} - setTestfilesData={jest.fn()} - translations={translations.en} - /> - ); + render( + <UploadTestFile + testfilesName={['testfile1.txt', 'testfile2.txt']} + setTestfilesName={setTestfilesName} + testfilesData={[]} + setTestfilesData={setTestfilesData} + /> + ); - // check that the buttons were rendered properly - expect(getByText_en('Upload')).toBeInTheDocument(); - }); -}); \ No newline at end of file + const input = screen.getByText('upload'); + fireEvent.change(input, { target: { files } }); + + }); +}); diff --git a/frontend/__test__/user_components/CancelButton.test.tsx b/frontend/__test__/user_components/CancelButton.test.tsx new file mode 100644 index 00000000..4cf43aa8 --- /dev/null +++ b/frontend/__test__/user_components/CancelButton.test.tsx @@ -0,0 +1,10 @@ +import {render, screen} from "@testing-library/react"; +import React from "react"; +import CancelButton from "@app/[locale]/components/user_components/CancelButton"; + +describe("CancelButton", () => { + it("renders cancel button and click", async () => { + render(<CancelButton/>); + screen.getByText(/cancel/i).click(); + }); +}); diff --git a/frontend/__test__/user_components/DeleteButton.test.tsx b/frontend/__test__/user_components/DeleteButton.test.tsx new file mode 100644 index 00000000..32b22681 --- /dev/null +++ b/frontend/__test__/user_components/DeleteButton.test.tsx @@ -0,0 +1,34 @@ +import {render, screen, fireEvent} from "@testing-library/react"; +import React from "react"; +import DeleteButton from "@app/[locale]/components/user_components/DeleteButton"; + +jest.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key) => key + }), +})); + + +jest.mock("../../lib/api", () => ({ + deleteUser: jest.fn(() => Promise.resolve()), +})); + +describe("DeleteButton", () => { + it("render and delete", async () => { + render(<DeleteButton userId={1}/>); + + const deleteButton = screen.getByRole("button", {name: /delete user/i}); + expect(deleteButton).toBeInTheDocument(); + + fireEvent.click(deleteButton); + + const dialogTitle = screen.getByText("Are you sure you want to delete this user?"); + const cancelButton = screen.getByRole("button", {name: /cancel/i}); + const deleteButtonInDialog = screen.getByRole("button", {name: /delete/i}); + + fireEvent.click(cancelButton); + + fireEvent.click(deleteButton); + fireEvent.click(deleteButtonInDialog); + }); +}); diff --git a/frontend/app/[locale]/admin/users/[id]/edit/page.tsx b/frontend/app/[locale]/admin/users/[id]/edit/page.tsx new file mode 100644 index 00000000..f91fb118 --- /dev/null +++ b/frontend/app/[locale]/admin/users/[id]/edit/page.tsx @@ -0,0 +1,66 @@ +"use client" +import NavBar from "@app/[locale]/components/NavBar"; +import Box from "@mui/material/Box"; +import initTranslations from "@app/i18n"; +import DeleteButton from "@app/[locale]/components/user_components/DeleteButton"; +import EditUserForm from "@app/[locale]/components/EditUserForm"; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import React, {useEffect, useState} from "react"; +import {getUserData, UserData} from "@lib/api"; +import {CircularProgress} from "@mui/material"; + +function UserEditPage({params: {locale, id}}: { params: { locale: any, id: number } }) { + const [resources, setResources] = useState(); + const [user, setUser] = useState<UserData | null>(null); + const [userLoading, setUserLoading] = useState(true); + + useEffect(() => { + initTranslations(locale, ["common"]).then((result) => { + setResources(result.resources); + }) + + const fetchUser = async () => { + try { + setUser(await getUserData()); + } catch (error) { + console.error("There was an error fetching the user data:", error); + } + } + + fetchUser().then(() => setUserLoading(false)); + }, [locale]) + + return ( + <TranslationsProvider + resources={resources} + locale={locale} + namespaces={["common"]} + > + <NavBar/> + {userLoading ? ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ) : ( + user?.role !== 1 ? ( + window.location.href = `/403/` + ) : ( + <Box + padding={5} + width={"fit-content"} + sx={{ + display: 'flex', + alignItems: 'space-between', + justifyContent: 'space-between', + }} + > + <EditUserForm userId={id}/> + <DeleteButton userId={id}/> + </Box> + ))} + <div id="extramargin" style={{height: "100px"}}></div> + </TranslationsProvider> + ); +} + +export default UserEditPage; diff --git a/frontend/app/[locale]/admin/users/page.tsx b/frontend/app/[locale]/admin/users/page.tsx index 2e29593a..37798eae 100644 --- a/frontend/app/[locale]/admin/users/page.tsx +++ b/frontend/app/[locale]/admin/users/page.tsx @@ -1,18 +1,52 @@ -import React from 'react'; +"use client"; +import React, {useEffect, useState} from 'react'; import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import Footer from "@app/[locale]/components/Footer"; -import ListView from '@app/[locale]/components/ListView'; -import BackButton from '@app/[locale]/components/BackButton'; +import {fetchUserData, UserData} from "@lib/api"; +import UserList from "@app/[locale]/components/admin_components/UserList"; +import {Box, CircularProgress} from "@mui/material"; const i18nNamespaces = ['common']; -export default async function Users({ params: { locale } }: { params: { locale: any } }) { - const { t, resources } = await initTranslations(locale, i18nNamespaces); +export default function Users({ params: { locale } }: { params: { locale: any } }) { + const [resources, setResources] = useState(); + const [user, setUser] = useState<UserData | null>(null); + const [userLoading, setUserLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(false); + const [isLoading, setIsLoading] = useState(true); - const headers = [t('email'), t('role')]; - const headers_backend = ['email', 'role']; + useEffect(() => { + const initialize = async () => { + try { + const result = await initTranslations(locale, ["common"]); + setResources(result.resources); + const userData = await fetchUserData(); + setUser(userData); + if (userData.role !== 1) { + setAccessDenied(true); + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error initializing the page:", error); + } finally { + setUserLoading(false); + setIsLoading(false); + } + }; + + initialize(); + }, [locale]); + + if (isLoading) { + return ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ); + } return ( <TranslationsProvider @@ -21,26 +55,7 @@ export default async function Users({ params: { locale } }: { params: { locale: namespaces={i18nNamespaces} > <NavBar /> - <div style={{marginTop:60, padding:20}}> - <BackButton - destination={'/admin'} - text={t('back_to') + ' ' + t('admin') + ' ' + t('page')} - /> - <ListView - admin={true} - headers={headers} - headers_backend={headers_backend} - sortable={[true, false]} - get={'users'} - action_name={'remove'} - action_text={t('remove_user')} - search_text={t('search')} - /> - </div> - <BackButton - destination={'/admin'} - text={t('back_to') + ' ' + t('admin') + ' ' + t('page')} - /> + {!accessDenied && <UserList />} </TranslationsProvider> ); } diff --git a/frontend/app/[locale]/calendar/page.tsx b/frontend/app/[locale]/calendar/page.tsx new file mode 100644 index 00000000..55087499 --- /dev/null +++ b/frontend/app/[locale]/calendar/page.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ProjectCalendar from '../components/ProjectCalendar'; +import initTranslations from "@app/i18n"; +import NavBar from '@app/[locale]/components/NavBar'; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import {Box, Typography} from "@mui/material"; + + + +const CalendarPage: React.FC = async ({params: {locale}}: { params: { locale: any } }) => { + const {t, resources} = await initTranslations(locale, ['common']) + + return ( + <TranslationsProvider + resources={resources} + locale={locale} + namespaces={["common"]} + > + <NavBar/> + <Box + padding={15} + > + <Typography + variant={"h3"} + align={"center"} + > + {t("project_calendar")} + </Typography> + <ProjectCalendar/> + </Box> + </TranslationsProvider> +); +}; + +export default CalendarPage; diff --git a/frontend/app/[locale]/calendar/useProjects.tsx b/frontend/app/[locale]/calendar/useProjects.tsx new file mode 100644 index 00000000..74e627b0 --- /dev/null +++ b/frontend/app/[locale]/calendar/useProjects.tsx @@ -0,0 +1,43 @@ +import { useState, useEffect } from 'react'; +import { getCoursesForUser, getProjectsFromCourse } from '@lib/api'; + +interface Data { + id: number; + name: string; + deadline: string; // ISO date string +} + +export const useProjects = () => { + const [projects, setProjects] = useState<Data[]>([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<Error | null>(null); + + useEffect(() => { + const fetchProjects = async () => { + try { + const courses = await getCoursesForUser(); + const temp_projects: Data[] = []; + for (const course of courses) { + const course_projects = await getProjectsFromCourse(course.course_id); + for (const project of course_projects) { + temp_projects.push({ + id: project.project_id, + name: project.name, + deadline: project.deadline, + }); + } + } + console.log(temp_projects); + setProjects(temp_projects); + } catch (err) { + setError(err as Error); + } finally { + setLoading(false); + } + }; + + fetchProjects(); + }, []); + + return { projects, loading, error }; +}; diff --git a/frontend/app/[locale]/components/AccountMenu.tsx b/frontend/app/[locale]/components/AccountMenu.tsx index e30db10d..a9efdd43 100644 --- a/frontend/app/[locale]/components/AccountMenu.tsx +++ b/frontend/app/[locale]/components/AccountMenu.tsx @@ -80,7 +80,7 @@ export default function AccountMenu() { aria-haspopup="true" aria-expanded={open ? 'true' : undefined} > - <Typography variant="body1" sx={{ whiteSpace: 'nowrap'}}>{user?.first_name + " " + user?.last_name}</Typography> + <Typography variant="body1" sx={{ whiteSpace: 'nowrap'}}>{(user?.first_name ?? "------") + " " + (user?.last_name ?? "------")}</Typography> </Button> </Tooltip> </Box> diff --git a/frontend/app/[locale]/components/AddProjectButton.tsx b/frontend/app/[locale]/components/AddProjectButton.tsx index 672a5406..0b17a986 100644 --- a/frontend/app/[locale]/components/AddProjectButton.tsx +++ b/frontend/app/[locale]/components/AddProjectButton.tsx @@ -1,8 +1,8 @@ "use client"; import {useTranslation} from "react-i18next"; -import {Button, Typography} from "@mui/material"; -import Link from "next/link"; -import {addProject} from "@lib/api"; +import {Button, Typography, Skeleton} from "@mui/material"; +import {getUserData, UserData} from "@lib/api"; +import {useState, useEffect} from "react"; interface EditCourseButtonProps{ course_id:number @@ -10,30 +10,58 @@ interface EditCourseButtonProps{ const AddProjectButton = ({course_id}: EditCourseButtonProps) => { const {t} = useTranslation(); + const [user, setUser] = useState<UserData | null>(null); + const [loading, setLoading] = useState(true); + + + useEffect(() => { + const fetchUser = async () => { + try { + setUser(await getUserData()); + } catch (error) { + console.error("There was an error fetching the user data:", error); + } + } + + setLoading(false); + fetchUser(); + }, []) return ( - <Button - variant="contained" - color="secondary" - sx={{ - margin: '10px' - }} - onClick={async () => { - const project_id = await addProject(course_id); - window.location.href = `/project/${project_id}/edit`; - }} - > - <Typography - variant="subtitle1" + loading ? + <Skeleton + variant='rectangular' + width={150} + height={45} + sx={{ + borderRadius: '8px' + }} + /> : + <> + {user?.role !== 3 && ( + <Button + variant="contained" + color="secondary" sx={{ - color: 'secondary.contrastText', - display: 'inline-block', - whiteSpace: 'nowrap', + width: 'fit-content', + height: 'fit-content', }} + href={`/course/${course_id}/add_project/`} > - {t("add_project")} - </Typography> - </Button> + <Typography + variant="subtitle1" + sx={{ + color: 'secondary.contrastText', + display: 'inline-block', + whiteSpace: 'nowrap', + width: 'fit-content', + }} + > + {t("add_project")} + </Typography> + </Button> + )} + </> ) } export default AddProjectButton \ No newline at end of file diff --git a/frontend/app/[locale]/components/CourseBanner.tsx b/frontend/app/[locale]/components/CourseBanner.tsx index 242c5b4b..fd9bad87 100644 --- a/frontend/app/[locale]/components/CourseBanner.tsx +++ b/frontend/app/[locale]/components/CourseBanner.tsx @@ -1,10 +1,10 @@ "use client" import React, {useEffect, useState} from 'react'; -import {Box, Typography} from "@mui/material"; +import {Box, Typography, Skeleton} from "@mui/material"; import EditCourseButton from "@app/[locale]/components/EditCourseButton"; import {APIError, Course, getCourse, UserData, getUserData} from "@lib/api"; -import AddProjectButton from "@app/[locale]/components/AddProjectButton"; +import defaultBanner from "../../../public/ugent_banner.png"; interface CourseBannerProps { course_id: number; @@ -14,6 +14,7 @@ const CourseBanner = ({course_id}: CourseBannerProps) => { const [user, setUser] = useState<UserData | null>(null); const [course, setCourse] = useState<Course | null>(null); const [error, setError] = useState<APIError | null>(null); + const [loading, setLoading] = useState(true); useEffect(() => { const fetchCourse = async () => { @@ -24,62 +25,75 @@ const CourseBanner = ({course_id}: CourseBannerProps) => { if (error instanceof APIError) setError(error); console.log(error); } - }; - fetchCourse(); + fetchCourse().then(() => setLoading(false)); }, [course_id]); return ( - <Box - sx={{ - backgroundColor: 'primary.main', - color: 'whiteS', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - height: '140px', - width: "calc(100% - 40px)", - borderRadius: '16px', - margin: "0 auto", - }} - > + loading ? ( + <Skeleton + variant="rounded" + height={"150px"} + sx={{ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '16px', + margin: "0 auto", + }} + /> + ) : ( <Box - display="flex" - justifyContent="flex-start" - alignItems="center" - width={"calc(100% - 200px)"} - height={'100%'} + sx={{ + backgroundImage: `url(${course?.banner || defaultBanner.src})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", + color: 'whiteS', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '150px', + borderRadius: '16px', + }} > - <Typography - variant="h1" - textAlign="center" - noWrap={true} - padding={0} - sx={{ - color: 'white', - height: 'fit-content', - }} - > - {course?.name} - </Typography> - </Box> - {user?.role !== 3 ? ( <Box - height="100%" display="flex" - flexDirection="column" - justifyContent="flex-start" - alignItems="flex-start" - textAlign="left" - paddingY={2} + justifyContent={{ xs: 'center', sm: 'flex-start' }} + alignItems="center" + width={{ xs: '100%', sm: "calc(100% - 200px)" }} + height={{ xs: 'auto', sm: '100%' }} + textAlign={{ xs: 'center', sm: 'left' }} > - <EditCourseButton course_id={course_id}/> - <AddProjectButton course_id={course_id}/> + <Typography + variant="h4" + sx={{ + color: 'white', + height: 'fit-content', + fontSize: { xs: '1.5rem', sm: '2rem', md: '2.5rem' }, + whiteSpace: { xs: 'normal', sm: 'nowrap' }, + overflow: 'hidden', + textOverflow: 'ellipsis', + }} + > + {course?.name + (course?.year === null ? "" : " " + course?.year)} + </Typography> </Box> - ): null} - </Box> - ) + {user?.role !== 3 ? ( + <Box + display="flex" + justifyContent={{ xs: 'center', sm: 'flex-start' }} + alignItems="center" + paddingY={{ xs: 1, sm: 0 }} + width={{ xs: '100%', sm: 'auto' }} + > + <EditCourseButton course_id={course_id} /> + </Box> + ) : null} + </Box> + ) + ); } -export default CourseBanner \ No newline at end of file +export default CourseBanner; diff --git a/frontend/app/[locale]/components/CourseCard.tsx b/frontend/app/[locale]/components/CourseCard.tsx index f64c085e..0f6ab71e 100644 --- a/frontend/app/[locale]/components/CourseCard.tsx +++ b/frontend/app/[locale]/components/CourseCard.tsx @@ -1,35 +1,38 @@ "use client"; import React, {useEffect, useState} from 'react'; -import {ThemeProvider} from '@mui/material/styles'; -import {CourseCardTheme} from '../../../styles/theme'; -import { - Card, - CardContent, - Typography, -} from '@mui/material'; -import { - Course, - getLastSubmissionFromProject, - getProjectsFromCourse, - Project, - Submission, - User -} from "@lib/api"; +import {Card, CardContent, CardMedia, Typography, Box} from '@mui/material'; +import {Course, getLastSubmissionFromProject, getProjectsFromCourse, Project, Submission,} from "@lib/api"; import {useTranslation} from "react-i18next"; -import ListView from './ListView'; +import ListView from '@app/[locale]/components/ListView'; +import AccesAlarm from '@mui/icons-material/AccessAlarm'; +import Person from '@mui/icons-material/Person'; const CourseCard = ({params: {course}}: { params: { course: Course } }) => { - const [teachers, setTeachers] = useState<User[]>([]); + const [projects, setProjects] = useState<Project[]>([]); + const [last_submission, setSubmission] = + useState<Submission>({ + submission_id: 0, + group_id: 0, + submission_nr: 0, + file: '', + timestamp: '', + output_simple_test: false, + feedback_simple_test: {}, + eval_output: "", + eval_result: false, + }); + const [hover, setHover] = useState(false); + const {t} = useTranslation() useEffect(() => { const fetchProjects = async () => { try { - const fetched_projects: Project[] = await getProjectsFromCourse(course.course_id); + setProjects(await getProjectsFromCourse(course.course_id)); const fetched_submissions = new Map<number, Submission>(); - for (let i = 0; i < fetched_projects.length; i++) { - const project = fetched_projects[i]; - const last_submission = await getLastSubmissionFromProject(project.project_id); + for (let i = 0; i < projects.length; i++) { + const project = projects[i]; + setSubmission(await getLastSubmissionFromProject(project.project_id)); if (last_submission.group_id !== null) { fetched_submissions.set(project.project_id, last_submission); } @@ -40,49 +43,111 @@ const CourseCard = ({params: {course}}: { params: { course: Course } }) => { }; - const fetchTeachers = async () => { - try { - setTeachers(await getTeachersFromCourse(course.course_id)); - } catch (error) { - console.log(error); - } - - }; - fetchProjects(); }, [course.course_id]); - const headers = [t('name'), t('deadline'), t('view')] - const headers_backend = ['name', 'deadline', 'view'] + const headers = [ + <React.Fragment key="name"><Person style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('name')}</React.Fragment>, + <React.Fragment key="deadline"><AccesAlarm style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " +t('deadline')}</React.Fragment>, + '' + ]; + + const headers_backend = ['name', 'deadline', ''] + return ( - <ThemeProvider theme={CourseCardTheme}> - <Card> - <CardContent> - <Typography variant="h6" component="div" gutterBottom> - <a href={`/course/${course.course_id}`} style={{color: 'black'}}> + <Card + sx={{ + width: 600, + height: 450, + margin: '16px', + borderRadius: '8px', + border: hover? 1 : 'none', // Conditional border + transition: 'border 0.1s ease-in-out', + borderColor: 'secondary.main', + boxShadow: hover? 6 : 2, // Conditional shadow + }} + > + <CardMedia + sx={{ + height: 75, + backgroundColor: 'secondary.main', + }} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + > + <Box + display={'flex'} + justifyContent="flex-start" + alignItems="center" + height={'100%'} + width={'100%'} + onClick={() => window.location.href = `/course/${course.course_id}`} + sx={{ + cursor: 'pointer', + }} + > + <Typography + variant="h5" + component="div" + justifyContent={'center'} + margin={2} + > {course.name} - </a> - </Typography> - <Typography color="text.text" gutterBottom style={{whiteSpace: 'pre-line'}}> - {teachers.map((teacher: User) => teacher.first_name + " " + teacher.last_name).join('\n')} + </Typography> + </Box> + </CardMedia> + <CardContent + sx={{ + height: 300, + width: '100%', + }} + > + <Typography + variant="h6" + component="div" + justifyContent={'center'} + > + {t('projects')} </Typography> - <ListView - admin={false} - headers={headers} - headers_backend={headers_backend} - sortable={[true, true, false]} - get={'projects'} - get_id={course.course_id} - search={false} - page_size={3} - /> + { + projects.length == 0 ? ( + <Box + height={'100%'} + width={'100%'} + display={'flex'} + justifyContent={'center'} + alignItems={'center'} + > + <Typography + variant={'h4'} + alignContent={'center'} + alignItems={'center'} + sx= {{ + color: 'text.disabled' + }} + > + {t('no_projects')} + </Typography> + </Box> + ) : ( + <ListView + admin={false} + headers={headers} + headers_backend={headers_backend} + sortable={[true, true, false]} + get={'projects'} + get_id={course.course_id} + search={false} + page_size={3} + /> + ) + } </CardContent> </Card> - </ThemeProvider> ); }; -export default CourseCard; +export default CourseCard; \ No newline at end of file diff --git a/frontend/app/[locale]/components/CourseControls.tsx b/frontend/app/[locale]/components/CourseControls.tsx index 8ae4e359..d70fd1fc 100644 --- a/frontend/app/[locale]/components/CourseControls.tsx +++ b/frontend/app/[locale]/components/CourseControls.tsx @@ -1,23 +1,40 @@ "use client"; -import React, {useState} from 'react'; -import {Box, Button, MenuItem, Select, SelectChangeEvent, Stack, Typography} from '@mui/material'; -import FilterListIcon from '@mui/icons-material/FilterList'; +import React, {useEffect, useState} from 'react'; +import {Box, Button, MenuItem, Select, Stack, Typography, Skeleton} from '@mui/material'; import AddCircleIcon from '@mui/icons-material/AddCircle'; import ViewListIcon from '@mui/icons-material/ViewList'; import ArchiveIcon from '@mui/icons-material/Archive'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; import {useTranslation} from "react-i18next"; -import Link from 'next/link'; +import {APIError, fetchUserData, UserData} from "@lib/api"; -const CourseControls = () => { +const CourseControls = ({selectedYear, onYearChange}) => { const currentYear = new Date().getFullYear(); const academicYear = `${currentYear - 1}-${currentYear.toString().slice(-2)}`; - const [selectedYear, setSelectedYear] = useState(academicYear); + + const [user, setUser] = useState<UserData | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<APIError | null>(null); + const {t} = useTranslation() - const handleYearChange = (event: SelectChangeEvent) => { - setSelectedYear(event.target.value as string); - }; + useEffect(() => { + const fetchUser = async () => { + try { + setUser(await fetchUserData()); + } catch (error) { + if (error instanceof APIError) setError(error); + console.error(error); + } + }; + + fetchUser(); + setLoading(false); + }, []); + + + const years = [ `${currentYear - 2}-${(currentYear - 1).toString().slice(-2)}`, @@ -26,31 +43,105 @@ const CourseControls = () => { ]; return ( - <Box sx={{pt: 9, px: 2, display: 'flex', alignItems: 'center', gap: 2}}> - <Stack direction="column" spacing={2}> - <Typography variant="h6" gutterBottom> + loading ? + <Box + sx={{ + py:1, + px: 2, + display: 'flex', + alignItems: 'center', + gap: 2 + }}> + <Stack + marginX={{sm: 6, xs: 0}} + direction="column" + spacing={2} + width={'100%'} + > + <Typography variant="h3" gutterBottom> + {t("courses")} + </Typography> + <Stack direction="row" spacing={2} alignItems="center"> + {[1, 2, 3, 4, 5].map((i) => ( + <Skeleton + key={i} + variant="rectangular" + width={150} + height={45} + sx={{ + borderRadius: '8px' + }} + /> + ))} + </Stack> + </Stack> + </Box> + : + <Box + width={'100%'} + sx={{ + py:1, + px: 2, + display: 'flex', + alignItems: 'center', + gap: 2, + overflowX: 'auto' + }} + > + <Stack + marginX={{sm: 6, xs: 0}} + direction="column" + spacing={2} + width={'100%'} + > + <Typography variant="h3" gutterBottom> {t("courses")} </Typography> - <Stack direction="row" spacing={2} alignItems="center"> - <Button variant="contained" color="secondary" startIcon={<FilterListIcon/>}> - {t("filter_courses")} - </Button> - <Link href="/course/add" passHref> - <Button variant="contained" color="secondary" startIcon={<AddCircleIcon/>}> + <Box + display="flex" + justifyContent={'space-between'} + alignItems="center" + width={'fit-content'} + height={'fit-content'} + gap={2} + > + {user?.role !== 3 ? ( + <Button + variant="contained" + color="secondary" + startIcon={<AddCircleIcon/>} + href={'/course/add'} + > {t("create_course")} </Button> - </Link> - <Link href="/course/all" passHref> - <Button variant="contained" color="secondary" startIcon={<ViewListIcon/>}> - {t("all_courses")} - </Button> - </Link> - <Button variant="contained" color="secondary" startIcon={<ArchiveIcon/>}> + ) : null} + <Button + variant="contained" + color="secondary" + startIcon={<ViewListIcon/>} + href={'/course/all'} + > + {t("all_courses")} + </Button> + <Button + variant="contained" + color="secondary" + startIcon={<CalendarMonthIcon/>} + href={'/calendar'} + > + {t("deadlines")} + </Button> + <Button + variant="contained" + color="secondary" + startIcon={<ArchiveIcon/>} + href={'/course/archived'} + > {t("view_archive")} </Button> <Select value={selectedYear} - onChange={handleYearChange} + onChange={onYearChange} displayEmpty color="secondary" variant="outlined" @@ -63,10 +154,13 @@ const CourseControls = () => { </MenuItem> ))} </Select> - </Stack> + </Box> </Stack> </Box> ); + + + }; -export default CourseControls; +export default CourseControls; \ No newline at end of file diff --git a/frontend/app/[locale]/components/CourseDetails.tsx b/frontend/app/[locale]/components/CourseDetails.tsx index a02622ed..528d819a 100644 --- a/frontend/app/[locale]/components/CourseDetails.tsx +++ b/frontend/app/[locale]/components/CourseDetails.tsx @@ -84,9 +84,9 @@ export default function CourseDetails({course_id}: CourseDetailsProps) { <Typography variant="h6" > - {"https://sel2-1.ugent.be/" + "?token=" + course?.invite_token} + {"https://sel2-1.ugent.be/course/" + course?.course_id + "?token=" + course?.invite_token} </Typography> - <CopyToClipboardButton text={"https://sel2-1.ugent.be/" + "?token=" + course?.invite_token}/> + <CopyToClipboardButton text={"https://sel2-1.ugent.be/course/" + course?.course_id + "?token=" + course?.invite_token}/> </Box> </> ) : null diff --git a/frontend/app/[locale]/components/CoursesGrid.tsx b/frontend/app/[locale]/components/CoursesGrid.tsx index e05ccb36..c94bd4c3 100644 --- a/frontend/app/[locale]/components/CoursesGrid.tsx +++ b/frontend/app/[locale]/components/CoursesGrid.tsx @@ -1,37 +1,99 @@ "use client"; import React, {useEffect, useState} from 'react'; -import {APIError, Course, getCourses, getCoursesForUser, getUserData, UserData} from '@lib/api'; -import {Container, Grid} from '@mui/material'; -import CourseCard from './CourseCard'; +import {APIError, Course, getCoursesForUser} from '@lib/api'; +import { Grid, Skeleton } from '@mui/material'; +import CourseCard from '@app/[locale]/components/CourseCard'; +import {useTranslation} from "react-i18next"; -const CoursesGrid = () => { - const [user, setUser] = useState<UserData>({id: 0, emai: "", first_name: "", last_name: "", course: [], role: 3}); +const CoursesGrid = ({selectedYear}) => { + const [courses, setCourses] = useState<Course[]>([]); + const [filteredCourses, setFilteredCourses] = useState<Course[]>([]); + const [loading, setLoading] = useState(true); const [error, setError] = useState<APIError | null>(null); - const [courses, setCourses] = useState<Course[]>([]); // Initialize courses as an empty array + + const {t} = useTranslation() + + const loadingArray = [1, 2, 3, 4, 5, 6]; useEffect(() => { - const fetchCoursesAndUser = async () => { + const fetchCourses = async () => { try { - setUser(await getUserData()); setCourses(await getCoursesForUser()); } catch (error) { if (error instanceof APIError) setError(error); } }; - fetchCoursesAndUser(); - }, []); // assuming locale might affect how courses are fetched, though not used directly here + fetchCourses(); + setLoading(false); + }, []); + + useEffect(() => { + const [startYear, endYearSuffix] = selectedYear.split('-'); + const startYearNumber = parseInt(startYear, 10); + const endYearNumber = parseInt(startYear.slice(0, 2) + endYearSuffix, 10); + + const filtered = courses.filter(course => { + return course.year === startYearNumber || course.year === endYearNumber; + }); + + setFilteredCourses(filtered); + }, [selectedYear, courses]); return ( - <Container sx={{pt: 2, pb: 4, maxHeight: 'calc(150vh - 260px)', overflowY: 'auto'}}> - <Grid container justifyContent="center" alignItems="flex-start" spacing={2}> - {courses.map((course: Course, index) => ( - <Grid md={6} item ={true} key={index}> - <CourseCard params={{course: course}}/> - </Grid> - ))} + <Grid + container + justifyContent="center" + spacing={2} + + sx={{ + paddingRight: 2, + paddingBottom: 2, + flexGrow: 1 + }} + > + {loading ? ( + loadingArray.map((index) => ( + <Grid + item={true} + key={index} + xs={12} + sm={12} + md={12} + lg={6} + > + <Skeleton + variant="rounded" + sx={{ + height: 450, + width: 600, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '16px', + margin: "0 auto", + }} + /> + </Grid> + )) + ) : ( + filteredCourses.map((course: Course, index) => ( + <Grid + display={'flex'} + item + key={index} + xs={12} + sm={12} + md={12} + lg={6} + width={'100%'} + justifyContent={'center'} + > + <CourseCard params={{course: course}}/> + </Grid> + )) + )} </Grid> - </Container> ); }; diff --git a/frontend/app/[locale]/components/CreateCourseForm.tsx b/frontend/app/[locale]/components/CreateCourseForm.tsx index 24b947cd..b777c0ff 100644 --- a/frontend/app/[locale]/components/CreateCourseForm.tsx +++ b/frontend/app/[locale]/components/CreateCourseForm.tsx @@ -6,17 +6,24 @@ import {useTranslation} from "react-i18next"; import React, {useEffect, useState} from 'react'; import banner from '../../../public/ugent_banner.png' import Typography from "@mui/material/Typography"; -import {Button, Input, MenuItem, Select, TextField} from "@mui/material"; +import {Button, Input, MenuItem, Select, TextField, + Dialog, DialogActions, DialogTitle} from "@mui/material"; +import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; +import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import UploadFileIcon from "@mui/icons-material/UploadFile"; import {visuallyHidden} from "@mui/utils"; +import dayjs from "dayjs"; const CreateCourseForm = () => { - const {t} = useTranslation(); + const { t } = useTranslation(); const [selectedImage, setSelectedImage] = useState<File | null>(null); const [selectedImageURL, setSelectedImageURL] = useState<string>(""); const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [open, setOpen] = useState(false); + const [openConfirmation, setOpenConfirmation] = useState(false); // State for confirmation dialog + const [year, setYear] = useState(0); const handleImageUpload = (event: any) => { const imageFile = event.target.files[0]; @@ -28,22 +35,32 @@ const CreateCourseForm = () => { const handleSubmit = async (event: any) => { event.preventDefault(); + setOpenConfirmation(true); // Open confirmation dialog + }; + + const handleConfirmationClose = () => { + setOpenConfirmation(false); + }; + + const handleConfirmationYes = async () => { + setOpenConfirmation(false); const formData = new FormData(); formData.append('name', name); formData.append('description', description); formData.append('open_course', open.toString()); + formData.append('year', year.toString()); const fileReader = new FileReader(); fileReader.onload = async function () { const arrayBuffer = this.result; if (arrayBuffer !== null) { - formData.append('banner', new Blob([arrayBuffer], {type: 'image/png'})); + formData.append('banner', new Blob([arrayBuffer], { type: 'image/png' })); await postData("/courses/", formData).then((response) => { window.location.href = `/course/${response.course_id}`; }); } } if (selectedImage) fileReader.readAsArrayBuffer(selectedImage); - } + }; useEffect(() => { if (selectedImage === null) { @@ -89,10 +106,29 @@ const CreateCourseForm = () => { fontFamily: 'Quicksand, sans-serif', borderRadius: '6px', height: '30px', - width: '400px' + width: '400px', + marginBottom: '32px' }} /> </Box> + <Box + height={'fit-content'} + > + <Typography variant={"h3"}> + {t("year")} + </Typography> + <LocalizationProvider dateAdapter={AdapterDayjs}> + <DatePicker + views={['year']} + value={year !== 0 ? dayjs().year(year) : null} + onChange={(date: any) => setYear(date.year())} + sx={{ + width: 'fit-content', + height: 'fit-content', + }} + /> + </LocalizationProvider> + </Box> <Box sx={{marginTop: '32px', height: 'fit-content'}}> <Typography variant="h3" @@ -207,6 +243,22 @@ const CreateCourseForm = () => { {t("cancel")} </Button> </Box> + <Dialog + open={openConfirmation} + onClose={handleConfirmationClose} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{t("Are you sure you want to submit this course?")}</DialogTitle> + <DialogActions> + <Button onClick={handleConfirmationClose} color="primary"> + {t("cancel")} + </Button> + <Button onClick={handleConfirmationYes} color="primary" autoFocus> + {t("create_course")} + </Button> + </DialogActions> + </Dialog> </Box> ) } diff --git a/frontend/app/[locale]/components/EditCourseForm.tsx b/frontend/app/[locale]/components/EditCourseForm.tsx index e0ef6200..44600b17 100644 --- a/frontend/app/[locale]/components/EditCourseForm.tsx +++ b/frontend/app/[locale]/components/EditCourseForm.tsx @@ -3,9 +3,13 @@ import React, {useEffect, useState} from "react"; import {useTranslation} from "react-i18next"; import {getCourse, getImage, postData, updateCourse} from "@lib/api"; import Typography from "@mui/material/Typography"; -import {Box, Button, Input, MenuItem, Select, TextField} from "@mui/material"; +import {Box, Button, Input, LinearProgress, MenuItem, Select, TextField, Dialog, DialogActions, DialogTitle} from "@mui/material"; +import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; +import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; +import {DatePicker} from '@mui/x-date-pickers/DatePicker'; import {visuallyHidden} from '@mui/utils'; import UploadFileIcon from '@mui/icons-material/UploadFile'; +import dayjs from "dayjs"; interface EditCourseFormProps { courseId: number @@ -15,10 +19,13 @@ const EditCourseForm = ({courseId}: EditCourseFormProps) => { const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [open, setOpen] = useState(false); + const [year, setYear] = useState(0); const {t} = useTranslation(); const [selectedImage, setSelectedImage] = useState<File | null>(null); const [selectedImageURL, setSelectedImageURL] = useState<string>(""); const [loading, setLoading] = useState(true); + const [openConfirmation, setOpenConfirmation] = useState(false); // State for confirmation dialog + useEffect(() => { const fetchCourseData = async () => { @@ -27,6 +34,7 @@ const EditCourseForm = ({courseId}: EditCourseFormProps) => { setName(course.name); setDescription(course.description); setOpen(course.open_course); + setYear(course.year); const image = await getImage(course.banner); const fileReader = new FileReader(); fileReader.onload = function () { @@ -51,25 +59,34 @@ const EditCourseForm = ({courseId}: EditCourseFormProps) => { const handleSubmit = async (event: any) => { event.preventDefault(); + setOpenConfirmation(true); // Open confirmation dialog + }; + + const handleConfirmationClose = () => { + setOpenConfirmation(false); + }; + + const handleConfirmationYes = async () => { + setOpenConfirmation(false); const formData = new FormData(); formData.append('name', name); formData.append('description', description); formData.append('open_course', open.toString()); + formData.append('year', year.toString()); const fileReader = new FileReader(); fileReader.onload = async function () { const arrayBuffer = this.result; if (arrayBuffer !== null) { - formData.append('banner', new Blob([arrayBuffer], {type: 'image/png'})); - await postData("/courses/", formData).then((response) => { - window.location.href = `/course/${response.course_id}`; + formData.append('banner', new Blob([arrayBuffer], { type: 'image/png' })); + await updateCourse(courseId, formData).then((response) => { + window.location.href = `/course/${courseId}/`; }); } } if (selectedImage) fileReader.readAsArrayBuffer(selectedImage); - await updateCourse(courseId, formData); - // window.location.href = `/course/${courseId}/`; }; + const handleImageUpload = (event: any) => { const imageFile = event.target.files[0]; setSelectedImage(imageFile); @@ -78,152 +95,196 @@ const EditCourseForm = ({courseId}: EditCourseFormProps) => { setSelectedImageURL(imageURL); }; + if (loading) { + return <LinearProgress/>; + } + return ( - loading ? (<div>Loading...</div>) : ( + <Box + component={"form"} + onSubmit={handleSubmit} + sx={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-evenly', + flexWrap: 'no-wrap', + height: 'fit-content', + width: '100%', + }} + > <Box - component={"form"} - onSubmit={handleSubmit} - sx={{ - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-evenly', - height: 'fit-content', - width: '100%', - }} + height={'fit-content'} > - <Box - height={'fit-content'} + <Typography + variant="h3" > - <Typography - variant="h3" - > - {t("course name")} - </Typography> - <TextField type="text" id="name" name="name" defaultValue={name} - onChange={(event: any) => setName(event.target.value)} required style={{ + {t("course name")} + </Typography> + <TextField + type="text" + id="name" + name="name" + defaultValue={name} + onChange={(event: any) => setName(event.target.value)} + required + style={{ fontSize: '20px', fontFamily: 'Quicksand, sans-serif', borderRadius: '6px', height: '30px', - width: '400px' + width: '400px', + marginBottom: '32px' }}/> - </Box> - <Box sx={{marginTop: '32px', height: 'fit-content'}}> - <Typography - variant="h3" - > - {t("banner")} - </Typography> - <Box - borderRadius={'16px'} - sx={{position: 'relative', width: '100%', height: 'fit-content', borderRadius: '16px',}} - > - <Box - component={'img'} - alt={t('select image')} - src={selectedImageURL} - sx={{ - borderRadius: '16px', - height: 'fit-content', - maxHeight: '200px', - width: '100%' - }} - /> - </Box> - </Box> - <Box> - <Button variant={"contained"} color={"secondary"} size={'small'} - startIcon={<UploadFileIcon sx={{color: 'secondary.contrastText'}}/>} - disableElevation - component="label" - role={undefined} - tabIndex={-1} - sx={{ - padding: 1, - width: 'fit-content', - color: 'secondary.contrastText', - marginTop: '16px' - }} - > - {t("select image")} - <Input type="file" - id="Image" - name="Image" - onChange={handleImageUpload} - style={visuallyHidden} - /> - </Button> - </Box> - <Box sx={{marginTop: '16px'}}> - <Typography - variant="h3" - > - {t("description")} - </Typography> - <TextField id="description" name="description" defaultValue={description} - label="Description" - multiline - rows={4} - onChange={(event: any) => setDescription(event.target.value)} required - style={{ - width: '100%', - fontFamily: 'Quicksand', - color: 'black', - borderRadius: '6px', - padding: '10px', - boxSizing: 'border-box' - }}/> - </Box> - <Box sx={{marginTop: '16px'}}> - <Typography - variant="h3" - > - {t("access")} - </Typography> - <Select - id="choice" - name="choice" - label={t("access")} - value={open} - onChange={(event: any) => setOpen(event.target.value)} + </Box> + <Box + height={'fit-content'} + > + <Typography variant={"h3"}> + {t("year")} + </Typography> + <LocalizationProvider dateAdapter={AdapterDayjs}> + <DatePicker + views={['year']} + value={year !== 0 ? dayjs().year(year) : null} + onChange={(date: any) => setYear(date.year())} sx={{ - fontSize: '20px', + width: 'fit-content', height: 'fit-content', }} - > - <MenuItem value="false">{t("private")}</MenuItem> - <MenuItem value="true">{t("public")}</MenuItem> - </Select> - </Box> + /> + </LocalizationProvider> + </Box> + <Box sx={{marginTop: '32px', height: 'fit-content'}}> + <Typography + variant="h3" + > + {t("banner")} + </Typography> <Box - display={'flex'} - sx={{marginTop: '16px', gap: 2}} + borderRadius={'16px'} + sx={{position: 'relative', width: '100%', height: 'fit-content', borderRadius: '16px',}} > - <Button - type="submit" - color={'primary'} + <Box + component={'img'} + alt={t('select image')} + src={selectedImageURL} sx={{ - width: 'fit-content', - backgroundColor: 'primary.main', - color: 'primary.contrastText' + borderRadius: '16px', + height: 'fit-content', + maxHeight: '200px', + width: '100%' }} - > - {t("save changes")} - </Button> - <Button - href={'/course/' + courseId + "/"} + /> + </Box> + </Box> + <Box> + <Button variant={"contained"} color={"secondary"} size={'small'} + startIcon={<UploadFileIcon sx={{color: 'secondary.contrastText'}}/>} + component="label" + role={undefined} + tabIndex={-1} sx={{ + padding: 1, width: 'fit-content', - backgroundColor: 'secondary.main', - color: 'secondary.contrastText' + color: 'secondary.contrastText', + marginTop: '16px' }} - > + > + {t("select image")} + <Input type="file" + id="Image" + name="Image" + onChange={handleImageUpload} + style={visuallyHidden} + /> + </Button> + </Box> + <Box sx={{marginTop: '16px'}}> + <Typography + variant="h3" + > + {t("description")} + </Typography> + <TextField id="description" name="description" defaultValue={description} + label="Description" + multiline + rows={4} + onChange={(event: any) => setDescription(event.target.value)} required + style={{ + width: '100%', + fontFamily: 'Quicksand', + color: 'black', + borderRadius: '6px', + padding: '10px', + boxSizing: 'border-box' + }}/> + </Box> + <Box sx={{marginTop: '16px'}}> + <Typography + variant="h3" + > + {t("access")} + </Typography> + <Select + id="choice" + name="choice" + label={t("access")} + value={open} + onChange={(event: any) => setOpen(event.target.value)} + sx={{ + fontSize: '20px', + height: 'fit-content', + }} + > + <MenuItem value="false">{t("private")}</MenuItem> + <MenuItem value="true">{t("public")}</MenuItem> + </Select> + </Box> + <Box + display={'flex'} + sx={{marginTop: '16px', gap: 2}} + > + <Button + type="submit" + color={'primary'} + onClick={handleSubmit} + sx={{ + width: 'fit-content', + backgroundColor: 'primary.main', + color: 'primary.contrastText' + }} + > + {t("save changes")} + </Button> + <Button + href={'/course/' + courseId + "/"} + sx={{ + width: 'fit-content', + backgroundColor: 'secondary.main', + color: 'secondary.contrastText' + }} + > + {t("cancel")} + </Button> + </Box> + <Dialog + open={openConfirmation} + onClose={handleConfirmationClose} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{t("Are you sure you want to submit this course?")}</DialogTitle> + <DialogActions> + <Button onClick={handleConfirmationClose} color="primary"> {t("cancel")} </Button> - </Box> - </Box> - )); - + <Button onClick={handleConfirmationYes} color="primary" autoFocus> + {t("edit course")} + </Button> + </DialogActions> + </Dialog> + </Box> + ); } -export default EditCourseForm \ No newline at end of file +export default EditCourseForm diff --git a/frontend/app/[locale]/components/EditUserForm.tsx b/frontend/app/[locale]/components/EditUserForm.tsx new file mode 100644 index 00000000..f003fe8c --- /dev/null +++ b/frontend/app/[locale]/components/EditUserForm.tsx @@ -0,0 +1,190 @@ +"use client" +import React, {useEffect, useState} from "react"; +import {useTranslation} from "react-i18next"; +import {getUser, updateUserData} from "@lib/api"; +import Typography from "@mui/material/Typography"; +import {Box, Button, Input, MenuItem, Select, TextField} from "@mui/material"; + +interface EditUserFormProps { + userId: number +} + +const EditUserForm = ({userId}: EditUserFormProps) => { + const [firstname, setFirstName] = useState(''); + const [lastname, setLastName] = useState(''); + const [role, setRole] = useState(''); + const [email, setEmail] = useState(''); + const {t} = useTranslation(); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchCourseData = async () => { + try { + const user = await getUser(userId); + setFirstName(user.first_name); + setLastName(user.last_name); + setRole(user.role); + setEmail(user.email); + } catch (error) { + console.error("There was an error fetching the user data:", error); + } + setLoading(false); + }; + + fetchCourseData(); + }, [userId]); + + const handleSubmit = async (event: any) => { + event.preventDefault(); + const formData = new FormData(); + formData.append('first_name', firstname); + formData.append('last_name', lastname); + formData.append('role', role.toString()); + formData.append('email', email); + await updateUserData(userId, formData); + }; + + return ( + loading ? (<div>Loading...</div>) : ( + <Box + component={"form"} + onSubmit={handleSubmit} + sx={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-evenly', + height: 'fit-content', + width: '100%', + }} + > + <Typography + variant={'h3'} + paddingBottom={2} + > + {t("edit_user_details")} + </Typography> + <Box + height={'fit-content'} + mb={3} + > + <Typography + variant="h4" + > + {t("email")} + </Typography> + <Typography + variant={'body1'} + style={{ + + borderRadius: '6px', + height: 'fit-content', + width: '400px', + padding: '6px', // Add padding for better appearance + backgroundColor: '#f0f0f0', // Add background color for better contrast + }} + > + {email} + </Typography> + </Box> + <Box + height={'fit-content'} + mb={3} + > + <Typography + variant="h4" + > + {t("first name")} + </Typography> + <TextField + type="text" + id="name" + name="name" + defaultValue={firstname} + onChange={(event: any) => setFirstName(event.target.value)} + required + style={{ + fontSize: '20px', + borderRadius: '6px', + height: 'fit-content', + width: '400px' + }} /> + </Box> + <Box + height={'fit-content'} + mb={3} + > + <Typography + variant="h4" + > + {t("last name")} + </Typography> + <TextField + type="text" + id="name" + name="name" + defaultValue={lastname} + onChange={(event: any) => setLastName(event.target.value)} + required + style={{ + fontSize: '20px', + borderRadius: '6px', + height: 'fit-content', + width: '400px' + }} /> + </Box> + <Box + height={'fit-content'} + mb={3} + > + <Typography + variant="h4" + > + {t("role")} + </Typography> + <Select + value={role} + onChange={(event: any) => setRole(event.target.value)} + style={{ + fontSize: '20px', + borderRadius: '6px', + height: 'fit-content', + width: '400px' + }} + > + <MenuItem value={1}>{t("admin")}</MenuItem> + <MenuItem value={2}>{t("teacher")}</MenuItem> + <MenuItem value={3}>{t("student")}</MenuItem> + </Select> + </Box> + <Box + display={'flex'} + sx={{ marginTop: '16px', gap: 2 }} + > + <Button + variant="contained" + type="submit" + color={'primary'} + sx={{ + width: 'fit-content', + color: 'primary.contrastText' + }} + > + {t("save changes")} + </Button> + <Button + variant={'contained'} + href={'/admin/users'} + color='secondary' + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + }} + > + {t("cancel")} + </Button> + </Box> + </Box> + )); + +} +export default EditUserForm \ No newline at end of file diff --git a/frontend/app/[locale]/components/Footer.tsx b/frontend/app/[locale]/components/Footer.tsx index 97d80df1..66859de1 100644 --- a/frontend/app/[locale]/components/Footer.tsx +++ b/frontend/app/[locale]/components/Footer.tsx @@ -11,6 +11,7 @@ const Footer = () => { width={"100vw"} bottom={0} left={0} + marginTop={2} sx={{ display: 'flex', justifyContent: 'space-between', @@ -18,7 +19,7 @@ const Footer = () => { backgroundColor: 'primary.main', height: 80, marginX: 0, - position: 'fixed', + position: 'static', right: 0, bottom: 0, clear: 'both', diff --git a/frontend/app/[locale]/components/GroupSubmissionList.tsx b/frontend/app/[locale]/components/GroupSubmissionList.tsx index d0d334fe..04f67477 100644 --- a/frontend/app/[locale]/components/GroupSubmissionList.tsx +++ b/frontend/app/[locale]/components/GroupSubmissionList.tsx @@ -2,39 +2,39 @@ import ListView from "@app/[locale]/components/ListView"; import React from "react"; +import {useTranslation} from "react-i18next"; +import GroupsIcon from '@mui/icons-material/Groups'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -const GroupSubmissionList = ({project_id, showActions, page_size = 5}: { +const GroupSubmissionList = ({project_id, page_size = 5, search}: { project_id: number, - showActions: boolean, - page_size: number + page_size: number, + search: string }) => { - const headers = ["Group number", "Submission date", "Status", "View"] - const headers_backend = ["group_nr", "submission_date", "status", "View"] + const {t} = useTranslation() + const headers = [ + <React.Fragment key="group_nr"><GroupsIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('group_number')}</React.Fragment>, + <React.Fragment key="submission_date"><CalendarMonthIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('submission_date')}</React.Fragment>, + <React.Fragment key="status"><CheckCircleOutlineIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('Status')}</React.Fragment>, + "" + ]; + const headers_backend = ["group_nr", "submission_date", "status", ""] const sortable = [true, true, false] return ( - (showActions ? - <ListView - admin={true} - headers={headers} - get={'submissions_group'} - get_id={project_id} - sortable={sortable} - action_name={'download_submission'} - page_size={page_size} - headers_backend={headers_backend} - /> - : - <ListView - admin={true} - headers={headers} - get={'submissions_group'} - get_id={project_id} - sortable={sortable} - page_size={page_size} - headers_backend={headers_backend} - /> - ) + <ListView + admin={true} + headers={headers} + get={'submissions_group'} + get_id={project_id} + sortable={sortable} + action_name={'download_submission'} + page_size={page_size} + headers_backend={headers_backend} + search_text={search} + /> + ) } diff --git a/frontend/app/[locale]/components/JoinCourseWithToken.tsx b/frontend/app/[locale]/components/JoinCourseWithToken.tsx index e483ecf4..cb95515e 100644 --- a/frontend/app/[locale]/components/JoinCourseWithToken.tsx +++ b/frontend/app/[locale]/components/JoinCourseWithToken.tsx @@ -33,7 +33,7 @@ const JoinCourseWithToken = ({token, course_id}: { token: any, course_id: any }) } }; join(); - }, []); + }, [course_id, token]); if (joined) { // redirect to the course page (without the token parameter in the url) diff --git a/frontend/app/[locale]/components/LanguageSelect.tsx b/frontend/app/[locale]/components/LanguageSelect.tsx index 1d0d9642..dc1d21b6 100644 --- a/frontend/app/[locale]/components/LanguageSelect.tsx +++ b/frontend/app/[locale]/components/LanguageSelect.tsx @@ -1,6 +1,6 @@ "use client" -import {MenuItem, Select, SelectChangeEvent} from '@mui/material'; +import { MenuItem, Select, SelectChangeEvent } from '@mui/material'; import { useRouter } from 'next/navigation'; import { usePathname } from 'next/navigation'; import { useTranslation } from 'react-i18next'; @@ -12,7 +12,7 @@ const LanguageSelect = () => { const router = useRouter(); let currentPathname = usePathname(); - const handleChange = (e:SelectChangeEvent<String>) => { + const handleChange = (e: SelectChangeEvent<String>) => { const newLocale = e.target.value; // set cookie for next-i18n-router @@ -40,7 +40,7 @@ const LanguageSelect = () => { router.refresh(); }; - return( + return ( <div> <Select autoWidth @@ -77,11 +77,15 @@ const LanguageSelect = () => { } }} > - <MenuItem value='en'>en</MenuItem> - <MenuItem value='nl'>nl</MenuItem> + <MenuItem value='en'> + English + </MenuItem> + <MenuItem value='nl'> + Nederlands + </MenuItem> </Select> </div> ); } -export default LanguageSelect; \ No newline at end of file +export default LanguageSelect; diff --git a/frontend/app/[locale]/components/ListView.tsx b/frontend/app/[locale]/components/ListView.tsx index 80f9fe8f..2ae785f6 100644 --- a/frontend/app/[locale]/components/ListView.tsx +++ b/frontend/app/[locale]/components/ListView.tsx @@ -1,13 +1,38 @@ 'use client' import React, {useEffect, useState} from 'react'; -import {Box, Button, Checkbox, Container, CssBaseline, IconButton, TextField} from '@mui/material'; +import { + Box, + Button, + Checkbox, + Container, + CssBaseline, + Typography, + IconButton, + TextField, + tableCellClasses, + TableSortLabel, + TablePagination, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Dialog, + DialogActions, + DialogTitle +} from '@mui/material'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import CropSquareIcon from '@mui/icons-material/CropSquare'; import {styled} from '@mui/system'; import {NextPage} from 'next'; import checkMarkImage from './check-mark.png'; import CheckIcon from '@mui/icons-material/Check'; import CancelIcon from "@mui/icons-material/Cancel"; import { - deleteData, + deleteData, getArchivedCourses, getCourses, getGroups_by_project, getGroupSubmissions, @@ -19,112 +44,36 @@ import { getUser, getUserData, getUsers, - postData + postData, + getOpenCourses, + fetchUserData } from '@lib/api'; +import baseTheme from "../../../styles/theme"; +import {useTranslation} from "react-i18next"; +import StudentCoTeacherButtons from './StudentCoTeacherButtons'; const backend_url = process.env['NEXT_PUBLIC_BACKEND_URL']; -interface Theme { - theme: { - spacing: (multiplier: number) => number; - shadows: string[]; - palette: { - primary: { - dark: string; - contrastText: string; - }; - secondary: { - main: string; - }; - background: { - default: string; - }; - success: { - main: string; - }; - }; - }; -} - -const RootContainer = styled(Container)(({theme}: Theme) => ({ - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - backgroundColor: 'white', - padding: theme.spacing(1), - borderRadius: theme.spacing(1), - boxShadow: theme.shadows[1], - marginTop: '20px', - width: '75%', - maxWidth: '100%', -})); - -const Table = styled('table')(({theme}: Theme) => ({ - marginTop: theme.spacing(2), - width: '100%', - borderCollapse: 'collapse', - '& th, td': { - border: '1px solid #ddd', - padding: theme.spacing(1), - textAlign: 'left', - height: '24px', +const StyledTableCell = styled(TableCell)(() => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: baseTheme.palette.primary.main, + color: baseTheme.palette.primary.contrastText, }, - '& th': { - backgroundColor: theme.palette.primary.dark, - color: theme.palette.primary.contrastText, - fontSize: '14px', + [`&.${tableCellClasses.body}`]: { + fontSize: 14, }, })); -const TableRow = styled('tr')(({theme}: Theme) => ({ - '&:nth-child(even)': { - backgroundColor: theme.palette.background.default, - }, - '&:nth-child(odd)': { - backgroundColor: theme.palette.secondary.main, +const StyledTableRow = styled(TableRow)(() => ({ + '&:nth-of-type(odd)': { + backgroundColor: baseTheme.palette.secondary.main, }, -})); - -const GreenCheckbox = styled(Checkbox)(({theme}: Theme) => ({ - color: theme.palette.success.main, - '&.Mui-checked': { - color: theme.palette.success.main, + // hide last border + '&:last-child td, &:last-child th': { + border: 0, }, })); -const CustomCheckmarkWrapper = styled('div')({ - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: '100%', - height: '100%', -}); - -const WhiteSquareIcon = () => ( - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> - <rect width="12" height="12" fill="white"/> - </svg> -); - -const WhiteTriangleUpIcon = () => ( - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M6 12L12 0L0 0L6 12Z" fill="white"/> - </svg> -); - -const WhiteTriangleDownIcon = () => ( - <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M6 0L12 12L0 12L6 0Z" fill="white"/> - </svg> -); - - -const SearchBar = styled(TextField)({ - marginBottom: '16px', - width: '50%', // Adjust the width to cover only 50% of the container -}); - const RemoveButton = styled(Button)({ marginBottom: '16px', alignSelf: 'flex-end', @@ -148,7 +97,10 @@ interface ListViewProps { search: boolean; } -const convertDate = (date_str: string) => { +const convertDate = (t, date_str) => { + if (date_str === null) { + return t('no_deadline'); + } let date = new Date(date_str); date = new Date(date.getTime()); let date_local = date.toLocaleString('en-US', { @@ -184,10 +136,12 @@ const ListView: NextPage<ListViewProps> = ({ const [user, setUser] = useState<any>(); const [user_is_in_group, setUserIsInGroup] = useState(false); const [project, setProject] = useState<any>(); + const [group_size, setGroupSize] = useState(0); // multiple pages const [currentPage, setCurrentPage] = useState(1); const [previousPage, setPreviousPage] = useState(0); const [nextPage, setNextPage] = useState(0); + const {t} = useTranslation(); // ids of selected row items const [selected, setSelected] = useState<number[]>([]); @@ -221,7 +175,7 @@ const ListView: NextPage<ListViewProps> = ({ 'course_students': (data) => [data.id, data.email], 'course_teachers': (data) => [data.id, data.email], 'courses': (data) => [data.course_id, data.name, data.description, data.open_course], - 'projects': (data) => [data.project_id, data.name, convertDate(data.deadline)], + 'projects': (data) => [data.project_id, data.name, convertDate(t, data.deadline)], 'groups': async (data) => { let l = []; // Iterate over the values of the object @@ -232,10 +186,12 @@ const ListView: NextPage<ListViewProps> = ({ } l.push(i.email); } + setGroupSize((await getProject(data.project_id)).group_size); return [data.group_id, data.user, data.group_nr, l.join(', ')]; }, - 'submissions': (data) => [data.submission_id, data.group_id, convertDate(data.timestamp), data.output_test !== undefined], - 'submissions_group': (data) => [data.submission_id, data.group_id, convertDate(data.timestamp), data.output_test !== undefined] + 'submissions': (data) => [data.submission_id, data.group_id, convertDate(t, data.timestamp), data?.output_simple_test && data?.eval_result], + 'submissions_group': (data) => [data.submission_id, data.group_id, convertDate(t, data.timestamp), data?.output_simple_test && data?.eval_result], + 'archived_courses': (data) => [data.course_id, data.name, data.description, data.open_course], }; const hashmap_get_to_function: { [key: string]: (project_id?: number) => Promise<any> } = { @@ -249,7 +205,7 @@ const ListView: NextPage<ListViewProps> = ({ return parse_pages(await getTeachers_by_course(get_id, currentPage, page_size, searchTerm, sortConfig.key.toLowerCase(), sortConfig.direction === 'asc' ? 'asc' : 'desc')); }, 'courses': async () => { - return parse_pages(await getCourses(currentPage, page_size, searchTerm, sortConfig.key.toLowerCase(), sortConfig.direction === 'asc' ? 'asc' : 'desc')); + return parse_pages(await getOpenCourses(currentPage, page_size, searchTerm, sortConfig.key.toLowerCase(), sortConfig.direction === 'asc' ? 'asc' : 'desc')); }, 'projects': async () => { return parse_pages(await getProjects_by_course(get_id, currentPage, page_size, searchTerm, sortConfig.key.toLowerCase(), sortConfig.direction === 'asc' ? 'asc' : 'desc')); @@ -262,11 +218,14 @@ const ListView: NextPage<ListViewProps> = ({ }, 'submissions_group': async () => { return parse_pages(await getGroupSubmissions(get_id, currentPage, page_size, searchTerm, sortConfig.key.toLowerCase(), sortConfig.direction === 'asc' ? 'asc' : 'desc')); + }, + 'archived_courses': async () => { + return parse_pages(await getArchivedCourses(currentPage, page_size, searchTerm)); } }; // Get user data - const user = await getUserData(); + const user = await fetchUserData(); setUser(user); if (get === 'groups') { @@ -287,7 +246,7 @@ const ListView: NextPage<ListViewProps> = ({ }; fetchData(); // the values below will be constantly updated - }, [currentPage, searchTerm, currentPage, sortConfig]); + }, [searchTerm, currentPage, sortConfig, get, get_id, page_size]); const handleChangePage = (direction: 'next' | 'prev') => { @@ -324,23 +283,56 @@ const ListView: NextPage<ListViewProps> = ({ return ( - <GreenCheckbox checked={checked} onChange={handleCheckboxChange}> - <CustomCheckmarkWrapper> - {checked && <img src={checkMarkImage} alt="Checkmark" - style={{width: '100%', height: '100%', objectFit: 'contain'}}/>} - </CustomCheckmarkWrapper> - </GreenCheckbox> + <Checkbox checked={checked} onChange={handleCheckboxChange} sx={{color: "black"}}/> ); }; + const [open, setOpen] = useState(false); + const [checklist, setchecklist] = useState([]); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + + const deleteAction = () => { + const checkboxes = checklist; + checkboxes.forEach((checkbox, index) => { + if ((checkbox as HTMLInputElement).checked) { + const id = rows[index][0]; + if (!isNaN(id)) { + if (!isNaN(id)) { + if (action_name === 'remove_from_course') { + postData('/users/' + id + '/remove_course_from_user/', {course_id: get_id}) + .then(() => { + window.location.reload(); + }); + } + } + } else { + console.error("Invalid id", rows[index][0]); + } + } + }); + }; + return ( - <RootContainer component="main"> + <Box + display={'flex'} + flexDirection={'column'} + alignItems={'center'} + justifyContent={'center'} + label='list-view' + > <CssBaseline/> {search && - <SearchBar + <TextField label={search_text} variant="outlined" - fullWidth value={searchTerm} onChange={(e: { target: { value: any; }; }) => { setCurrentPage(1); @@ -351,54 +343,47 @@ const ListView: NextPage<ListViewProps> = ({ (checkbox as HTMLInputElement).checked = false; }); }} + InputLabelProps={{ + style: {color: 'gray'} + }} + sx={{ + width: '30%', + height: 'fit-content', + marginBottom: '16px', + }} /> } - {admin && action_name && action_name !== 'download_submission' && ( + {admin && action_name && action_name !== 'download_submission' && !(action_name && user?.role === 3) && ( <RemoveButton onClick={() => { const checkboxes = document.querySelectorAll('input[type="checkbox"]'); - checkboxes.forEach((checkbox, index) => { - if ((checkbox as HTMLInputElement).checked) { - /** - * - * EDIT - * - */ - const id = rows[index][0]; - if (!isNaN(id)) { - if (!isNaN(id)) { - if (action_name === 'remove_from_course') { - postData('/users/' + id + '/remove_course_from_user/', {course_id: get_id}) - .then(() => { - window.location.reload(); - }); - } else if (action_name === 'remove') { - deleteData('/users/' + id) - .then(() => { - window.location.reload(); - }); - } else if (action_name === 'join_course') { - postData('/courses/' + id + '/join_course/', {course_id: id}) - .then(() => { - window.location.href = '/course/' + id; - }); - } - } - } else { - console.error("Invalid id", rows[index][0]); - } - } - }); + setchecklist(checkboxes) + handleOpen(); }} > { action_text } </RemoveButton> - )} - - {admin && action_name && action_name === 'download_submission' && ( + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{t("Are you sure you want to delete the selection?")}</DialogTitle> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {t("cancel")} + </Button> + <Button onClick={deleteAction} color="error" autoFocus> + {t("delete")} + </Button> + </DialogActions> + </Dialog> + + {admin && user?.role !== 3 && action_name && action_name === 'download_submission' && ( <RemoveButton onClick={() => { const download_url = `${backend_url}/projects/${get_id}/download_submissions` @@ -409,7 +394,7 @@ const ListView: NextPage<ListViewProps> = ({ </RemoveButton> )} - {admin && action_name && action_name === 'download_submission' && ( + {admin && user?.role !== 3 && action_name && action_name === 'download_submission' && ( <RemoveButton onClick={() => { const selected_ids = Array.from(document.querySelectorAll('input[type="checkbox"]')) @@ -432,134 +417,218 @@ const ListView: NextPage<ListViewProps> = ({ Download selected submissions </RemoveButton> )} - - <Table> - <thead> - <tr> - {(get !== 'groups' && get !== 'projects' && !(get === 'submissions' && !action_name)) && - <th>Select</th>} - {headers.map((header, index) => - <th key={index}> - {sortable[index] && - <IconButton size="small" onClick={() => handleSort(headers_backend[index])}> - {sortConfig.key === headers_backend[index] ? (sortConfig.direction === 'asc' ? - <WhiteTriangleUpIcon/> : <WhiteTriangleDownIcon/>) : <WhiteSquareIcon/>} - </IconButton> - } - {header} - </th> - )} - </tr> - </thead> - <tbody> - {rows.map((row, index) => ( - <TableRow key={index}> - {((get !== 'groups' && get !== 'projects' && !(get === 'submissions' && !action_name)) && - <td> - {<CheckBoxWithCustomCheck checked={false}/>} - </td>)} - {get === 'groups' && row.slice(2).map((cell, cellIndex) => ( - <td key={cellIndex}>{typeof cell == "boolean" ? (cell ? <CheckIcon/> : - <CancelIcon/>) : cell}</td> - ))} - {get !== 'groups' && row.slice(1).map((cell, cellIndex) => ( - <td key={cellIndex}>{typeof cell == "boolean" ? (cell ? <CheckIcon/> : - <CancelIcon/>) : cell}</td> - ))} - { - // course leave button - get === 'courses' && user.course.includes(row[0]) && ( - <td> - <Button - onClick={() => postData('/courses/' + row[0] + '/leave_course/', {course_id: row[0]}).then(() => window.location.reload()) - }> - Leave - </Button> - </td> - ) - } - { - // course join button - get === 'courses' && (!user.course.includes(row[0])) && ( - <td> - <Button - onClick={() => postData('/courses/' + row[0] + '/join_course/', {course_id: row[0]}).then(() => window.location.href = '/course/' + row[0]) - } - disabled={!row[3]} - style={{backgroundColor: row[3] ? '' : 'gray'}} - > - Join - </Button> - </td> - ) - } - { - // group join button - get === 'groups' && (!row[1].includes(user.id)) && ( - <td> - { - // join button isn't shown when user is already in group - // or when group is full - // TODO i18n join button - (user.role == 3) && (!user_is_in_group) && (row[1].length < project.group_size) && ( + <Paper + sx={{ + width: "100%", + }} + > + <TableContainer component={Paper}> + <Table + sx={{ + width: "100%", + borderRadius: '16px' + }} + > + <TableHead> + <TableRow> + {(get !== 'groups' && get !== 'projects' && get !== 'courses' && !(get === 'submissions' && !action_name)) && + get !== 'course_teachers' && get !== 'users' && !(action_name && user?.role === 3) && get !== 'archived_courses' && + <StyledTableCell> + <Typography + variant={"body1"} + sx={{ + color: 'primary.contrastText', + display: 'inline-block', + whiteSpace: 'nowrap', + }} + > + {t('select')} + </Typography> + </StyledTableCell>} + {headers.map((header, index) => + <StyledTableCell key={index}> + {sortable[index] && <Button - onClick={() => postData('/groups/' + row[0] + '/join/', {group_id: row[0]}).then(() => window.location.reload()) - }> - Join + onClick={() => handleSort(headers_backend[index])} + endIcon={ + sortable[index] && + sortConfig.key === headers_backend[index] ? (sortConfig.direction === 'asc' ? + <KeyboardArrowUpIcon sx={{color: "white"}}/> : + <KeyboardArrowDownIcon sx={{color: "white"}}/>) : + <KeyboardArrowUpIcon sx={{color: "primary.main"}}/> + } + sx={{ + width: 'fit-content', + textAlign: 'left', + alignContent: 'left', + '&:hover': { + '.MuiButton-endIcon': { + '& svg': { + color: 'DarkGray', + }, + }, + }, + }} + > + <Typography + variant={"body1"} + sx={{ + color: 'primary.contrastText', + display: 'inline-block', + whiteSpace: 'nowrap', + }} + > + {header} + </Typography> </Button> + } + {!sortable[index] && + <Typography + variant={"body1"} + sx={{ + color: 'primary.contrastText', + display: 'inline-block', + whiteSpace: 'nowrap', + }} + > + {header} + </Typography> + } + </StyledTableCell> + )} + </TableRow> + </TableHead> + <TableBody> + {rows.map((row, index) => ( + <StyledTableRow key={index}> + {((get !== 'groups' && get !== 'projects' && get !== 'courses' && !(get === 'submissions' && !action_name) && get != 'users') && + get !== 'course_teachers' && !(action_name && user?.role === 3) && get !== 'archived_courses' && + <StyledTableCell> + {<CheckBoxWithCustomCheck checked={false}/>} + </StyledTableCell>)} + {get === 'groups' && row.slice(2).map((cell, cellIndex) => ( + <StyledTableCell key={cellIndex}>{typeof cell == "boolean" ? (cell ? + <CheckIcon/> : + <CancelIcon/>) : cell}</StyledTableCell> + ))} + {get !== 'groups' && row.slice(1).map((cell, cellIndex) => ( + <StyledTableCell key={cellIndex}>{typeof cell == "boolean" ? (cell ? + <CheckIcon/> : + <CancelIcon/>) : cell}</StyledTableCell> + ))} + { + // course leave button + get === 'courses' && user.course.includes(row[0]) && ( + <StyledTableCell> + <Button + onClick={() => postData('/courses/' + row[0] + '/leave_course/', {course_id: row[0]}).then(() => window.location.reload()) + }> + {t('Leave')} + </Button> + </StyledTableCell> ) } - </td>) - } - { - // group leave button - get === 'groups' && (row[1].includes(user.id)) && ( - <td> { - (user.role == 3) && (user_is_in_group) && ( + // course join button + get === 'courses' && (!user.course.includes(row[0])) && ( + <StyledTableCell> + <Button + onClick={() => postData('/courses/' + row[0] + '/join_course/', {course_id: row[0]}).then(() => window.location.href = '/course/' + row[0]) + } + disabled={!row[3]} + style={{backgroundColor: row[3] ? '' : 'gray'}} + > + {t('Join')} + </Button> + </StyledTableCell> + ) + } + { + // group join button + get === 'groups' && (!row[1].includes(user.id)) && ( + <StyledTableCell> + { + // join button isn't shown when user is already in group + // or when group is full + // TODO i18n join button + (user.role == 3) && (!user_is_in_group) && (row[1].length < project.group_size) && ( + <Button + onClick={() => postData('/groups/' + row[0] + '/join/', {group_id: row[0]}).then(() => window.location.reload()) + }> + {t('Join')} + </Button> + ) + } + </StyledTableCell>) + } + { + // view archived course + get == 'archived_courses' && + <StyledTableCell> + <Button onClick={() => window.location.href = '/course/' + row[0]}> + {t('View')} + </Button> + </StyledTableCell> + } + { + // group leave button + get === 'groups' && (row[1].includes(user.id)) && ( + <StyledTableCell> + { + (user.role == 3) && (user_is_in_group) && (group_size > 1) && ( + <Button + onClick={() => postData('/groups/' + row[0] + '/leave/', {group_id: row[0]}).then(() => window.location.reload()) + }> + {t('Leave')} + </Button> + )} + </StyledTableCell>) + } + {get == 'projects' && ( + <StyledTableCell> + <Button onClick={() => window.location.href = '/project/' + row[0]}> + {t('View')} + </Button> + </StyledTableCell> + )} + {(get == 'submissions' || get == 'submissions_group') && ( + <StyledTableCell> + <Button onClick={() => window.location.href = '/submission/' + row[0]}> + {t('View')} + </Button> + </StyledTableCell> + + )} + {get == 'users' && ( + <StyledTableCell> <Button - onClick={() => postData('/groups/' + row[0] + '/leave/', {group_id: row[0]}).then(() => window.location.reload()) - }> - Leave + onClick={() => window.location.href = '/admin/users/' + row[0] + '/edit'}> + {t('Edit')} </Button> - )} - </td>) - } - {get == 'projects' && ( - <td> - <Button onClick={() => window.location.href = '/project/' + row[0]}> - View - </Button> - </td> - - )} - {get == 'submissions' && ( - <td> - <Button onClick={() => window.location.href = '/submission/' + row[0]}> - View - </Button> - </td> - - )} - </TableRow> - ))} - </tbody> - </Table> + </StyledTableCell> + + )} + </StyledTableRow> + ))} + </TableBody> + </Table> + </TableContainer> + </Paper> <Box style={{display: 'flex', gap: '8px'}}> <Button disabled={previousPage === 0} onClick={() => handleChangePage('prev')} > - Prev + {t('Prev')} </Button> <Button disabled={nextPage === 0} onClick={() => handleChangePage('next')} > - Next + {t('Next')} </Button> </Box> - </RootContainer> + </Box> ); } export default ListView; \ No newline at end of file diff --git a/frontend/app/[locale]/components/NavBar.tsx b/frontend/app/[locale]/components/NavBar.tsx index 42c6a4c3..a8d7dfdc 100644 --- a/frontend/app/[locale]/components/NavBar.tsx +++ b/frontend/app/[locale]/components/NavBar.tsx @@ -53,7 +53,7 @@ const NavBar = () => { const handleBottomItems = (event: React.MouseEvent<HTMLElement>, button: string) => { switch (button) { case t('manual'): - //TODO: Route to manual page(in wiki or separate page?) + window.location.href = "https://github.com/SELab-2/UGent-1/blob/user-manual/user_manual/manual.pdf" break; case t('github'): window.location.href = 'https://github.com/SELab-2/UGent-1' @@ -115,7 +115,7 @@ const NavBar = () => { {user?.role === 1 ? ( <> <Divider/> - <Link href={'/admin'} style={{textDecoration: 'none', color: 'inherit'}}> + <Link href={'/admin/users'} style={{textDecoration: 'none', color: 'inherit'}}> <ListItemButton> <ListItemIcon> <ConstructionIcon/> diff --git a/frontend/app/[locale]/components/ProfileEditCard.tsx b/frontend/app/[locale]/components/ProfileEditCard.tsx index 776d5023..03f5f85c 100644 --- a/frontend/app/[locale]/components/ProfileEditCard.tsx +++ b/frontend/app/[locale]/components/ProfileEditCard.tsx @@ -63,13 +63,14 @@ const ProfileEditCard = () => { formData.append('email', user.email); // Assuming you want to send email or other user details formData.append('first_name', user.first_name); formData.append('last_name', user.last_name); + formData.append('course', user.course); const fileReader = new FileReader(); fileReader.onload = async () => { const arrayBuffer = fileReader.result; formData.append('picture', new Blob([arrayBuffer], {type: 'image/png'})); try { await updateUserData(user.id, formData).then((response) => { - window.location.href = '/profile/'; + //window.location.href = '/profile/'; }); } catch (error) { if (error instanceof APIError) setError(error); @@ -143,7 +144,7 @@ const ProfileEditCard = () => { </Typography> </Box> <Box sx={{display: 'flex', gap: 2, width: '100%', justifyContent: 'center'}}> - <Button variant="contained" color="primary" onClick={handleSaveChanges}> + <Button variant="contained" color="primary" data-testid="save-changes" onClick={handleSaveChanges}> {t('save_changes')} </Button> <Link href="/profile" passHref> diff --git a/frontend/app/[locale]/components/ProjectCalendar.tsx b/frontend/app/[locale]/components/ProjectCalendar.tsx new file mode 100644 index 00000000..15524bc9 --- /dev/null +++ b/frontend/app/[locale]/components/ProjectCalendar.tsx @@ -0,0 +1,213 @@ +'use client'; + +import React, { useState, useRef, useEffect } from 'react'; +import { DateCalendar, PickersDay, PickersDayProps } from '@mui/x-date-pickers'; +import { CircularProgress, Box, Typography, Paper, List, ListItem, Divider, Badge, Skeleton } from '@mui/material'; +import ScheduleIcon from '@mui/icons-material/Schedule'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { useProjects } from '../calendar/useProjects'; +import { useTranslation } from 'react-i18next'; + +function fakeFetch( + date: Date, + { signal }: { signal: AbortSignal }, + deadlines: Date[] +) { + return new Promise<{ deadlinesToDisplay: Date[] }>((resolve, reject) => { + const timeout = setTimeout(() => { + const deadlinesToDisplay = deadlines.filter( + (deadline) => + deadline.getMonth() === date.getMonth() && + deadline.getFullYear() === date.getFullYear() + ); + resolve({ deadlinesToDisplay }); + }, 500); + + signal.onabort = () => { + clearTimeout(timeout); + reject(new DOMException('aborted', 'AbortError')); + }; + }); +} + +const ProjectCalendar: React.FC = () => { + const requestAbortController = useRef<AbortController | null>(null); + const [value, setValue] = useState<Date | null>(new Date()); + const [highlightedDays, setHighlightedDays] = useState<number[]>([]); + const [loadingCalendar, setLoadingCalendar] = useState<boolean>(true); + const { projects, loading, error } = useProjects(); + const { t } = useTranslation(); + + const fetchHighlightedDays = (date: Date) => { + const controller = new AbortController(); + fakeFetch( + date, + { signal: controller.signal }, + projects.map((project) => new Date(project.deadline)) + ) + .then(({ deadlinesToDisplay }) => { + const uniqueDays = new Set( + deadlinesToDisplay.map((deadline) => deadline.getDate()) + ); + setHighlightedDays(Array.from(uniqueDays)); + }) + .catch((error: Error) => { + if (error.name !== 'AbortError') { + console.error(error); + } + }); + + requestAbortController.current = controller; + }; + + useEffect(() => { + try { + fetchHighlightedDays(new Date()); + } catch (error) { + console.error(error); + } finally { + setLoadingCalendar(false); + } + return () => requestAbortController.current?.abort(); + }, [projects]); + + if (loading || loadingCalendar) { + return( + <Box + display={'flex'} + flexDirection={'column'} + justifyContent={'center'} + alignItems={'center'} + width={'100%'} + > + <Skeleton + variant="rectangular" + width={300} height={300} + sx={{ + borderRadius: 1, + marginTop: 2 + }} + /> + <Skeleton + variant="rectangular" + width={400} height={100} + sx={{ + borderRadius: 1, + marginTop: 4 + }} + /> + </Box> + ); + } + + if (error) { + return <div>Error: {error.message}</div>; + } + + const isSameDay = (date1: Date, date2: Date): boolean => { + return ( + date1.getFullYear() === date2.getFullYear() && + date1.getMonth() === date2.getMonth() && + date1.getDate() === date2.getDate() + ); + }; + + const handleMonthChange = (date: Date) => { + if (requestAbortController.current) { + requestAbortController.current.abort(); + } + + setHighlightedDays([]); + fetchHighlightedDays(date); + }; + + const handleYearChange = (date: Date) => { + if (requestAbortController.current) { + requestAbortController.current.abort(); + } + + setHighlightedDays([]); + fetchHighlightedDays(date); + }; + + const renderProjects = (date: Date) => { + const projectsOnThisDay = projects.filter((project) => + isSameDay(new Date(project.deadline), date) + ); + return projectsOnThisDay.length > 0 ? ( + <List> + {projectsOnThisDay.map((project, index) => ( + <div key={project.id}> + <ListItem onClick={() => window.location.href = `/project/${project.id}`}> + <Typography>{project.name}</Typography> + </ListItem> + {index !== projectsOnThisDay.length - 1 && <Divider />} + </div> + ))} + </List> + ) : ( + <Typography marginTop={2} color={'text.disabled'}> + {t('no_projects_on_selected_date')} + </Typography> + ); + }; + + function customDay(props: PickersDayProps<Date> & { highlightedDays?: number[] }) { + const { highlightedDays = [], day, outsideCurrentMonth, ...other } = props; + + const isHighlighted = !outsideCurrentMonth && highlightedDays.includes(day.getDate()); + + return ( + <Badge + key={props.day.toString()} + overlap="circular" + badgeContent={ + isHighlighted ? ( + <ScheduleIcon color={'primary'} fontSize={'small'} /> + ) : undefined + } + > + <PickersDay {...other} outsideCurrentMonth={outsideCurrentMonth} day={day} /> + </Badge> + ); + } + + return ( + <Box + sx={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + }} + > + <LocalizationProvider dateAdapter={AdapterDateFns}> + <DateCalendar + value={value} + onChange={(date) => { + setValue(date); + }} + onMonthChange={(newValue) => handleMonthChange(newValue)} + onYearChange={(newValue) => handleYearChange(newValue)} + slots={{ + day: customDay, + }} + slotProps={{ + day: { + highlightedDays, + } as never, + }} + /> + </LocalizationProvider> + <Paper sx={{ marginTop: 2, padding: 2, width: '100%' }}> + <Typography variant="h5" sx={{ marginBottom: 2 }}> + {t('projects_on_selected_date')} + </Typography> + <Divider /> + {value && renderProjects(value)} + </Paper> + </Box> + ); +}; + +export default ProjectCalendar; diff --git a/frontend/app/[locale]/components/ProjectDetailsPage.tsx b/frontend/app/[locale]/components/ProjectDetailsPage.tsx new file mode 100644 index 00000000..f0c4b093 --- /dev/null +++ b/frontend/app/[locale]/components/ProjectDetailsPage.tsx @@ -0,0 +1,347 @@ +'use client' +import React, { useEffect, useState } from "react"; +import {checkGroup, fetchUserData, getGroup, getProject, getUserData, Project, UserData} from "@lib/api"; +import { useTranslation } from "react-i18next"; +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import { Grid, IconButton, LinearProgress, ThemeProvider, Skeleton } from "@mui/material"; +import ProjectSubmissionsList from "@app/[locale]/components/ProjectSubmissionsList"; +import GroupSubmissionList from "@app/[locale]/components/GroupSubmissionList"; +import baseTheme from "@styles/theme"; +import Button from "@mui/material/Button"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import AddIcon from "@mui/icons-material/Add"; +import GroupIcon from "@mui/icons-material/Group"; +import EditIcon from "@mui/icons-material/Edit"; +import Divider from "@mui/material/Divider"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import DownloadIcon from "@mui/icons-material/Download"; +import AccessTimeIcon from "@mui/icons-material/AccessTime"; + +const backend_url = process.env["NEXT_PUBLIC_BACKEND_URL"]; + +interface ProjectDetailsPageProps { + locale: any; + project_id: number; +} + +const ProjectDetailsPage: React.FC<ProjectDetailsPageProps> = ({ + locale, + project_id, +}) => { + const { t } = useTranslation(); + + const [project, setProject] = useState<Project>(); + const [loadingProject, setLoadingProject] = useState<boolean>(true); + const [user, setUser] = useState<UserData | null>(null); + const [isExpanded, setIsExpanded] = useState(false); + const [loadingUser, setLoadingUser] = useState(true); + const [isInGroup, setIsInGroup] = useState(false); + const previewLength = 300; + const deadlineColorType = project?.deadline + ? checkDeadline(project.deadline) + : "textSecondary"; + const deadlineColor = + baseTheme.palette[deadlineColorType]?.main || + baseTheme.palette.text.secondary; + + + useEffect(() => { + const fetchUser = async () => { + try { + setUser(await fetchUserData()); + } catch (error) { + console.error("There was an error fetching the user data:", error); + } + }; + + fetchUser().then(() => setLoadingUser(false)); + }, []); + + useEffect(() => { + const fetchProject = async () => { + try { + setProject(await getProject(project_id)); + } catch (error) { + console.error("There was an error fetching the project:", error); + } + }; + + fetchProject().then(() => setLoadingProject(false)); + checkGroup(project_id).then((response) => setIsInGroup(response)); + }, [project_id]); + + useEffect(() => { + if (!loadingUser && !loadingProject && user) { + if (!user.course.includes(Number(project?.course_id))) { + window.location.href = `/403/`; + } else { + console.log("User is in course"); + } + } + }, [loadingUser, user, loadingProject, project]); + + if (loadingProject) { + return <LinearProgress />; + } + + const toggleDescription = () => { + setIsExpanded(!isExpanded); + }; + + function formatDate(isoString: string): string { + const options: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }; + const date = new Date(isoString); + return date.toLocaleString(locale, options); + } + + function checkDeadline(deadline) { + const now = new Date(); + const deadlineDate = new Date(deadline); + return now < deadlineDate ? "success" : "failure"; + } + + return ( + <ThemeProvider theme={baseTheme}> + <Box style={{ padding: "16px", maxWidth: "100%" }}> + <Grid container spacing={2} alignItems="center"> + <Grid item xs={12}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon />} + href={`/${locale}/course/${project?.course_id}`} + > + {t("return_course")} + </Button> + <Box + display='flex' + justifyContent='space-between' + alignItems='center' + width={'100%'} + marginY={2} + > + <Typography + variant="h4" + display={'inline-block'} + whiteSpace={'nowrap'} + marginRight={2} + > + {project?.name} + </Typography> + <Box + width={'fit-content'} + > + {loadingUser ? ( + [1, 2].map((i) => ( + <Skeleton + key={i} + variant="rectangular" + width={150} + height={45} + sx={{ + borderRadius: "8px", + marginX: 1, + }} + /> + ))) : ( + <> + {user?.role !== 3 && ( + <> + <Button + variant="contained" + color="secondary" + href={`/${locale}/project/${project_id}/submissions`} + sx={{ + fontSize: "0.75rem", + py: 1, + marginRight: 1, + marginY: 1, + }} + > + {t("submissions")} + </Button> + <Button + variant="contained" + color="secondary" + startIcon={<EditIcon />} + href={`/${locale}/project/${project_id}/edit`} + sx={{ + fontSize: "0.75rem", + py: 1, + marginRight: 1, + marginY: 1, + }} + > + {t("edit_project")} + </Button> + </> + )} + <Button + variant="contained" + color="secondary" + startIcon={<GroupIcon />} + href={`/${locale}/project/${project_id}/groups`} + sx={{ fontSize: "0.75rem", py: 1 }} + > + {t("groups")} + </Button> + </> + )} + </Box> + </Box> + <Divider style={{ marginBottom: "1rem" }} /> + <Typography variant="h5">{t("assignment")}</Typography> + <Typography> + {project?.description && + project?.description.length > previewLength && + !isExpanded + ? `${project?.description.substring(0, previewLength)}...` + : project?.description} + </Typography> + {project?.description && project?.description.length > previewLength && ( + <IconButton color="primary" onClick={toggleDescription}> + {isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />} + </IconButton> + )} + <Typography variant="h6">{t("required_files")}</Typography> + {project?.file_structure && project?.file_structure.length > 0 ? ( + <Typography variant={"body1"}> + <pre> + {generateDirectoryTree(project?.file_structure).split('\n').map((line: string, index: number) => ( + <React.Fragment key={index}> + {line} + <br/> + </React.Fragment> + ))} + </pre> + </Typography> + ) : ( + <Typography>{t("no_required_files")}</Typography> + )} + <Typography variant="h6">{t("conditions")}</Typography> + <Typography>{project?.conditions}</Typography> + <Typography> + <b>{t("max_score")}: </b> + {project?.max_score} + </Typography> + <Typography> + <b>{t("number_of_groups")}: </b> + {project?.number_of_groups} + </Typography> + <Typography> + <b>{t("group_size")}: </b> + {project?.group_size} + </Typography> + {user?.role !== 3 && ( + <Button + variant="contained" + color="secondary" + href={`${backend_url}/projects/${project_id}/download_testfiles`} + startIcon={<DownloadIcon />} + sx={{ my: 1 }} + > + {t("test_files")} + </Button> + )} + </Grid> + <Grid item xs={12}> + <Typography variant="h5">{t("submissions")}</Typography> + <div style={{ display: "flex", alignItems: "center", my: 1 }}> + <AccessTimeIcon style ={{ marginRight: 4, color: deadlineColor }} /> + <Typography variant="body1" style={{ color: deadlineColor }}> + {project?.deadline ? formatDate(project.deadline) : "No Deadline"} + </Typography> + </div> + {user?.role === 3 ? ( + isInGroup ? ( + <Button + variant="contained" + color="primary" + startIcon={<AddIcon />} + href={`/${locale}/project/${project_id}/submit`} + sx={{ my: 1 }} + > + {t("add_submission")} + </Button> + ) : ( + <Typography variant="body1" style={{ color: "red", marginTop: "5px" }}> + {t("not_in_group")} + </Typography> + ) + ) : null} + </Grid> + <Grid item xs={12}> + {user?.role === 3 ? ( + <GroupSubmissionList + project_id={project_id} + page_size={8} + search={t("submission_search")} + /> + ) : ( + <ProjectSubmissionsList + project_id={project_id} + page_size={8} + search={t("submission_search")} + /> + )} + </Grid> + </Grid> + </Box> + </ThemeProvider> + ); +}; + +function buildTree(paths) { + const tree = {}; + if (!paths) { + return tree; + } + + const paths_list = paths.split(','); + paths_list.forEach(path => { + const parts = path.split('/'); + let current = tree; + + parts.forEach((part, index) => { + if (!current[part]) { + if (index === parts.length - 1) { + current[part] = {}; + } else { + current[part] = current[part] || {}; + } + } + current = current[part]; + }); + }); + + return tree; +} + +function buildTreeString(tree, indent = '') { + let treeString = ''; + + const keys = Object.keys(tree); + keys.forEach((key, index) => { + const isLast = index === keys.length - 1; + treeString += `${indent}${isLast ? '└── ' : '├── '}${key}\n`; + treeString += buildTreeString(tree[key], indent + (isLast ? ' ' : '│ ')); + }); + + return treeString; +} + +function generateDirectoryTree(filePaths) { + const tree = buildTree(filePaths); + return `.\n${buildTreeString(tree)}`; +} + +export default ProjectDetailsPage; diff --git a/frontend/app/[locale]/components/ProjectReturnButton.tsx b/frontend/app/[locale]/components/ProjectReturnButton.tsx new file mode 100644 index 00000000..aa561222 --- /dev/null +++ b/frontend/app/[locale]/components/ProjectReturnButton.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import {useTranslation} from "react-i18next"; +import {Button, ThemeProvider} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import {baseTheme} from "@styles/theme"; + +interface ProjectReturnButtonProps { + locale: any; + project_id: number | undefined; +} + +const ProjectReturnButton: React.FC<ProjectReturnButtonProps> = ({locale, project_id}) => { + const {t} = useTranslation(); + + return ( + <ThemeProvider theme={baseTheme}> + <Button + component="a" + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/${locale}/project/${project_id}`} + > + {t("return_project")} + </Button> + </ThemeProvider> + ); +}; + +export default ProjectReturnButton; diff --git a/frontend/app/[locale]/components/ProjectSubmissionsList.tsx b/frontend/app/[locale]/components/ProjectSubmissionsList.tsx index 792673ab..4d488ec4 100644 --- a/frontend/app/[locale]/components/ProjectSubmissionsList.tsx +++ b/frontend/app/[locale]/components/ProjectSubmissionsList.tsx @@ -2,35 +2,40 @@ import ListView from "@app/[locale]/components/ListView"; import React from "react"; +import {useTranslation} from "react-i18next"; +import GroupsIcon from '@mui/icons-material/Groups'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; -const ProjectSubmissionsList = ({project_id, showActions, page_size=5}: { project_id: number, showActions: boolean, page_size: number }) => { - const headers = ["Group number", "Submission date", "Status", "View"] - const headers_backend = ["group_nr", "submission_date", "status", "View"] + +const ProjectSubmissionsList = ({project_id, page_size = 5, search}: { + project_id: number, + page_size: number, + search: string +}) => { + const {t} = useTranslation() + const headers = [ + <React.Fragment key="group_nr"><GroupsIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('group_number')}</React.Fragment> + , + <React.Fragment key="submission_date"><CalendarMonthIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('submission_date')}</React.Fragment> + , + <React.Fragment key="status"><CheckCircleOutlineIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('Status')}</React.Fragment> + , ""] + const headers_backend = ["group_nr", "submission_date", "status", ""] const sortable = [true, true, false] return ( - (showActions ? - <ListView - admin={true} - headers={headers} - headers_backend={headers_backend} - get={'submissions'} - get_id={project_id} - sortable={sortable} - action_name={'download_submission'} - page_size={page_size} - /> - : - <ListView - admin={true} - headers={headers} - headers_backend={headers_backend} - get={'submissions'} - get_id={project_id} - sortable={sortable} - page_size={page_size} - /> - ) + <ListView + admin={true} + headers={headers} + headers_backend={headers_backend} + get={'submissions'} + get_id={project_id} + sortable={sortable} + action_name={'download_submission'} + page_size={page_size} + search_text={search} + /> ) } diff --git a/frontend/app/[locale]/components/ProjectTable.tsx b/frontend/app/[locale]/components/ProjectTable.tsx new file mode 100644 index 00000000..d91ebaa3 --- /dev/null +++ b/frontend/app/[locale]/components/ProjectTable.tsx @@ -0,0 +1,307 @@ +'use client'; + +import React, {useEffect, useState} from 'react'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Checkbox, + Button, + Box, + Typography, + tableCellClasses, + TableSortLabel, + TablePagination +} from '@mui/material'; +import { visuallyHidden } from '@mui/utils'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import {useTranslation} from "react-i18next"; +import { styled } from '@mui/material/styles'; +import {Project, getProjectsFromCourse, APIError, UserData, getUserData} from "@lib/api"; +import baseTheme from "../../../styles/theme"; + +interface ProjectTableTeacherProps { + course_id: number; +} + +const StyledTableCell = styled(TableCell)(() => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: baseTheme.palette.primary.main, + color: baseTheme.palette.primary.contrastText, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, +})); + +const StyledTableRow = styled(TableRow)(() => ({ + '&:nth-of-type(odd)': { + backgroundColor: baseTheme.palette.secondary.main, + }, + // hide last border + '&:last-child td, &:last-child th': { + border: 0, + }, +})); + +interface Data { + id: string; + name: string; + deadline: string; + visibility: boolean; +} + +const convertDate = (date_str: string) => { + let date = new Date(date_str); + date = new Date(date.getTime()); + let date_local = date.toLocaleString('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }); + date_local = date_local.replace(" at", "").replace(",", ""); + return date_local; +}; + +function descendingComparator<T>(a: T, b: T, orderBy: keyof T) { + if (b[orderBy] < a[orderBy]) { + return -1; + } + if (b[orderBy] > a[orderBy]) { + return 1; + } + return 0; +} + +type Order = 'asc' | 'desc'; + +function getComparator<Key extends keyof any>( + order: Order, + orderBy: Key, +): ( + a: { [key in Key]: number | string }, + b: { [key in Key]: number | string }, +) => number { + return order === 'desc' + ? (a, b) => descendingComparator(a, b, orderBy) + : (a, b) => -descendingComparator(a, b, orderBy); +} + +function stableSort<T>(array: readonly T[], comparator: (a: T, b: T) => number) { + const stabilizedThis = array.map((el, index) => [el, index] as [T, number]); + stabilizedThis.sort((a, b) => { + const order = comparator(a[0], b[0]); + if (order !== 0) { + return order; + } + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); +} + +interface HeadCell { + disablePadding: boolean; + id: keyof Data; + label: string; + numeric: boolean; +} + +const headCells: readonly HeadCell[] = [ + {id: 'name', numeric: false, disablePadding: true, label: 'Project Name'}, + {id: 'deadline', numeric: false, disablePadding: false, label: 'Deadline'}, + {id: 'visibility', numeric: false, disablePadding: false, label: 'Visibility'}, +]; + +interface EnhancedTableProps { + onRequestSort: (event: React.MouseEvent<unknown>, property: keyof Data) => void; + order: Order; + orderBy: string; + rowCount: number; +} + +function EnhancedTableHead(props: EnhancedTableProps) { + const { order, orderBy, rowCount, onRequestSort } = + props; + const createSortHandler = + (property: keyof Data) => (event: React.MouseEvent<unknown>) => { + onRequestSort(event, property); + }; + + return ( + <TableHead> + <TableRow> + <StyledTableCell/> + {headCells.map((headCell) => ( + <StyledTableCell + key={headCell.id} + align={headCell.numeric ? 'right' : 'left'} + padding={headCell.disablePadding ? 'none' : 'normal'} + sortDirection={orderBy === headCell.id ? order : false} + > + <TableSortLabel + active={orderBy === headCell.id} + direction={orderBy === headCell.id ? order : 'asc'} + onClick={createSortHandler(headCell.id)} + > + {headCell.label} + {orderBy === headCell.id ? ( + <Box component="span" sx={visuallyHidden}> + {order === 'desc' ? 'sorted descending' : 'sorted ascending'} + </Box> + ) : null} + </TableSortLabel> + </StyledTableCell> + ))} + </TableRow> + </TableHead> + ); +} + +function ProjectTable({course_id}: ProjectTableTeacherProps) { + const [order, setOrder] = React.useState<Order>('asc'); + const [orderBy, setOrderBy] = React.useState<keyof Data>('calories'); + const [rowsPerPage, setRowsPerPage] = React.useState(5); + const [page, setPage] = React.useState(0); + const [user, setUser] = useState<UserData | null>(null); + const [projects, setProjects] = useState<Project[]>([]); + const [error, setError] = useState<APIError | null>(null); + const [selectedRow, setSelectedRow] = useState(null); + const [selectedId, setSelectedId] = useState(null); + const {t} = useTranslation(); + + const handleRequestSort = ( + event: React.MouseEvent<unknown>, + property: keyof Data, + ) => { + const isAsc = orderBy === property && order === 'asc'; + setOrder(isAsc ? 'desc' : 'asc'); + setOrderBy(property); + }; + + const handleChangePage = (event: unknown, newPage: number) => { + setPage(newPage); + }; + + const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { + setRowsPerPage(parseInt(event.target.value, 10)); + setPage(0); + }; + + const emptyRows = + page > 0 ? Math.max(0, (1 + page) * rowsPerPage - projects.length) : 0; + + const visibleRows = React.useMemo( + () => + stableSort(projects, getComparator(order, orderBy)).slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ), + [order, orderBy, page, rowsPerPage, projects], + ); + + useEffect(() => { + const fetchProjects = async () => { + try { + setProjects(await getProjectsFromCourse(course_id)); + setUser(await getUserData()); + } catch (error) { + if (error instanceof APIError) setError(error); + console.error(error); + } + + }; + + fetchProjects(); + }, [course_id]); + + const handleRowClick = (row: any) => { + setSelectedRow((prevSelectedRow) => (prevSelectedRow === row ? null : row)); + setSelectedId((prevSelectedId) => (prevSelectedId === row.project_id ? null : row.project_id)); + }; + + return ( + <> + <Box + sx={{ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + width: "calc(100% - 40px)", + + }} + > + <Button + variant="contained" + color='secondary' + disabled={selectedId === null} + href={'/project/' + selectedId} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + marginBottom: 1 + }} + > + {t("details")} + </Button> + </Box> + {projects.length === 0 ? ( + <Typography + variant="h5" + sx={{ + color: 'text.disabled', + marginTop: 2 + }} + > + {t('no_projects')} + </Typography> + ) : ( + <Paper> + <TableContainer component={Paper}> + <Table sx={{width: "100%"}}> + <EnhancedTableHead + order={order} + orderBy={orderBy} + onRequestSort={handleRequestSort} + rowCount={projects.length} + /> + <TableBody> + {visibleRows.map((row) => ( + <StyledTableRow key={row.project_id} onClick={() => handleRowClick(row)} + selected={selectedRow === row}> + <StyledTableCell padding="checkbox"> + <Checkbox checked={selectedRow === row} sx={{color:"black"}}/> + </StyledTableCell> + <StyledTableCell>{row.name}</StyledTableCell> + <StyledTableCell>{convertDate(row.deadline)}</StyledTableCell> + <StyledTableCell> + {row.visible ? <VisibilityIcon/> : <VisibilityOffIcon/>} + </StyledTableCell> + </StyledTableRow> + ))} + </TableBody> + </Table> + </TableContainer> + <TablePagination + rowsPerPageOptions={[5, 10, 25]} + component="div" + count={projects.length} + rowsPerPage={rowsPerPage} + page={page} + onPageChange={handleChangePage} + onRowsPerPageChange={handleChangeRowsPerPage} + /> + </Paper> + )} + </> + ); +} + +export default ProjectTable; \ No newline at end of file diff --git a/frontend/app/[locale]/components/ProjectTableTeacher.tsx b/frontend/app/[locale]/components/ProjectTableTeacher.tsx deleted file mode 100644 index f389abba..00000000 --- a/frontend/app/[locale]/components/ProjectTableTeacher.tsx +++ /dev/null @@ -1,125 +0,0 @@ -'use client'; - -import React, {useEffect, useState} from 'react'; -import { - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Paper, - Checkbox, - Button, - Box, - Typography -} from '@mui/material'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; -import {useTranslation} from "react-i18next"; -import AddProjectButton from "@app/[locale]/components/AddProjectButton"; -import {Project, getProjectsFromCourse, APIError, UserData, getUserData} from "@lib/api"; - -interface ProjectTableTeacherProps { - course_id: number; -} - -function ProjectTableTeacher({course_id}: ProjectTableTeacherProps) { - const [user, setUser] = useState<UserData | null>(null); - const [projects, setProjects] = useState<Project[]>([]); - const [error, setError] = useState<APIError | null>(null); - const [selectedRow, setSelectedRow] = useState(null); - const [selectedId, setSelectedId] = useState(null); - const {t} = useTranslation(); - - useEffect(() => { - const fetchProjects = async () => { - try { - setProjects(await getProjectsFromCourse(course_id)); - setUser(await getUserData()); - } catch (error) { - if (error instanceof APIError) setError(error); - console.error(error); - } - - }; - - fetchProjects(); - }, [course_id]); - - const handleRowClick = (row: any) => { - setSelectedRow((prevSelectedRow) => (prevSelectedRow === row ? null : row)); - setSelectedId((prevSelectedId) => (prevSelectedId === row.project_id ? null : row.project_id)); - }; - - return ( - <> - <Box - sx={{ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - width: "calc(100% - 40px)", - - }} - > - {user?.role !== 3 ? ( - <AddProjectButton/> - ): null} - <Button - variant="contained" - color='secondary' - disabled={selectedId === null} - href={'/project/' + selectedId} - sx={{ - width: 'fit-content', - color: 'secondary.contrastText', - }} - > - {t("details")} - </Button> - </Box> - {projects.length === 0 ? ( - <Typography - variant="h5" - sx={{ - color: 'text.disabled', - marginTop: 2 - }} - > - {t('no_projects')} - </Typography> - ) : ( - <TableContainer component={Paper}> - <Table sx={{width: "calc(100% - 40px)"}}> - <TableHead> - <TableRow> - <TableCell/> - <TableCell>{t("project_name")}</TableCell> - <TableCell>{t("deadline")}</TableCell> - <TableCell>{t("visibility")}</TableCell> - </TableRow> - </TableHead> - <TableBody> - {projects.map((row) => ( - <TableRow key={row.project_id} onClick={() => handleRowClick(row)} - selected={selectedRow === row}> - <TableCell padding="checkbox"> - <Checkbox checked={selectedRow === row}/> - </TableCell> - <TableCell>{row.name}</TableCell> - <TableCell>{row.deadline}</TableCell> - <TableCell> - {row.visible ? <VisibilityIcon/> : <VisibilityOffIcon/>} - </TableCell> - </TableRow> - ))} - </TableBody> - </Table> - </TableContainer> - )} - </> - ); -} - -export default ProjectTableTeacher; \ No newline at end of file diff --git a/frontend/app/[locale]/components/StatusButton.tsx b/frontend/app/[locale]/components/StatusButton.tsx new file mode 100644 index 00000000..72d5841f --- /dev/null +++ b/frontend/app/[locale]/components/StatusButton.tsx @@ -0,0 +1,64 @@ +import ClearIcon from '@mui/icons-material/Clear'; +import React, {useState} from 'react'; +import CheckIcon from "@mui/icons-material/Check"; +import {Button} from "@mui/material"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; + +interface StatusButtonProps { + files: any[], + setFiles: (value: (((prevState: any[]) => any[]) | any[])) => void, + fileIndex: number, +} + +function StatusButton( + {files, setFiles, fileIndex}: StatusButtonProps, +) { + const [statusIndex, setStatusIndex] = useState(getStart(files[fileIndex])); + const statuses = [ + { icon: <CheckIcon style={{ color: '#66bb6a' }} /> }, + { icon: <HelpOutlineIcon style={{ color: '#000000' }} />}, + { icon: <ClearIcon style={{ color: '#ef5350' }} /> }, + ]; + const status_valeus = ['+', '~', '-']; + + const handleClick = () => { + const newStatusIndex = (statusIndex + 1) % statuses.length; + setStatusIndex(newStatusIndex); + const newFiles = [...files]; + newFiles[fileIndex] = status_valeus[newStatusIndex]; + setFiles(newFiles); + }; + + return ( + <Button + variant="contained" + onClick={handleClick} + sx={{ + border: 'none', + backgroundColor: 'transparent', + minWidth: '30px', // Ensure the button is square by setting equal width and height + minHeight: '30px', // Adjust the size as needed + padding: 0, + margin: 1, + }} + > + {statuses[statusIndex].icon} + </Button> + ); +} + +function getStart(file: string | undefined) { + if (!file || file.length === 0) { + return 2; + } + + if (file[0] === '+') { + return 0; + } else if (file[0] === '~') { + return 1; + } else { + return 2; + } +} + +export default StatusButton; \ No newline at end of file diff --git a/frontend/app/[locale]/components/StudentCoTeacherButtons.tsx b/frontend/app/[locale]/components/StudentCoTeacherButtons.tsx index 78ffd112..6f40e418 100644 --- a/frontend/app/[locale]/components/StudentCoTeacherButtons.tsx +++ b/frontend/app/[locale]/components/StudentCoTeacherButtons.tsx @@ -2,15 +2,31 @@ import {Box, Button} from "@mui/material"; import {useTranslation} from "react-i18next"; import {useEffect, useState} from "react"; -import {APIError, Course, getCourse} from "@lib/api"; +import {APIError, Course, getCourse, getProjectsFromCourse, getUserData, UserData} from "@lib/api"; interface StudentCoTeacherButtonsProps { course_id:number } const StudentCoTeacherButtons = ({course_id}: StudentCoTeacherButtonsProps) => { + const [user, setUser] = useState<UserData | null>(null); + const [error, setError] = useState<APIError | null>(null); const {t} = useTranslation() + useEffect(() => { + const fetchProjects = async () => { + try { + setUser(await getUserData()); + } catch (error) { + if (error instanceof APIError) setError(error); + console.error(error); + } + + }; + + fetchProjects(); + }, [course_id]); + return ( <Box sx={{ @@ -33,17 +49,20 @@ const StudentCoTeacherButtons = ({course_id}: StudentCoTeacherButtonsProps) => { > {t("view_students")} </Button> - <Button - variant="contained" - color='secondary' - href={'/course/'+course_id+'/teachers'} - sx={{ - width: 'fit-content', - color: 'secondary.contrastText', - }} - > - {t("view_co_teachers")} - </Button> + {user?.role !== 3 ? ( + <Button + variant="contained" + color='secondary' + href={'/course/'+course_id+'/teachers'} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + }} + > + {t("view_co_teachers")} + </Button> + ): null + } </Box> ); } diff --git a/frontend/app/[locale]/components/SubmissionDetailsPage.tsx b/frontend/app/[locale]/components/SubmissionDetailsPage.tsx new file mode 100644 index 00000000..fbef3e31 --- /dev/null +++ b/frontend/app/[locale]/components/SubmissionDetailsPage.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import React, { useEffect, useState } from "react"; +import { getProjectFromSubmission, getSubmission, Project, Submission } from "@lib/api"; +import { Button, Card, CardContent, Grid, LinearProgress, ThemeProvider, Divider, Typography, Paper } from "@mui/material"; +import ProjectReturnButton from "@app/[locale]/components/ProjectReturnButton"; +import { baseTheme } from "@styles/theme"; +import CheckIcon from "@mui/icons-material/Check"; +import CancelIcon from "@mui/icons-material/Cancel"; +import DownloadIcon from "@mui/icons-material/CloudDownload"; + +const backend_url = process.env['NEXT_PUBLIC_BACKEND_URL']; + +interface SubmissionDetailsPageProps { + locale: any, + submission_id: number; +} + +const SubmissionDetailsPage: React.FC<SubmissionDetailsPageProps> = ({ locale, submission_id }) => { + const { t } = useTranslation(); + + const [submission, setSubmission] = useState<Submission>(); + const [projectId, setProjectId] = useState<number>(); + const [loadingSubmission, setLoadingSubmission] = useState<boolean>(true); + + useEffect(() => { + const fetchSubmission = async () => { + try { + setSubmission(await getSubmission(submission_id)); + } catch (error) { + console.error("There was an error fetching the submission data:", error); + } + }; + + const fetchProject = async () => { + try { + setProjectId(await getProjectFromSubmission(submission_id)); + } catch (error) { + console.error("There was an error fetching the project data:", error); + } + }; + + fetchSubmission().then(() => { + fetchProject().then(() => setLoadingSubmission(false)); + }); + }, [submission_id]); + + if (loadingSubmission) { + return <LinearProgress />; + } + + function formatDate(isoString: string): string { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false + }; + const date = new Date(isoString); + return date.toLocaleString(locale, options); + } + + return ( + <ThemeProvider theme={baseTheme}> + <Grid container direction={'column'} justifyContent="flex-start" alignItems="stretch" style={{ padding: '20px', width: '100%'}}> + <Grid item style={{paddingBottom: '20px', width: '100%'}}> + <ProjectReturnButton locale={locale} project_id={projectId} /> + </Grid> + <Grid item> + <Card raised style={{ width: '100%' }}> + <CardContent> + <Typography variant="h4" style={{ fontWeight: 'bold', marginBottom: '20px' }}> + {`${t("submission")} #${submission?.submission_nr}`} + </Typography> + <Divider style={{ marginBottom: '20px' }}/> + <Grid container spacing={2}> + <Grid item xs={12} sm={6} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}> + <Typography variant="h6" style={{ fontWeight: 'bold', marginBottom: '10px' }}> + {`${t("evaluation")} status`} + </Typography> + <div style={{ display: "flex", alignItems: "center", columnGap: "10px" }}> + {(submission?.output_simple_test && submission?.eval_result) ? ( + <CheckIcon color="success" style={{ fontSize: 40 }}/> + ) : ( + <CancelIcon color="error" style={{ fontSize: 40 }}/> + )} + <div> + <Typography variant="subtitle1" style={{ fontWeight: 'bold' }}> + {(submission?.output_simple_test && submission?.eval_result) ? t("accepted") : t("denied")} + </Typography> + <Typography variant="caption"> + {`(${t("timestamp")}: ${formatDate(submission?.timestamp ?? "")})`} + </Typography> + + {submission?.output_simple_test ? ( + <Typography variant="subtitle1" style={{ fontWeight: 'bold' }}> + {t("simple_tests_ok")} + </Typography> + ):( + <Typography variant="subtitle1" style={{ fontWeight: 'bold' }}> + {t("simple_tests_failed")} + </Typography> + )} + { + !submission?.output_simple_test ? ( + <> + <Divider style={{ marginBottom: '20px', marginTop: '20px' }}/> + + {submission?.feedback_simple_test?.["0"].length > 0 ? ( + <> + <Typography variant="h6" style={{ fontWeight: 'bold', marginBottom: '10px' }}> + {t("feedback_simple_test_0")} + </Typography> + {submission?.feedback_simple_test["0"].map((feedback, index) => ( + <Typography key={index} variant="body1" style={{ marginBottom: '10px' }}> + {feedback} + </Typography> + ))} + </> + ) : null} + + {submission?.feedback_simple_test?.["2"].length > 0 ? ( + <> + <Typography variant="h6" style={{ fontWeight: 'bold', marginBottom: '10px' }}> + {t("feedback_simple_test_2")} + </Typography> + {submission?.feedback_simple_test["2"].map((feedback, index) => ( + <Typography key={index} variant="body1" style={{ marginBottom: '10px' }}> + {feedback} + </Typography> + ))} + </> + ) : null} + </> + ) : null + } + <Paper style={{ padding: '10px', marginTop: '10px' }}> + {submission?.eval_result ? ( + <Typography variant="subtitle1" style={{ fontWeight: 'bold' }}> + {t("advanced_tests_ok")} + </Typography> + ):( + <Typography variant="subtitle1" style={{ fontWeight: 'bold' }}> + {t("advanced_tests_failed")} + </Typography> + )} + { + submission?.eval_output ? ( + <Typography variant="body1"> + {submission?.eval_output} + </Typography> + ) : null + } + </Paper> + </div> + </div> + </Grid> + <Grid item xs={12} sm={6} style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end' }}> + <Typography variant="h6" gutterBottom style={{ fontWeight: 'bold', color: 'primary.main', marginBottom: '10px' }}> + {t("uploaded_files")} + </Typography> + <Button + variant="contained" + color="primary" + startIcon={<DownloadIcon />} + href={`${backend_url}/submissions/${submission_id}/download`} + download + size="small" + > + {t("download_file")} + </Button> + <Typography variant="h6" gutterBottom style={{ fontWeight: 'bold', color: 'primary.main', marginBottom: '10px', marginTop: '40px' }}> + {t("artifacts")} + </Typography> + <Button + variant="contained" + color="primary" + startIcon={<DownloadIcon />} + href={`${backend_url}/submissions/${submission_id}/download_artifacts`} + download + size="small" + > + {t("download_artifacts")} + </Button> + </Grid> + </Grid> + </CardContent> + </Card> + </Grid> + </Grid> + </ThemeProvider> + ); +}; + +export default SubmissionDetailsPage; diff --git a/frontend/app/[locale]/components/SubmitDetailsPage.tsx b/frontend/app/[locale]/components/SubmitDetailsPage.tsx new file mode 100644 index 00000000..8adc50ca --- /dev/null +++ b/frontend/app/[locale]/components/SubmitDetailsPage.tsx @@ -0,0 +1,225 @@ +'use client'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Box, + Button, + Card, + CardContent, + Divider, + Grid, + IconButton, + Input, + LinearProgress, + ThemeProvider, + Typography, +} from '@mui/material'; +import { + getProject, fetchUserData, + Project, + uploadSubmissionFile, UserData, +} from '@lib/api'; +import baseTheme from '@styles/theme'; +import ProjectReturnButton from '@app/[locale]/components/ProjectReturnButton'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import PublishIcon from '@mui/icons-material/Publish'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorIcon from '@mui/icons-material/Error'; +import Tree from '@app/[locale]/components/Tree'; + +interface SubmitDetailsPageProps { + locale: any; + project_id: number; +} + +const SubmitDetailsPage: React.FC<SubmitDetailsPageProps> = ({ + locale, + project_id, +}) => { + const { t } = useTranslation(); + + const [projectData, setProjectData] = useState<Project>(); + const [paths, setPaths] = useState<string[]>([]); + const [filepaths, setFilePaths] = useState<string[]>([]); + const [folderpaths, setFolderPaths] = useState<string[]>([]); + const [submitted, setSubmitted] = useState<string>('no'); + const [loadingProject, setLoadingProject] = useState<boolean>(true); + const [isExpanded, setIsExpanded] = useState(false); + const [user, setUser] = useState<UserData | null>(null); + const [userLoading, setUserLoading] = useState(true); + const [disabled, setDisabled] = useState<boolean>(true); + const [accessDenied, setAccessDenied] = useState(true); + const previewLength = 300; + + const toggleDescription = () => { + setIsExpanded(!isExpanded); + }; + + const handleSubmit = async (e) => { + const response = await uploadSubmissionFile(e, project_id); + setSubmitted(response); + window.location.href = `/submission/${response.submission_id}/`; + }; + + useEffect(() => { + const fetchData = async () => { + try { + const project: Project = await getProject(project_id); + setProjectData(project); + const userData = await fetchUserData(); + setUser(userData); + + if (!userData?.course.includes(Number(project?.course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + console.log("User is in course"); + } + } catch (e) { + console.error(e); + } finally { + setLoadingProject(false); + setUserLoading(false); + } + } + + fetchData(); + }, [projectData?.course_id, project_id]); + + + function folderAdded(event: any) { + let newpaths: string[] = []; + let result: string[] = []; + if (event.target.id === 'filepicker2') { + for (const file of event.target.files) { + newpaths.push(file.name); + } + setFilePaths(newpaths); + result = [...folderpaths, ...newpaths]; + } else { + for (const file of event.target.files) { + let text: string = file.webkitRelativePath; + if (text.includes('/')) { + text = text.substring((text.indexOf('/') ?? 0) + 1, text.length); + } + newpaths.push(text); + } + setFolderPaths(newpaths); + result = [...filepaths, ...newpaths]; + } + setPaths(result); + setDisabled(newpaths.length === 0) + } + + if (loadingProject) { + return <LinearProgress />; + } + + return ( + !accessDenied && + <ThemeProvider theme={baseTheme}> + <Grid container justifyContent="center" alignItems="flex-start"> + <Grid item xs={12} style={{ padding: 20 }}> + <ProjectReturnButton locale={locale} project_id={projectData?.project_id} /> + </Grid> + <Grid item xs={12} style={{ padding: 20 }}> + <Card raised> + <CardContent> + <Typography variant="h3" sx={{ fontWeight: 'medium' }}> + {projectData?.name} + </Typography> + <Divider style={{ marginBottom: 10, marginTop: 10 }} /> + <Typography> + {projectData?.description && projectData?.description.length > previewLength && !isExpanded + ? `${projectData?.description.substring(0, previewLength)}...` + : projectData?.description} + </Typography> + {projectData?.description && projectData?.description.length > previewLength && ( + <IconButton color="primary" onClick={toggleDescription} sx={{ padding: 0 }}> + {isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />} + </IconButton> + )} + <Typography variant="h6" sx={{ fontWeight: 'bold', marginTop: 2 }}> + {t('upload_folders')} + </Typography> + + <Box component="form" onSubmit={handleSubmit} encType="multipart/form-data"> + <Input + sx={{ + border: '2px dashed', + borderColor: baseTheme.palette.primary.main, + borderRadius: 2, + textAlign: 'center', + marginTop: 1, + p: 4, + cursor: 'pointer', + '&:hover': { + backgroundColor: baseTheme.palette.background.default, + }, + }} + onChange={folderAdded} + type="file" + id="filepicker" + name="fileList" + inputProps={{ webkitdirectory: 'true', multiple: true }} + /> + + <Typography variant="h6" sx={{ fontWeight: 'bold', marginTop: 2 }}> + {t('files')} + </Typography> + + <Input + sx={{ + border: '2px dashed', + borderColor: baseTheme.palette.primary.main, + borderRadius: 2, + textAlign: 'center', + marginTop: 1, + p: 4, + cursor: 'pointer', + '&:hover': { + backgroundColor: baseTheme.palette.background.default, + }, + }} + onChange={folderAdded} + type="file" + id="filepicker2" + name="fileList2" + inputProps={{ multiple: true }} + /> + + <Tree paths={paths} /> + + {submitted['result'] === 'ok' && ( + <Box sx={{ display: 'flex', alignItems: 'center', color: baseTheme.palette.success.main, mb: 1 }}> + <CheckCircleIcon sx={{ mr: 1 }} /> + + <Typography variant="h6" sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}> + {t('submitted')} + </Typography> + </Box> + )} + {submitted['result'] === 'error' && ( + <Box sx={{ display: 'flex', alignItems: 'center', color: baseTheme.palette.error.main, mb: 1 }}> + <ErrorIcon sx={{ mr: 1 }} /> + <Typography variant="h6" sx={{ fontWeight: 'bold', fontSize: '0.875rem' }}> + {t('submission_error')}: {t(submitted['errorcode'])} + </Typography> + </Box> + )} + {submitted['result'] !== 'ok' && ( + <Button variant="contained" color="primary" startIcon={<PublishIcon />} type="submit"> + {t('submit')} + </Button> + )} + </Box> + </CardContent> + </Card> + </Grid> + </Grid> + </ThemeProvider> + ); +}; + +export default SubmitDetailsPage; diff --git a/frontend/app/[locale]/components/Tree.tsx b/frontend/app/[locale]/components/Tree.tsx new file mode 100644 index 00000000..4b07d5e6 --- /dev/null +++ b/frontend/app/[locale]/components/Tree.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import {List} from '@mui/material'; +import TreeNode from '@app/[locale]/components/TreeNode'; + +interface TreeNodeData { + name: string; + level?: number; + isLeaf?: boolean; + children?: TreeNodeData[]; +} + +interface TreeNodeDataMap { + [key: string]: TreeNodeData; +} + +function createTreeStructure(paths: string[]): TreeNodeDataMap { + const tree: TreeNodeDataMap = {}; + + paths.forEach((path) => { + const parts = path.split('/'); + let currentLevel = tree; + + parts.forEach((part, index) => { + if (!currentLevel[part]) { + currentLevel[part] = { + name: part, + children: {}, + } as TreeNodeData; + } + if (index === parts.length - 1) { + currentLevel[part].isLeaf = true; + } else { + currentLevel = currentLevel[part].children as TreeNodeDataMap; + } + }); + }); + + return tree; +} + +function convertToNodes(tree: TreeNodeDataMap, level: number = 0): TreeNodeData[] { + return Object.values(tree).map((node) => ({ + name: node.name, + level: level, + isLeaf: node.isLeaf ?? false, + children: node.children ? convertToNodes(node.children as TreeNodeDataMap, level + 1) : [], + })); +} + +// Tree component +interface TreeProps { + paths: string[]; +} + +const Tree: React.FC<TreeProps> = ({paths}) => { + const treeData = createTreeStructure(paths); + const nodes = convertToNodes(treeData); + + return ( + <List> + {nodes.map((node) => ( + <TreeNode key={node.name} node={node}/> + ))} + </List> + ); +}; + +export default Tree; diff --git a/frontend/app/[locale]/components/TreeNode.tsx b/frontend/app/[locale]/components/TreeNode.tsx new file mode 100644 index 00000000..f0ce4c1e --- /dev/null +++ b/frontend/app/[locale]/components/TreeNode.tsx @@ -0,0 +1,48 @@ +import React, {useEffect, useState} from 'react'; +import {Collapse, IconButton, List, ListItem, ListItemText} from '@mui/material'; +import ExpandLess from '@mui/icons-material/ExpandLess'; +import ExpandMore from '@mui/icons-material/ExpandMore'; + +interface TreeNodeProps { + node: { + name: string; + level: number; + isLeaf: boolean; + children: TreeNodeProps['node'][]; + }; + initiallyOpen?: boolean; +} + +const TreeNode: React.FC<TreeNodeProps> = ({node, initiallyOpen = false}) => { + const [open, setOpen] = useState(initiallyOpen); + + const handleClick = () => setOpen(!open); + + useEffect(() => { + setOpen(initiallyOpen); + }, [initiallyOpen]); + + return ( + <> + <ListItem onClick={handleClick} dense sx={{pl: node.level * 2}}> + <ListItemText primary={node.name}/> + {!node.isLeaf && ( + <IconButton edge="end"> + {open ? <ExpandLess/> : <ExpandMore/>} + </IconButton> + )} + </ListItem> + {!node.isLeaf && ( + <Collapse in={open} timeout="auto" unmountOnExit> + <List component="div" disablePadding> + {node.children.map((child) => ( + <TreeNode key={child.name} node={child} initiallyOpen={false}/> + ))} + </List> + </Collapse> + )} + </> + ); +}; + +export default TreeNode; diff --git a/frontend/app/[locale]/components/YearStateComponent.tsx b/frontend/app/[locale]/components/YearStateComponent.tsx new file mode 100644 index 00000000..a4b7f2db --- /dev/null +++ b/frontend/app/[locale]/components/YearStateComponent.tsx @@ -0,0 +1,37 @@ +"use client"; +import {Box} from "@mui/material"; +import CourseControls from "@app/[locale]/components/CourseControls"; +import CoursesGrid from "@app/[locale]/components/CoursesGrid"; +import React, {useState} from "react"; +import {useTranslation} from "react-i18next"; + +const YearStateComponent = () => { + const currentYear = new Date().getFullYear(); + const academicYear = `${currentYear - 1}-${currentYear.toString().slice(-2)}`; + const [selectedYear, setSelectedYear] = useState(academicYear); + + const {t} = useTranslation() + + const handleYearChange = (event) => { + setSelectedYear(event.target.value); + }; + + return ( + <> + <Box + display={'flex'} + flexDirection={'column'} + height={'fit-content'} + width={'100%'} + justifyContent={'center'} + > + + <CourseControls selectedYear={selectedYear} onYearChange={handleYearChange}/> + <CoursesGrid selectedYear={selectedYear}/> + + </Box> + </> + ); +}; + +export default YearStateComponent; diff --git a/frontend/app/[locale]/components/admin_components/UserList.tsx b/frontend/app/[locale]/components/admin_components/UserList.tsx new file mode 100644 index 00000000..ad54df46 --- /dev/null +++ b/frontend/app/[locale]/components/admin_components/UserList.tsx @@ -0,0 +1,44 @@ +import BackButton from "@app/[locale]/components/BackButton"; +import ListView from "@app/[locale]/components/ListView"; +import React from "react"; +import EmailIcon from "@mui/icons-material/Email"; +import WorkIcon from "@mui/icons-material/Work"; +import {useTranslation} from "react-i18next"; + + +const UserList = () => { + const {t} = useTranslation(); + + const headers = [ + <React.Fragment key="email"><EmailIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('email')}</React.Fragment>, + , + <React.Fragment key="role"><WorkIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('role')}</React.Fragment> + , '']; + const headers_backend = ['email', 'role', '']; + + return ( + <div + style={{ + marginTop: 60, + padding: 20, + width: '100%', + }}> + <BackButton + destination={'/home'} + text={t('back_to') + ' ' + t('home') + ' ' + t('page')} + /> + <ListView + admin={true} + headers={headers} + headers_backend={headers_backend} + sortable={[true, false]} + get={'users'} + action_name={'remove'} + action_text={t('remove_user')} + search_text={t('search')} + /> + </div> + ); +} + +export default UserList; \ No newline at end of file diff --git a/frontend/app/[locale]/components/course_components/ArchiveButton.tsx b/frontend/app/[locale]/components/course_components/ArchiveButton.tsx new file mode 100644 index 00000000..8a94e3cd --- /dev/null +++ b/frontend/app/[locale]/components/course_components/ArchiveButton.tsx @@ -0,0 +1,81 @@ +"use client" + +import {Button} from "@mui/material"; +import ArchiveIcon from '@mui/icons-material/Archive'; +import {APIError, archiveCourse, getCourse, unArchiveCourse} from "@lib/api"; +import {useTranslation} from "react-i18next"; +import {useEffect, useState} from "react"; + +interface ArchiveButtonProps { + course_id: number +} + +const ArchiveButton = ({course_id}: ArchiveButtonProps) => { + const [error, setError] = useState<APIError | null>(null); + const [archived, setArchived] = useState<boolean>(false); + const {t} = useTranslation() + + + useEffect(() => { + const fetchCourseData = async () => { + try { + const course = await getCourse(course_id); + setArchived(course.archived); + } catch (error) { + console.error("There was an error fetching the course data:", error); + } + }; + + fetchCourseData(); + }, [course_id]); + + + + const handleClick = async () => { + if (archived){ + try { + await unArchiveCourse(course_id); + window.location.href = "/home"; + } catch (error) { + if (error instanceof APIError) setError(error); + console.error(error); + } + } + else { + try { + await archiveCourse(course_id); + window.location.href = "/home"; + } catch (error) { + if (error instanceof APIError) setError(error); + console.error(error); + } + } + + } + + return ( + <Button + variant="contained" + color="secondary" + startIcon={ + <ArchiveIcon /> + } + sx={{ + whiteSpace: 'nowrap', + width: 'fit-content', + height: 'fit-content', + marginX: 1, + paddingX: 3, + }} + onClick={handleClick} + > + { + archived ? + t("unarchive course") : + t("archive course") + } + </Button> + ) +} + +export default ArchiveButton \ No newline at end of file diff --git a/frontend/app/[locale]/components/course_components/DeleteButton.tsx b/frontend/app/[locale]/components/course_components/DeleteButton.tsx index 533eac83..67094e37 100644 --- a/frontend/app/[locale]/components/course_components/DeleteButton.tsx +++ b/frontend/app/[locale]/components/course_components/DeleteButton.tsx @@ -1,38 +1,63 @@ 'use client'; -import React from 'react' -import {useTranslation} from "react-i18next"; -import {deleteCourse} from "@lib/api"; -import {Button} from '@mui/material'; +import React, { useState } from 'react'; +import { useTranslation } from "react-i18next"; +import { deleteCourse } from "@lib/api"; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from '@mui/material'; - - -interface EditCourseFormProps { +interface DeleteButtonProps { courseId: number } -const DeleteButton = ({courseId}: EditCourseFormProps) => { - const {t} = useTranslation() +const DeleteButton = ({ courseId }: DeleteButtonProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; const handleDelete = async () => { - await deleteCourse(courseId) + await deleteCourse(courseId); window.location.href = "/home"; - } + }; return ( - <Button - variant='contained' - onClick={handleDelete} - color='error' - sx={{ - width: 'fit-content', - height: 'fit-content', - whiteSpace: 'nowrap', - }} - > - {t("delete course")} - </Button> - ) -} - -export default DeleteButton \ No newline at end of file + <> + <Button + variant='contained' + onClick={handleOpen} + color='error' + sx={{ + width: 'fit-content', + height: 'fit-content', + whiteSpace: 'nowrap', + }} + > + {t("delete course")} + </Button> + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{t("Are you sure you want to delete this course?")}</DialogTitle> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {t("cancel")} + </Button> + <Button onClick={handleDelete} color="error" autoFocus> + {t("delete")} + </Button> + </DialogActions> + </Dialog> + </> + ); +}; + +export default DeleteButton; diff --git a/frontend/app/[locale]/components/general/ItemsList.tsx b/frontend/app/[locale]/components/general/ItemsList.tsx new file mode 100644 index 00000000..e2564ed1 --- /dev/null +++ b/frontend/app/[locale]/components/general/ItemsList.tsx @@ -0,0 +1,107 @@ +"use client" + +import {IconButton, List, ListItem, ListItemText, TextField, Typography, Button} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import React, {useState} from "react"; +import Box from "@mui/material/Box"; + +interface ItemsListProps { + items: string[], + setItems: (value: (((prevState: any[]) => any[]) | any[])) => void, + input_placeholder: string, + empty_list_placeholder:string, + button_text: string +} + +const ItemsList = ({items, setItems, input_placeholder, empty_list_placeholder, button_text}: ItemsListProps) => { + const [newItem, setNewItem] = useState('') + const [noInput, setNoInput] = useState(false) + + const handleDelete = (index: number) => { + const newFields = [...items]; + newFields.splice(index, 1); + setItems(newFields); + } + + const addNewFile = () => { + if (newItem !== '') { + const newItems = [...items]; + newItems.push(newItem) + setItems(newItems); + setNewItem(''); + setNoInput(false); + console.log(items); + } else { + setNoInput(true); + } + } + + return ( + <Box> + {items.length === 0 ? ( + <Typography variant={"body1"} color={"text.disabled"} sx={{padding: 1}}>{empty_list_placeholder}</Typography> + ) : ( + <List + sx={{ + width: '100%', + maxWidth: 360, + bgcolor: 'background.paper', + maxHeight: 150, + overflow: 'auto', + }} + > + {items.map((field, index) => ( + <ListItem + key={index} + secondaryAction={ + <IconButton + edge="end" + aria-label="delete" + onClick={() => handleDelete(index)} + > + <DeleteIcon /> + </IconButton> + } + > + <ListItemText + primary={field} + /> + </ListItem> + ))} + </List> + ) + } + <Box + display={'flex'} + flexDirection={'row'} + justifyContent={'flex-start'} + > + <TextField + value={newItem} + onChange={(event) => setNewItem(event.target.value)} + variant="outlined" + size="small" + error={noInput} + placeholder={input_placeholder} + sx={{ + width: 'fit-content', + }} + /> + <Button + onClick={() => addNewFile()} + variant={'contained'} + color={'secondary'} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + marginX: 1 + }} + > + <Typography>{button_text}</Typography> + </Button> + </Box> + </Box> + ); +} + +export default ItemsList \ No newline at end of file diff --git a/frontend/app/[locale]/components/general/RequiredFilesList.tsx b/frontend/app/[locale]/components/general/RequiredFilesList.tsx new file mode 100644 index 00000000..5cd34792 --- /dev/null +++ b/frontend/app/[locale]/components/general/RequiredFilesList.tsx @@ -0,0 +1,131 @@ +"use client" + +import {Button, IconButton, List, ListItem, ListItemText, Tooltip, TextField, Typography} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import React, {useState} from "react"; +import Box from "@mui/material/Box"; +import StatusButton from "@app/[locale]/components/StatusButton"; +import {useTranslation} from "react-i18next"; + +interface ItemsListProps { + items: string[], + setItems: (value: (((prevState: any[]) => any[]) | any[])) => void, + input_placeholder: string, + empty_list_placeholder: string, + button_text: string, + items_status: string[], + setItemsStatus: (value: (((prevState: any[]) => any[]) | any[])) => void, +} + +const ItemsList = ({ + items, + setItems, + input_placeholder, + empty_list_placeholder, + button_text, + items_status, + setItemsStatus + }: ItemsListProps) => { + const [newItem, setNewItem] = useState('') + const [noInput, setNoInput] = useState(false) + const {t} = useTranslation(); + + const handleDelete = (index: number) => { + const newFields = [...items]; + newFields.splice(index, 1); + setItems(newFields); + } + + const addNewFile = () => { + if (newItem !== '') { + const newItems = [...items]; + const newStatuses = [...items_status]; + newItems.push(newItem); + newStatuses.push("+"); + setItems(newItems); + setItemsStatus(newStatuses); + setNewItem(''); + setNoInput(false); + } else { + setNoInput(true); + } + } + + return ( + <Box> + {items.length === 0 ? ( + <Typography variant={"body1"} color={"text.disabled"} + sx={{padding: 1}}>{empty_list_placeholder}</Typography> + ) : ( + <List + sx={{ + width: '100%', + maxWidth: 360, + bgcolor: 'background.paper', + maxHeight: 150, + overflow: 'auto', + }} + > + {items.map((field, index) => ( + <ListItem + disablePadding={true} + key={index} + secondaryAction={ + <div> + <IconButton + edge="end" + aria-label="delete" + onClick={() => handleDelete(index)} + > + <DeleteIcon/> + </IconButton> + </div> + } + > + <Tooltip title={t("status_button_tooltip")} placement="top"> + <div style={{ display: 'flex', alignItems: 'center' }}> + <StatusButton files={items_status} setFiles={setItemsStatus} fileIndex={index} /> + </div> + </Tooltip> + <ListItemText + primary={field} + /> + </ListItem> + ))} + </List> + ) + } + <Box + display={'flex'} + flexDirection={'row'} + justifyContent={'flex-start'} + > + <TextField + value={newItem} + onChange={(event) => setNewItem(event.target.value)} + variant="outlined" + size="small" + error={noInput} + placeholder={input_placeholder} + sx={{ + width: 'fit-content', + }} + /> + <Button + onClick={() => addNewFile()} + variant={'contained'} + color={'secondary'} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + marginX: 1 + }} + > + <Typography>{button_text}</Typography> + </Button> + </Box> + </Box> + ); +} + +export default ItemsList \ No newline at end of file diff --git a/frontend/app/[locale]/components/project_components/assignment.tsx b/frontend/app/[locale]/components/project_components/assignment.tsx index 79bb30d8..33e21176 100644 --- a/frontend/app/[locale]/components/project_components/assignment.tsx +++ b/frontend/app/[locale]/components/project_components/assignment.tsx @@ -3,31 +3,43 @@ import Box from "@mui/material/Box"; import {TextField} from "@mui/material"; import React from "react"; import '../../project/[project_id]/edit/project_styles.css'; +import {useTranslation} from "react-i18next"; interface AssignmentProps { isAssignmentEmpty: boolean, setDescription: (value: (((prevState: string) => string) | string)) => void, description: string, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } } -function Assignment({isAssignmentEmpty, setDescription, description, translations}: AssignmentProps) { +function Assignment({isAssignmentEmpty, setDescription, description}: AssignmentProps) { + const {t} = useTranslation(); + return ( <div> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("assignment")} + {t("assignment")} </Typography> - <Box> + <Box + marginTop={1} + > <TextField variant="outlined" multiline={true} + rows={4} error={isAssignmentEmpty} + label={t("assignment")} onChange={(event: any) => setDescription(event.target.value)} value={description} - helperText={isAssignmentEmpty ? translations.t("assignment_required") : ""} + helperText={isAssignmentEmpty ? t("assignment_required") : ""} size="small" + fullWidth={true} + sx={{ + overflowY: 'auto', + height: '100%', + }} /> </Box> + </div> ) } diff --git a/frontend/app/[locale]/components/project_components/conditions.tsx b/frontend/app/[locale]/components/project_components/conditions.tsx index 15dea6ad..996b3cb8 100644 --- a/frontend/app/[locale]/components/project_components/conditions.tsx +++ b/frontend/app/[locale]/components/project_components/conditions.tsx @@ -3,34 +3,25 @@ import Tooltip from "@mui/material/Tooltip"; import React from "react"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import Box from "@mui/material/Box"; -import {TextField} from "@mui/material"; +import {useTranslation} from "react-i18next"; +import ItemsList from "@app/[locale]/components/general/ItemsList"; interface ConditionsProps { conditions: string[], setConditions: (value: (((prevState: string[]) => string[]) | string[])) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } } -function Conditions({conditions, setConditions, translations}: ConditionsProps) { - const handleConditionsChange = (index: number, event: any) => { - const newConditions = [...conditions]; - newConditions[index] = event.target.value; - setConditions(newConditions); +function Conditions({conditions, setConditions}: ConditionsProps) { + const {t} = useTranslation(); - if (index === conditions.length - 1 && event.target.value !== '') { - setConditions([...newConditions, '']); - } else if (event.target.value === '' && index < conditions.length - 1) { - newConditions.splice(index, 1); - setConditions(newConditions); - } - } + console.log(conditions) return <div> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("conditions")} + {t("conditions")} <Tooltip title={ <Typography variant="body1" className={"conditionsText"}> - {translations.t("conditions_info").split('\n').map((line: string, index: number) => ( + {t("conditions_info").split('\n').map((line: string, index: number) => ( <React.Fragment key={index}> {line} <br/> @@ -42,17 +33,13 @@ function Conditions({conditions, setConditions, translations}: ConditionsProps) </Tooltip> </Typography> <Box className={"conditionsBox"}> - {conditions.map((condition, index) => ( - <TextField - key={index} - variant="outlined" - className={"conditionsSummation"} - value={condition} - onChange={(event) => handleConditionsChange(index, event)} - margin={'normal'} - size="small" - /> - ))} + <ItemsList + items={conditions} + setItems={setConditions} + input_placeholder={t("conditions_example")} + empty_list_placeholder={t("no_conditions")} + button_text={t("add")} + /> </Box> </div>; } diff --git a/frontend/app/[locale]/components/project_components/deadline.tsx b/frontend/app/[locale]/components/project_components/deadline.tsx index 8f35f994..063a47a4 100644 --- a/frontend/app/[locale]/components/project_components/deadline.tsx +++ b/frontend/app/[locale]/components/project_components/deadline.tsx @@ -1,8 +1,10 @@ import dayjs from "dayjs"; +import {Box} from "@mui/material"; import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; import {DateCalendar} from "@mui/x-date-pickers/DateCalendar"; import {TimePicker} from "@mui/x-date-pickers"; +import { renderTimeViewClock } from '@mui/x-date-pickers/timeViewRenderers'; import React from "react"; interface DeadlineProps { @@ -13,22 +15,35 @@ interface DeadlineProps { } function Deadline({deadline, setDeadline, hasDeadline}: DeadlineProps) { - return <LocalizationProvider dateAdapter={AdapterDayjs}> - <DateCalendar - value={deadline} - onChange={(event) => setDeadline(event)} - minDate={dayjs()} - disabled={!hasDeadline} - /> - <TimePicker - value={deadline} - onChange={(event) => { - if (event != null) setDeadline(event) - }} - disabled={!hasDeadline} - /> - - </LocalizationProvider>; + return ( + <LocalizationProvider dateAdapter={AdapterDayjs}> + <Box + display={"flex"} + flexDirection={"column"} + alignItems={"center"} + justifyContent={"center"} + width={"100%"} + > + <DateCalendar + value={deadline} + onChange={(event) => setDeadline(event)} + minDate={dayjs()} + disabled={!hasDeadline} + /> + <TimePicker + value={deadline} + onChange={(event) => { + if (event != null) setDeadline(event) + }} + disabled={!hasDeadline} + viewRenderers={{ + hours: renderTimeViewClock, + minutes: renderTimeViewClock, + }} + /> + </Box> + </LocalizationProvider> + ); } export default Deadline; \ No newline at end of file diff --git a/frontend/app/[locale]/components/project_components/finishbuttons.tsx b/frontend/app/[locale]/components/project_components/finishbuttons.tsx index abbe9126..4c7442c5 100644 --- a/frontend/app/[locale]/components/project_components/finishbuttons.tsx +++ b/frontend/app/[locale]/components/project_components/finishbuttons.tsx @@ -1,79 +1,118 @@ -import {Grid, IconButton} from "@mui/material"; +import {Grid, IconButton, Button, Typography, Tooltip} from "@mui/material"; import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; import React from "react"; import AlarmOnIcon from '@mui/icons-material/AlarmOn'; import AlarmOffIcon from '@mui/icons-material/AlarmOff'; +import {useTranslation} from "react-i18next"; interface FinishButtonsProps { visible: boolean, setVisible: (value: (((prevState: boolean) => boolean) | boolean)) => void, handleSave: () => Promise<void>, setConfirmRemove: (value: (((prevState: boolean) => boolean) | boolean)) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; }, - course_id: number, + project_id: (number|null), + course_id: (number|null), setHasDeadline: (value: (((prevState: boolean) => boolean) | boolean)) => void, - hasDeadline: boolean + hasDeadline: boolean, + createProject: boolean, } function FinishButtons( { - visible, setVisible, handleSave, setConfirmRemove, translations, - course_id, setHasDeadline, hasDeadline + visible, setVisible, handleSave, setConfirmRemove, + project_id, setHasDeadline, hasDeadline, createProject, course_id }: FinishButtonsProps ) { + const {t} = useTranslation(); + + const cancelLink = createProject ? `/course/${course_id}/` : `/project/${project_id}/`; + return <div> <Grid container spacing={0} alignItems={"center"} justifyContent={"space-between"}> <Grid display={"flex"}> { visible ? ( - <IconButton onClick={() => setVisible(!visible)}> - <VisibilityIcon/> - </IconButton> + <Tooltip title={t("visibility")} placement={"top"}> + <IconButton onClick={() => setVisible(!visible)}> + <VisibilityIcon/> + </IconButton> + </Tooltip> ) : ( - <IconButton onClick={() => setVisible(!visible)}> - <VisibilityOffIcon/> - </IconButton> + <Tooltip title={t("visibility")} placement={"top"}> + <IconButton onClick={() => setVisible(!visible)}> + <VisibilityOffIcon/> + </IconButton> + </Tooltip> ) } </Grid> <Grid display={"flex"}> { hasDeadline ? ( - <IconButton onClick={() => setHasDeadline(!hasDeadline)}> - <AlarmOnIcon/> - </IconButton> + <Tooltip title={t("has_deadline")} placement={"top"}> + <IconButton onClick={() => setHasDeadline(!hasDeadline)}> + <AlarmOnIcon/> + </IconButton> + </Tooltip> ) : ( - <IconButton onClick={() => setHasDeadline(!hasDeadline)}> - <AlarmOffIcon/> - </IconButton> + <Tooltip title={t("has_deadline")} placement={"top"}> + <IconButton onClick={() => setHasDeadline(!hasDeadline)}> + <AlarmOffIcon/> + </IconButton> + </Tooltip> ) } </Grid> <Grid className={"buttonsGrid"}> - <button + <Button onClick={handleSave} className={"saveButton"} + variant={'contained'} + color={'primary'} + sx={{ + width: 'fit-content', + color: 'primary.contrastText', + }} > - {translations.t("save")} - </button> + <Typography> + {createProject ? t("create") : t("save")} + </Typography> + </Button> </Grid> <Grid className={"buttonsGrid"}> - <button - onClick={() => window.location.href = "/course/" + course_id + "/"} + <Button + onClick={() => window.location.href = cancelLink} className={"saveButton"} + variant={'contained'} + color={'secondary'} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + }} > - {translations.t("cancel")} - </button> - </Grid> - <Grid className={"buttonsGrid"}> - <button - onClick={() => setConfirmRemove(true)} - className={"removeButton"} - > - {translations.t("remove")} - </button> + <Typography> + {t("cancel")} + </Typography> + </Button> </Grid> + {!createProject && ( + <Grid className={"buttonsGrid"}> + <Button + onClick={() => setConfirmRemove(true)} + className={"removeButton"} + variant={'contained'} + color={'error'} + sx={{ + width: 'fit-content', + }} + > + <Typography> + {t("remove")} + </Typography> + </Button> + </Grid> + )} </Grid> </div> } diff --git a/frontend/app/[locale]/components/project_components/groups.tsx b/frontend/app/[locale]/components/project_components/groups.tsx index 4ac886fd..dcbceb85 100644 --- a/frontend/app/[locale]/components/project_components/groups.tsx +++ b/frontend/app/[locale]/components/project_components/groups.tsx @@ -4,6 +4,7 @@ import React from "react"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import {Grid, TextField} from "@mui/material"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import {useTranslation} from "react-i18next"; interface GroupsProps { groupAmount: number, @@ -12,7 +13,6 @@ interface GroupsProps { isGroupSizeEmpty: boolean, setGroupAmount: (value: (((prevState: number) => number) | number)) => void, setGroupSize: (value: (((prevState: number) => number) | number)) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } } function Groups( @@ -23,9 +23,9 @@ function Groups( isGroupSizeEmpty, setGroupAmount, setGroupSize, - translations }: GroupsProps ) { + const {t} = useTranslation(); const handleGroupAmountChange = (event: any) => { if (event.target.value === '') { setGroupAmount(event.target.value); @@ -50,56 +50,57 @@ function Groups( } } - return <TranslationsProvider locale={translations.locale} namespaces={translations.i18nNamespaces} - resources={translations.resources}> - <Typography variant="h5" className={"typographyStyle"}> - {translations.t("groups")} - <Tooltip title={ - <Typography variant="body1" className={"conditionsText"}> - {translations.t("group_info").split('\n').map((line: string, index: number) => ( - <React.Fragment key={index}> - {line} - <br/> - </React.Fragment> - ))} - </Typography> - } placement={"right"}> - <HelpOutlineIcon className={"conditionsHelp"}/> - </Tooltip> - </Typography> - <Grid container spacing={1}> - <Grid item xs={6} className={"titleGrids"}> - <Typography variant="body1" className={"titleGrids"}>{translations.t("group_amount")}</Typography> + return ( + <div> + <Typography variant="h5" className={"typographyStyle"}> + {t("groups")} + <Tooltip title={ + <Typography variant="body1" className={"conditionsText"}> + {t("group_info").split('\n').map((line: string, index: number) => ( + <React.Fragment key={index}> + {line} + <br/> + </React.Fragment> + ))} + </Typography> + } placement={"right"}> + <HelpOutlineIcon className={"conditionsHelp"}/> + </Tooltip> + </Typography> + <Grid container spacing={1}> + <Grid item xs={6} className={"titleGrids"}> + <Typography variant="body1" className={"titleGrids"}>{t("group_amount")}</Typography> + </Grid> + <Grid item xs={6}> + <Typography variant="body1">{t("group_size")}</Typography> + </Grid> + <Grid item xs={6}> + <TextField + type="number" + inputProps={{min: 1, max: 1000}} + value={groupAmount} + onChange={handleGroupAmountChange} + className={"titleGrids"} + size="small" + error={isGroupAmountEmpty} + helperText={isGroupAmountEmpty ? t("group_amount_required") : ""} + /> + </Grid> + <Grid item xs={6}> + <TextField + type="number" + inputProps={{min: 1, max: 20}} + value={groupSize} + onChange={handleGroupSizeChange} + className={"titleGrids"} + size="small" + error={isGroupSizeEmpty} + helperText={isGroupSizeEmpty ? t("group_size_required") : ""} + /> + </Grid> </Grid> - <Grid item xs={6}> - <Typography variant="body1">{translations.t("group_size")}</Typography> - </Grid> - <Grid item xs={6}> - <TextField - type="number" - inputProps={{min: 1, max: 1000}} - value={groupAmount} - onChange={handleGroupAmountChange} - className={"titleGrids"} - size="small" - error={isGroupAmountEmpty} - helperText={isGroupAmountEmpty ? translations.t("group_amount_required") : ""} - /> - </Grid> - <Grid item xs={6}> - <TextField - type="number" - inputProps={{min: 1, max: 20}} - value={groupSize} - onChange={handleGroupSizeChange} - className={"titleGrids"} - size="small" - error={isGroupSizeEmpty} - helperText={isGroupSizeEmpty ? translations.t("group_size_required") : ""} - /> - </Grid> - </Grid> - </TranslationsProvider>; + </div> + ); } export default Groups; \ No newline at end of file diff --git a/frontend/app/[locale]/components/project_components/removedialog.tsx b/frontend/app/[locale]/components/project_components/removedialog.tsx index c53ac327..9997f108 100644 --- a/frontend/app/[locale]/components/project_components/removedialog.tsx +++ b/frontend/app/[locale]/components/project_components/removedialog.tsx @@ -1,42 +1,34 @@ -import {Box, Dialog, DialogActions, DialogContent, DialogTitle} from "@mui/material"; -import React from "react"; +import React from 'react'; +import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; +import { useTranslation } from 'react-i18next'; interface RemoveDialogProps { - confirmRemove: boolean, - handle_remove: () => void, - setConfirmRemove: (value: (((prevState: boolean) => boolean) | boolean)) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } + confirmRemove: boolean; + handleRemove: () => void; + setConfirmRemove: (value: boolean) => void; } -function RemoveDialog({confirmRemove, handle_remove, setConfirmRemove, translations}: RemoveDialogProps) { - return <div> - <Dialog open={confirmRemove} className={"dialogPadding"}> - <Box textAlign={"center"}> - <DialogTitle> - {translations.t("remove_dialog")} - </DialogTitle> - </Box> +const RemoveDialog: React.FC<RemoveDialogProps> = ({ confirmRemove, handleRemove, setConfirmRemove }) => { + const { t } = useTranslation(); + + return ( + <Dialog open={confirmRemove} onClose={() => setConfirmRemove(false)}> + <DialogTitle>{t('remove_dialog')}</DialogTitle> <DialogContent> - <Box textAlign={"center"} color={"grey"}> - {translations.t("action_dialog")} + <Box textAlign="center" color="grey"> + {t('action_dialog')} </Box> </DialogContent> - <DialogActions style={{justifyContent: 'space-between'}}> - <button - onClick={handle_remove} - className={"dialogRemove"} - > - {translations.t("remove_confirm")} - </button> - <button - onClick={() => setConfirmRemove(false)} - className={"dialogCancel"} - > - {translations.t("remove_cancel")} - </button> + <DialogActions> + <Button onClick={handleRemove} color="error"> + {t('remove_confirm')} + </Button> + <Button onClick={() => setConfirmRemove(false)} color="primary"> + {t('remove_cancel')} + </Button> </DialogActions> - </Dialog>; - </div> -} + </Dialog> + ); +}; -export default RemoveDialog; \ No newline at end of file +export default RemoveDialog; diff --git a/frontend/app/[locale]/components/project_components/requiredFiles.tsx b/frontend/app/[locale]/components/project_components/requiredFiles.tsx index 3aa47e87..cea2a229 100644 --- a/frontend/app/[locale]/components/project_components/requiredFiles.tsx +++ b/frontend/app/[locale]/components/project_components/requiredFiles.tsx @@ -1,39 +1,31 @@ +"use client"; + import Typography from "@mui/material/Typography"; import Tooltip from "@mui/material/Tooltip"; -import React from "react"; +import React, {useState} from "react"; import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; import Box from "@mui/material/Box"; -import {TextField} from "@mui/material"; -import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import {useTranslation} from "react-i18next"; +import RequiredFilesList from "@app/[locale]/components/general/RequiredFilesList"; interface RequiredFilesProps { files: any[], setFiles: (value: (((prevState: any[]) => any[]) | any[])) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } + file_status: any[], + setFileStatus: (value: (((prevState: any[]) => any[]) | any[])) => void, } function RequiredFiles( - {files, setFiles, translations}: RequiredFilesProps + {files, setFiles, file_status, setFileStatus}: RequiredFilesProps ) { - const handleFieldChange = (index: number, event: any) => { - const newFields = [...files]; - newFields[index] = event.target.value; - setFiles(newFields); - - if (index === files.length - 1 && event.target.value !== '') { - setFiles([...newFields, '']); - } else if (event.target.value === '' && index < files.length - 1) { - newFields.splice(index, 1); - setFiles(newFields); - } - } + const {t} = useTranslation(); return <div> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("required_files")} + {t("required_files")} <Tooltip title={ <Typography variant="body1" className={"conditionsText"}> - {translations.t("required_files_info").split('\n').map((line: string, index: number) => ( + {t("required_files_info").split('\n').map((line: string, index: number) => ( <React.Fragment key={index}> {line} <br/> @@ -45,16 +37,15 @@ function RequiredFiles( </Tooltip> </Typography> <Box className={"conditionsBox"}> - {files.map((field, index) => ( - <TextField - key={index} - variant="outlined" - className={"conditionsSummation"} - value={field} - onChange={(event) => handleFieldChange(index, event)} - size="small" - /> - ))} + <RequiredFilesList + items={files} + setItems={setFiles} + input_placeholder={t("new_file_example")} + empty_list_placeholder={t("no_required_files")} + button_text={t("add")} + items_status={file_status} + setItemsStatus={setFileStatus} + /> </Box> </div>; } diff --git a/frontend/app/[locale]/components/project_components/testfiles.tsx b/frontend/app/[locale]/components/project_components/testfiles.tsx index f8034fc7..c3bf135e 100644 --- a/frontend/app/[locale]/components/project_components/testfiles.tsx +++ b/frontend/app/[locale]/components/project_components/testfiles.tsx @@ -4,24 +4,21 @@ import DeleteIcon from "@mui/icons-material/Delete"; import DescriptionIcon from "@mui/icons-material/Description"; import React from "react"; import JSZip from "jszip"; +import {useTranslation} from "react-i18next"; interface TestFilesProps { testfilesName: string[], setTestfilesName: (value: (((prevState: string[]) => string[]) | string[])) => void, testfilesData: JSZip.JSZipObject[], setTestfilesData: (value: (((prevState: JSZip.JSZipObject[]) => JSZip.JSZipObject[]) | JSZip.JSZipObject[])) => void, - translations: { - t: any; - resources: any; - locale: any; - i18nNamespaces: string[] - } } -function TestFiles({testfilesName, setTestfilesName, testfilesData, setTestfilesData, translations}: TestFilesProps) { +function TestFiles({testfilesName, setTestfilesName, testfilesData, setTestfilesData}: TestFilesProps) { + const {t} = useTranslation(); + return <div> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("test_files")} + {t("test_files")} </Typography> <List dense={true}> {testfilesName.map((testfile, index) => ( diff --git a/frontend/app/[locale]/components/project_components/title.tsx b/frontend/app/[locale]/components/project_components/title.tsx index 57fbbe81..2c4f24fe 100644 --- a/frontend/app/[locale]/components/project_components/title.tsx +++ b/frontend/app/[locale]/components/project_components/title.tsx @@ -1,6 +1,7 @@ import {Grid, TextField} from "@mui/material"; import Typography from "@mui/material/Typography"; import React from "react"; +import {useTranslation} from "react-i18next"; interface TitleProps { isTitleEmpty: boolean, @@ -9,10 +10,11 @@ interface TitleProps { score: number, isScoreEmpty: boolean, setScore: (value: (((prevState: number) => number) | number)) => void, - translations: { t: any, resources: any, locale: any, i18nNamespaces: any }, } -function Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore, translations}: TitleProps) { +function Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore}: TitleProps) { + const {t} = useTranslation(); + const handleScoreChange = (event: any) => { if (event.target.value === '') { setScore(event.target.value); @@ -28,12 +30,12 @@ function Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore, tr return <Grid container spacing={1}> <Grid item xs={6} className={"titleGrids"}> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("title")} + {t("title")} </Typography> </Grid> <Grid item xs={6}> <Typography variant="h5" className={"typographyStyle"}> - {translations.t("max_score")} + {t("max_score")} </Typography> </Grid> <Grid item xs={6}> @@ -43,8 +45,10 @@ function Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore, tr onChange={(event) => setTitle(event.target.value)} value={title} className={"titleGrids"} - helperText={isTitleEmpty ? translations.t("title_required") : ""} + helperText={isTitleEmpty ? t("title_required") : ""} size="small" + placeholder={t("title")} + label={t("title")} /> </Grid> <Grid item xs={6}> @@ -57,7 +61,7 @@ function Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore, tr className={"titleGrids"} size="small" error={isScoreEmpty} - helperText={isScoreEmpty ? translations.t("score_required") : ""} + helperText={isScoreEmpty ? t("score_required") : ""} /> </Grid> </Grid> diff --git a/frontend/app/[locale]/components/project_components/uploadButton.tsx b/frontend/app/[locale]/components/project_components/uploadButton.tsx index 70cd8c77..b4644d0b 100644 --- a/frontend/app/[locale]/components/project_components/uploadButton.tsx +++ b/frontend/app/[locale]/components/project_components/uploadButton.tsx @@ -1,12 +1,14 @@ import React from "react"; import JSZip, {JSZipObject} from "jszip"; +import {useTranslation} from "react-i18next"; +import {Button, Typography} from "@mui/material"; +import UploadIcon from '@mui/icons-material/Upload'; interface UploadTestFileProps { testfilesName: string[], setTestfilesName: (value: (((prevState: string[]) => string[]) | string[])) => void, testfilesData: JSZipObject[], setTestfilesData: (value: (((prevState: JSZipObject[]) => JSZipObject[]) | JSZipObject[])) => void, - translations: { t: any; resources: any; locale: any; i18nNamespaces: string[]; } } function UploadTestFile( @@ -15,9 +17,9 @@ function UploadTestFile( setTestfilesName, testfilesData, setTestfilesData, - translations }: UploadTestFileProps ) { + const {t} = useTranslation(); const handleFileChange = async (event: any) => { let zip = new JSZip(); for (let i = 0; i < event.target.files.length; i++) { @@ -42,20 +44,32 @@ function UploadTestFile( setTestfilesData(testfiles_data); } - return <div> - <input - id="fileInput" - type="file" - className={"uploadInput"} - onChange={handleFileChange} - /> - <button - onClick={() => document.getElementById("fileInput")?.click()} - className={"uploadButton"} - > - {translations.t("upload")} - </button> - </div>; + return( + <div> + {testfilesName.length === 0 && ( + <Typography variant={"h6"} color={"text.disabled"}>{t("no_test_files_selected")}</Typography> + )} + <input + id="fileInput" + type="file" + className={"uploadInput"} + onChange={handleFileChange} + /> + <Button + onClick={() => document.getElementById("fileInput")?.click()} + className={"uploadButton"} + variant={"contained"} + color={'secondary'} + startIcon={<UploadIcon/>} + sx={{ + width: 'fit-content', + color: 'secondary.contrastText', + }} + > + {t("upload")} + </Button> + </div> + ); } export default UploadTestFile; \ No newline at end of file diff --git a/frontend/app/[locale]/components/user_components/CancelButton.tsx b/frontend/app/[locale]/components/user_components/CancelButton.tsx new file mode 100644 index 00000000..b9817030 --- /dev/null +++ b/frontend/app/[locale]/components/user_components/CancelButton.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react' +import {useTranslation} from "react-i18next"; + +const CancelButton = () => { + const {t} = useTranslation() + + const handleCancel = () => { + window.location.href = "/home"; + } + + return ( + <div> + <button onClick={handleCancel} style={{ + backgroundColor: 'red', + color: 'white', + padding: '8px 16px', + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + fontFamily: 'Quicksand', + position: 'absolute', + top: '20px', + right: '20px', + fontSize: '16px' + }}>{t("cancel")} + </button> + + </div> + ) +} + +export default CancelButton \ No newline at end of file diff --git a/frontend/app/[locale]/components/user_components/DeleteButton.tsx b/frontend/app/[locale]/components/user_components/DeleteButton.tsx new file mode 100644 index 00000000..390c9f59 --- /dev/null +++ b/frontend/app/[locale]/components/user_components/DeleteButton.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React, { useState } from 'react'; +import { useTranslation } from "react-i18next"; +import { deleteUser } from "@lib/api"; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Typography } from '@mui/material'; + +interface DeleteButtonProps { + userId: number +} + +const DeleteButton = ({ userId }: DeleteButtonProps) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleDelete = async () => { + await deleteUser(userId); + window.location.href = "/home"; + }; + + return ( + <> + <Button + variant='contained' + onClick={handleOpen} + color='error' + sx={{ + width: 'fit-content', + height: 'fit-content', + whiteSpace: 'nowrap', + }} + > + <Typography + paddingX={3} + > + {t("delete user")} + </Typography> + </Button> + <Dialog + open={open} + onClose={handleClose} + aria-labelledby="alert-dialog-title" + aria-describedby="alert-dialog-description" + > + <DialogTitle id="alert-dialog-title">{t("Are you sure you want to delete this user?")}</DialogTitle> + <DialogActions> + <Button onClick={handleClose} color="primary"> + {t("cancel")} + </Button> + <Button onClick={handleDelete} color="error" autoFocus> + {t("delete")} + </Button> + </DialogActions> + </Dialog> + </> + ); +}; + +export default DeleteButton; diff --git a/frontend/app/[locale]/course/[course_id]/add_project/page.tsx b/frontend/app/[locale]/course/[course_id]/add_project/page.tsx new file mode 100644 index 00000000..c3c8d2e9 --- /dev/null +++ b/frontend/app/[locale]/course/[course_id]/add_project/page.tsx @@ -0,0 +1,58 @@ +"use client" +import NavBar from "@app/[locale]/components/NavBar" +import ProjectEditForm from "@app/[locale]/project/[project_id]/edit/projectEditForm"; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import initTranslations from "@app/i18n"; +import {useEffect, useState} from "react"; +import {fetchUserData, UserData} from "@lib/api"; +import {Box, CircularProgress} from "@mui/material"; + +const i18nNamespaces = ['common'] + +function ProjectAddPage({params: {locale, course_id}}: { params: { locale: any, course_id: number } }) { + const [resources, setResources] = useState() + const [user, setUser] = useState<UserData | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); + + useEffect(() => { + const initialize = async () => { + try { + const result = await initTranslations(locale, ["common"]); + setResources(result.resources); + const userData = await fetchUserData(); + setUser(userData); + if (userData.role === 3 || !userData.course.includes(Number(course_id))) { // If the user is a student or the course is not in the user's courses + setAccessDenied(true); + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error initializing the page:", error); + } finally { + setIsLoading(false); + } + }; + + initialize(); + }, [course_id, locale]); + + // If the page is still loading, display a loading spinner + if (isLoading) { + return ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ); + } + + return ( + <TranslationsProvider locale={locale} namespaces={i18nNamespaces} resources={resources}> + <NavBar/> + {!accessDenied && <ProjectEditForm project_id={null} add_course_id={course_id}/>} + </TranslationsProvider> + ); +} + +export default ProjectAddPage; \ No newline at end of file diff --git a/frontend/app/[locale]/course/[course_id]/edit/page.tsx b/frontend/app/[locale]/course/[course_id]/edit/page.tsx index 5c9bef28..2d819479 100644 --- a/frontend/app/[locale]/course/[course_id]/edit/page.tsx +++ b/frontend/app/[locale]/course/[course_id]/edit/page.tsx @@ -1,12 +1,51 @@ +"use client"; +import React, { useState, useEffect } from 'react'; import NavBar from "@app/[locale]/components/NavBar"; -import Box from "@mui/material/Box"; +import {Box, CircularProgress} from "@mui/material"; import initTranslations from "@app/i18n"; import EditCourseForm from "@app/[locale]/components/EditCourseForm"; import DeleteButton from "@app/[locale]/components/course_components/DeleteButton"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import ArchiveButton from "@app/[locale]/components/course_components/ArchiveButton"; +import {UserData, fetchUserData} from "@lib/api"; -async function CourseEditPage({params: {locale, course_id}}: { params: { locale: any, course_id: number } }) { - const {t, resources} = await initTranslations(locale, ["common"]) +function CourseEditPage({params: {locale, course_id}}: { params: { locale: any, course_id: number } }) { + const [resources, setResources] = useState(); + const [user, setUser] = useState<UserData | null>(null); + const [accessDenied, setAccessDenied] = useState(true); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const initialize = async () => { + try { + const result = await initTranslations(locale, ["common"]); + setResources(result.resources); + const userData = await fetchUserData(); + setUser(userData); + if (userData.role === 3 || !userData.course.includes(Number(course_id))) { // If the user is a student or the course is not in the user's courses + setAccessDenied(true); + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error initializing the page:", error); + } finally { + setIsLoading(false); + } + }; + + initialize(); + }, [course_id, locale]); + + // If the page is still loading, display a loading spinner + if (isLoading) { + return ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ); + } return ( <TranslationsProvider @@ -15,18 +54,24 @@ async function CourseEditPage({params: {locale, course_id}}: { params: { locale: namespaces={["common"]} > <NavBar/> - <Box - padding={5} - sx={{ - display: 'flex', - alignItems: 'space-between', - justifyContent: 'space-between', - }} - > - <EditCourseForm courseId={course_id}/> - <DeleteButton courseId={course_id}/> - </Box> - <div id="extramargin" style={{height: "100px"}}></div> + {!accessDenied && + <> + <Box + padding={5} + sx={{ + display: 'flex', + alignItems: 'space-between', + justifyContent: 'space-between', + width: '100%', + }} + > + <EditCourseForm courseId={course_id}/> + <DeleteButton courseId={course_id}/> + <ArchiveButton course_id={course_id}/> + </Box> + <div id="extramargin" style={{height: "100px"}}></div> + </> + } </TranslationsProvider> ); } diff --git a/frontend/app/[locale]/course/[course_id]/page.tsx b/frontend/app/[locale]/course/[course_id]/page.tsx index f52aaa70..bf1621c5 100644 --- a/frontend/app/[locale]/course/[course_id]/page.tsx +++ b/frontend/app/[locale]/course/[course_id]/page.tsx @@ -1,6 +1,7 @@ +"use client" import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; -import {Box, Typography} from "@mui/material"; +import {Box, Typography, Grid, CircularProgress} from "@mui/material"; import NavBar from "@app/[locale]/components/NavBar"; import CourseBanner from "@app/[locale]/components/CourseBanner"; import CourseDetails from "@app/[locale]/components/CourseDetails"; @@ -8,57 +9,110 @@ import StudentCoTeacherButtons from "@app/[locale]/components/StudentCoTeacherBu import JoinCourseWithToken from "@app/[locale]/components/JoinCourseWithToken"; import ListView from '@app/[locale]/components/ListView'; import AddProjectButton from "@app/[locale]/components/AddProjectButton"; +import React, { useEffect, useState} from "react"; +import AccesAlarm from '@mui/icons-material/AccessAlarm'; +import Person from '@mui/icons-material/Person'; +import {fetchUserData, UserData} from "@lib/api"; const i18nNamespaces = ['common'] -export default async function Course({params: {locale, course_id}, searchParams: {token}}: +export default function Course({params: {locale, course_id}, searchParams: {token}}: { params: { locale: any, course_id: number }, searchParams: { token: string } }) { - const {t, resources} = await initTranslations(locale, i18nNamespaces) - const headers = [t('name'), t('deadline'), t('view')] - const headers_backend = ['name', 'deadline', 'view'] + const [user, setUser] = useState<UserData|null>(null); + const [translations, setTranslations] = useState({ t: (s) => '', resources: {} }); + const [loading, setLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); + + useEffect(() => { + initTranslations(locale, i18nNamespaces).then(({ t, resources }) => { + setTranslations({ t, resources }); + }); + + const fetchUser = async () => { + try { + const userData = await fetchUserData(); + setUser(userData); + if (!userData.course.includes(Number(course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + + } catch (error) { + console.error("There was an error fetching the user data:", error); + } finally { + setLoading(false); + } + } + + fetchUser(); + }, [locale, course_id]); + + const headers = [ + <React.Fragment key="name"><Person style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + translations.t('name')}</React.Fragment>, + <React.Fragment key="deadline"><AccesAlarm style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + translations.t('deadline')}</React.Fragment>, + ''] + const headers_backend = ['name', 'deadline', ''] return ( <TranslationsProvider - resources={resources} + resources={translations.resources} locale={locale} namespaces={i18nNamespaces} > - <JoinCourseWithToken token={token} course_id={course_id}></JoinCourseWithToken> - <NavBar/> - <Box - sx={{ - padding: 5 - }} - > - <CourseBanner course_id={course_id}/> - <CourseDetails course_id={course_id}/> - <Typography - variant="h3" - sx={{ - fontWeight: 'medium', - marginTop: 2 - }} - > - {t('projects')} - </Typography> - <AddProjectButton/> - <Box - justifyContent={'left'} - width={'100%'} - > - <ListView - admin={false} - headers={headers} - headers_backend={headers_backend} - sortable={[true, true, false, true]} - get={'projects'} - get_id={course_id} - /> - + <JoinCourseWithToken token={token} course_id={course_id} /> + <NavBar /> + {loading ? ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> </Box> - <StudentCoTeacherButtons course_id={course_id}/> - </Box> - + ): ( + !accessDenied && + <Box + sx={{ + paddingTop: 5, + width: '100%', + px: { xs: 2, sm: 3, md: 5 }, + }} + > + <CourseBanner course_id={course_id} /> + <CourseDetails course_id={course_id} /> + <Grid container alignItems="center" spacing={2} mt={2}> + <Grid item xs={12} sm="auto"> + <Typography + variant="h4" + sx={{ + fontWeight: 'medium', + textAlign: { xs: 'center', sm: 'left' }, + }} + > + {translations.t('projects')} + </Typography> + </Grid> + <Grid item xs={12} sm="auto"> + <Box display="flex" justifyContent={{ xs: 'center', sm: 'flex-start' }} padding={2}> + <AddProjectButton course_id={course_id} /> + </Box> + </Grid> + </Grid> + <Box + justifyContent="left" + width="100%" + > + <ListView + search_text={translations.t('search_for_project')} + admin={false} + headers={headers} + headers_backend={headers_backend} + sortable={[true, true, false, true]} + get={'projects'} + get_id={course_id} + /> + </Box> + <StudentCoTeacherButtons course_id={course_id} /> + </Box> + ) + } </TranslationsProvider> ) -} \ No newline at end of file +} diff --git a/frontend/app/[locale]/course/[course_id]/students/page.tsx b/frontend/app/[locale]/course/[course_id]/students/page.tsx index 7b952eac..0f6a9dc7 100644 --- a/frontend/app/[locale]/course/[course_id]/students/page.tsx +++ b/frontend/app/[locale]/course/[course_id]/students/page.tsx @@ -1,42 +1,100 @@ +"use client" import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; import ListView from '@app/[locale]/components/ListView'; -import BackButton from "@app/[locale]/components/BackButton"; +import {Button, Box, CircularProgress} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import React, {useEffect, useState} from "react"; +import EmailIcon from '@mui/icons-material/Email'; +import {fetchUserData, UserData} from "@lib/api"; +import Typography from "@mui/material/Typography"; const i18nNamespaces = ['common'] -export default async function StudentsPage({ params }: { params: { locale: any, course_id: number } }) { - const { locale, course_id } = params; - const { t, resources } = await initTranslations(locale, i18nNamespaces); +export default function StudentsPage({params: {locale, course_id}}: { params: { locale: any, course_id: number } }) { + const [user, setUser] = useState<UserData|null>(null); + const [translations, setTranslations] = useState({ t: (s) => '', resources: {} }); + const [userLoading, setUserLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); - const headers = [t('email')]; + + useEffect(() => { + + initTranslations(locale, i18nNamespaces).then(({ t, resources }) => { + setTranslations({ t, resources }); + }); + + const fetchUser = async () => { + try { + const userData = await fetchUserData(); + setUser(userData); + if (!userData.course.includes(Number(course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error fetching the user data:", error); + } finally { + setUserLoading(false); + } + } + + fetchUser(); + }, [course_id, locale]); + + const headers = [ + <React.Fragment key="email"><EmailIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + translations.t('email')}</React.Fragment>]; const headers_backend = ['email']; return ( <TranslationsProvider - resources={resources} + resources={translations.resources} locale={locale} namespaces={i18nNamespaces} > <NavBar /> - <div style={{marginTop:60, padding:20}}> - <BackButton - destination={`/course/${course_id}`} - text={t('back_to') + ' ' + t('course')} - /> - <ListView - admin={true} - headers={headers} - headers_backend={headers_backend} - sortable={[true]} - get_id={course_id} - get={'course_students'} - action_name={'remove_from_course'} - action_text={t('remove_user_from_course')} - search_text={t('search')} - /> - </div> + {userLoading ? ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ) : ( + !accessDenied && + <Box width={'100%'} style={{ padding: 20 }}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/course/${course_id}`} + > + {translations.t('back_to') + ' ' + translations.t('course') + ' ' + translations.t('page')} + </Button> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {translations.t('students')} + </Typography> + <Box marginTop={{ xs: 2, md: 4 }}> + <ListView + admin={true} + headers={headers} + headers_backend={headers_backend} + sortable={[true]} + get_id={course_id} + get={'course_students'} + action_name={'remove_from_course'} + action_text={translations.t('remove_user_from_course')} + search_text={translations.t('search_student')} + /> + </Box> + </Box> + )} </TranslationsProvider> ); } diff --git a/frontend/app/[locale]/course/[course_id]/teachers/page.tsx b/frontend/app/[locale]/course/[course_id]/teachers/page.tsx index 1538ae0a..8d792930 100644 --- a/frontend/app/[locale]/course/[course_id]/teachers/page.tsx +++ b/frontend/app/[locale]/course/[course_id]/teachers/page.tsx @@ -1,41 +1,97 @@ +"use client" import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; import ListView from '@app/[locale]/components/ListView'; -import BackButton from "@app/[locale]/components/BackButton"; +import {Box, Button, CircularProgress} from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import React, {useEffect, useState} from "react"; +import EmailIcon from '@mui/icons-material/Email'; +import {fetchUserData, UserData} from "@lib/api"; +import Typography from "@mui/material/Typography"; const i18nNamespaces = ['common'] -export default async function TeachersPage({ params }: { params: { locale: any, course_id: number } }) { - const { locale, course_id } = params; - const { t, resources } = await initTranslations(locale, i18nNamespaces); +export default function TeachersPage({params: {locale, course_id}}: { params: { locale: any, course_id: number } }) { + const [user, setUser] = useState<UserData|null>(null); + const [translations, setTranslations] = useState({ t: (s) => '', resources: {} }); + const [userLoading, setUserLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); - const headers = [t('email')]; + useEffect(() => { + + initTranslations(locale, i18nNamespaces).then(({ t, resources }) => { + setTranslations({ t, resources }); + }); + + const fetchUser = async () => { + try { + const userData = await fetchUserData(); + setUser(userData); + if (!userData.course.includes(Number(course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error fetching the user data:", error); + } finally { + setUserLoading(false); + } + } + + fetchUser(); + }, [course_id, locale]); + + + const headers = [<React.Fragment key="email"><EmailIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + translations.t('email')}</React.Fragment>]; const headers_backend = ['email']; - + return ( <TranslationsProvider - resources={resources} + resources={translations.resources} locale={locale} namespaces={i18nNamespaces} > <NavBar/> - <div style={{marginTop: 60, padding: 20}}> - <BackButton - destination={`/course/${course_id}`} - text={t('back_to') + ' ' + t('course')} - /> - <div style={{marginBottom: '100px'}}> + {userLoading ? ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ) : ( + !accessDenied && + <Box width={'100%'} style={{padding: 20}}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/course/${course_id}`} + > + {translations.t('back_to') + ' ' + translations.t('course') + ' ' + translations.t('page')} + </Button> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {translations.t('teachers')} + </Typography> + <Box marginTop={{ xs: 2, md: 4 }}> <ListView admin={true} headers={headers} + headers_backend={headers_backend} sortable={[true]} get_id={course_id} get={'course_teachers'} - search_text={t('search')} + search_text={translations.t('search_teacher')} /> - </div> - </div> + </Box> + </Box> + )} </TranslationsProvider> -); + ); } diff --git a/frontend/app/[locale]/course/add/page.tsx b/frontend/app/[locale]/course/add/page.tsx index 580c7969..2bb5e4d7 100644 --- a/frontend/app/[locale]/course/add/page.tsx +++ b/frontend/app/[locale]/course/add/page.tsx @@ -1,13 +1,51 @@ +'use client'; import NavBar from "@app/[locale]/components/NavBar"; -import { Box } from "@mui/material"; +import {Box, CircularProgress} from "@mui/material"; import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import CreateCourseForm from "@app/[locale]/components/CreateCourseForm"; +import React, {useEffect, useState} from "react"; +import {fetchUserData, UserData} from "@lib/api"; const i18nNamespaces = ['common'] -async function CourseCreatePage({params: {locale}}: { params: { locale: any } }) { - const {t, resources} = await initTranslations(locale, ["common"]) +function CourseCreatePage({params: {locale}}: { params: { locale: any } }) { + const [resources, setResources] = useState() + const [user, setUser] = useState<UserData | null>(null); + const [userLoading, setUserLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); + + + useEffect(() => { + const initialize = async () => { + try { + const result = await initTranslations(locale, ["common"]); + setResources(result.resources); + const userData = await fetchUserData(); + setUser(userData); + if (userData.role === 3) { + setAccessDenied(true); + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error initializing the page:", error); + } finally { + setUserLoading(false); + } + }; + + initialize(); + }, [locale]); + + if (userLoading) { + return ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ); + } return ( <TranslationsProvider @@ -16,14 +54,16 @@ async function CourseCreatePage({params: {locale}}: { params: { locale: any } }) namespaces={i18nNamespaces} > <NavBar/> - <Box - sx={{ - padding: 5, - }} - > - <CreateCourseForm/> - </Box> - + {!accessDenied && + <Box + width={'100%'} + sx={{ + padding: 5, + }} + > + <CreateCourseForm/> + </Box> + } </TranslationsProvider> ) } diff --git a/frontend/app/[locale]/course/all/page.tsx b/frontend/app/[locale]/course/all/page.tsx index ed757d7b..2ecd2982 100644 --- a/frontend/app/[locale]/course/all/page.tsx +++ b/frontend/app/[locale]/course/all/page.tsx @@ -2,17 +2,23 @@ import React from 'react' import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import Footer from "@app/[locale]/components/Footer"; import ListView from '@app/[locale]/components/ListView'; -import BackButton from '@app/[locale]/components/BackButton'; - +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import {Box, Button} from "@mui/material"; +import NotesIcon from '@mui/icons-material/Notes'; +import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; +import Typography from "@mui/material/Typography"; + const i18nNamespaces = ['common'] -export default async function AllCoursesPage({params: {locale}}: { params: { locale: any} }) { +export default async function AllCoursesPage({params: {locale}}: { params: { locale: any } }) { const {t, resources} = await initTranslations(locale, i18nNamespaces) - const headers = [t('name'), t('description'), t('open'), t('join/leave')] + const headers = [t('name'), + <React.Fragment key="description"><NotesIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('description')}</React.Fragment>, + , t('open'), + <React.Fragment key="joinleave"><MeetingRoomIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('join_leave')}</React.Fragment>]; const headers_backend = ['name', 'description', 'open', 'join/leave'] return ( @@ -22,21 +28,34 @@ export default async function AllCoursesPage({params: {locale}}: { params: { loc namespaces={i18nNamespaces} > <NavBar/> - <div style={{marginTop:60, padding:20}}> - <BackButton - destination={'/home'} - text={t('back_to') + ' ' + t('home') + ' ' + t('page')} - /> - <ListView - admin={true} - headers={headers} - headers_backend={headers_backend} - sortable={[true, false, false, false]} - get={'courses'} - action_name={'join_course'} - action_text={t('join_course')} - /> - </div> + <Box width={'100%'} style={{padding: 20}}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/${locale}/home`} + > + {t('back_to') + ' ' + t('home') + ' ' + t('page')} + </Button> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {t('courses_all')} + </Typography> + <ListView + admin={true} + headers={headers} + headers_backend={headers_backend} + sortable={[true, false, false, false]} + get={'courses'} + search_text={t("search_course")} + /> + </Box> </TranslationsProvider> ) } \ No newline at end of file diff --git a/frontend/app/[locale]/course/archived/page.tsx b/frontend/app/[locale]/course/archived/page.tsx new file mode 100644 index 00000000..f8be1461 --- /dev/null +++ b/frontend/app/[locale]/course/archived/page.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import NavBar from "@app/[locale]/components/NavBar"; +import ListView from '@app/[locale]/components/ListView'; +import initTranslations from "@app/i18n"; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import BackButton from "@app/[locale]/components/BackButton"; +import NotesIcon from '@mui/icons-material/Notes'; +import Typography from "@mui/material/Typography"; + +const i18nNamespaces = ['common']; + +const ArchivePage = async ({params: {locale}}) => { + const {t, resources} = await initTranslations(locale, i18nNamespaces); + const headers = [t('name'), + <React.Fragment key="description"><NotesIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('description')}</React.Fragment>, + , t('open'), '']; + const headers_backend = ['name', 'description', 'open']; + + return ( + <TranslationsProvider + resources={resources} + locale={locale} + namespaces={i18nNamespaces} + > + <NavBar/> + <div + style={{ + padding: 20, + width: '100%' + }} + > + <BackButton + destination={'/home'} + text={t('back_to') + ' ' + t('home') + ' ' + t('page')} + /> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {t('courses_archive')} + </Typography> + <ListView + admin={true} + headers={headers} + headers_backend={headers_backend} + sortable={[true, false, false]} + get={'archived_courses'} + /> + </div> + </TranslationsProvider> + ); +}; + +export default ArchivePage; diff --git a/frontend/app/[locale]/home/page.tsx b/frontend/app/[locale]/home/page.tsx index 38e9aaaf..e1832b3b 100644 --- a/frontend/app/[locale]/home/page.tsx +++ b/frontend/app/[locale]/home/page.tsx @@ -1,10 +1,8 @@ import React from 'react'; -import initTranslations from "../../i18n"; -import {Box, Container} from '@mui/material'; -import NavBar from '../components/NavBar'; -import CourseControls from '../components/CourseControls'; -import TranslationsProvider from "../components/TranslationsProvider"; -import CoursesGrid from '../components/CoursesGrid'; +import initTranslations from "@app/i18n"; +import NavBar from '@app/[locale]/components/NavBar'; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; +import YearStateComponent from "@app/[locale]/components/YearStateComponent"; const HomePage = async ({params: {locale}}: { params: { locale: any } }) => { const {t, resources} = await initTranslations(locale, ['common']) @@ -16,12 +14,7 @@ const HomePage = async ({params: {locale}}: { params: { locale: any } }) => { namespaces={["common"]} > <NavBar/> - <Box sx={{position: 'sticky', top: 0, zIndex: 10, backgroundColor: '#fff'}}> - <Container> - <CourseControls/> - </Container> - </Box> - <CoursesGrid/> + <YearStateComponent/> </TranslationsProvider> ); }; diff --git a/frontend/app/[locale]/layout.tsx b/frontend/app/[locale]/layout.tsx index aab371ea..a9f5ab36 100644 --- a/frontend/app/[locale]/layout.tsx +++ b/frontend/app/[locale]/layout.tsx @@ -1,25 +1,40 @@ import {AppRouterCacheProvider} from '@mui/material-nextjs/v13-appRouter'; import {ThemeProvider} from '@mui/material/styles'; -import loginTheme from '../../styles/theme'; +import {Box} from '@mui/material'; +import baseTheme from '../../styles/theme'; import React from "react"; import '../i18n' import Footer from "@app/[locale]/components/Footer"; -export const metadata = { - title: 'Pigeonhole', - description: 'Groep 1' -} - export default function RootLayout(props: React.PropsWithChildren<{}>) { const {children} = props; return ( <html lang="en"> <body style={{margin: 0}}> <AppRouterCacheProvider> - <ThemeProvider theme={loginTheme}> - <div style={{marginTop: "40px", height: "100vh"}}> - {children} + <ThemeProvider theme={baseTheme}> + <div + id={'center_box'} + style={{ + width: '100%', + display: 'flex', + justifyContent: 'center', + }} + > + <Box + style={{ + marginTop: "64px", + height: "fit-content", + minHeight: '100vh', + width: '100%', + maxWidth: '1500px', + display: 'flex', + justifyContent: 'center', + }} + > + {children} + </Box> </div> <div id='extrapadding' style={{height: "20px"}}></div> <Footer/> diff --git a/frontend/app/[locale]/project/[project_id]/details/AddSubmissionButton.tsx b/frontend/app/[locale]/project/[project_id]/details/AddSubmissionButton.tsx deleted file mode 100644 index 1b3ebbe8..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/AddSubmissionButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import "./project-details-styles.css"; -import {PlusIcon} from "lucide-react"; - -interface AddSubmissionButtonProps { - locale: any, - project_id: number | undefined; -} - -const AddSubmissionButton = ( - {locale, project_id}: AddSubmissionButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}/submit` - } - - return ( - <button - onClick={handleReturn} - className={"addSubmissionButton"} - > - <PlusIcon/> - {t("add_submission")} - </button> - ) -} - -export default AddSubmissionButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/CourseReturnButton.tsx b/frontend/app/[locale]/project/[project_id]/details/CourseReturnButton.tsx deleted file mode 100644 index b2fbbccc..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/CourseReturnButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import "./project-details-styles.css" - -interface CourseReturnButtonProps { - locale: any, - course_id: number | undefined; -} - -const CourseReturnButton = ( - {locale, course_id}: CourseReturnButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/course/${course_id}` - } - - return ( - <button - onClick={handleReturn} - className={"returnCourseButton"} - > - <ArrowBackIcon/> - {t("return_course")} - </button> - ) -} - -export default CourseReturnButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/ProjectDetailsPage.tsx b/frontend/app/[locale]/project/[project_id]/details/ProjectDetailsPage.tsx deleted file mode 100644 index 77f18c3d..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/ProjectDetailsPage.tsx +++ /dev/null @@ -1,205 +0,0 @@ -"use client" - -import React, {useEffect, useState} from "react"; -import {getProject, getUserData, Project, UserData} from "@lib/api"; -import {useTranslation} from "react-i18next"; -import Box from "@mui/material/Box"; - -import "./project-details-styles.css"; -import Typography from "@mui/material/Typography"; -import {Grid} from "@mui/material"; -import VisibilityIcon from "@mui/icons-material/Visibility"; -import VisibilityOffIcon from "@mui/icons-material/VisibilityOff"; -import {AdapterDayjs} from "@mui/x-date-pickers/AdapterDayjs"; -import {LocalizationProvider} from "@mui/x-date-pickers/LocalizationProvider"; -import {DemoContainer, DemoItem} from "@mui/x-date-pickers/internals/demo"; -import {DateCalendar} from "@mui/x-date-pickers/DateCalendar"; -import dayjs from "dayjs"; -import ProjectSubmissionsList from "@app/[locale]/components/ProjectSubmissionsList"; -import {MultiSectionDigitalClock} from "@mui/x-date-pickers"; -import CourseReturnButton from "@app/[locale]/project/[project_id]/details/CourseReturnButton"; -import ProjectEditButton from "@app/[locale]/project/[project_id]/details/ProjectEditButton"; -import ProjectGroupButton from "@app/[locale]/project/[project_id]/details/ProjectGroupButton"; -import GroupSubmissionList from "@app/[locale]/components/GroupSubmissionList"; -import AddSubmissionButton from "@app/[locale]/project/[project_id]/details/AddSubmissionButton"; -import TeacherSubmissionListButton from "@app/[locale]/project/[project_id]/details/TeacherSubmissionListButton"; - -const backend_url = process.env['NEXT_PUBLIC_BACKEND_URL']; - -interface ProjectDetailsPageProps { - locale: any; - project_id: number; -} - -const ProjectDetailsPage: React.FC<ProjectDetailsPageProps> = ({locale, project_id}) => { - const {t} = useTranslation(); - - const [project, setProject] = useState<Project>(); - const [loadingProject, setLoadingProject] = useState<boolean>(true); - const [user, setUser] = useState<UserData | null>(null); - - useEffect(() => { - const fetchUser = async () => { - try { - setUser(await getUserData()); - } catch (error) { - console.error("There was an error fetching the user data:", error); - } - } - - fetchUser(); - }, []) - - useEffect(() => { - const fetchProject = async () => { - try { - setProject(await getProject(project_id)); - } catch (error) { - console.error("There was an error fetching the project:", error); - } - }; - - fetchProject().then(() => setLoadingProject(false)); - }, [project_id]); - - return ( - (!loadingProject && ( - <div className={"mainContainer"}> - <Box sx={{marginTop: 4, marginBottom: 4}}> - <Grid container spacing={2} alignItems="center"> - <Grid item xs={12}> - <CourseReturnButton - locale={locale} - course_id={project?.course_id} - /> - <Grid container alignItems="center" sx={{marginBottom: 2}}> - <Typography variant={"h2"}> - {project?.name} - </Typography> - {user?.role !== 3 && ( - <ProjectEditButton - locale={locale} - project_id={project_id} - /> - )} - <ProjectGroupButton - locale={locale} - project_id={project_id} - /> - </Grid> - <Typography variant={"h4"}> - {t("assignment")} - </Typography> - <Typography sx={{marginBottom: 4}}> - {project?.description} - </Typography> - <Typography variant={"h5"}> - {t("required_files")} - </Typography> - <Typography sx={{marginBottom: 2}}> - {project?.file_structure} - </Typography> - <Typography variant={"h5"}> - {t("conditions")} - </Typography> - <Typography sx={{marginBottom: 2}}> - {project?.conditions} - </Typography> - {user?.role !== 3 && ( - <div> - <Typography variant={"h5"}> - {t("test_files")} - </Typography> - <a href={`${backend_url}/projects/${project_id}/download_testfiles`}> - <Typography sx={{marginBottom: 2}}> - Download - </Typography> - </a> - </div> - )} - <Typography> - <b>Max score: </b> - {project?.max_score} - </Typography> - <Typography> - <b>Number of groups: </b> - {project?.number_of_groups} - </Typography> - <Typography> - <b>Group size: </b> - {project?.group_size} - </Typography> - - </Grid> - <Grid item xs={12}> - <Grid container alignItems="center" sx={{marginBottom: 2}}> - <Typography>Visibility:</Typography> - {project?.visible ? <VisibilityIcon/> : <VisibilityOffIcon/>} - </Grid> - <Typography variant={"h4"} sx={{marginBottom: 2}}> - Deadline - </Typography> - <div style={{display: "flex"}}> - <LocalizationProvider dateAdapter={AdapterDayjs}> - <DemoContainer components={["DateCalendar", "TimeClock"]}> - <DemoItem> - <DateCalendar defaultValue={dayjs(project?.deadline)} readOnly/> - </DemoItem> - </DemoContainer> - </LocalizationProvider> - <LocalizationProvider dateAdapter={AdapterDayjs}> - <DemoContainer components={["DateCalendar", "TimeClock"]}> - <DemoItem> - <MultiSectionDigitalClock defaultValue={dayjs(project?.deadline)} readOnly/> - </DemoItem> - </DemoContainer> - </LocalizationProvider> - </div> - </Grid> - </Grid> - </Box> - {user?.role !== 3 ? ( - <Box sx={{marginTop: 4, marginBottom: 4}}> - <div style={{display: "flex"}}> - <Typography variant={"h4"}> - {t("my_submissions")} - </Typography> - <AddSubmissionButton - locale={locale} - project_id={project_id} - /> - </div> - <GroupSubmissionList - project_id={project_id} - showActions={false} - page_size={10} - /> - </Box> - ) : ( - <Box sx={{marginTop: 4, marginBottom: 4}} className={"submissionContainer"}> - <div style={{display: "flex"}}> - <AddSubmissionButton - locale={locale} - project_id={project_id} - /> - <TeacherSubmissionListButton - locale={locale} - project_id={project_id} - /> - <Typography variant={"h4"} style={{ marginLeft: "8px" }}> - {t("submissions")} - </Typography> - </div> - <ProjectSubmissionsList - project_id={project_id} - showActions={false} - page_size={10} - /> - </Box> - )} - </div> - )) - ) -} - -export default ProjectDetailsPage; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/ProjectEditButton.tsx b/frontend/app/[locale]/project/[project_id]/details/ProjectEditButton.tsx deleted file mode 100644 index 876ea4d4..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/ProjectEditButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import "./project-details-styles.css"; - -interface ProjectEditButtonProps { - locale: any, - project_id: number | undefined; -} - -const ProjectEditButton = ( - {locale, project_id}: ProjectEditButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}/edit` - } - - return ( - <button - onClick={handleReturn} - className={"editProjectButton"} - > - {t("edit_project")} - </button> - ) -} - -export default ProjectEditButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/ProjectGroupButton.tsx b/frontend/app/[locale]/project/[project_id]/details/ProjectGroupButton.tsx deleted file mode 100644 index c064e510..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/ProjectGroupButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import "./project-details-styles.css"; - -interface ProjectGroupButtonProps { - locale: any, - project_id: number | undefined; -} - -const ProjectGroupButton = ( - {locale, project_id}: ProjectGroupButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}/groups` - } - - return ( - <button - onClick={handleReturn} - className={"editProjectButton"} - > - {t("groups")} - </button> - ) -} - -export default ProjectGroupButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/TeacherSubmissionListButton.tsx b/frontend/app/[locale]/project/[project_id]/details/TeacherSubmissionListButton.tsx deleted file mode 100644 index e403b055..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/TeacherSubmissionListButton.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import "./project-details-styles.css"; - -interface TeacherSubmissionListButton { - locale: any, - project_id: number | undefined; -} - -const TeacherSubmissionListButton = ( - {locale, project_id}: TeacherSubmissionListButton, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}/submissions` - } - - return ( - <button - onClick={handleReturn} - className={"editProjectButton"} - > - {t("all submissions")} - </button> - ) -} - -export default TeacherSubmissionListButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/details/project-details-styles.css b/frontend/app/[locale]/project/[project_id]/details/project-details-styles.css deleted file mode 100644 index ea14104f..00000000 --- a/frontend/app/[locale]/project/[project_id]/details/project-details-styles.css +++ /dev/null @@ -1,55 +0,0 @@ -.pageBoxLeft { - margin-top: 20px !important; - padding: 50px 50px 50px 100px !important; -} - -.pageBoxRight { - margin-top: 64px !important; - padding: 50px 50px 50px 100px !important; -} - -.mainContainer { - display: flex; - padding: 20px; -} - -.returnCourseButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - align-items: center !important; - margin-bottom: 10px; -} - -.editProjectButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - align-items: center !important; - margin-left: 20px; -} - -.addSubmissionButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - align-items: center !important; - margin-left: 20px; -} - -.submissionContainer { - width: 75%; - max-width: 75%; -} \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/edit/page.tsx b/frontend/app/[locale]/project/[project_id]/edit/page.tsx index 19bc922a..e291fcb2 100644 --- a/frontend/app/[locale]/project/[project_id]/edit/page.tsx +++ b/frontend/app/[locale]/project/[project_id]/edit/page.tsx @@ -1,18 +1,55 @@ +"use client"; import NavBar from "../../../components/NavBar" -import BottomBar from "../../../components/BottomBar"; import ProjectEditForm from "@app/[locale]/project/[project_id]/edit/projectEditForm"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import initTranslations from "@app/i18n"; -import Footer from "@app/[locale]/components/Footer"; +import React, {useEffect, useState} from "react"; +import {fetchUserData, getUserData, UserData} from "@lib/api"; +import {Box, CircularProgress} from "@mui/material"; const i18nNamespaces = ['common'] -async function ProjectDetailPage({params: {locale, project_id}}: { params: { locale: any, project_id: any } }) { - const {t, resources} = await initTranslations(locale, i18nNamespaces) +function ProjectDetailPage({params: {locale, project_id}}: { params: { locale: any, project_id: any } }) { + const [resources, setResources] = useState() + const [user, setUser] = useState<UserData | null>(null); + const [userLoading, setUserLoading] = useState(true); + const [accessDenied, setAccessDenied] = useState(true); + + useEffect(() => { + const initialize = async () => { + try { + const result = await initTranslations(locale, ["common"]); + setResources(result.resources); + const userData = await fetchUserData(); + setUser(userData); + if (userData.role === 3) { // If the user is a student or the course is not in the user's courses + setAccessDenied(true); + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } catch (error) { + console.error("There was an error initializing the page:", error); + } finally { + setUserLoading(false); + } + }; + + initialize(); + }, [locale]); + + if (userLoading) { + return ( + <Box padding={5} sx={{ display: 'flex' }}> + <CircularProgress /> + </Box> + ); + } + return ( <TranslationsProvider locale={locale} namespaces={i18nNamespaces} resources={resources}> <NavBar/> - <ProjectEditForm project_id={project_id} locale={locale}/> + {!accessDenied && <ProjectEditForm project_id={project_id} add_course_id={-1}/>} </TranslationsProvider> ); } diff --git a/frontend/app/[locale]/project/[project_id]/edit/projectEditForm.tsx b/frontend/app/[locale]/project/[project_id]/edit/projectEditForm.tsx index 89f4c6f2..a7a9ee17 100644 --- a/frontend/app/[locale]/project/[project_id]/edit/projectEditForm.tsx +++ b/frontend/app/[locale]/project/[project_id]/edit/projectEditForm.tsx @@ -1,37 +1,59 @@ "use client" -import React, {useEffect, useState} from "react"; +import React, {useEffect, useState, useRef} from "react"; import dayjs from "dayjs"; -import JSZip, {JSZipObject} from "jszip"; -import {any} from "prop-types"; -import {deleteProject, getProject, getTestFiles, getUserData, Project, updateProject} from "@lib/api"; -import initTranslations from "@app/i18n"; +import { + addProject, + deleteProject, + getProject, + getTestFiles, + fetchUserData, + Project, + updateProject, + UserData +} from "@lib/api"; import Box from "@mui/material/Box"; import Title from "@app/[locale]/components/project_components/title"; import Assignment from "@app/[locale]/components/project_components/assignment"; import RequiredFiles from "@app/[locale]/components/project_components/requiredFiles"; import Conditions from "@app/[locale]/components/project_components/conditions"; import Groups from "@app/[locale]/components/project_components/groups"; -import TestFiles from "@app/[locale]/components/project_components/testfiles"; -import UploadTestFile from "@app/[locale]/components/project_components/uploadButton"; import FinishButtons from "@app/[locale]/components/project_components/finishbuttons"; import Deadline from "@app/[locale]/components/project_components/deadline"; import RemoveDialog from "@app/[locale]/components/project_components/removedialog"; +import {LinearProgress} from "@mui/material"; +import {useTranslation} from "react-i18next"; +import Typography from "@mui/material/Typography"; +import Tooltip from "@mui/material/Tooltip"; +import HelpOutlineIcon from "@mui/icons-material/HelpOutline"; +import {Grid, TextField} from "@mui/material"; + + + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle +} from "@mui/material"; + const i18nNamespaces = ['common'] interface ProjectEditFormProps { - project_id: number; - locale: string; - + project_id: number | null; + add_course_id: number; } -const ProjectEditForm: React.FC<ProjectEditFormProps> = ({project_id, locale}) => { +function ProjectEditForm({project_id, add_course_id}: ProjectEditFormProps) { const [files, setFiles] = useState<string[]>([]); - const [title, setTitle] = useState('Project 1'); - const [description, setDescription] = useState('Lorem\nIpsum\n'); + const [status_files, setStatusFiles] = useState<string[]>([]); + const [title, setTitle] = useState(''); + const [dockerImage, setDockerImage] = useState(''); + const [description, setDescription] = useState(''); const [groupAmount, setGroupAmount] = useState(1); const [groupSize, setGroupSize] = useState(1); - const [conditions, setConditions] = useState(['']); + const [conditions, setConditions] = useState<string[]>([]); const [testfilesName, setTestfilesName] = useState<string[]>([]); const [visible, setVisible] = useState(true); const [deadline, setDeadline] = React.useState(dayjs()); @@ -39,13 +61,18 @@ const ProjectEditForm: React.FC<ProjectEditFormProps> = ({project_id, locale}) const [loadingTranslations, setLoadingTranslations] = useState(true); const [loadingProject, setLoadingProject] = useState(true); const [confirmRemove, setConfirmRemove] = useState(false); - const [testfilesData, setTestfilesData] = useState<JSZipObject[]>([]); - const [translations, setTranslations] = useState({t: any, resources: null, locale: "en", i18nNamespaces: [""]}) const [isStudent, setIsStudent] = useState(false); const [isTeacher, setIsTeacher] = useState(false); const [loadingUser, setLoadingUser] = useState(true); + const [user, setUser] = useState<UserData | null>(null); const [hasDeadline, setHasDeadline] = useState(false); - const [course_id, setCourseId] = useState(0); + const [course_id, setCourseId] = useState<number>(0); + const [confirmSubmit, setConfirmSubmit] = useState(false); + const [accessDenied, setAccessDenied] = useState(true); + + const dockerfileref = useRef<HTMLInputElement>(null); + + const {t} = useTranslation(); const isTitleEmpty = !title const isAssignmentEmpty = !description @@ -53,31 +80,40 @@ const ProjectEditForm: React.FC<ProjectEditFormProps> = ({project_id, locale}) const isGroupAmountEmpty = !groupAmount const isGroupSizeEmpty = !groupSize + useEffect(() => { const fetchProject = async () => { try { - const project: Project = await getProject(project_id); - if (project.deadline !== null) setDeadline(dayjs(project["deadline"])); - setDescription(project.description) - if (project.file_structure !== null) { - const file_structure = project.file_structure.split(",").map((item: string) => item.trim().replace(/"/g, '')); - file_structure.push(""); - setFiles(file_structure); - } - setGroupSize(project["group_size"]) - setTitle(project["name"]) - setGroupAmount(project["number_of_groups"]) - setVisible(project["visible"]) - setCourseId(project.course_id); - if (project.test_files !== null) await setTestFiles(project); - setScore(+project["max_score"]); - if (project["conditions"] != null) { - const conditions_parsed = project["conditions"].split(",").map((item: string) => item.trim().replace(/"/g, '')); - conditions_parsed.push(""); - setConditions(conditions_parsed); + if (project_id !== null) { + const project: Project = await getProject(project_id); + if (project.deadline !== null) setDeadline(dayjs(project["deadline"])); + setDescription(project.description) + if (project.file_structure !== null && project.file_structure !== "") { + console.log(project.file_structure) + const file_structure = project.file_structure.split(",").map((item: string) => item.trim().replace(/"/g, '')); + const file_structure_status = file_structure.map((item: string) => item[0]); + const file_structure_name = file_structure.map((item: string) => item.substring(1)); + setFiles(file_structure_name); + setStatusFiles(file_structure_status); + } + setGroupSize(project["group_size"]) + setTitle(project["name"]) + setGroupAmount(project["number_of_groups"]) + setVisible(project["visible"]) + if (project.project_id !== null) { + setCourseId(project.course_id); + } + setScore(+project["max_score"]); + if (project["conditions"] != null) { + let conditions_parsed: string[] = []; + if (project["conditions"] !== "") { + conditions_parsed = project["conditions"].split(",").map((item: string) => item.trim().replace(/"/g, '')); + } + setConditions(conditions_parsed); + } + if (project.deadline !== null) setHasDeadline(true); } - if (project.deadline !== null) setHasDeadline(true); - await getUserData().then((response) => { + await fetchUserData().then((response) => { if (response.role === 3) { setIsStudent(true); } else { @@ -91,31 +127,45 @@ const ProjectEditForm: React.FC<ProjectEditFormProps> = ({project_id, locale}) } }; - const fetchTranslations = async () => { - const {t, resources} = await initTranslations(locale, i18nNamespaces) - setTranslations({t, resources, locale, i18nNamespaces}) + if (project_id !== null) { + fetchProject().then(() => setLoadingProject(false)); + } else { + setLoadingProject(false); } + }, [project_id, isStudent, loadingProject, isTeacher]); - fetchTranslations().then(() => setLoadingTranslations(false)); - fetchProject().then(() => setLoadingProject(false)); - }, [project_id, locale, loadingTranslations, isStudent, loadingProject, isTeacher]); - - async function setTestFiles(project: Project) { - const zip = new JSZip(); - console.log(project.test_files) - const test_files_zip = await getTestFiles(project.test_files); - const zipData = await zip.loadAsync(test_files_zip); - const testfiles_name: string[] = []; - const testfiles_data: JSZipObject[] = []; - zipData.forEach((relativePath, file) => { - testfiles_data.push(file); - testfiles_name.push(relativePath); - }); - setTestfilesName(testfiles_name); - setTestfilesData(testfiles_data); - } + useEffect(() => { + const fetchUser = async () => { + try { + const user = await fetchUserData(); + setUser(user) + if (!loadingUser && !loadingProject && user) { + if (project_id !== null) { + if (!user.course.includes(Number(course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } else { + if (!user.course.includes(Number(add_course_id))) { + window.location.href = `/403/`; + } else { + setAccessDenied(false); + } + } + } + } catch (error) { + console.error("There was an error fetching the user data:", error); + } finally { + setLoadingUser(false); + } + } + + fetchUser(); + }, [add_course_id, course_id, loadingProject, loadingUser, project_id]); const handleSave = async () => { + console.log(files); let message = "The following fields are required:\n"; if (isTitleEmpty) message += "- Title\n"; @@ -130,73 +180,151 @@ const ProjectEditForm: React.FC<ProjectEditFormProps> = ({project_id, locale}) alert(message); return; } else { - const zip = new JSZip(); - testfilesData.forEach((file) => { - zip.file(file.name, file.async("blob")); - }); - - const zipFileBlob = await zip.generateAsync({type: "blob"}); const formData = new FormData(); - const zipFile = new File([zipFileBlob], "test_files.zip"); - files.pop(); - conditions.pop(); - formData.append("test_files", zipFile); + formData.append("test_docker_image", dockerImage); + + const required_files = files.map((item, index) => status_files[index] + item); formData.append("name", title); formData.append("description", description); formData.append("max_score", score.toString()); formData.append("number_of_groups", groupAmount.toString()); formData.append("group_size", groupSize.toString()); - formData.append("file_structure", files.join(",")); + formData.append("file_structure", required_files.join(",")); formData.append("conditions", conditions.join(",")); formData.append("visible", visible.toString()); - formData.append("course_id", course_id.toString()); + if (add_course_id < 0) { + formData.append("course_id", course_id.toString()); + } else { + formData.append("course_id", add_course_id.toString()); + } if (hasDeadline) { formData.append("deadline", deadline.format()); } else { formData.append("deadline", ""); } - await updateProject(project_id, formData).then((response) => console.log(response)); - location.reload(); + if (project_id !== null) { + await updateProject(project_id, formData); + location.href = "/project/" + project_id + "/"; + } else { + const new_project_id = await addProject(formData); + location.href = "/project/" + new_project_id + "/" + } } } + const SubmitConfirmationDialog = ({ open, handleClose, handleConfirm }) => { + return ( + <Dialog open={open} onClose={handleClose}> + <DialogTitle>Confirm Submission</DialogTitle> + <DialogContent> + Are you sure you want to submit this project? + </DialogContent> + <DialogActions> + <Button onClick={handleClose}>Cancel</Button> + <Button onClick={handleConfirm} color="primary" autoFocus> + Submit + </Button> + </DialogActions> + </Dialog> + ); + }; + + const handle_remove = async () => { - await deleteProject(project_id).then((response) => console.log(response)); + if (project_id !== null) { + await deleteProject(project_id).then((response) => console.log(response)); + } window.location.href = "/course/" + course_id + "/" } + if(loadingProject || loadingUser){ + return <LinearProgress/>; + } + return ( - (loadingTranslations && loadingProject && loadingUser) ? ( - <div>Loading...</div> - ) : ( - (!isStudent) ? ( - <div> - <Box - display="grid" - gridTemplateColumns="65% 35%" - height="100vh" - > - <Box className={"pageBoxLeft"}> - {Title({isTitleEmpty, setTitle, title, score, isScoreEmpty, setScore, translations})} - {Assignment({isAssignmentEmpty, setDescription, description, translations})} - {RequiredFiles({files, setFiles, translations})} - {Conditions({conditions, setConditions, translations})} - {Groups({groupAmount, isGroupAmountEmpty, groupSize, isGroupSizeEmpty, setGroupAmount, setGroupSize, translations})} - {TestFiles({testfilesName, setTestfilesName, testfilesData, setTestfilesData, translations})} - {UploadTestFile({testfilesName, setTestfilesName, testfilesData, setTestfilesData, translations})} - </Box> - <Box className={"pageBoxRight"}> - {FinishButtons({visible, setVisible, handleSave, setConfirmRemove, translations, course_id, setHasDeadline, hasDeadline})} - {Deadline({deadline, setDeadline, hasDeadline})} - </Box> - </Box> - {RemoveDialog({confirmRemove, handle_remove, setConfirmRemove, translations})} - </div> - ) : ( - <div>Students cannot edit project</div> - ) - ) + (!isStudent) ? ( + !accessDenied && + <div> + <Box + display="grid" + gridTemplateColumns="65% 35%" + height="fit-content" + > + <Box className={"pageBoxLeft"} height={'fit-content'}> + <Title + isTitleEmpty={isTitleEmpty} + isScoreEmpty={isTitleEmpty} + setTitle={setTitle} + title={title} + score={score} + setScore={setScore}/> + <Assignment + isAssignmentEmpty={isAssignmentEmpty} + setDescription={setDescription} + description={description}/> + <RequiredFiles + files={files} + setFiles={setFiles} + file_status={status_files} + setFileStatus={setStatusFiles} + /> + <Conditions + conditions={conditions} + setConditions={setConditions}/> + <Groups + groupAmount={groupAmount} + isGroupAmountEmpty={isGroupAmountEmpty} + groupSize={groupSize} + isGroupSizeEmpty={isGroupSizeEmpty} + setGroupAmount={setGroupAmount} + setGroupSize={setGroupSize}/> + + <Typography variant="h5" className={"typographyStyle"}> + {t("evaluation_docker_image")} + <Tooltip title={ + <Typography variant="body1" className={"conditionsText"}> + {t("evaluation_docker_image_tooltip")} + </Typography> + } placement={"right"}> + <HelpOutlineIcon className={"conditionsHelp"}/> + </Tooltip> + </Typography> + <TextField + variant="outlined" + onChange={(event) => setDockerImage(event.target.value)} + value={dockerImage} + className={"titleGrids"} + size="small" + placeholder="test-helloworld:latest" + label={t("evaluation_docker_image")} + /> + </Box> + <Box className={"pageBoxRight"}> + <FinishButtons + visible={visible} + setVisible={setVisible} + handleSave={handleSave} + setConfirmRemove={setConfirmRemove} + course_id={add_course_id} + project_id={project_id} + setHasDeadline={setHasDeadline} + hasDeadline={hasDeadline} + createProject={(project_id === null)}/> + <Deadline + deadline={deadline} + setDeadline={setDeadline} + hasDeadline={hasDeadline}/> + </Box> + </Box> + <RemoveDialog + confirmRemove={confirmRemove} + handleRemove={handle_remove} + setConfirmRemove={setConfirmRemove}/> + </div> + ) : ( + <div>Students cannot edit project</div> + ) ) } diff --git a/frontend/app/[locale]/project/[project_id]/edit/project_styles.css b/frontend/app/[locale]/project/[project_id]/edit/project_styles.css index fd550612..f33b4c54 100644 --- a/frontend/app/[locale]/project/[project_id]/edit/project_styles.css +++ b/frontend/app/[locale]/project/[project_id]/edit/project_styles.css @@ -30,31 +30,10 @@ margin-bottom: 10px !important; } -.saveButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; -} - .buttonsGrid { padding: 10px !important; } -.removeButton { - background-color: #E15E5E !important; - padding: 5px 20px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - color: white !important; -} - .titleGrids { margin: 0 !important; width: 100% !important; @@ -64,17 +43,6 @@ width: 100% !important; } -.uploadButton { - background-color: #D0E4FF !important; - padding: 15px 30px !important; - margin-bottom: 30px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; -} - .uploadInput { display: none !important; } diff --git a/frontend/app/[locale]/project/[project_id]/groups/page.tsx b/frontend/app/[locale]/project/[project_id]/groups/page.tsx index 6142c7cd..2d68c4f2 100644 --- a/frontend/app/[locale]/project/[project_id]/groups/page.tsx +++ b/frontend/app/[locale]/project/[project_id]/groups/page.tsx @@ -1,9 +1,14 @@ import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import Footer from "@app/[locale]/components/Footer"; import ListView from '@app/[locale]/components/ListView'; -import BackButton from "@app/[locale]/components/BackButton"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import { Button, Box } from "@mui/material"; +import React from "react"; +import GroupsIcon from '@mui/icons-material/Groups'; +import MeetingRoomIcon from '@mui/icons-material/MeetingRoom'; +import Typography from "@mui/material/Typography"; + const i18nNamespaces = ['common'] @@ -11,7 +16,10 @@ export default async function GroupPage({ params }: { params: { locale: any, pro const { locale, project_id: projectId } = params; const { t, resources } = await initTranslations(locale, i18nNamespaces); - const headers = [t('group_nr'), t('members'), t('join/leave')]; + const headers = [ + <React.Fragment key="group_nr"><GroupsIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('group_nr')}</React.Fragment> + , t('members'), + <React.Fragment key="joinleave"><MeetingRoomIcon style={{ fontSize: '20px', verticalAlign: 'middle', marginBottom: '3px' }}/>{" " + t('join_leave')}</React.Fragment>]; const headers_backend = ['group_nr', 'members', 'join/leave']; return ( @@ -21,11 +29,25 @@ export default async function GroupPage({ params }: { params: { locale: any, pro namespaces={i18nNamespaces} > <NavBar /> - <div style={{marginTop:60, padding:20}}> - <BackButton - destination={`/project/${projectId}`} - text={t('back_to') + ' ' + t('project') + ' ' + t('page')} - /> + <Box width={'100%'} style={{padding:20}}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/${locale}/project/${projectId}`} + > + {t("return_project")} + </Button> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {t('groups')} + </Typography> <ListView admin={true} headers={headers} @@ -33,8 +55,9 @@ export default async function GroupPage({ params }: { params: { locale: any, pro sortable={[true, false, false]} get_id={projectId} get={'groups'} + search_text={t("group_search")} /> - </div> + </Box> </TranslationsProvider> ); } diff --git a/frontend/app/[locale]/project/[project_id]/page.tsx b/frontend/app/[locale]/project/[project_id]/page.tsx index a9864042..7d3f95b4 100644 --- a/frontend/app/[locale]/project/[project_id]/page.tsx +++ b/frontend/app/[locale]/project/[project_id]/page.tsx @@ -1,8 +1,7 @@ import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import ProjectDetailsPage from "@app/[locale]/project/[project_id]/details/ProjectDetailsPage"; -import Footer from "@app/[locale]/components/Footer"; +import ProjectDetailsPage from "@app/[locale]/components/ProjectDetailsPage"; const i18nNamespaces = ['common'] diff --git a/frontend/app/[locale]/project/[project_id]/submissions/ProjectReturnButton.tsx b/frontend/app/[locale]/project/[project_id]/submissions/ProjectReturnButton.tsx deleted file mode 100644 index 5b53c806..00000000 --- a/frontend/app/[locale]/project/[project_id]/submissions/ProjectReturnButton.tsx +++ /dev/null @@ -1,32 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; - -interface ProjectReturnButtonProps { - locale: any, - project_id: number | undefined; -} - -const ProjectReturnButton = ( - {locale, project_id}: ProjectReturnButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}` - } - - return ( - <button - onClick={handleReturn} - className={"returnProjectButton"} - > - <ArrowBackIcon/> - {t("return_project")} - </button> - ) -} - -export default ProjectReturnButton; \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/submissions/page.tsx b/frontend/app/[locale]/project/[project_id]/submissions/page.tsx index bc3217d0..0d1c8694 100644 --- a/frontend/app/[locale]/project/[project_id]/submissions/page.tsx +++ b/frontend/app/[locale]/project/[project_id]/submissions/page.tsx @@ -1,20 +1,17 @@ +import initTranslations from "@app/i18n"; +import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import {Box, Button} from "@mui/material"; import React from "react"; import ProjectSubmissionsList from "@app/[locale]/components/ProjectSubmissionsList"; -import Box from "@mui/material/Box"; -import "./submissions_styles.css"; -import ProjectReturnButton from "@app/[locale]/project/[project_id]/submissions/ProjectReturnButton"; -import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; -import initTranslations from "@app/i18n"; -import Footer from "@app/[locale]/components/Footer"; import Typography from "@mui/material/Typography"; const i18nNamespaces = ['common'] -const SubmissionsPage = async ({params: {locale, project_id}}: { - params: { locale: any, project_id: number } -}) => { - const {t, resources} = await initTranslations(locale, i18nNamespaces) +export default async function SubmissionsPage({params}: { params: { locale: any, project_id: number } }) { + const {locale, project_id: projectId} = params; + const {t, resources} = await initTranslations(locale, i18nNamespaces); return ( <TranslationsProvider @@ -23,25 +20,31 @@ const SubmissionsPage = async ({params: {locale, project_id}}: { namespaces={i18nNamespaces} > <NavBar/> - <Box className={"pageBox"}> - <Box className={"pageRow"}> - <ProjectReturnButton - locale={locale} - project_id={project_id} - /> - </Box> - <Box className={"pageRow"}> - <Typography variant="h3" className={"submissionTitle"}> - {t("submissions")} - </Typography> - </Box> + <Box width={'100%'} style={{padding: 20}}> + <Button + variant="outlined" + color="primary" + startIcon={<ArrowBackIcon/>} + href={`/${locale}/project/${projectId}`} + > + {t("return_project")} + </Button> + <Typography + variant="h3" + sx={{ + fontWeight: 'medium', + marginTop: 2, + marginBottom: 2 + }} + > + {t('all_submissions')} + </Typography> + <ProjectSubmissionsList + project_id={projectId} + page_size={10} + search={t("submission_search")} + /> </Box> - <ProjectSubmissionsList - project_id={project_id} - showActions={true} - /> </TranslationsProvider> - ) + ); } - -export default SubmissionsPage \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/submissions/submissions_styles.css b/frontend/app/[locale]/project/[project_id]/submissions/submissions_styles.css deleted file mode 100644 index 0fa03e8c..00000000 --- a/frontend/app/[locale]/project/[project_id]/submissions/submissions_styles.css +++ /dev/null @@ -1,32 +0,0 @@ -.pageBox { - margin-top: 80px !important; - width: 100% !important; - max-width: 100% !important; - padding: 8px !important; - display: flex !important; - flex-direction: column !important; - justify-content: center !important; - align-items: center !important; -} - -.pageRow { - width: 75% !important; - max-width: 75% !important; -} - -.returnProjectButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - align-items: center !important; -} - -.submissionTitle { - margin-top: 20px !important; - padding: 5px 10px !important; - border-radius: 20px !important; -} \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/submit/SubmitPage.tsx b/frontend/app/[locale]/project/[project_id]/submit/SubmitPage.tsx deleted file mode 100644 index 61dde669..00000000 --- a/frontend/app/[locale]/project/[project_id]/submit/SubmitPage.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client" - -import {useState, useEffect, useRef} from 'react'; -import {useTranslation} from "react-i18next"; -import {Box, Typography} from "@mui/material"; -import AddButton from "@app/[locale]/components/AddButton"; -import { Project, getProject } from '@lib/api'; -import { uploadSubmissionFile } from "@lib/api"; - -import { use } from 'chai'; - -export default function SubmitPage({project_id}: { project_id: string }){ - const { t } = useTranslation(); - - const [projectData, setProjectData] = useState<Project>() - const [paths, setPaths] = useState<string[]>([]); - const [submitted, setSubmitted] = useState<string>("no"); - - useEffect(() => { - const fetchProject = async () => { - try { - const project: Project = await getProject(+project_id); - setProjectData(project) - } catch (e) { - console.error(e) - } - } - fetchProject(); - }, [project_id]); - - - return ( - <Box sx={{marginTop: '64px', padding: 5}}> - <Typography - variant="h3" - sx={{ - fontWeight: 'medium', - marginTop: 2 - }} - > - {t('submit_project')}: {projectData?.name} - </Typography> - <Typography variant="h6"> - {projectData?.description} - </Typography> - <Typography - variant="h3" - sx={{ - fontWeight: 'medium', - marginTop: 2 - }} - > - {t('files')} - </Typography> - - - <form onSubmit={async (e)=>{setSubmitted(await uploadSubmissionFile(e, project_id));}} encType="multipart/form-data"> - - <input style={{width: "300px", height: "120px", backgroundColor: "lightgrey", border: "6px dotted black"}} type="file" id="filepicker" name="file"/> - - <input type="hidden" name="project_id" value={project_id}/> - <input type="hidden" name="mode" value="submitform"/> - - <ul id="listing"></ul> - - <ul> - {paths.map(path => ( - <li key={path}>{path}</li> - ))} - </ul> - {submitted === "yes" && <Typography variant="h6">{t('submitted')}</Typography>} - {submitted === "error" && <Typography variant="h6">{t('submission_error')}</Typography>} - {submitted !== "yes" && - <button type='submit' - style={{ - backgroundColor: '#1E64C8', - color: 'white', - padding: '8px 16px', - border: 'none', - borderRadius: '6px', - cursor: 'pointer', - fontFamily: 'Quicksand', - fontSize: '16px', - marginTop: '10px', - marginLeft: '15px' - }}> - <Typography variant="h6">{t("submit")}</Typography> - </button> - } - </form> - - </Box> - ); -} \ No newline at end of file diff --git a/frontend/app/[locale]/project/[project_id]/submit/page.tsx b/frontend/app/[locale]/project/[project_id]/submit/page.tsx index c68507e5..65ab2d8d 100644 --- a/frontend/app/[locale]/project/[project_id]/submit/page.tsx +++ b/frontend/app/[locale]/project/[project_id]/submit/page.tsx @@ -2,17 +2,17 @@ import React from 'react'; import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import Footer from "@app/[locale]/components/Footer"; -import SubmitPage from './SubmitPage'; +import SubmitDetailsPage from '@app/[locale]/components/SubmitDetailsPage'; + const i18nNamespaces = ['common'] export default async function Course({params: {locale, project_id}, searchParams: {token}}: - { params: { locale: any, project_id: string }, searchParams: { token: string } }) { + { + params: { locale: any, project_id: number }, + searchParams: { token: string } + }) { const {t, resources} = await initTranslations(locale, i18nNamespaces) - const project_selected = false - - const desc_mock = "TODO: zet hier indieninstructies van het project, en misschien ook nog groepnummer, ook vorige indieningen een samenvatting ofzo" return ( <TranslationsProvider resources={resources} @@ -21,9 +21,7 @@ export default async function Course({params: {locale, project_id}, searchParams > <NavBar/> - <SubmitPage project_id={project_id}> - - </SubmitPage> + <SubmitDetailsPage locale={locale} project_id={project_id} /> </TranslationsProvider> ) diff --git a/frontend/app/[locale]/submission/[submission_id]/details/ProjectReturnButton.tsx b/frontend/app/[locale]/submission/[submission_id]/details/ProjectReturnButton.tsx deleted file mode 100644 index b9b35e79..00000000 --- a/frontend/app/[locale]/submission/[submission_id]/details/ProjectReturnButton.tsx +++ /dev/null @@ -1,33 +0,0 @@ -"use client" -import React from "react"; -import {useTranslation} from "react-i18next"; -import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import "./submission-details-styles.css" - -interface ProjectReturnButtonProps { - locale: any, - project_id: number | undefined; -} - -const ProjectReturnButton = ( - {locale, project_id}: ProjectReturnButtonProps, -) => { - const {t} = useTranslation(); - - - const handleReturn = () => { - window.location.href = `/${locale}/project/${project_id}` - } - - return ( - <button - onClick={handleReturn} - className={"returnProjectButton"} - > - <ArrowBackIcon/> - {t("return_project")} - </button> - ) -} - -export default ProjectReturnButton; \ No newline at end of file diff --git a/frontend/app/[locale]/submission/[submission_id]/details/SubmissionDetailsPage.tsx b/frontend/app/[locale]/submission/[submission_id]/details/SubmissionDetailsPage.tsx deleted file mode 100644 index 82e6bf0f..00000000 --- a/frontend/app/[locale]/submission/[submission_id]/details/SubmissionDetailsPage.tsx +++ /dev/null @@ -1,98 +0,0 @@ -"use client" - -import {useTranslation} from "react-i18next"; -import React, {useEffect, useState} from "react"; -import {getProjectFromSubmission, getSubmission, Project, Submission} from "@lib/api"; -import Typography from "@mui/material/Typography"; - -import "./submission-details-styles.css" -import CheckIcon from "@mui/icons-material/Check"; -import CancelIcon from "@mui/icons-material/Cancel"; -import {red} from "@mui/material/colors"; -import ProjectReturnButton from "@app/[locale]/project/[project_id]/submissions/ProjectReturnButton"; - - -const backend_url = process.env['NEXT_PUBLIC_BACKEND_URL']; - -interface ProjectDetailsPageProps { - locale: any; - submission_id: number; -} - -const ProjectDetailsPage: React.FC<ProjectDetailsPageProps> = ({locale, submission_id}) => { - const {t} = useTranslation(); - - const [submission, setSubmission] = useState<Submission>(); - const [project, setProject] = useState<Project>(); - const [loadingSubmission, setLoadingSubmission] = useState<boolean>(true); - - useEffect(() => { - const fetchSubmission = async () => { - try { - setSubmission(await getSubmission(submission_id)); - } catch (error) { - console.error("There was an error fetching the submission data:", error); - } - } - - const fetchProject = async () => { - try { - setProject(await getProjectFromSubmission(submission_id)); - } catch (error) { - console.error("There was an error fetching the project data:", error); - } - } - - fetchSubmission().then(() => { - fetchProject().then(() => setLoadingSubmission(false)); - }); - }, [submission_id]) - - return ( - (!loadingSubmission && ( - <div className={"mainContainer"}> - <ProjectReturnButton - locale={locale} - project_id={project?.project_id} - /> - <Typography variant={"h3"} sx={{marginBottom: 2}}> - {`${t('submission')} #${submission?.submission_nr}`} - </Typography> - <Typography sx={{marginBottom: 2}}> - <b>{`${t("timestamp")}: `}</b> - {submission?.timestamp} - </Typography> - <Typography variant={"h4"}> - {t("assignment")} - </Typography> - <Typography sx={{marginBottom: 4}}> - {project?.description} - </Typography> - <Typography variant={"h4"}> - {t("uploaded_files")} - </Typography> - <Typography sx={{marginBottom: 4}}> - <a href={`${backend_url}/submissions/${submission_id}/download`}> - Download - </a> - </Typography> - <Typography variant={"h4"}> - {`${t("evaluation")} status`} - </Typography> - {submission?.output_test !== undefined ? ( - <Typography> - <CheckIcon color={"success"}/> - {t('accepted')} - </Typography> - ) : ( - <Typography> - <CancelIcon sx={{color: red}}/> - {t('denied')} - </Typography> - )} - </div> - )) - ) -} - -export default ProjectDetailsPage; \ No newline at end of file diff --git a/frontend/app/[locale]/submission/[submission_id]/details/submission-details-styles.css b/frontend/app/[locale]/submission/[submission_id]/details/submission-details-styles.css deleted file mode 100644 index e05d4618..00000000 --- a/frontend/app/[locale]/submission/[submission_id]/details/submission-details-styles.css +++ /dev/null @@ -1,16 +0,0 @@ -.mainContainer { - margin-top: 70px; - padding: 20px; -} - -.returnProjectButton { - background-color: #D0E4FF !important; - padding: 5px 10px !important; - border-radius: 20px !important; - border: none !important; - cursor: pointer !important; - font-weight: bold !important; - font-size: 1.2em !important; - align-items: center !important; - margin-bottom: 10px; -} \ No newline at end of file diff --git a/frontend/app/[locale]/submission/[submission_id]/page.tsx b/frontend/app/[locale]/submission/[submission_id]/page.tsx index cb5682f6..ecca2297 100644 --- a/frontend/app/[locale]/submission/[submission_id]/page.tsx +++ b/frontend/app/[locale]/submission/[submission_id]/page.tsx @@ -1,7 +1,7 @@ import initTranslations from "@app/i18n"; import TranslationsProvider from "@app/[locale]/components/TranslationsProvider"; import NavBar from "@app/[locale]/components/NavBar"; -import SubmissionDetailsPage from "@app/[locale]/submission/[submission_id]/details/SubmissionDetailsPage"; +import SubmissionDetailsPage from "@app/[locale]/components/SubmissionDetailsPage"; const i18nNamespaces = ['common'] @@ -25,4 +25,4 @@ const SubmissionPage = async ({params: {locale, submission_id}}: { ) } -export default SubmissionPage; \ No newline at end of file +export default SubmissionPage; diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts index 05486418..797af7f9 100644 --- a/frontend/cypress.config.ts +++ b/frontend/cypress.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ e2e: { experimentalModifyObstructiveThirdPartyCode: true, experimentalInteractiveRunEvents: true, + experimentalRunAllSpecs: true, chromeWebSecurity: false, pageLoadTimeout: 100000, }, diff --git a/frontend/cypress/e2e/homepage.cy.ts b/frontend/cypress/e2e/homepage.cy.ts deleted file mode 100644 index d69273ef..00000000 --- a/frontend/cypress/e2e/homepage.cy.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { username, password, email } from '../fixtures/login.json'; - -describe('go to home page', () => { - it('should log in successfully', () => { - cy.visit('http://localhost:3000/') - cy.login(username, password); - - }); -}); \ No newline at end of file diff --git a/frontend/cypress/e2e/login.cy.ts b/frontend/cypress/e2e/login.cy.ts deleted file mode 100644 index 8c26b6d4..00000000 --- a/frontend/cypress/e2e/login.cy.ts +++ /dev/null @@ -1,21 +0,0 @@ -describe('Login', () => { - it('should successfully initiate login via CAS', () => { - cy.visit('http://localhost:3000/'); - - // click login button - cy.get('button').click(); - - // login microsoft, fill in password and do 2FA manually. (for now) - cy.origin( - 'https://login.microsoftonline.com', - () => { - cy.get('input[type="email"]').type("email") - cy.get('input[type="submit"]').click() - // cy.get('input[type="password"]').type("", { - // log: false, - // }) - cy.get('input[type="submit"]').click() - }); - - }); -}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/course_page.cy.ts b/frontend/cypress/e2e/student/course_page.cy.ts new file mode 100644 index 00000000..a938cbbb --- /dev/null +++ b/frontend/cypress/e2e/student/course_page.cy.ts @@ -0,0 +1,46 @@ +import {studentUsername, studentPassword} from '../../fixtures/login.json'; + +describe('student course page', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + }); + + it('go to course page ', () => { + cy.contains('Artificiële intelligentie').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1'); + + cy.contains('Artificiële intelligentie'); + + cy.contains('Description'); + cy.contains('Kennisgebaseerd redeneren, machinaal leren, heuristische zoekstrategieën,' + + ' neurale netwerken en deep learning, natuurlijke taalverwerking'); + + + cy.contains('Projects'); + cy.contains('AI project') + cy.contains('12/12/2021 13:12') + cy.contains('View students'); + }); + + it('view students in course page ', () => { + cy.contains('Artificiële intelligentie').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1'); + + cy.contains('View students').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1/students'); + }); + + + it('next and prev on students, and go back to course page ', () => { + cy.contains('Artificiële intelligentie').click(); + cy.contains('View students').click(); + + cy.contains('Next').click(); + cy.contains('Prev').click(); + + cy.contains('Back to Course').click(); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/homepage.cy.ts b/frontend/cypress/e2e/student/homepage.cy.ts new file mode 100644 index 00000000..36b5e679 --- /dev/null +++ b/frontend/cypress/e2e/student/homepage.cy.ts @@ -0,0 +1,105 @@ +import {studentUsername, studentPassword} from '../../fixtures/login.json'; + +describe('go to home page as student', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + }); + + it('go to home page, check everything', () => { + cy.contains('Pigeonhole'); + + cy.contains('Filter Courses'); + cy.contains('View Archive'); + cy.contains('View All Courses'); + cy.contains('Site Users'); + cy.contains('en'); + + }); + + it('go to view all courses in home page', () => { + cy.contains('View All Courses').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/all'); + }); + + it('change language on home page', () => { + // select language selector + cy.contains('en').click(); + // change language + cy.contains('nl').click(); + + cy.contains('Filter Cursussen'); + cy.contains('Bekijk Archief'); + cy.contains('Bekijk Alle Cursussen'); + cy.contains('nl'); + }); + + it('test logout', () => { + // click the name top right in page + cy.get('button[aria-label="Account settings"]').click(); + + cy.contains('Settings'); + cy.contains('My Profile'); + cy.contains('Log out').click(); + + cy.url().should('eq', 'http://localhost:3000/en') + }); + + + it('test going to profile', () => { + // click the name top right in page + cy.get('button[aria-label="Account settings"]').click(); + + cy.contains('Log out'); + cy.contains('Settings'); + cy.contains('My Profile').click(); + + cy.url().should('eq', 'http://localhost:3000/en/profile') + }); + + it('test menu course', () => { + cy.get('button[aria-label="menu"]').click(); + + cy.contains('Manual'); + cy.contains('GitHub'); + cy.contains('My Profile'); + cy.contains('Log out'); + cy.contains('Artificiële intelligentie').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1') + }); + + + it('test menu logout', () => { + cy.get('button[aria-label="menu"]').click(); + + cy.contains('Manual'); + cy.contains('GitHub'); + cy.contains('My Profile'); + cy.contains('Log out').click(); + + cy.url().should('eq', 'http://localhost:3000/en') + }); + + it('test menu profile', () => { + cy.get('button[aria-label="menu"]').click(); + + cy.contains('Manual'); + cy.contains('GitHub'); + cy.contains('Log out'); + cy.contains('My Profile').click(); + + cy.url().should('eq', 'http://localhost:3000/en/profile') + }); + + it('test menu github', () => { + cy.get('button[aria-label="menu"]').click(); + + cy.contains('My Profile'); + cy.contains('Manual'); + cy.contains('Log out'); + cy.contains('GitHub').click(); + + cy.url().should('eq', 'https://github.com/SELab-2/UGent-1') + }); + +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/join_courses.cy.ts b/frontend/cypress/e2e/student/join_courses.cy.ts new file mode 100644 index 00000000..a1f82b8d --- /dev/null +++ b/frontend/cypress/e2e/student/join_courses.cy.ts @@ -0,0 +1,28 @@ +import {studentUsername, studentPassword} from '../../fixtures/login.json'; + +describe('join courses', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword) + cy.visit('http://localhost:3000/en/course/all'); + }); + + it('back to homepage button', () => { + cy.contains('Back to home page').click(); + cy.url().should('eq', 'http://localhost:3000/en/home'); + }); + + it('next and previous page', () => { + cy.contains('Next').click(); + cy.contains('Prev').click(); + }); + + it('leave a course', () => { + // TODO dit geeft nog errors, iets fout met backend misschien? + // cy.contains('Leave').click(); + }); + + it('join a course', () => { + // TODO add some open courses + }); + +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/login.cy.ts b/frontend/cypress/e2e/student/login.cy.ts new file mode 100644 index 00000000..b42706eb --- /dev/null +++ b/frontend/cypress/e2e/student/login.cy.ts @@ -0,0 +1,11 @@ +import {studentUsername, studentPassword, studentEmail} from '../../fixtures/login.json'; + +describe('profile page', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + }); + it('should login', () => { + cy.url().should('eq', 'http://localhost:3000/en/home') + + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/profile_page.cy.ts b/frontend/cypress/e2e/student/profile_page.cy.ts new file mode 100644 index 00000000..4994639a --- /dev/null +++ b/frontend/cypress/e2e/student/profile_page.cy.ts @@ -0,0 +1,26 @@ +import {studentUsername, studentPassword, studentEmail} from '../../fixtures/login.json'; + +describe('profile page', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + cy.visit('http://localhost:3000/en/profile' ); + }); + + it('check all fields', () => { + cy.contains(studentEmail); + cy.contains('Role: Student'); + cy.contains('Edit Account') + }); + + it('edit account and cancel', () => { + cy.contains('Edit Account').click(); + + cy.contains(studentEmail); + cy.contains('Role: Student'); + cy.contains('Save'); + cy.url().should('eq', 'http://localhost:3000/en/profile/edit'); + + cy.contains('Cancel').click(); + cy.url().should('eq', 'http://localhost:3000/en/profile'); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/project_page.cy.ts b/frontend/cypress/e2e/student/project_page.cy.ts new file mode 100644 index 00000000..08cf0ed5 --- /dev/null +++ b/frontend/cypress/e2e/student/project_page.cy.ts @@ -0,0 +1,59 @@ +import {studentUsername, studentPassword} from '../../fixtures/login.json'; + +describe('student project page', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + }); + + + it('go to project page ', () => { + cy.contains('Artificiële intelligentie').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/1'); + + //TODO this works because translations are not implemented yet + cy.contains('en').click(); + cy.contains('nl').click(); + + cy.contains('View').click(); + // cy.contains('en').click(); + // cy.contains('en').click(); + cy.url().should('eq', 'http://localhost:3000/nl/project/2'); + + cy.contains('AI project'); + cy.contains('12 december 2021 om 13:12'); + cy.contains('Groepen'); + cy.contains('Opdrachtomschrijving'); + cy.contains('Max score: 10'); + }); + + it('view groups of project ', () => { + cy.contains('Artificiële intelligentie').click(); + cy.contains('en').click(); + cy.contains('nl').click(); + cy.contains('View').click(); + + cy.contains('Groepen').click(); + cy.url().should('eq', 'http://localhost:3000/nl/project/2/groups'); + + cy.contains('group_nr'); + cy.contains('Leden'); + cy.contains('alexander.vanoyen@sel2-1.ugent.be, axel.lorreyne@sel2-1.ugent.be'); + + cy.contains('Terug naar Project pagina').click(); + cy.url().should('eq', 'http://localhost:3000/nl/project/2'); + }); + + it ('go to submission page from a project', () => { + cy.contains('Artificiële intelligentie').click(); + cy.contains('en').click(); + cy.contains('nl').click(); + cy.contains('View').click(); + + cy.contains('Indiening toevoegen').click(); + cy.url().should('eq', 'http://localhost:3000/nl/project/2/submit'); + + cy.contains('submit'); + cy.contains('Project inleveren: AI project') + cy.contains('Bestanden'); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/student/submission_page.cy.ts b/frontend/cypress/e2e/student/submission_page.cy.ts new file mode 100644 index 00000000..41e04a2a --- /dev/null +++ b/frontend/cypress/e2e/student/submission_page.cy.ts @@ -0,0 +1,29 @@ +import {studentUsername, studentPassword} from '../../fixtures/login.json'; +import 'cypress-file-upload'; + +describe('student project page', () => { + beforeEach(() => { + cy.login(studentUsername, studentPassword); + }); + + it ('add a submission to a project', () => { + cy.contains('Artificiële intelligentie').click(); + cy.contains('English').click(); + cy.contains('Nederlands').click(); + cy.contains('View').click(); + + cy.contains('Indiening toevoegen').click(); + cy.url().should('eq', 'http://localhost:3000/nl/project/2/submit'); + + cy.contains('submit'); + cy.contains('AI project') + cy.contains('Upload een map'); + cy.contains('Upload bestanden'); + + cy.contains('submit').should('be.disabled'); + + + }); + + +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/course_page.cy.ts b/frontend/cypress/e2e/teacher/course_page.cy.ts new file mode 100644 index 00000000..12befee1 --- /dev/null +++ b/frontend/cypress/e2e/teacher/course_page.cy.ts @@ -0,0 +1,62 @@ +import {teacherUsername, teacherPassword, teacherEmail} from '../../fixtures/login.json'; + +describe('teacher course page', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + cy.contains('Artificiële intelligentie').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1'); + + }); + + it('go to course page ', () => { + + cy.contains('Artificiële intelligentie'); + + cy.contains('Description'); + cy.contains('Kennisgebaseerd redeneren, machinaal leren, heuristische zoekstrategieën,' + + ' neurale netwerken en deep learning, natuurlijke taalverwerking'); + + + cy.contains('Access'); + cy.contains('Open course'); + cy.contains('https://sel2-1.ugent.be/course/1?token=pzWjXxrowWKkXhFZvmrC') + + + cy.contains('Projects'); + cy.contains('AI project') + cy.contains('12/12/2021 13:12') + + cy.contains('Edit course'); + cy.contains('Add project'); + + cy.contains('View students'); + cy.contains('View co-teachers'); + }); + + it('copies to clipboard and view co-teachers', () => { + const expectedCopiedText = 'https://sel2-1.ugent.be/course/1?token=pzWjXxrowWKkXhFZvmrC'; + + cy.window().then((win) => { + cy.stub(win.navigator.clipboard, 'writeText').withArgs(expectedCopiedText).returns(Promise.resolve()); + }); + + // Trigger the copy action + cy.get('[aria-label="Copy"]').click(); + + // Check the copied text + cy.window().then((win) => { + expect(win.navigator.clipboard.writeText).to.have.been.calledWith(expectedCopiedText); + }); + + cy.contains('View co-teachers').click() + + cy.url().should('eq', 'http://localhost:3000/en/course/1/teachers'); + + cy.contains(teacherEmail); + + cy.contains('Back to Course').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/1'); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/create_course.cy.ts b/frontend/cypress/e2e/teacher/create_course.cy.ts new file mode 100644 index 00000000..974ed5d0 --- /dev/null +++ b/frontend/cypress/e2e/teacher/create_course.cy.ts @@ -0,0 +1,37 @@ +import {teacherUsername, teacherPassword} from '../../fixtures/login.json'; + +describe('go to home page as teacher', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + cy.contains('Create Course').click(); + cy.wait(1000); // give the page some time to load + cy.url().should('eq', 'http://localhost:3000/en/course/add'); + }); + + it('go to create course page, check everything and go back to home', () => { + cy.contains('Create Course').click(); + + cy.contains('Course name'); + cy.contains('Banner'); + cy.contains('Description'); + cy.contains('Access'); + cy.contains('Select image') + cy.get('#choice').should('exist'); + + cy.contains('Create Course'); + cy.contains('Cancel').click({force: true}); + + cy.url().should('eq', 'http://localhost:3000/en/home'); + }); + + it('create course', () => { + // fill in the text boxes + cy.get('#name').type('Test Course'); + cy.get('textarea[name="description"]').type('Test description for course'); + // make course public + cy.get('#choice').click(); + cy.contains('Public').click(); + cy.contains('Create Course').click(); + // TODO create course werkt blijkbaar nog niet ? + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/edit_course.cy.ts b/frontend/cypress/e2e/teacher/edit_course.cy.ts new file mode 100644 index 00000000..e7149a4c --- /dev/null +++ b/frontend/cypress/e2e/teacher/edit_course.cy.ts @@ -0,0 +1,52 @@ +import {teacherUsername, teacherPassword, teacherEmail} from '../../fixtures/login.json'; + +describe('teacher course page', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + cy.contains('Algoritmen en datastructuren 3').click(); + + cy.url().should('eq', 'http://localhost:3000/en/course/2'); + }); + + it('edit course and check if updated correctly ', () => { + cy.contains('Edit course').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/2/edit'); + + // Check everything + cy.contains('Course name'); + cy.contains('Banner'); + cy.contains('Description'); + cy.contains('Access'); + cy.contains('Select image'); + cy.get('#choice').should('exist'); + cy.contains('Save changes'); + cy.contains('Cancel'); + + // Edit the course + cy.get('#name').clear().type('Algoritmen en datastructuren 3 - Edited'); + cy.get('textarea[name="description"]').clear().type('Test description for course - Edited'); + cy.get('#choice').click(); + cy.contains('Private').click(); + cy.contains('Save changes').click(); + + cy.contains('Cancel').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/2'); + + // check that everything got updated + cy.contains('Algoritmen en datastructuren 3 - Edited'); + cy.contains('Test description for course - Edited'); + cy.contains('Private course'); + }); + + it('edit course back to starting state', () => { + cy.contains('Edit course').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/2/edit'); + + // Edit the course + cy.get('#name').clear().type('Algoritmen en datastructuren 3'); + cy.get('textarea[name="description"]').clear().type('Algoritme, datastructuur, efficiëntie'); + cy.get('#choice').click(); + cy.contains('Public').click(); + cy.contains('Save changes').click(); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/homepage.cy.ts b/frontend/cypress/e2e/teacher/homepage.cy.ts new file mode 100644 index 00000000..f825f00b --- /dev/null +++ b/frontend/cypress/e2e/teacher/homepage.cy.ts @@ -0,0 +1,22 @@ +import { teacherUsername, teacherPassword } from '../../fixtures/login.json'; + +describe('go to home page as teacher', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + }); + + it('go to home page, check everything', () => { + cy.contains('Pigeonhole'); + + cy.contains('Filter Courses'); + cy.contains('View Archive'); + cy.contains('View All Courses'); + cy.contains('Create Course'); + + }); + + it('create course page', () => { + cy.contains('Create Course').click(); + cy.url().should('eq', 'http://localhost:3000/en/course/add'); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/profile_page.cy.ts b/frontend/cypress/e2e/teacher/profile_page.cy.ts new file mode 100644 index 00000000..848d0861 --- /dev/null +++ b/frontend/cypress/e2e/teacher/profile_page.cy.ts @@ -0,0 +1,26 @@ +import {teacherUsername, teacherPassword, teacherEmail} from '../../fixtures/login.json'; + +describe('teacher profile page', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + cy.visit('http://localhost:3000/en/profile' ); + }); + + it('check all fields', () => { + cy.contains(teacherEmail); + cy.contains('Role: Teacher'); + cy.contains('Edit Account') + }); + + it('edit account and cancel', () => { + cy.contains('Edit Account').click(); + + cy.contains(teacherEmail); + cy.contains('Role: Teacher'); + cy.contains('Save'); + cy.url().should('eq', 'http://localhost:3000/en/profile/edit'); + + cy.contains('Cancel').click(); + cy.url().should('eq', 'http://localhost:3000/en/profile'); + }); +}); \ No newline at end of file diff --git a/frontend/cypress/e2e/teacher/project_page.cy.ts b/frontend/cypress/e2e/teacher/project_page.cy.ts new file mode 100644 index 00000000..2363a13e --- /dev/null +++ b/frontend/cypress/e2e/teacher/project_page.cy.ts @@ -0,0 +1,14 @@ +import {teacherUsername, teacherPassword, teacherEmail} from '../../fixtures/login.json'; + +describe('teacher project page', () => { + beforeEach(() => { + cy.login(teacherUsername, teacherPassword); + cy.contains('Artificiële intelligentie').click(); + }); + + it('go to project page ', () => { + cy.contains('button', 'View').click(); + cy.url().should('eq', 'http://localhost:3000/en/project/2'); + }); + +}); \ No newline at end of file diff --git a/frontend/cypress/fixtures/login.json b/frontend/cypress/fixtures/login.json index 1a5060a6..8b93e4b0 100644 --- a/frontend/cypress/fixtures/login.json +++ b/frontend/cypress/fixtures/login.json @@ -1,5 +1,11 @@ { - "email": "azernic@test.com", - "password": "azernic", - "username": "azernic" + "studentEmail": "alexander.vanoyen@sel2-1.ugent.be", + "studentPassword": "selab123", + "studentUsername": "alexandervanoyen", + "teacherEmail": "teacher@sel2-1.ugent.be", + "teacherPassword": "selab123", + "teacherUsername": "teacher", + "adminEmail": "administrator@sel2-1.ugent.be", + "adminPassword": "selab123", + "adminUsername": "administrator" } diff --git a/frontend/cypress/fixtures/submission.txt b/frontend/cypress/fixtures/submission.txt new file mode 100644 index 00000000..e69de29b diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 94c31711..d0a631ba 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -1,13 +1,25 @@ export {} -import AuthAgent from '../../src/auth/auth-agent'; - Cypress.Commands.add('login', (username: string, password: string) => { cy.visit('http://localhost:8000/api-auth/login/?next=/'); cy.get('input[name="username"]').type(username); cy.get('input[name="password"]').type(password); cy.get('input[type=submit').click() - cy.visit('http://localhost:3000/home'); + cy.visit('http://localhost:3000/en/home'); + cy.window().then((win) => { + win.localStorage.setItem('user', JSON.stringify({ + "data": { + "id": 1, + "email": "alexander.vanoyen@sel2-1.ugent.be", + "first_name": "Alexander", + "last_name": "Van Oyen", + "course": [1,3,4,5,6,7,8,9,10,2], + "role": 3, + "picture": "http://localhost:8000/media/profile_pictures/default_picture.png" + }, + "lastcache": "1715417897558" + })); + }); }); diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts index f80f74f8..45c4f9c3 100644 --- a/frontend/cypress/support/e2e.ts +++ b/frontend/cypress/support/e2e.ts @@ -17,4 +17,10 @@ import './commands' // Alternatively you can use CommonJS syntax: -// require('./commands') \ No newline at end of file +// require('./commands') + +Cypress.on('uncaught:exception', (err, runnable) => { + // returning false here prevents Cypress from + // failing the test + return false +}) \ No newline at end of file diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 4a6d5d4b..27bc397c 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,5 +1,6 @@ import axios, {AxiosError} from 'axios'; import dayjs from "dayjs"; +import {JSZipObject} from "jszip"; const backend_url = process.env['NEXT_PUBLIC_BACKEND_URL']; @@ -33,6 +34,8 @@ export type Course = { open_course: boolean; invite_token: string; banner: string; + archived: boolean; + year: number; } export type Project = { @@ -69,6 +72,18 @@ export type Group = { } +export type Submission = { + submission_id: number; + group_id: number; + submission_nr: number; + file: string; + timestamp: string; + output_simple_test: boolean; + feedback_simple_test: object; + eval_result : boolean; + eval_output: string | null; +} + export type UserData = { id: number; email: string; @@ -263,6 +278,54 @@ export async function getCourses(page = 1, pageSize = 5, keyword?: string, order return await getRequest(url); } +export async function getArchivedCourses(page = 1, pageSize = 5, keyword?: string, orderBy?: string, sortOrder?: string): Promise<Course[]> { + let url = `/courses/get_archived_courses?page=${page}&page_size=${pageSize}`; + + if (keyword) { + url += `&keyword=${keyword}`; + } + + if (orderBy) { + url += `&order_by=${orderBy}`; + } + + if (sortOrder) { + url += `&sort_order=${sortOrder}`; + } + + return await getRequest(url); +} + +export async function getOpenCourses(page = 1, pageSize = 5, keyword?: string, orderBy?: string, sortOrder?: string): Promise<Course[]> { + let url = `/courses/get_open_courses?page=${page}&page_size=${pageSize}`; + + if (keyword) { + url += `&keyword=${keyword}`; + } + + if (orderBy) { + url += `&order_by=${orderBy}`; + } + + if (sortOrder) { + url += `&sort_order=${sortOrder}`; + } + + return await getRequest(url); +} + +export async function archiveCourse(id: number): Promise<number> { + return (await patchData(`/courses/${id}/`, { + archived: true + })).course_id; +} + +export async function unArchiveCourse(id: number): Promise<number> { + return (await patchData(`/courses/${id}/`, { + archived: false + })).course_id; +} + export async function getCoursesForUser(): Promise<Course[]> { let page = 1; let results: Course[] = [] @@ -282,9 +345,14 @@ export async function updateCourse(id: number, data: any): Promise<Course> { } export async function updateUserData(id: number, data: any): Promise<UserData> { + localStorage.setItem('user', JSON.stringify({data: userData, lastcache: "0"})); return (await putData(`/users/${id}/`, data)); } +export async function deleteUser(id: number): Promise<void> { + return (await deleteData(`/users/${id}`)); +} + export async function deleteCourse(id: number): Promise<void> { return (await deleteData(`/courses/${id}`)); } @@ -306,35 +374,23 @@ export async function updateProject(id: number, data: any): Promise<Project> { } export async function deleteProject(id: number): Promise<void> { - return (await deleteData(`/projects/${id}/`)); + return (await deleteData(`/projects/${id}`)); } export async function getProjects(): Promise<Project[]> { return (await getListRequest('/projects')); } -export async function addProject(course_id: number): Promise<number> { - return (await postData('/projects/', { - name: "New Project", - course_id: course_id, - description: "Description", - deadline: dayjs(), - visible: true, - max_score: 100, - number_of_groups: 1, - group_size: 1, - file_structure: "extra/verslag.pdf", - test_files: null, - conditions: "Project must compile and run without errors." - })).project_id; +export async function addProject(data: any): Promise<number> { + return (await postData(`/projects/`, data)).project_id; } export async function getProjectsFromCourse(id: number): Promise<Project[]> { return (await getListRequest('/courses/' + id + '/get_projects')) } -export async function getProjectFromSubmission(id: number): Promise<Project> { - return (await getRequest(`/submissions/${id}/get_project`)) +export async function getProjectFromSubmission(id: number): Promise<number> { + return (await getRequest(`/submissions/${id}/get_project`)).project; } export async function getTeachersFromCourse(id: number): Promise<User[]> { @@ -371,6 +427,15 @@ export async function getGroup(id: number): Promise<Group> { return (await getRequest(`/groups/${id}`)); } +export async function checkGroup(id: number) { + try { + await axios.get(backend_url + "/projects/" + id + "/get_group/", {withCredentials: true}); + return true; + } catch (error) { + return false; + } +} + export async function getGroups(): Promise<Group[]> { return (await getListRequest('/groups')); } @@ -432,17 +497,40 @@ export async function getGroupSubmissions(id: number, page = 1, pageSize = 5, ke let userData: UserData | undefined = undefined; export async function getUserData(): Promise<UserData> { + if(!userData && !localStorage.getItem('user') && window.location.pathname !== "/"){ + await fetchUserData(); + } + if (userData) { return userData; - }/*else if(localStorage.getItem('user')){ - let user : UserData = JSON.parse(localStorage.getItem('user') as string); - userData = user; - return user; - }*/ else { - let user: UserData = await getRequest('/users/current'); - //localStorage.setItem('user', JSON.stringify(user)); - return user; + }else if(localStorage.getItem('user')){ + const userobj = JSON.parse(localStorage.getItem('user') as string); + const lastcache : string | undefined = userobj?.lastcache; + + if(lastcache && Date.now() - parseInt(lastcache) < 2 * 60 * 1000){ + console.log(Date.now() - parseInt(lastcache)); + let user : UserData = userobj.data; + userData = user; + return user; + }else{ + return fetchUserData(); + } + }else { + return fetchUserData(); + } +} + +export async function fetchUserData() : Promise<UserData> { + try{ + userData = await getRequest('/users/current'); + localStorage.setItem('user', JSON.stringify({data: userData, lastcache: Date.now().toString()})); + return userData!; + }catch(e){ + console.error(e); + window.location.href = "/"; + return userData!; } + } export async function logOut() { @@ -547,6 +635,39 @@ export async function putData(path: string, data: any) { } } +export async function patchData(path: string, data: any) { + axios.defaults.headers.patch['X-CSRFToken'] = getCookieValue('csrftoken'); + + try { + const response = await axios.patch(backend_url + path, data, {withCredentials: true}); + + if (response.status === 200 && response?.data) { + return response.data; + } else if (response?.data?.detail) { + console.error("Unexpected response structure:", response.data); + const error: APIError = new APIError(); + error.status = response.status; + error.message = response.data.detail; + error.type = ErrorType.UNKNOWN; + error.trace = undefined; + throw error; + } else { + const error: APIError = new APIError(); + error.status = response.status; + error.message = response.statusText; + error.type = ErrorType.UNKNOWN; + error.trace = undefined; + throw error; + } + } catch (error) { + const apierror: APIError = new APIError(); + apierror.message = "error on put request"; + apierror.type = ErrorType.REQUEST_ERROR; + apierror.trace = error; + throw apierror; + } +} + export async function deleteData(path: string) { axios.defaults.headers.delete['X-CSRFToken'] = getCookieValue('csrftoken'); @@ -566,24 +687,52 @@ export async function joinCourseUsingToken(course_id: number, token: string) { return (await postData(`/courses/${course_id}/join_course_with_token/${token}/`, {})); } -export async function uploadSubmissionFile(event: any, project_id : string) : Promise<string>{ - axios.defaults.headers.post['X-CSRFToken'] = getCookieValue('csrftoken'); +type uploadResult = { + result: string; + errorcode: string | undefined; + submission_id: number; +} + +export async function uploadSubmissionFile(event: any, project_id: string) : Promise<uploadResult>{ axios.defaults.headers.get['X-CSRFToken'] = getCookieValue('csrftoken'); + axios.defaults.headers.post['X-CSRFToken'] = getCookieValue('csrftoken'); event.preventDefault(); + const formData = new FormData(event.target); + //filter files by key + + for(let file of event.target.fileList.files){ + let path = file.webkitRelativePath; + if (path.includes("/")) { + path = path.substring((path.indexOf("/")??0)+1, path.length); + } + formData.append(path, file); + } + + for(let file of event.target.fileList2.files){ + formData.append(file.name, file); + } + + formData.delete("fileList"); const formDataObject = Object.fromEntries(formData.entries()); + console.log(formDataObject) try { let groupres = await axios.get(backend_url + "/projects/" + project_id + "/get_group/", {withCredentials: true}); const group_id = groupres.data.group_id; formDataObject.group_id = group_id; - await axios.post(backend_url + "/submissions/", formDataObject, {withCredentials: true, headers: {'Content-Type': 'multipart/form-data'}}); - return "yes"; + const response = await axios.post(backend_url + '/submissions/', formDataObject, + { withCredentials: true, + headers: { + 'Content-Type': 'multipart/form-data' + } + }); + return {result: "ok", errorcode: undefined, submission_id: response.data.submission_id}; } catch (error) { - const apierror: APIError = new APIError(); + const apierror : APIError = new APIError(); apierror.message = "error posting form"; apierror.type = ErrorType.REQUEST_ERROR; apierror.trace = error; - console.error(error); - return "error" + console.error(apierror); + return {result: "error", errorcode: error.response?.data?.errorcode}; } } \ No newline at end of file diff --git a/frontend/locales/en/common.json b/frontend/locales/en/common.json index d347ab75..ec8ff111 100644 --- a/frontend/locales/en/common.json +++ b/frontend/locales/en/common.json @@ -1,90 +1,6 @@ { - "accepted": "accepted", - "test": "English: Hello world!", - "courses": "My Courses", - "my_profile": "My Profile", - "settings": "Settings", - "logout": "Log out", - "github": "GitHub", - "manual": "Manual", - "edit_course": "Edit course", - "description": "Description", - "projects": "Projects", - "add_project": "Add project", - "details": "Details", - "no_courses": "No courses found", - "no_description": "No description given.", - "view_students": "View students", - "view_co_teachers": "View co-teachers", - "no_projects": "This course does not have any projects yet.", - "project_name": "Project name", - "deadline": "Deadline", - "visibility": "Visibility", - "admin_page": "Admin page", - "access": "Access", - "open_course": "Open course", - "private_course": "Private course", - "page_not_found": "Page not found", - "no_access_message": "You don't have access to this page", - "title": "Title", - "max_score": "Maximal score", - "title_required": "Title is required", - "score_required": "Score is required", - "assignment": "Assignment", - "assignment_required": "Assignment is required", - "required_files": "Required files", - "required_files_info": "\n Here you can add the required files for the project.\n \n There are 2 options for adding files:\n 1. Add a specific file: /extra/report.pdf\n - in this case the file report.pdf is required in the directory extra\n \n 2. Add a file type: src/*.py\n - in this case the only file type allowed in the src directory will be python files\n", - "conditions": "Conditions", - "conditions_info": "\n Here you can add the conditions for the project.\n \n For example:\n - The program needs to compile\n - The program needs to run without errors\n - The program needs to be written in python\n - Execution time is less than 15 second\n - Use the MVC pattern\n", - "group_info": "\n Here you can add the amount of groups and the group size.\n \n The amount of groups is the total amount of groups that will be created.\n The group size is the amount of students that will be in a group.\n", - "groups": "Groups", - "group_size": "Group size", - "group_amount": "Amount of groups", - "group_size_required": "Group size is required", - "group_amount_required": "Amount of groups is required", - "test_files": "Test files", - "upload": "Upload", - "save": "Save", - "cancel": "Cancel", - "remove": "Remove", - "remove_dialog": "Remove project?", - "action_dialog": "This action cannot be undone.", - "remove_confirm": "Remove", - "remove_cancel": "Cancel", - "add_file_folder": "Add file or folder", - "files": "files", - "submit_project": "Submit project", - "copy": "Copy", - "copied_to_clipboard": "Copied to clipboard", - "course name": "Course name", - "delete": "Delete", - "save changes": "Save changes", - "delete course": "Delete course", - "save course": "Save course", - "banner": "Banner", - "select image": "Select image", - "public": "Public", - "private": "Private", - "name": "Name", - "email": "Email", - "role": "Role", - "remove_user": "Remove selected users", - "remove_user_from_course": "Remove user from course", - "search": "Search", - "back_to": "Back to", - "page": "page", - "course_detail": "course detail", - "join_course": "Join course", - "filter_courses": "Filter Courses", - "create_course": "Create Course", - "view_archive": "View Archive", - "project": "Project", - "course": "Course", - "return_project": "Return to project", - "submissions": "Submissions", + "accepted": "Accepted", "description_of_your_course": "Description of your course", - "submission_error": "Submission error, try again", - "submitted": "Sucessfully submitted", "access": "Access", "action_dialog": "This action cannot be undone.", "add_file_folder": "Add file or folder", @@ -116,7 +32,6 @@ "edit_project": "Edit", "email": "Email", "evaluation": "Evaluation", - "files": "files", "filter_courses": "Filter Courses", "github": "GitHub", "group": "Group", @@ -161,6 +76,7 @@ "role": "Role", "save changes": "Save changes", "save course": "Save course", + "edit course": "Edit course", "save": "Save", "save_changes": "Save Changes", "score_required": "Score is required", @@ -182,5 +98,69 @@ "view_archive": "View Archive", "view_co_teachers": "View co-teachers", "view_students": "View students", - "visibility": "Visibility" + "visibility": "Visibility", + "year": "Year", + "archive_course": "Archive course", + "download_file": "Download files", + "denied": "Denied", + "show_more": "Show more", + "show_less": "Show less", + "select": "Select", + "search_for_project": "Search project", + "has_deadline": "Has deadline", + "no_test_files_selected": "No test files selected", + "create": "Create", + "new_file_example": "E.g.: /extra/report.pdf", + "no_required_files": "No required files", + "add": "Add", + "conditions_example": "E.g.: The program must to compile.", + "no_conditions": "No conditions", + "group_search": "Search group", + "submission_search": "Search submission", + "search_course": "Search course", + "search_student": "Search student", + "search_teacher": "Search teacher", + "Are you sure you want to delete this user?": "Are you sure you want to delete this user?", + "delete user": "Delete user", + "first name": "First name", + "last name": "Surname", + "user email": "Email of user", + "Are you sure you want to submit this course?": "Are you sure you want to submit this course?", + "ERROR_NOT_IN_GROUP": "You're not in a group", + "ERROR_GROUP_NOT_FOUND": "Group not found", + "ERROR_DEADLINE_EXPIRED": "Deadline expired", + "ERROR_FILE_UPLOAD": "Error when uploading file", + "ERROR_SUBMISSION_NOT_FOUND": "Submission not found", + "deadlines": "Deadlines", + "upload_folders": "Upload folders:", + "group_number": "Group number", + "submission_date": "Submission date", + "status": "Status", + "join/leave": "Join/Leave", + "evaluation_docker_image": "Evaluation docker image", + "evaluation_docker_image_tooltip": "The docker evaluation image uploaded to the registry on sel2-1.ugent.be:2002, for more info: see the manual.", + "group_nr": "Group nr", + "join_leave": "Join/Leave", + "not_in_group": "Join a group to submit", + "projects_on_selected_date": "Projects on selected date", + "no_projects_on_selected_date": "No projects on selected date", + "project_calendar": "Project calendar", + "edit_user_details": "Edit user details", + "status_button_tooltip": "Required, optional or forbidden file", + "no_deadline": "No deadline", + "all_submissions": "All submissions", + "students": "Students", + "teachers": "Teachers", + "courses_archive": "Courses archive", + "courses_all": "All courses", + "open": "Open", + "feedback_simple_test_0": "You forgot to upload the following files:", + "feedback_simple_test_2": "You uploaded the following files that were not required:", + "simple_tests_failed": "File structure tests: Failed", + "advanced_tests_failed": "Advanced tests: Failed", + "simple_tests_ok": "File structure tests: OK", + "advanced_tests_ok": "Advanced tests: OK", + "download_artifacts": "Download artifacts", + "artifacts": "Artifacts", + "unarchive course": "Unarchive course" } \ No newline at end of file diff --git a/frontend/locales/nl/common.json b/frontend/locales/nl/common.json index 42621a39..058d2d46 100644 --- a/frontend/locales/nl/common.json +++ b/frontend/locales/nl/common.json @@ -1,92 +1,6 @@ { - "accepted": "aanvaard", - "test": "Nederlands: Hallo wereld!", - "courses": "Mijn Cursussen", - "my_profile": "Mijn Profiel", - "settings": "Instellingen", - "logout": "Uitloggen", - "github": "GitHub", - "manual": "Handleiding", - "edit_course": "Cursus bewerken", - "description": "Beschrijving", - "projects": "Projecten", - "add_project": "Project toevoegen", - "details": "Details", - "no_courses": "Geen cursussen om weer te geven.", - "no_description": "Geen beschrijving gegeven", - "view_students": "Bekijk studenten", - "view_co_teachers": "Bekijk mede-docenten", - "no_projects": "Deze cursus heeft momenteel nog geen projecten.", - "project_name": "Projectnaam", - "deadline": "Deadline", - "visibility": "Zichtbaarheid", - "admin_page": "Beheerderspagina", - "access": "Toegang", - "open_course": "Open cursus", - "private_course": "Privé cursus", - "page_not_found": "Pagina niet gevonden", - "no_access_message": "Je hebt geen toegang tot deze pagina.", - "title": "Titel", - "max_score": "Maximale score", - "title_required": "Titel is verplicht", - "score_required": "Score is verplicht", - "assignment": "Opdrachtomschrijving", - "assignment_required": "Opdrachtomschrijving is verplicht", - "required_files": "Verplichte bestanden", - "required_files_info": "\n Hier kun je verplichte bestanden toevoegen voor het project.\n \n Er zijn 2 opties om bestanden toe te voegen:\n 1. Een specifiek bestand: /extra/verslag.pdf\n - In dit geval is het bestand verslag.pdf vereist in de map extra\n \n 2. Een bestandstype toevoegen: src/*.py\n - In dit geval is het enigste bestandstype toegestaan in de map src python bestanden\n", - "conditions": "Voorwaarden", - "conditions_info": "\n Hier kan je de voorwaarden van het project toevoegen.\n \n Bijvoorbeeld:\n - Het programma moet compileren\n - Het programma moet zonder fouten draaien\n - Het programma moet in python geschreven zijn\n - Uitvoeringstijd is minder dan 15 seconden\n - Gebruik het MVC-patroon\n", - "group_info": "\n Hier kan je aanpassen hoeveel groepen je wilt en hoe groot de groepen moeten zijn. \n \n Het aantal groepen is hoeveel groepen er zullen worden gemaakt.\n De groepsgrootte is hoeveel studenten er in een groep zitten.\n", - "groups": "Groepen", - "group_size": "Groepsgrootte", - "group_amount": "Aantal groepen", - "group_size_required": "Groepsgrootte is verplicht", - "group_amount_required": "Aantal groepen is verplicht", - "test_files": "Testbestanden", - "upload": "Upload", - "save": "Opslaan", - "cancel": "Annuleren", - "remove": "Verwijderen", - "remove_dialog": "Project verwijderen?", - "action_dialog": "Deze actie kan niet ongedaan worden gemaakt.", - "remove_confirm": "Verwijderen", - "remove_cancel": "Annuleren", - "add_file_folder": "Bestand of map toevoegen", - "files": "Bestanden", - "submit_project": "Project inleveren", - "copy": "Kopiëren", - "copied_to_clipboard": "Gekopieerd naar klembord", - "course name": "Cursus naam", - "delete": "Verwijderen", - "save changes": "Wijzigingen opslaan", - "save course": "Cursus opslaan", - "delete course": "Cursus verwijderen", - "banner": "Banner", - "select image": "Selecteer afbeelding", - "private": "Privé", - "public": "Publiek", - "name": "Naam", - "email": "email", - "group": "Groep", - "role": "Rol", - "remove_user": "Geselecteerde gebruikers verwijderen", - "remove_user_from_course": "Geselecteerde gebruikers verwijderen uit cursus", - "search": "Zoeken", - "back_to": "Terug naar", - "page": "pagina", - "course_detail": "cursus details", - "join_course": "Cursus toetreden", - "members": "Leden", - "filter_courses": "Filter Cursussen", - "create_course": "Cursus Aanmaken", - "view_archive": "Bekijk Archief", - "project": "Project", - "course": "Cursus", - "return_project": "Terug naar project", - "submissions": "Indieningen", + "accepted": "Aanvaard", "description_of_your_course": "Beschrijving van je cursus", - "submission_error": "Fout bij inleveren, probeer het opnieuw", - "submitted": "Inlevering bevestigd!", "access": "Toegang", "action_dialog": "Deze actie kan niet ongedaan worden gemaakt.", "add_file_folder": "Bestand of map toevoegen", @@ -118,7 +32,6 @@ "edit_project": "Aanpassen", "email": "E-mail", "evaluation": "Evaluatie", - "files": "Bestanden", "filter_courses": "Filter Cursussen", "github": "GitHub", "group": "Groep", @@ -163,6 +76,7 @@ "role": "Rol", "save changes": "Wijzigingen opslaan", "save course": "Cursus opslaan", + "edit course": "Cursus bewerken", "save": "Opslaan", "save_changes": "Wijzigingen Opslaan", "score_required": "Score is verplicht", @@ -184,5 +98,73 @@ "view_archive": "Bekijk Archief", "view_co_teachers": "Bekijk mede-docenten", "view_students": "Bekijk studenten", - "visibility": "Zichtbaarheid" + "visibility": "Zichtbaarheid", + "download_file": "Download bestanden", + "denied": "Geweigerd", + "show_more": "Toon meer", + "show_less": "Toon minder", + "select": "Selecteer", + "search_for_project": "Zoek project", + "has_deadline": "Heeft deadline", + "no_test_files_selected": "Geen testbestanden geselecteerd", + "create": "Aanmaken", + "new_file_example": "Bv.: /extra/verslag.pdf", + "no_required_files": "Geen verplichte bestanden", + "add": "Toevoegen", + "conditions_example": "Bv.: Het programma moet compileren", + "no_conditions": "Geen voorwaarden", + "Prev" : "Vorige", + "Next": "Volgende", + "View": "Bekijk", + "group_search": "Zoek groep", + "submission_search": "Zoek indiening", + "search_course": "Zoek cursus", + "search_student": "Zoek student", + "search_teacher": "Zoek docent", + "Are you sure you want to delete this user?": "Ben je zeker dat je deze gebruiker wil verwijderen?", + "delete user": "Verwijder gebruiker", + "first name": "Voornaam", + "last name": "Achternaam", + "user email": "Gebruiker email", + "Are you sure you want to submit this course?": "Ben je zeker dat je deze cursus wil aanmaken?", + "year": "Jaargang", + "archive_course": "Cursus archiveren", + "files": "Upload bestanden:", + "ERROR_NOT_IN_GROUP": "Je zit niet in een groep", + "ERROR_GROUP_NOT_FOUND": "Groep niet gevonden", + "ERROR_DEADLINE_EXPIRED": "Deadline is verstreken", + "ERROR_FILE_UPLOAD": "Fout bij uploaden bestand", + "ERROR_SUBMISSION_NOT_FOUND": "Inlevering niet gevonden", + "deadlines": "Deadlines", + "upload_folders": "Upload een map:", + "group_number": "Groep nummer", + "submission_date": "Inleverdatum", + "status": "Status", + "join/leave": "Toetreden/Verlaten", + "evaluation_docker_image": "Docker image voor evaluatie", + "evaluation_docker_image_tooltip": "De docker image die gebruikt wordt voor evaluatie, geupload op de registry bij sel2-1.ugent.be:2002, voor meer info: zie de handleiding.", + "group_nr": "Groep nr", + "join_leave": "Toetreden/Verlaten", + "not_in_group": "Je kan niet indienen zonder in een groep te zitten", + "projects_on_selected_date": "Projecten op geselecteerde datum", + "no_projects_on_selected_date": "Geen projecten op geselecteerde datum", + "project_calendar": "Project kalender", + "edit_user_details": "Gebruiker bewerken", + "status_button_tooltip": "Verplicht, optioneel of verboden bestand", + "feedback_simple_test_0": "Je hebt de volgende bestanden niet ingeleverd:", + "feedback_simple_test_2": "Je hebt de volgende bestanden ingeleverd die niet nodig zijn:", + "no_deadline": "Geen deadline", + "all_submissions": "Alle indieningen", + "students": "Studenten", + "teachers": "Docenten", + "courses_archive": "Gearchiveerde cursussen", + "courses_all": "Alle cursussen", + "open": "Open", + "simple_tests_failed": "Bestandsstructuur tests: Gefaald", + "advanced_tests_failed": "Geadvanceerde tests: Gefaald", + "simple_tests_ok": "Bestandsstructuur: OK", + "advanced_tests_ok": "Geadvanceerde tests: OK", + "download_artifacts": "Download artefacten", + "artifacts": "Artefacten", + "unarchive course": "Cursus uit archief halen" } \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 81019ae2..69cf393c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,26 +9,29 @@ "version": "0.1.0", "dependencies": { "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", + "@emotion/styled": "^11.11.5", "@fontsource/roboto": "^5.0.12", "@mui/icons-material": "^5.15.12", - "@mui/material": "^5.15.12", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.15", "@mui/material-nextjs": "^5.15.11", - "@mui/x-date-pickers": "^7.0.0", + "@mui/x-date-pickers": "^6.3.0", "@radix-ui/react-slot": "^1.0.2", "@types/file-saver": "^2.0.7", "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^2.29.3", "dayjs": "^1.11.10", "file-saver": "^2.0.5", "i18next": "^23.10.1", "i18next-resources-to-backend": "^1.2.0", "jszip": "^3.10.1", "lucide-react": "^0.344.0", - "next": "14.1.0", + "next": "14.1.1", "next-i18n-router": "^5.3.1", "react": "^18", + "react-calendar": "^5.0.0", "react-dom": "^18", "react-dropzone": "^14.2.3", "react-i18next": "^14.1.0", @@ -45,7 +48,8 @@ "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", - "cypress": "^13.7.1", + "cypress": "^13.8.1", + "cypress-file-upload": "^5.0.8", "eslint": "^8", "eslint-config-next": "14.1.0", "jest": "^29.7.0", @@ -607,9 +611,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", - "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", + "version": "7.24.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.5.tgz", + "integrity": "sha512-Nms86NXrsaeU9vbBJKni6gXiEXZ4CVpYVzEjDH9Sb8vmZ3UljyA1GSOJl/6LGPO8EHLuSF9H+IxNXHPX8QHJ4g==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -1602,9 +1606,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "5.15.15", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.15.tgz", - "integrity": "sha512-aXnw29OWQ6I5A47iuWEI6qSSUfH6G/aCsW9KmW3LiFqr7uXZBK4Ks+z8G+qeIub8k0T5CMqlT2q0L+ZJTMrqpg==", + "version": "5.15.18", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.18.tgz", + "integrity": "sha512-/9pVk+Al8qxAjwFUADv4BRZgMpZM4m5E+2Q/20qhVPuIJWqKp4Ie4tGExac6zu93rgPTYVQGgu+1vjiT0E+cEw==", "funding": { "type": "opencollective", "url": "https://opencollective.com/mui-org" @@ -1635,6 +1639,46 @@ } } }, + "node_modules/@mui/lab": { + "version": "5.0.0-alpha.170", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.170.tgz", + "integrity": "sha512-0bDVECGmrNjd3+bLdcLiwYZ0O4HP5j5WSQm5DV6iA/Z9kr8O6AnvZ1bv9ImQbbX7Gj3pX4o43EKwCutj3EQxQg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/system": "^5.15.15", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", + "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", + "@mui/material": ">=5.15.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": { "version": "5.15.15", "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.15.tgz", @@ -1850,16 +1894,14 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.2.0.tgz", - "integrity": "sha512-hsXugZ+n1ZnHRYzf7+PFrjZ44T+FyGZmTreBmH0M2RUaAblgK+A1V3KNLT+r4Y9gJLH+92LwePxQ9xyfR+E51A==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-6.3.0.tgz", + "integrity": "sha512-Qux/nRGb0HueZU4L0h1QEqmORCrpgLukWWhG1Im6cFCmLtZbBey/0JuAFRa+OgvIXgGktDt8SY/FYsRkfGt2TQ==", "dependencies": { - "@babel/runtime": "^7.24.0", - "@mui/base": "^5.0.0-beta.40", - "@mui/system": "^5.15.14", - "@mui/utils": "^5.15.14", - "@types/react-transition-group": "^4.4.10", - "clsx": "^2.1.0", + "@babel/runtime": "^7.21.0", + "@mui/utils": "^5.12.0", + "@types/react-transition-group": "^4.4.5", + "clsx": "^1.2.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, @@ -1868,21 +1910,23 @@ }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/mui-org" + "url": "https://opencollective.com/mui" }, "peerDependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14", - "date-fns": "^2.25.0 || ^3.2.0", + "@mui/base": "^5.0.0-alpha.87", + "@mui/material": "^5.8.6", + "@mui/system": "^5.8.0", + "date-fns": "^2.25.0", "date-fns-jalali": "^2.13.0-0", "dayjs": "^1.10.7", "luxon": "^3.0.2", "moment": "^2.29.4", "moment-hijri": "^2.1.2", "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", - "react": "^17.0.0 || ^18.0.0", - "react-dom": "^17.0.0 || ^18.0.0" + "react": "^17.0.2 || ^18.0.0", + "react-dom": "^17.0.2 || ^18.0.0" }, "peerDependenciesMeta": { "@emotion/react": { @@ -1914,10 +1958,18 @@ } } }, + "node_modules/@mui/x-date-pickers/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "engines": { + "node": ">=6" + } + }, "node_modules/@next/env": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.0.tgz", - "integrity": "sha512-Py8zIo+02ht82brwwhTg36iogzFqGLPXlRGKQw5s+qP/kMNc4MAyDeEwBKDijk6zTIbegEgu8Qy7C1LboslQAw==" + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/env/-/env-14.1.1.tgz", + "integrity": "sha512-7CnQyD5G8shHxQIIg3c7/pSeYFeMhsNbpU/bmvH7ZnDql7mNRgg8O2JZrhrc/soFnfBnKP4/xXNiiSIPn2w8gA==" }, "node_modules/@next/eslint-plugin-next": { "version": "14.1.0", @@ -1929,9 +1981,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.0.tgz", - "integrity": "sha512-nUDn7TOGcIeyQni6lZHfzNoo9S0euXnu0jhsbMOmMJUBfgsnESdjN97kM7cBqQxZa8L/bM9om/S5/1dzCrW6wQ==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.1.1.tgz", + "integrity": "sha512-yDjSFKQKTIjyT7cFv+DqQfW5jsD+tVxXTckSe1KIouKk75t1qZmj/mV3wzdmFb0XHVGtyRjDMulfVG8uCKemOQ==", "cpu": [ "arm64" ], @@ -1944,9 +1996,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.0.tgz", - "integrity": "sha512-1jgudN5haWxiAl3O1ljUS2GfupPmcftu2RYJqZiMJmmbBT5M1XDffjUtRUzP4W3cBHsrvkfOFdQ71hAreNQP6g==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-14.1.1.tgz", + "integrity": "sha512-KCQmBL0CmFmN8D64FHIZVD9I4ugQsDBBEJKiblXGgwn7wBCSe8N4Dx47sdzl4JAg39IkSN5NNrr8AniXLMb3aw==", "cpu": [ "x64" ], @@ -1959,9 +2011,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.0.tgz", - "integrity": "sha512-RHo7Tcj+jllXUbK7xk2NyIDod3YcCPDZxj1WLIYxd709BQ7WuRYl3OWUNG+WUfqeQBds6kvZYlc42NJJTNi4tQ==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-14.1.1.tgz", + "integrity": "sha512-YDQfbWyW0JMKhJf/T4eyFr4b3tceTorQ5w2n7I0mNVTFOvu6CGEzfwT3RSAQGTi/FFMTFcuspPec/7dFHuP7Eg==", "cpu": [ "arm64" ], @@ -1974,9 +2026,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.0.tgz", - "integrity": "sha512-v6kP8sHYxjO8RwHmWMJSq7VZP2nYCkRVQ0qolh2l6xroe9QjbgV8siTbduED4u0hlk0+tjS6/Tuy4n5XCp+l6g==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-14.1.1.tgz", + "integrity": "sha512-fiuN/OG6sNGRN/bRFxRvV5LyzLB8gaL8cbDH5o3mEiVwfcMzyE5T//ilMmaTrnA8HLMS6hoz4cHOu6Qcp9vxgQ==", "cpu": [ "arm64" ], @@ -1989,9 +2041,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.0.tgz", - "integrity": "sha512-zJ2pnoFYB1F4vmEVlb/eSe+VH679zT1VdXlZKX+pE66grOgjmKJHKacf82g/sWE4MQ4Rk2FMBCRnX+l6/TVYzQ==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.1.1.tgz", + "integrity": "sha512-rv6AAdEXoezjbdfp3ouMuVqeLjE1Bin0AuE6qxE6V9g3Giz5/R3xpocHoAi7CufRR+lnkuUjRBn05SYJ83oKNQ==", "cpu": [ "x64" ], @@ -2004,9 +2056,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.0.tgz", - "integrity": "sha512-rbaIYFt2X9YZBSbH/CwGAjbBG2/MrACCVu2X0+kSykHzHnYH5FjHxwXLkcoJ10cX0aWCEynpu+rP76x0914atg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.1.1.tgz", + "integrity": "sha512-YAZLGsaNeChSrpz/G7MxO3TIBLaMN8QWMr3X8bt6rCvKovwU7GqQlDu99WdvF33kI8ZahvcdbFsy4jAFzFX7og==", "cpu": [ "x64" ], @@ -2019,9 +2071,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.0.tgz", - "integrity": "sha512-o1N5TsYc8f/HpGt39OUQpQ9AKIGApd3QLueu7hXk//2xq5Z9OxmV6sQfNp8C7qYmiOlHYODOGqNNa0e9jvchGQ==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-14.1.1.tgz", + "integrity": "sha512-1L4mUYPBMvVDMZg1inUYyPvFSduot0g73hgfD9CODgbr4xiTYe0VOMTZzaRqYJYBA9mana0x4eaAaypmWo1r5A==", "cpu": [ "arm64" ], @@ -2034,9 +2086,9 @@ } }, "node_modules/@next/swc-win32-ia32-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.0.tgz", - "integrity": "sha512-XXIuB1DBRCFwNO6EEzCTMHT5pauwaSj4SWs7CYnME57eaReAKBXCnkUE80p/pAZcewm7hs+vGvNqDPacEXHVkw==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-14.1.1.tgz", + "integrity": "sha512-jvIE9tsuj9vpbbXlR5YxrghRfMuG0Qm/nZ/1KDHc+y6FpnZ/apsgh+G6t15vefU0zp3WSpTMIdXRUsNl/7RSuw==", "cpu": [ "ia32" ], @@ -2049,9 +2101,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.0.tgz", - "integrity": "sha512-9WEbVRRAqJ3YFVqEZIxUqkiO8l1nool1LmNxygr5HWF8AcSYsEpneUDhmjUVJEzO2A04+oPtZdombzzPPkTtgg==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-14.1.1.tgz", + "integrity": "sha512-S6K6EHDU5+1KrBDLko7/c1MNy/Ya73pIAmvKeFwsF4RmBFJSO7/7YeD4FnZ4iBdzE69PpQ4sOMU9ORKeNuxe8A==", "cpu": [ "x64" ], @@ -2890,6 +2942,14 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@wojtekmaj/date-utils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-1.5.1.tgz", + "integrity": "sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww==", + "funding": { + "url": "https://github.com/wojtekmaj/date-utils?sponsor=1" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -4047,9 +4107,9 @@ } }, "node_modules/clsx": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.0.tgz", - "integrity": "sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "engines": { "node": ">=6" } @@ -4233,9 +4293,9 @@ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/cypress": { - "version": "13.7.3", - "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.3.tgz", - "integrity": "sha512-uoecY6FTCAuIEqLUYkTrxamDBjMHTYak/1O7jtgwboHiTnS1NaMOoR08KcTrbRZFCBvYOiS4tEkQRmsV+xcrag==", + "version": "13.8.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.8.1.tgz", + "integrity": "sha512-Uk6ovhRbTg6FmXjeZW/TkbRM07KPtvM5gah1BIMp4Y2s+i/NMxgaLw0+PbYTOdw1+egE0FP3mWRiGcRkjjmhzA==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -4289,6 +4349,18 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, + "node_modules/cypress-file-upload": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz", + "integrity": "sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==", + "dev": true, + "engines": { + "node": ">=8.2.1" + }, + "peerDependencies": { + "cypress": ">3.0.0" + } + }, "node_modules/cypress/node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -4393,6 +4465,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "2.29.3", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", + "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "engines": { + "node": ">=0.11" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/date-fns" + } + }, "node_modules/dayjs": { "version": "1.11.10", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz", @@ -5912,6 +5996,17 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/get-user-locale": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-2.3.2.tgz", + "integrity": "sha512-O2GWvQkhnbDoWFUJfaBlDIKUEdND8ATpBXD6KXcbhxlfktyD/d8w6mkzM/IlQEqGZAMz/PW6j6Hv53BiigKLUQ==", + "dependencies": { + "mem": "^8.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" + } + }, "node_modules/getos": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", @@ -8404,6 +8499,40 @@ "tmpl": "1.0.5" } }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mem": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", + "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==", + "dependencies": { + "map-age-cleaner": "^0.1.3", + "mimic-fn": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/mem?sponsor=1" + } + }, + "node_modules/mem/node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8551,11 +8680,11 @@ "peer": true }, "node_modules/next": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/next/-/next-14.1.0.tgz", - "integrity": "sha512-wlzrsbfeSU48YQBjZhDzOwhWhGsy+uQycR8bHAOt1LY1bn3zZEcDyHQOEoN3aWzQ8LHCAJ1nqrWCc9XF2+O45Q==", + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/next/-/next-14.1.1.tgz", + "integrity": "sha512-McrGJqlGSHeaz2yTRPkEucxQKe5Zq7uPwyeHNmJaZNY4wx9E9QdxmTp310agFRoMuIYgQrCrT3petg13fSVOww==", "dependencies": { - "@next/env": "14.1.0", + "@next/env": "14.1.1", "@swc/helpers": "0.5.2", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", @@ -8570,15 +8699,15 @@ "node": ">=18.17.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "14.1.0", - "@next/swc-darwin-x64": "14.1.0", - "@next/swc-linux-arm64-gnu": "14.1.0", - "@next/swc-linux-arm64-musl": "14.1.0", - "@next/swc-linux-x64-gnu": "14.1.0", - "@next/swc-linux-x64-musl": "14.1.0", - "@next/swc-win32-arm64-msvc": "14.1.0", - "@next/swc-win32-ia32-msvc": "14.1.0", - "@next/swc-win32-x64-msvc": "14.1.0" + "@next/swc-darwin-arm64": "14.1.1", + "@next/swc-darwin-x64": "14.1.1", + "@next/swc-linux-arm64-gnu": "14.1.1", + "@next/swc-linux-arm64-musl": "14.1.1", + "@next/swc-linux-x64-gnu": "14.1.1", + "@next/swc-linux-x64-musl": "14.1.1", + "@next/swc-win32-arm64-msvc": "14.1.1", + "@next/swc-win32-ia32-msvc": "14.1.1", + "@next/swc-win32-x64-msvc": "14.1.1" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8915,6 +9044,14 @@ "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", "dev": true }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9555,6 +9692,30 @@ "node": ">=0.10.0" } }, + "node_modules/react-calendar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-5.0.0.tgz", + "integrity": "sha512-bHcE5e5f+VUKLd4R19BGkcSQLpuwjKBVG0fKz74cwPW5xDfNsReHdDbfd4z3mdjuUuZzVtw4Q920mkwK5/ZOEg==", + "dependencies": { + "@wojtekmaj/date-utils": "^1.1.3", + "clsx": "^2.0.0", + "get-user-locale": "^2.2.1", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-calendar?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -11376,6 +11537,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/watchpack": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8eeb5ac4..d07285dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,26 +10,29 @@ }, "dependencies": { "@emotion/react": "^11.11.4", - "@emotion/styled": "^11.11.0", + "@emotion/styled": "^11.11.5", "@fontsource/roboto": "^5.0.12", "@mui/icons-material": "^5.15.12", - "@mui/material": "^5.15.12", + "@mui/lab": "^5.0.0-alpha.170", + "@mui/material": "^5.15.15", "@mui/material-nextjs": "^5.15.11", - "@mui/x-date-pickers": "^7.0.0", + "@mui/x-date-pickers": "^6.3.0", "@radix-ui/react-slot": "^1.0.2", "@types/file-saver": "^2.0.7", "axios": "^1.6.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^2.29.3", "dayjs": "^1.11.10", "file-saver": "^2.0.5", "i18next": "^23.10.1", "i18next-resources-to-backend": "^1.2.0", "jszip": "^3.10.1", "lucide-react": "^0.344.0", - "next": "14.1.0", + "next": "14.1.1", "next-i18n-router": "^5.3.1", "react": "^18", + "react-calendar": "^5.0.0", "react-dom": "^18", "react-dropzone": "^14.2.3", "react-i18next": "^14.1.0", @@ -46,7 +49,8 @@ "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", - "cypress": "^13.7.1", + "cypress": "^13.8.1", + "cypress-file-upload": "^5.0.8", "eslint": "^8", "eslint-config-next": "14.1.0", "jest": "^29.7.0", diff --git a/frontend/styles/theme.ts b/frontend/styles/theme.ts index c8f722d7..b08a9a64 100644 --- a/frontend/styles/theme.ts +++ b/frontend/styles/theme.ts @@ -29,56 +29,11 @@ export const baseTheme = createTheme({ primary: '#001D36', secondary: '#FFFFFF' }, - }, -}); - -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, - }, - h1: { - fontWeight: 400, - }, - }, - components: { - MuiTextField: { - defaultProps: { - InputLabelProps: { - shrink: true, - }, - margin: 'normal', - required: true, - fullWidth: true, - }, - }, - MuiButton: { - defaultProps: { - variant: 'contained', - color: 'primary', - fullWidth: true, - style: {margin: '10px 0'}, - }, }, }, }); diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 429cee87..e8bbb941 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -31,6 +31,9 @@ ], "@app/*": [ "./app/*" + ], + "@styles/*": [ + "./styles/*" ] }, "types": [ diff --git a/package-lock.json b/package-lock.json index 452abe47..c150f0c0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,10 @@ "packages": { "": { "dependencies": { - "jszip": "^3.10.1" + "@mui/icons-material": "^5.15.17", + "@mui/lab": "^5.0.0-alpha.170", + "jszip": "^3.10.1", + "react-calendar": "^5.0.0" }, "devDependencies": { "@testing-library/jest-dom": "^6.4.2", @@ -556,7 +559,6 @@ "version": "7.24.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", "integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", - "dev": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -681,6 +683,72 @@ "ms": "^2.1.1" } }, + "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/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/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/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/@floating-ui/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.1.tgz", + "integrity": "sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==", + "dependencies": { + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.5.tgz", + "integrity": "sha512-Nsdud2X65Dz+1RHjAIP0t8z5e2ff/IRbei6BqFrl1urT8sDVzM1HMQ+R0XcU5ceRfyO3I6ayeqIfh+6Wb8LGTw==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.9.tgz", + "integrity": "sha512-q0umO0+LQK4+p6aGyvzASqKbKOJcAHJ7ycE9CuUvfx3s9zTHWmGJTPOIlM/hmSBfUfg/XfY5YhLBLR/LHwShQQ==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.2.tgz", + "integrity": "sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1050,6 +1118,302 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.40", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.40.tgz", + "integrity": "sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@floating-ui/react-dom": "^2.0.8", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@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.17", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.15.17.tgz", + "integrity": "sha512-DVAejDQkjNnIac7MfP8sLzuo7fyrBPxNdXe+6bYqOqg1z2OPTlfFAejSNzWe7UenRMuFu9/AyFXj/X2vN2w6dA==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "5.15.17", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.15.17.tgz", + "integrity": "sha512-xVzl2De7IY36s/keHX45YMiCpsIx3mNv2xwDgtBkRSnZQtVk+Gqufwj1ktUxEyjzEhBl0+PiNJqYC31C+n1n6A==", + "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/lab": { + "version": "5.0.0-alpha.170", + "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.170.tgz", + "integrity": "sha512-0bDVECGmrNjd3+bLdcLiwYZ0O4HP5j5WSQm5DV6iA/Z9kr8O6AnvZ1bv9ImQbbX7Gj3pX4o43EKwCutj3EQxQg==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/system": "^5.15.15", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "clsx": "^2.1.0", + "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", + "@mui/material": ">=5.15.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": { + "version": "5.15.17", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.15.17.tgz", + "integrity": "sha512-ru/MLvTkCh0AZXmqwIpqGTOoVBS/sX48zArXq/DvktxXZx4fskiRA2PEc7Rk5ZlFiZhKh4moL4an+l8zZwq49Q==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/base": "5.0.0-beta.40", + "@mui/core-downloads-tracker": "^5.15.17", + "@mui/system": "^5.15.15", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "@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/private-theming": { + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.15.14.tgz", + "integrity": "sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/utils": "^5.15.14", + "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.14", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.15.14.tgz", + "integrity": "sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw==", + "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.15", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.15.15.tgz", + "integrity": "sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ==", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@mui/private-theming": "^5.15.14", + "@mui/styled-engine": "^5.15.14", + "@mui/types": "^7.2.14", + "@mui/utils": "^5.15.14", + "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.14", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.14.tgz", + "integrity": "sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "5.15.14", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.15.14.tgz", + "integrity": "sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA==", + "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/@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/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -1254,6 +1618,30 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" + }, + "node_modules/@types/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.1.tgz", + "integrity": "sha512-V0kuGBX3+prX+DQ/7r2qsv1NsdfnCLnTgnRJ1pYnxykBhGMz+qj+box5lq7XsO5mtZsBqpjwwTu/7wszPfMBcw==", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "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==", + "peer": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", @@ -1297,6 +1685,14 @@ "@types/node": "*" } }, + "node_modules/@wojtekmaj/date-utils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@wojtekmaj/date-utils/-/date-utils-1.5.1.tgz", + "integrity": "sha512-+i7+JmNiE/3c9FKxzWFi2IjRJ+KzZl1QPu6QNrsgaa2MuBgXvUy4gA1TVzf/JMdIIloB76xSKikTWuyYAIVLww==", + "funding": { + "url": "https://github.com/wojtekmaj/date-utils?sponsor=1" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -1964,6 +2360,14 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -2088,6 +2492,11 @@ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", "dev": true }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, "node_modules/cypress": { "version": "13.7.2", "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.7.2.tgz", @@ -2307,6 +2716,16 @@ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true }, + "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==", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -2730,6 +3149,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-user-locale": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/get-user-locale/-/get-user-locale-2.3.2.tgz", + "integrity": "sha512-O2GWvQkhnbDoWFUJfaBlDIKUEdND8ATpBXD6KXcbhxlfktyD/d8w6mkzM/IlQEqGZAMz/PW6j6Hv53BiigKLUQ==", + "dependencies": { + "mem": "^8.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/get-user-locale?sponsor=1" + } + }, "node_modules/getos": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz", @@ -3888,8 +4318,7 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { "version": "3.14.1", @@ -4160,6 +4589,17 @@ "node": ">=8" } }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4238,6 +4678,40 @@ "tmpl": "1.0.5" } }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/mem": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-8.1.1.tgz", + "integrity": "sha512-qFCFUDs7U3b8mBDPyz5EToEKoAkgCzqquIgi9nkkR9bixxOVOre+09lbuH7+9Kn2NFpm56M3GUWVbU2hQgdACA==", + "dependencies": { + "map-age-cleaner": "^0.1.3", + "mimic-fn": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/mem?sponsor=1" + } + }, + "node_modules/mem/node_modules/mimic-fn": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-3.1.0.tgz", + "integrity": "sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==", + "engines": { + "node": ">=8" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -4367,6 +4841,14 @@ "node": ">=8" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", @@ -4406,6 +4888,14 @@ "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", "dev": true }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -4664,6 +5154,21 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/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==" + }, "node_modules/proxy-from-env": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", @@ -4733,11 +5238,75 @@ "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", "dev": true }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-calendar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-calendar/-/react-calendar-5.0.0.tgz", + "integrity": "sha512-bHcE5e5f+VUKLd4R19BGkcSQLpuwjKBVG0fKz74cwPW5xDfNsReHdDbfd4z3mdjuUuZzVtw4Q920mkwK5/ZOEg==", + "dependencies": { + "@wojtekmaj/date-utils": "^1.1.3", + "clsx": "^2.0.0", + "get-user-locale": "^2.2.1", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-calendar?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, "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==", - "dev": true + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + }, + "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==", + "peer": true, + "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/readable-stream": { "version": "2.3.8", @@ -4769,8 +5338,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/request-progress": { "version": "3.0.0", @@ -4887,6 +5455,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5160,6 +5737,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, "node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -5534,6 +6116,14 @@ "makeerror": "1.0.12" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 84e1b0a5..7802e529 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "ts-jest": "^29.1.2" }, "dependencies": { - "jszip": "^3.10.1" + "@mui/icons-material": "^5.15.17", + "@mui/lab": "^5.0.0-alpha.170", + "jszip": "^3.10.1", + "react-calendar": "^5.0.0" } } diff --git a/scripts/eval_test.py b/scripts/eval_test.py new file mode 100644 index 00000000..d10820ea --- /dev/null +++ b/scripts/eval_test.py @@ -0,0 +1,24 @@ +from docker import DockerClient + + +def run(): + client = DockerClient(base_url='unix://var/run/docker.sock') + + container = client.containers.run( + image='busybox:latest', + name='pigeonhole-submission-evaluation-test', + + # Keep the container running for testing + detach=True, + tty=True, + stdin_open=True, + + volumes={ + 'submissions': { + 'bind': '/usr/src/submissions/', + 'mode': 'ro' + } + } + ) + + client.close() diff --git a/scripts/fibonacci_correct/main.py b/scripts/fibonacci_correct/main.py new file mode 100644 index 00000000..70a6b0dc --- /dev/null +++ b/scripts/fibonacci_correct/main.py @@ -0,0 +1,11 @@ +def fibonacci(n): + if n == 1: + return [0] + elif n == 2: + return [0, 1] + + sequence = [0, 1] + for i in range(2, n): + sequence.append(sequence[i - 1] + sequence[i - 2]) + + return sequence diff --git a/scripts/fibonacci_incorrect/main.py b/scripts/fibonacci_incorrect/main.py new file mode 100644 index 00000000..dc1120f1 --- /dev/null +++ b/scripts/fibonacci_incorrect/main.py @@ -0,0 +1,2 @@ +def fibonacci(n): + return [i for i in range(n)] diff --git a/scripts/mockdata.py b/scripts/mockdata.py index 333b40b0..b79d871a 100644 --- a/scripts/mockdata.py +++ b/scripts/mockdata.py @@ -1,11 +1,15 @@ from django.core.files.uploadedfile import SimpleUploadedFile +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 +from django.conf import settings + +base_dir = settings.BASE_DIR + lorem_ipsum = ( "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore " "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " @@ -13,6 +17,12 @@ "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." ) +lorem_ipsum_file = SimpleUploadedFile( + 'lorem_ipsum.txt', + lorem_ipsum.encode('utf-8'), + content_type="text/plain" +) + def run(): # Create courses @@ -20,38 +30,45 @@ def run(): course_1, _ = Course.objects.get_or_create( name='Artificiële intelligentie', description='Kennisgebaseerd redeneren, machinaal leren, heuristische zoekstrategieën, ' - 'neurale netwerken en deep learning, natuurlijke taalverwerking' + 'neurale netwerken en deep learning, natuurlijke taalverwerking', + open_course=True, ) course_2, _ = Course.objects.get_or_create( name='Algoritmen en datastructuren 3', - description='Algoritme, datastructuur, efficiëntie' + description='Algoritme, datastructuur, efficiëntie', + open_course=True ) course_3, _ = Course.objects.get_or_create( name='Besturingssystemen', - description='procesbeheer, geheugenbeheer, systeembeheer, beveiliging' + description='procesbeheer, geheugenbeheer, systeembeheer, beveiliging', + open_course=True ) course_4, _ = Course.objects.get_or_create( name='Logisch programmeren', - description='Programmeertalen, logisch programmeren, unificatie, backtracking, metavertolkers, Prolog' + description='Programmeertalen, logisch programmeren, unificatie, backtracking, metavertolkers, Prolog', + open_course=True ) course_5, _ = Course.objects.get_or_create( name='Software Engineering Lab 2', - description='Projectwerk, vakoverschrijdend, groepswerk, software-ontwikkelingspraktijk' + description='Projectwerk, vakoverschrijdend, groepswerk, software-ontwikkelingspraktijk', + open_course=True ) course_6, _ = Course.objects.get_or_create( name='Computationele biologie', description='Rekenmethoden, moleculaire biologie, genoomstructuur, genpredictie, sequenties aligneren, ' - 'fylogenie, vergelijkend genoomonderzoek, analyse van genexpressie' + 'fylogenie, vergelijkend genoomonderzoek, analyse van genexpressie', + open_course=True ) course_7, _ = Course.objects.get_or_create( name='Automaten, berekenbaarheid en complexiteit', - description='Eindige automaten, formele talen, stapelautomaten, Turingmachines, berekenbaarheid, complexiteit' + description='Eindige automaten, fo.rmele talen, stapelautomaten, Turingmachines, berekenbaarheid, complexiteit', + open_course=False ) course_8, _ = Course.objects.get_or_create( @@ -61,59 +78,69 @@ def run(): 'computersystemen met gedeeld geheugen, cachecoherentie, geheugenconsistentie, ' 'multi-core processors, meerdradige uitvoering, datacenters, supercomputers, ' 'fundamentele concepten betreffende prestatie, impact van technologie op computerarchitectuur, ' - 'vermogen/energie, betrouwbaarheid' + 'vermogen/energie, betrouwbaarheid', + open_course=False ) course_9, _ = Course.objects.get_or_create( name='Informatiebeveiliging', - description='beveiliging, encryptie' + description='beveiliging, encryptie', + open_course=False ) course_10, _ = Course.objects.get_or_create( name='Modelleren en simuleren', description='Gewone en partiële differentiaalvergelijkingen, Fourier-analyse, ' - 'toevalsgetallen, meervoudige integralen' + 'toevalsgetallen, meervoudige integralen', + open_course=True ) course_11, _ = Course.objects.get_or_create( name='Inleiding tot de telecommunicatie', - description='Telecommunicatie, signalen, datacommunicatie, broncodering, kanaalcodering.' + description='Telecommunicatie, signalen, datacommunicatie, broncodering, kanaalcodering.', + open_course=False ) course_12, _ = Course.objects.get_or_create( name='Inleiding tot de elektrotechniek', description='Analoge en digitale elektronica, elektrische netwerken, netwerkanalyse, circuitsynthese, ' 'signaalvoorstelling, stelling van Shannon-Nyquist, elektrische interconnecties, ' - 'computerarchitectuur, klokfrequentie, vermogenverbruik, schaalbaarheid.' + 'computerarchitectuur, klokfrequentie, vermogenverbruik, schaalbaarheid.', + open_course=True ) course_13, _ = Course.objects.get_or_create( name='Wiskundige modellering in de ingenieurswetenschappen', description='Wiskundige basisconcepten, wiskundige modellen voor ingenieurstoepassingen, ' - 'differentiaalvergelijkingen, integraaltransformaties, vectorcalculus' + 'differentiaalvergelijkingen, integraaltransformaties, vectorcalculus', + open_course=False ) course_14, _ = Course.objects.get_or_create( name='Krachtige leeromgevingen', description='Didactiek, visies op leren en onderwijzen, onderwijskundig referentiekader, werkvormen, ' - 'toetsing en evaluatie, individuele verschillen' + 'toetsing en evaluatie, individuele verschillen', + open_course=False ) course_15, _ = Course.objects.get_or_create( name='Vakdidactiek wetenschappen', description='Krachtige leeromgeving, didactiek voor wetenschapsonderwijs, onderzoekend leren, STEM, ' - 'computationeel denken, ethiek, misconcepten' + 'computationeel denken, ethiek, misconcepten', + open_course=False ) course_16, _ = Course.objects.get_or_create( name='Oriëntatiestage wetenschappen', - description='Eindtermen, leerplannen, lesvoorbereiding, microteaching, stage, reflectie.' + description='Eindtermen, leerplannen, lesvoorbereiding, microteaching, stage, reflectie.', + open_course=False ) course_17, _ = Course.objects.get_or_create( name='Vakkennis wiskunde', description='Vlakke Analytische Meetkunde; Ruimtemeetkunde; Driehoeksmeting; Matrices, ' - 'Determinanten en Stelsels; combinatoriek, kansrekening' + 'Determinanten en Stelsels; combinatoriek, kansrekening', + open_course=False ) # Create users @@ -128,6 +155,8 @@ def run(): user_1.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_8, course_9, course_10] ) + user_1.set_password('selab123') + user_1.save() user_2, _ = User.objects.get_or_create( username='axellorreyne', @@ -139,6 +168,8 @@ def run(): user_2.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_8, course_9, course_10] ) + user_2.set_password('selab123') + user_2.save() user_3, _ = User.objects.get_or_create( username='gillesarnout', @@ -150,6 +181,8 @@ def run(): user_3.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_8, course_9, course_10] ) + user_3.set_password('selab123') + user_3.save() user_4, _ = User.objects.get_or_create( username='pieterjandesmijter', @@ -161,6 +194,8 @@ def run(): user_4.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_11, course_12, course_13] ) + user_4.set_password('selab123') + user_4.save() user_5, _ = User.objects.get_or_create( username='reinharddepaepe', @@ -172,6 +207,8 @@ def run(): user_5.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_11, course_12, course_13] ) + user_5.set_password('selab123') + user_5.save() user_6, _ = User.objects.get_or_create( username='robinparet', @@ -183,6 +220,8 @@ def run(): user_6.course.set( [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_11, course_12, course_13] ) + user_6.set_password('selab123') + user_6.save() user_7, _ = User.objects.get_or_create( username='runedyselinck', @@ -195,6 +234,8 @@ def run(): [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_14, course_15, course_16, course_17] ) + user_7.set_password('selab123') + user_7.save() user_8, _ = User.objects.get_or_create( username='thibaudcollyn', @@ -207,6 +248,32 @@ def run(): [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_14, course_15, course_16, course_17] ) + user_8.set_password('selab123') + user_8.save() + + user_9, _ = User.objects.get_or_create( + username='teacher', + email='teacher@sel2-1.ugent.be', + first_name='Teacher', + last_name='1', + role=2 + ) + user_9.course.set( + [course_1, course_2, course_3, course_4, course_5, course_6, course_7, course_8, course_9, course_10, course_11, + course_12, course_13, course_14, course_15, course_16, course_17] + ) + user_9.set_password('selab123') + user_9.save() + + user_10, _ = User.objects.get_or_create( + username='administrator', + email='administrator@sel2-1.ugent.be', + first_name='Administrator', + last_name='1', + role=1 + ) + user_10.set_password('selab123') + user_10.save() # Create projects @@ -214,40 +281,48 @@ def run(): name='SELab 2 project', description=lorem_ipsum, course_id=course_5, - deadline='2021-12-12 12:12:14', + deadline='2030-12-12 12:12:14', visible=True, number_of_groups=1, - group_size=8 + group_size=8, + file_structure='+*.py', + test_docker_image='fibonacci-python' ) project_2, _ = Project.objects.get_or_create( name='AI project', description=lorem_ipsum, course_id=course_1, - deadline='2021-12-12 12:12:14', + deadline='2030-12-12 12:12:14', visible=True, number_of_groups=4, - group_size=2 + group_size=2, + file_structure='+*.py', + test_docker_image='fibonacci-python' ) project_3, _ = Project.objects.get_or_create( - name='Opdracht 1', + name='Opdracht met file structure', description=lorem_ipsum, course_id=course_6, - deadline='2021-12-12 12:12:14', + deadline='2030-12-12 12:12:14', visible=True, number_of_groups=10, - group_size=2 + group_size=2, + file_structure='+*.py', + test_docker_image='fibonacci-python' ) project_4, _ = Project.objects.get_or_create( name='Opdracht 2', description=lorem_ipsum, course_id=course_6, - deadline='2021-12-12 12:12:14', + deadline='2030-12-12 12:12:14', visible=True, number_of_groups=10, - group_size=2 + group_size=2, + file_structure='+*.py', + test_docker_image='fibonacci-python' ) # Create groups @@ -316,7 +391,7 @@ def run(): ) group_6.user.set( - [user_7, user_8] + [user_7] ) group_7, _ = Group.objects.get_or_create( @@ -333,64 +408,213 @@ def run(): # Create submissions - lorem_ipsum_file = SimpleUploadedFile( - 'lorem_ipsum.txt', - lorem_ipsum.encode('utf-8'), - content_type="text/plain" - ) - - Submissions.objects.get_or_create( - group_id=group_1, - submission_nr=1, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:12' - ) - - Submissions.objects.get_or_create( - group_id=group_1, - submission_nr=2, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:13' - ) - - Submissions.objects.get_or_create( - group_id=group_1, - submission_nr=3, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:15' - ) - - Submissions.objects.get_or_create( - group_id=group_1, - submission_nr=4, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:16' - ) - - Submissions.objects.get_or_create( - group_id=group_2, - submission_nr=1, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:12' - ) - - Submissions.objects.get_or_create( - group_id=group_3, - submission_nr=1, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:13' - ) - - Submissions.objects.get_or_create( - group_id=group_4, - submission_nr=1, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:15' - ) - - Submissions.objects.get_or_create( - group_id=group_5, - submission_nr=1, - file=lorem_ipsum_file, - timestamp='2021-12-12 12:12:16' - ) + client = APIClient() + client.force_authenticate(user_1) + + lorem_ipsum_file.seek(0) + response = client.post( + "/submissions/", + { + "group_id": group_1.group_id, + "file_urls": "main.py, lorem_ipsum.txt", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb'), + "lorem_ipsum.txt": lorem_ipsum_file, + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_1.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_1.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_1.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + client.logout() + + client = APIClient() + client.force_authenticate(user_1) + + lorem_ipsum_file.seek(0) + response = client.post( + "/submissions/", + { + "group_id": group_2.group_id, + "file_urls": "main.py, lorem_ipsum.txt", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb'), + "lorem_ipsum.txt": lorem_ipsum_file, + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_2.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_2.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + client.logout() + + client = APIClient() + client.force_authenticate(user_3) + + lorem_ipsum_file.seek(0) + response = client.post( + "/submissions/", + { + "group_id": group_3.group_id, + "file_urls": "main.py, lorem_ipsum.txt", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb'), + "lorem_ipsum.txt": lorem_ipsum_file, + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_3.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_3.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + client.logout() + + client = APIClient() + client.force_authenticate(user_5) + + lorem_ipsum_file.seek(0) + response = client.post( + "/submissions/", + { + "group_id": group_4.group_id, + "file_urls": "main.py, lorem_ipsum.txt", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb'), + "lorem_ipsum.txt": lorem_ipsum_file, + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_4.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_4.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + client.logout() + + client = APIClient() + client.force_authenticate(user_7) + + lorem_ipsum_file.seek(0) + response = client.post( + "/submissions/", + { + "group_id": group_5.group_id, + "file_urls": "main.py, lorem_ipsum.txt", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb'), + "lorem_ipsum.txt": lorem_ipsum_file, + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_5.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + response = client.post( + "/submissions/", + { + "group_id": group_5.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + print(f'{response.status_code} {response.data}') + + client.logout() diff --git a/scripts/opdracht_bestand_leeg/leeg b/scripts/opdracht_bestand_leeg/leeg new file mode 100644 index 00000000..e69de29b diff --git a/scripts/opdracht_correct/extra/verslag.pdf b/scripts/opdracht_correct/extra/verslag.pdf new file mode 100644 index 00000000..2a206e64 Binary files /dev/null and b/scripts/opdracht_correct/extra/verslag.pdf differ diff --git a/scripts/opdracht_correct/src/main.py b/scripts/opdracht_correct/src/main.py new file mode 100644 index 00000000..2cb6db2c --- /dev/null +++ b/scripts/opdracht_correct/src/main.py @@ -0,0 +1,6 @@ +def main(): + print("Secrete Data") + + +if 'name' == __main__: + main() diff --git a/scripts/opdracht_enkel_verboden/extra/verslag.pdf b/scripts/opdracht_enkel_verboden/extra/verslag.pdf new file mode 100644 index 00000000..e69de29b diff --git a/scripts/opdracht_enkel_verboden/src/main.py b/scripts/opdracht_enkel_verboden/src/main.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/opdracht_enkel_verboden/tests/test.test b/scripts/opdracht_enkel_verboden/tests/test.test new file mode 100644 index 00000000..e69de29b diff --git a/scripts/opdracht_incorrect/secret.secret b/scripts/opdracht_incorrect/secret.secret new file mode 100644 index 00000000..0981a4d2 --- /dev/null +++ b/scripts/opdracht_incorrect/secret.secret @@ -0,0 +1 @@ +dataaaaaaaaaaaaaaaaaaaaaa diff --git a/scripts/opdracht_incorrect/tests/test.test b/scripts/opdracht_incorrect/tests/test.test new file mode 100644 index 00000000..73d1dd37 --- /dev/null +++ b/scripts/opdracht_incorrect/tests/test.test @@ -0,0 +1 @@ +dit bestand is verboden diff --git a/scripts/submit.py b/scripts/submit.py new file mode 100644 index 00000000..78b36060 --- /dev/null +++ b/scripts/submit.py @@ -0,0 +1,89 @@ +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 + +from django.conf import settings + +base_dir = settings.BASE_DIR + +lorem_ipsum = ( + "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore " + "magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo " + "consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. " + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." +) + + +def run(): + course, _ = Course.objects.get_or_create( + name='Python Course', + description='We learn python today !', + open_course=True + ) + + user, _ = User.objects.get_or_create( + username='mrpython', + email='python@python.org', + first_name='Python', + last_name='McPython', + role=3 + ) + user.course.set( + [course] + ) + user.save() + + project, _ = Project.objects.get_or_create( + name='Fibonacci', + description='Generate the first n Fibonacci numbers using Python.', + course_id=course, + deadline='2030-12-12 12:12:14', + file_structure='*.py', + test_docker_image='fibonacci-python', + visible=True, + number_of_groups=1, + group_size=2 + ) + + group, _ = Group.objects.get_or_create( + group_nr=1, + final_score=20, + project_id=project, + feedback=lorem_ipsum, + visible=True + ) + group.user.set( + [user] + ) + + client = APIClient() + client.force_authenticate(user) + + response = client.post( + "/submissions/", + { + "group_id": group.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_correct/main.py', 'rb') + }, + format='multipart', + ) + + print(response.status_code) + print(response.data) + + response = client.post( + "/submissions/", + { + "group_id": group.group_id, + "file_urls": "main.py", + "main.py": open(f'{base_dir}/../scripts/fibonacci_incorrect/main.py', 'rb') + }, + format='multipart', + ) + + print(response.status_code) + print(response.data) diff --git a/user_manual/images/Access_private.png b/user_manual/images/Access_private.png new file mode 100644 index 00000000..6b6685c0 Binary files /dev/null and b/user_manual/images/Access_private.png differ diff --git a/user_manual/images/all_submissions.png b/user_manual/images/all_submissions.png new file mode 100644 index 00000000..5b64a5de Binary files /dev/null and b/user_manual/images/all_submissions.png differ diff --git a/user_manual/images/allcourses.png b/user_manual/images/allcourses.png new file mode 100644 index 00000000..3b86b993 Binary files /dev/null and b/user_manual/images/allcourses.png differ diff --git a/user_manual/images/calendar.png b/user_manual/images/calendar.png new file mode 100644 index 00000000..c5068fdb Binary files /dev/null and b/user_manual/images/calendar.png differ diff --git a/user_manual/images/course_page.png b/user_manual/images/course_page.png new file mode 100644 index 00000000..4892e44b Binary files /dev/null and b/user_manual/images/course_page.png differ diff --git a/user_manual/images/coursepage.png b/user_manual/images/coursepage.png new file mode 100644 index 00000000..b5b122d1 Binary files /dev/null and b/user_manual/images/coursepage.png differ diff --git a/user_manual/images/createcourse.png b/user_manual/images/createcourse.png new file mode 100644 index 00000000..a243dc7f Binary files /dev/null and b/user_manual/images/createcourse.png differ diff --git a/user_manual/images/createproject.png b/user_manual/images/createproject.png new file mode 100644 index 00000000..d0e4d4e8 Binary files /dev/null and b/user_manual/images/createproject.png differ diff --git a/user_manual/images/home_page.png b/user_manual/images/home_page.png new file mode 100644 index 00000000..a189c03d Binary files /dev/null and b/user_manual/images/home_page.png differ diff --git a/user_manual/images/image(1).png b/user_manual/images/image(1).png new file mode 100644 index 00000000..a1622283 Binary files /dev/null and b/user_manual/images/image(1).png differ diff --git a/user_manual/images/join_public.png b/user_manual/images/join_public.png new file mode 100644 index 00000000..540e0882 Binary files /dev/null and b/user_manual/images/join_public.png differ diff --git a/user_manual/images/login.png b/user_manual/images/login.png new file mode 100644 index 00000000..ff419f3f Binary files /dev/null and b/user_manual/images/login.png differ diff --git a/user_manual/images/projectpage.png b/user_manual/images/projectpage.png new file mode 100644 index 00000000..0c174e13 Binary files /dev/null and b/user_manual/images/projectpage.png differ diff --git a/user_manual/images/select_language.png b/user_manual/images/select_language.png new file mode 100644 index 00000000..2221fd19 Binary files /dev/null and b/user_manual/images/select_language.png differ diff --git a/user_manual/images/studentpage.png b/user_manual/images/studentpage.png new file mode 100644 index 00000000..17bb52f7 Binary files /dev/null and b/user_manual/images/studentpage.png differ diff --git a/user_manual/images/submissionfeedback.png b/user_manual/images/submissionfeedback.png new file mode 100644 index 00000000..f24c0e2a Binary files /dev/null and b/user_manual/images/submissionfeedback.png differ diff --git a/user_manual/images/submit.png b/user_manual/images/submit.png new file mode 100644 index 00000000..9a62b9dc Binary files /dev/null and b/user_manual/images/submit.png differ diff --git a/user_manual/manual.pdf b/user_manual/manual.pdf new file mode 100644 index 00000000..fc971e7f Binary files /dev/null and b/user_manual/manual.pdf differ diff --git a/user_manual/manual.tex b/user_manual/manual.tex new file mode 100644 index 00000000..c45be731 --- /dev/null +++ b/user_manual/manual.tex @@ -0,0 +1,245 @@ +\documentclass{article} +\usepackage[utf8]{inputenc} +\usepackage{hyperref} +\usepackage{graphicx} +\usepackage{float} +\usepackage{enumitem} + +\title{PigeonHole UGent Submission Platform User Manual} +\author{SEL group 1 2024} +\date{\today} + +\begin{document} + +\maketitle + +\tableofcontents + +\section{Introduction} +Welcome to the PigeonHole UGent Submission Platform User Manual. This manual provides instructions for users, teachers, and administrators on how to effectively use the platform, step by step. The platform is designed to streamline the process of submitting, reviewing, and providing feedback on projects. It seeks to create a middle ground between being easy to use but also providing the necessary features for automatic feedback on big projects. As for now, we only support UGent accounts to use the platform. + +\section{Getting Started} +\subsection{Creating an Account} +Users can only log in with their UGent account. If you do not have an account, please contact the administrator to create one for you. + +\subsection{Logging In} +When accessing the platform, students will be prompted to log in with their UGent account. UGent uses OAuth2 for authentication, so you will be redirected to the UGent login page. After logging in, you will be redirected back to the platform. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/login.png} + \caption{Login Screen} +\end{figure} + +\subsection{Changing language} +We support English and Dutch. You can change in the small dropdown menu in the top right corner of every page's topbar. + +\subsection{Changing your profile picture} +By clicking on your name in the top right corner of the page, you can navigate to your profile page. Here you can change your profile picture by clicking on the image, selecting a new image, and clicking save. + +\section{Student Section} + +\subsection{View your courses} + +You can view all courses you are enrolled in and the projects that are available for submission central on the homepage. By clicking on a course title you can navigate to the course page, by clicking view project you can navigate to the project page. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/studentpage.png} + \caption{Home page} +\end{figure} + +\subsubsection{Archived courses} +Courses of previous years are archived and can be viewed by clicking on the "Archived courses" button. + +\subsubsection{Filter courses} +You can filter the courses by school year by clicking on the dropdown menu on the homepage, this will show a list of all archived courses. + +\subsubsection{View deadlines} +When navigating to the calendar page from the home screen, you can see all deadlines of the projects you are enrolled in. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/calendar.png} + \caption{Calendar page} +\end{figure} + +\subsection{Join a new course} +There are two types of courses: public and private. +In case the course is private, you need to be invited by the teacher to join the course. + + +In case the course is public, you can either also be invited by the teacher or join the course yourself. +To get a list of the public courses, click the "all open courses" button on the homepage. +On the "all open courses" page, you can see all public courses. By clicking on the "Join" button, you can join the course. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/allcourses.png} + \caption{All open courses page} +\end{figure} + +\subsection{Get an overview of the projects of a course} +On the home page, you can click on the course title to navigate to the course page. Here you can see all the projects of the course. You can see the status of the project and the deadline. +On the home page, you can also click on the "View project" button to navigate to a specific project page. +From the course page, you can also navigate to the project page by clicking on the "View project" button. + + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/course_page.png} + \caption{Course page} +\end{figure} + + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/projectpage.png} + \caption{Project page} +\end{figure} + +\subsection{Joining a group for a project} +If a project requires you to work in a group, you can view all groups by clicking on the "View groups" button on the project page. Here you can see all groups and their members. You can join a group by clicking on the "Join group" button. + +\subsection{Handing in a submission} +If you want to hand in a submission, you can do this by clicking on the "Make a submission" button on the project page. On the submit page, you can upload your files and submit them by dragging them into the dropzone or by clicking on the dropzone and selecting the files. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/submit.png} + \caption{Project page} +\end{figure} + +\subsection{Viewing feedback} +The automatic feedback will be immediately available after submitting and can be viewed on the submit feedback page. Here you will be able to acces the feedback of the file structure tests and advanced tests provided by the teacher. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/submissionfeedback.png} + \caption{Project page} +\end{figure} + +\subsection{View your past submissions} +All your past submissions can be viewed on the project page. + +\section{Teacher Section} + +\subsection{Creating a Course} +Teachers can create a new course by clicking the "Create a course" button on the homepage. They can set the course name, description, and privacy settings. After creating the course, they can invite students to join with the link provided. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/createcourse.png} + \caption{Create course page} +\end{figure} + +\subsection{Creating a Project} +Teachers can create a new project by clicking the "Create a project" button on the course page. They can set the project name, description, and deadline. After creating the project, students can submit their work. +Teachers can also set the automatic feedback settings for the project. This ranges from directory structure to advanced test on students code. +Here, the teacher can also set the necessary group size and amount of groups for the project. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/createproject.png} + \caption{Create project page} +\end{figure} + +The teacher can also set simple and advanced tests for the project. The simple tests are run on the file structure of the submission, while the advanced tests are run on the code itself. Even when tests fail, the submission will be saved and the teacher can still view the submission. + +\subsubsection{A quick quide to the simple tests} + +You can add required files in the \textit{required files} field on the add/edit project page. +There are two options for adding files: + +\begin{enumerate}[label=\arabic*.] + \item \textbf{Add a specific file:} \texttt{/extra/report.pdf}\\ + In this case, the file \texttt{report.pdf} is required in the directory \texttt{extra}. + + \item \textbf{Add a file type:} \texttt{src/*.py}\\ + In this case, the only file type al=<lowed in the \texttt{src} directory will be Python files. +\end{enumerate} + +There are also several options on how to use these files: + +\begin{enumerate}[label=\arabic*.] + \item \textbf{Required files}\\ + Using a checkmark, the files will be required and submissions without these files will be considered invalid. + + \item \textbf{Forbidden files}\\ + Using a crossmark, the files will be forbidden and submissions with these files will be considered invalid. + + \item \textbf{Maybe files}\\ + Using a question mark (?), the files will not be checked, and their presence will not affect the validity of the submission. +\end{enumerate} + +\subsubsection{A quick quide to the advanced tests} + +For programming projects hosted on our platform that need to verify that the code compiles/runs/gives the correct output, we offer an advanced interface to run code on each submission the moment they're submitted using docker images. + +The process of setting up the advanced tests is as follows: +\begin{enumerate}[label=\textbf{\arabic*.}] + \item \textbf{Write your tests and package them in a Docker image} + \begin{itemize} + \item We offer an example Docker image in \texttt{examples/advanced-evaluation}. + \item Place the submission files in a volume under \texttt{/usr/src/submission}, output artifacts under \texttt{/usr/out/artifacts}, and determine correctness with the exit code (0 for success, other values for failure). + \end{itemize} + + \item \textbf{Push your image to our private Docker registry} + \begin{itemize} + \item Use the command: \texttt{docker push sel2-1.ugent.be:2002/[YOUR\_IMAGE\_NAME]} + \end{itemize} + + \item \textbf{Configure your project on the site} + \begin{itemize} + \item When creating or editing your project, fill in the Evaluation Docker image name in the text field and save. + \end{itemize} +\end{enumerate} + +You will be able to see the results and artifacts in the submission's page. + +\subsection{Viewing Submissions} +Teachers can view all submissions for a project by clicking the "View submissions" button on the project page. They can see the submission status and download the files and automatic feedback. + +\begin{figure}[H] + \centering + \includegraphics[width=0.75\textwidth]{images/all_submissions.png} + \caption{All submissions page} +\end{figure} + + +\subsection{Viewing Courses and Projects} +Teachers can view all courses they are teaching on the homepage. By clicking on the course title, they can navigate to the course page. Here they can see all the projects of the course and the students enrolled in the course. + +\subsection{Viewing students} +On the course page, teachers can see all students enrolled in the course. By clicking on the 'View students' button, they can navigate to the students' page. Here they can see all students. + +\subsubsection{Removing a student from a course} +On the view student page, teachers can select and remove students from the course. + +\subsection{Adding a co-teacher} +You can use the course link to invite a co-teacher to the course. The co-teacher will have the same rights as the teacher. + +\subsection{Viewing co-teachers} +Similarly to students, teachers can see all co-teachers on the course page. By clicking on the 'View co-teachers' button, they can navigate to the co-teachers' page. + +\subsection{Editing a course or project} +Teachers can edit a course or project by clicking on the 'Edit' button on the course or project page. Here they can change the course or project name, description, or deadline. The edit panel looks the same as the create panel, but with the fields filled in. + +\section{Admin Section} + +Admins have the same functionality as teachers, but can also edit users. Admins have in the sidebar a button to navigate to the user page. Here they can see all users and edit them. + +\subsection{Editing or Deleting a user} +Admins can edit a user by clicking on the 'Edit' button on the user page. Here they can change the user's name, email, or role. They might also opt to delete the user by clicking the 'Delete' button on the user page. + +\section{A bit more information on the lists} +The lists of courses, projects, and submissions are paginated. You can navigate through the pages by clicking on the page number or the next/previous buttons. +Most lists are also filterable by typing in the search bar, and you can sort the list by clicking on the column headers. + +\section{Troubleshooting} +If you encounter any issues while using the platform, please contact our support team for assistance. You can reach us via email at 'axel.lorreyne@ugent.be'. + +\section{Conclusion} +This user manual covers the basic functionality of the PigeonHole Project Submission Platform for students, teachers, and administrators. For technical information, you can view our GitHub repository at \url{https://github.com/SELab-2/UGent-1/}. + +\end{document}