From 694a932d423d1aacb73098726e4150f5f65a6486 Mon Sep 17 00:00:00 2001 From: Ewout Verlinde Date: Thu, 23 May 2024 11:21:25 +0200 Subject: [PATCH] feat: project views, footer (#452) * feat: project views * fix: project permissions * fix: backend tests * chore: project views * fix: permissions * fix: linting * chore: fixes * chore: added socials --- backend/api/permissions/group_permissions.py | 45 ++-- .../api/permissions/project_permissions.py | 53 +++-- backend/api/permissions/role_permissions.py | 7 +- backend/api/serializers/group_serializer.py | 30 ++- backend/api/tests/test_group.py | 12 +- backend/api/views/project_view.py | 22 ++ frontend/src/assets/lang/app/en.json | 14 ++ frontend/src/assets/lang/app/nl.json | 14 ++ frontend/src/components/Loading.vue | 2 +- .../src/components/help/HelpBaseLayout.vue | 28 --- frontend/src/components/help/HelpHeader.vue | 208 ------------------ .../TeacherAssistantCard.vue | 4 +- .../TeacherAssistantList.vue | 0 .../TeacherAssistantSearch.vue | 0 .../TeacherAssistantUpdateButton.vue | 0 .../buttons/CourseRoleAddButton.vue | 0 .../buttons/LeaveCourseButton.vue | 0 frontend/src/components/layout/Footer.vue | 25 --- ...on.vue => ProjectDownloadGradesButton.vue} | 39 ++-- ...pload.vue => ProjectExtraChecksEditor.vue} | 0 .../src/components/projects/ProjectForm.vue | 4 +- ...ureTree.vue => ProjectStructureEditor.vue} | 0 ...hooseGroupCard.vue => GroupChooseCard.vue} | 40 ++-- ...oinedGroupCard.vue => GroupJoinedCard.vue} | 13 +- .../projects/groups/GroupsManageTable.vue | 130 +++++++++++ .../components/submissions/ProjectMeter.vue | 16 +- .../components/submissions/SubmissionCard.vue | 64 +++--- .../src/composables/services/group.service.ts | 26 ++- frontend/src/composables/services/helpers.ts | 5 +- .../composables/services/project.service.ts | 4 +- frontend/src/config/endpoints.ts | 1 + frontend/src/router/router.ts | 45 ---- frontend/src/types/Group.ts | 18 +- frontend/src/types/Project.ts | 10 +- frontend/src/views/admin/DockerImagesView.vue | 6 +- frontend/src/views/admin/UsersView.vue | 6 +- .../src/views/authentication/LoginView.vue | 4 +- .../src/views/authentication/VerifyView.vue | 4 +- frontend/src/views/calendar/CalendarView.vue | 4 +- frontend/src/views/courses/CourseView.vue | 2 +- .../src/views/courses/CreateCourseView.vue | 4 +- frontend/src/views/courses/JoinCourseView.vue | 6 +- .../src/views/courses/SearchCourseView.vue | 4 +- .../src/views/courses/UpdateCourseView.vue | 4 +- .../courses/roles/AssistantCourseView.vue | 4 +- .../views/courses/roles/StudentCourseView.vue | 4 +- .../views/courses/roles/TeacherCourseView.vue | 6 +- .../src/views/dashboard/DashboardView.vue | 2 +- .../roles/AssistantDashboardView.vue | 2 +- .../dashboard/roles/StudentDashboardView.vue | 2 +- .../dashboard/roles/TeacherDashboardView.vue | 2 +- frontend/src/views/help/HelpDashboard.vue | 58 ----- .../src/{components => views}/layout/Body.vue | 0 frontend/src/views/layout/Footer.vue | 64 ++++++ .../{components => views}/layout/Title.vue | 0 .../layout/admin/AdminHeader.vue | 0 .../layout/admin/AdminLayout.vue | 6 +- .../layout/admin/AdminSidebar.vue | 0 .../layout/base/BaseHeader.vue | 0 .../layout/base/BaseLayout.vue | 6 +- .../src/views/projects/CreateProjectView.vue | 8 +- frontend/src/views/projects/ProjectView.vue | 68 ++---- frontend/src/views/projects/ProjectsView.vue | 4 +- .../src/views/projects/UpdateProjectView.vue | 4 +- .../projects/roles/StudentProjectView.vue | 203 +++++++++++------ .../projects/roles/TeacherProjectView.vue | 155 ++++++++++--- .../src/views/submissions/SubmissionView.vue | 4 +- 67 files changed, 790 insertions(+), 735 deletions(-) delete mode 100644 frontend/src/components/help/HelpBaseLayout.vue delete mode 100644 frontend/src/components/help/HelpHeader.vue rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantCard.vue (92%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantList.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantSearch.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/TeacherAssistantUpdateButton.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/buttons/CourseRoleAddButton.vue (100%) rename frontend/src/components/{teachers_assistants => instructors}/buttons/LeaveCourseButton.vue (100%) delete mode 100644 frontend/src/components/layout/Footer.vue rename frontend/src/components/projects/{DownloadCSVButton.vue => ProjectDownloadGradesButton.vue} (63%) rename frontend/src/components/projects/{ExtraChecksUpload.vue => ProjectExtraChecksEditor.vue} (100%) rename frontend/src/components/projects/{ProjectStructureTree.vue => ProjectStructureEditor.vue} (100%) rename frontend/src/components/projects/groups/{ChooseGroupCard.vue => GroupChooseCard.vue} (72%) rename frontend/src/components/projects/groups/{JoinedGroupCard.vue => GroupJoinedCard.vue} (75%) create mode 100644 frontend/src/components/projects/groups/GroupsManageTable.vue delete mode 100644 frontend/src/views/help/HelpDashboard.vue rename frontend/src/{components => views}/layout/Body.vue (100%) create mode 100644 frontend/src/views/layout/Footer.vue rename frontend/src/{components => views}/layout/Title.vue (100%) rename frontend/src/{components => views}/layout/admin/AdminHeader.vue (100%) rename frontend/src/{components => views}/layout/admin/AdminLayout.vue (70%) rename frontend/src/{components => views}/layout/admin/AdminSidebar.vue (100%) rename frontend/src/{components => views}/layout/base/BaseHeader.vue (100%) rename frontend/src/{components => views}/layout/base/BaseLayout.vue (69%) diff --git a/backend/api/permissions/group_permissions.py b/backend/api/permissions/group_permissions.py index 00f15f6a..29280cdf 100644 --- a/backend/api/permissions/group_permissions.py +++ b/backend/api/permissions/group_permissions.py @@ -1,4 +1,8 @@ +from api.models.assistant import Assistant from api.models.group import Group +from api.models.project import Project +from api.models.student import Student +from api.models.teacher import Teacher from api.permissions.role_permissions import (is_assistant, is_student, is_teacher) from authentication.models import User @@ -62,34 +66,25 @@ class GroupSubmissionPermission(BasePermission): """Permission class for submission related group endpoints""" def has_permission(self, request: Request, view: APIView) -> bool: - user: User = request.user - group_id = view.kwargs.get('pk') - group: Group | None = Group.objects.get(id=group_id) if group_id else None - - if group is None: - return True + """Check if user has permission to view a general group submission endpoint.""" + user = request.user - # Teachers and assistants of that course can view all submissions - if is_teacher(user): - return group.project.course.teachers.filter(id=user.teacher.id).exists() + # Get the individual permission clauses. + return request.method in SAFE_METHODS or is_teacher(user) or is_assistant(user) - if is_assistant(user): - return group.project.course.assistants.filter(id=user.assistant.id).exists() - - return is_student(user) and group.students.filter(id=user.student.id).exists() - - def had_object_permission(self, request: Request, view: ViewSet, group) -> bool: - user: User = request.user + def had_object_permission(self, request: Request, view: ViewSet, group: Group) -> bool: + """Check if user has permission to view a detailed group submission endpoint""" + user = request.user course = group.project.course - teacher_or_assitant = is_teacher(user) and user.teacher.courses.filter( - id=course.id).exists() or is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() - if request.method in SAFE_METHODS: - # Users related to the group can view the submissions of the group - return teacher_or_assitant or (is_student(user) and user.student.groups.filter(id=group.id).exists()) + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() - # Student can only add submissions to their own group - if is_student(user) and request.data.get("student") == user.id and view.action == "create": # type: ignore - return user.student.courses.filter(id=course.id).exists() + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=course.id).exists() + student_permission = student is not None and student.groups.filter(id=group.id).exists() - return teacher_or_assitant + return teacher_permission or assistant_permission or student_permission diff --git a/backend/api/permissions/project_permissions.py b/backend/api/permissions/project_permissions.py index a13b4946..70510469 100644 --- a/backend/api/permissions/project_permissions.py +++ b/backend/api/permissions/project_permissions.py @@ -1,3 +1,6 @@ +from api.models.assistant import Assistant +from api.models.student import Student +from api.models.teacher import Teacher from api.permissions.role_permissions import (is_assistant, is_student, is_teacher) from authentication.models import User @@ -11,38 +14,46 @@ class ProjectPermission(BasePermission): def has_permission(self, request: Request, view: ViewSet) -> bool: """Check if user has permission to view a general project endpoint.""" - user: User = request.user - - # We only allow teachers and assistants to create new projects. - return is_teacher(user) or is_assistant(user) + return is_teacher(request.user) or is_assistant(request.user) or request.method in SAFE_METHODS def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: """Check if user has permission to view a detailed project endpoint""" - user: User = request.user - course = project.course - teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + user = request.user + + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() + + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=project.course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=project.course.id).exists() + student_permission = student is not None and student.courses.filter(id=project.course.id).exists() if request.method in SAFE_METHODS: - # Users that are linked to the course can view the project. - return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + return teacher_permission or assistant_permission or student_permission - # We only allow teachers and assistants to modify specified projects. - return teacher_or_assistant + return teacher_permission or assistant_permission class ProjectGroupPermission(BasePermission): """Permission class for project related group endpoints""" + def has_permission(self, request: Request, view: ViewSet) -> bool: + """Check if user has permission to view a general project group endpoint.""" + return is_teacher(request.user) or is_assistant(request.user) or request.method in SAFE_METHODS def has_object_permission(self, request: Request, view: ViewSet, project) -> bool: - user: User = request.user - course = project.course - teacher_or_assistant = is_teacher(user) and user.teacher.courses.filter(id=course.id).exists() or \ - is_assistant(user) and user.assistant.courses.filter(id=course.id).exists() + """Check if user has permission to view a detailed project group endpoint""" + user = request.user - if request.method in SAFE_METHODS: - # Users that are linked to the course can view the group. - return teacher_or_assistant or (is_student(user) and user.student.courses.filter(id=course.id).exists()) + # Check if the user is a teacher that has the course linked to the project. + teacher = Teacher.objects.filter(id=user.id).first() + assistant = Assistant.objects.filter(id=user.id).first() + student = Student.objects.filter(id=user.id).first() + + # Get the individual permission clauses. + teacher_permission = teacher is not None and teacher.courses.filter(id=project.course.id).exists() + assistant_permission = assistant is not None and assistant.courses.filter(id=project.course.id).exists() + student_permission = student is not None and student.courses.filter(id=project.course.id).exists() - # We only allow teachers and assistants to create new groups. - return teacher_or_assistant + return teacher_permission or assistant_permission or student_permission diff --git a/backend/api/permissions/role_permissions.py b/backend/api/permissions/role_permissions.py index 68cc428e..b0846746 100644 --- a/backend/api/permissions/role_permissions.py +++ b/backend/api/permissions/role_permissions.py @@ -1,3 +1,4 @@ +from django.contrib.auth.base_user import AbstractBaseUser from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.viewsets import ViewSet @@ -7,17 +8,17 @@ from api.models.teacher import Teacher -def is_student(user: User) -> bool: +def is_student(user: AbstractBaseUser) -> bool: """Check whether the user is a student""" return Student.objects.filter(id=user.id, is_active=True).exists() -def is_assistant(user: User) -> bool: +def is_assistant(user: AbstractBaseUser) -> bool: """Check whether the user is an assistant""" return Assistant.objects.filter(id=user.id, is_active=True).exists() -def is_teacher(user: User) -> bool: +def is_teacher(user: AbstractBaseUser) -> bool: """Check whether the user is a teacher""" return Teacher.objects.filter(id=user.id, is_active=True).exists() diff --git a/backend/api/serializers/group_serializer.py b/backend/api/serializers/group_serializer.py index ae2e474f..b62102c7 100644 --- a/backend/api/serializers/group_serializer.py +++ b/backend/api/serializers/group_serializer.py @@ -13,8 +13,8 @@ class GroupSerializer(serializers.ModelSerializer): - project = ProjectSerializer( - read_only=True, + project = serializers.HyperlinkedIdentityField( + view_name="project-detail" ) students = serializers.HyperlinkedIdentityField( @@ -22,28 +22,32 @@ class GroupSerializer(serializers.ModelSerializer): read_only=True, ) + occupation = serializers.SerializerMethodField() + submissions = serializers.HyperlinkedIdentityField( view_name="group-submissions", read_only=True, ) - class Meta: - model = Group - fields = "__all__" + def get_occupation(self, instance: Group): + """Get the number of students in the group""" + return instance.students.count() - def to_representation(self, instance): + def to_representation(self, instance: Group): + """Convert the group to a JSON representation""" data = super().to_representation(instance) user = self.context["request"].user course_id = instance.project.course.id # If you are not a student, you can always see the score - if is_student(user): + if is_student(user) and not is_teacher(user) and not is_assistant(user) and not user.is_staff: student_in_course = user.student.courses.filter(id=course_id).exists() + # Student can not see the score if they are not part of the course associated with group and # neither an assistant or a teacher, # or it is not visible yet when they are part of the course associated with the group - if not student_in_course and not is_assistant(user) and not is_teacher(user) or \ + if not student_in_course or \ not instance.project.score_visible and student_in_course: data.pop("score") @@ -51,14 +55,20 @@ def to_representation(self, instance): def validate(self, attrs): # Make sure the score of the group is lower or equal to the maximum score - self.instance: Group - group = self.instance + group: Group = self.instance or self.context.get("group") + + if group is None: + raise ValueError("Group is not in context") if "score" in attrs and attrs["score"] > group.project.max_score: raise ValidationError(gettext("group.errors.score_exceeds_max")) return attrs + class Meta: + model = Group + fields = "__all__" + class StudentJoinGroupSerializer(StudentIDSerializer): diff --git a/backend/api/tests/test_group.py b/backend/api/tests/test_group.py index a3fb21bd..fb2f45ad 100644 --- a/backend/api/tests/test_group.py +++ b/backend/api/tests/test_group.py @@ -8,6 +8,8 @@ from django.urls import reverse from rest_framework.test import APITestCase +from ypovoli import settings + class GroupModelTests(APITestCase): def setUp(self) -> None: @@ -39,7 +41,9 @@ def test_group_detail_view(self): content_json = json.loads(response.content.decode("utf-8")) self.assertEqual(int(content_json["id"]), group.id) - self.assertEqual(content_json["project"]["id"], group.project.id) + self.assertEqual(content_json["project"], + settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)]) + ) self.assertEqual(content_json["score"], group.score) def test_group_project(self): @@ -70,11 +74,7 @@ def test_group_project(self): # Parse the JSON content from the response content_json = content_json["project"] - self.assertEqual(content_json["name"], project.name) - self.assertEqual(content_json["description"], project.description) - self.assertEqual(content_json["visible"], project.visible) - self.assertEqual(content_json["archived"], project.archived) - self.assertEqual(content_json["course"]["id"], course.id) + self.assertEqual(content_json, settings.TESTING_BASE_LINK + reverse("project-detail", args=[str(project.id)])) def test_group_students(self): """Able to retrieve students details of a group.""" diff --git a/backend/api/views/project_view.py b/backend/api/views/project_view.py index 180dd5d1..d13660e5 100644 --- a/backend/api/views/project_view.py +++ b/backend/api/views/project_view.py @@ -1,8 +1,10 @@ from api.models.group import Group from api.models.project import Project +from api.models.student import Student from api.models.submission import Submission from api.permissions.project_permissions import (ProjectGroupPermission, ProjectPermission) +from api.permissions.role_permissions import is_student, IsStudent from api.serializers.checks_serializer import (ExtraCheckSerializer, StructureCheckSerializer) from api.serializers.group_serializer import GroupSerializer @@ -31,6 +33,26 @@ class ProjectViewSet(RetrieveModelMixin, serializer_class = ProjectSerializer permission_classes = [IsAdminUser | ProjectPermission] # GroupPermission has exact the same logic as for a project + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission], url_path='student-group') + def student_group(self, request: Request, **_) -> Response: + """Returns the group of the student for the given project""" + + # Get the student object from the user + student = Student.objects.get(id=request.user.id) + + # Get the group of the student for the project + group = student.groups.filter(project=self.get_object()).first() + + if group is None: + return Response(None) + + # Serialize the group object + serializer = GroupSerializer( + group, context={"request": request} + ) + + return Response(serializer.data) + @action(detail=True, permission_classes=[IsAdminUser | ProjectGroupPermission]) def groups(self, request, **_): """Returns a list of groups for the given project""" diff --git a/frontend/src/assets/lang/app/en.json b/frontend/src/assets/lang/app/en.json index 5188ad3b..e15bc785 100644 --- a/frontend/src/assets/lang/app/en.json +++ b/frontend/src/assets/lang/app/en.json @@ -15,6 +15,13 @@ "nl": "Nederlands", "en": "English" } + }, + "footer": { + "home": "Dashboard", + "about": "Help", + "privacy": "Cookies", + "contact": "Contact", + "rights": "All rights reserved" } }, "views": { @@ -41,11 +48,18 @@ }, "projects": { "all": "All projects", + "backToCourse": "Back to course", "coming": "Near deadlines", "deadline": "Deadline", "days": "Today at {hour} | Tomorrow at {hour} | In {count} days", "ago": "1 day ago | {count} days ago", + "chooseGroupMessage": "Choose a group before {0}", + "groupScore": "Group score", + "noGroupScore": "No group score", + "noGroupMembers": "No group members", + "publishScores": "Publish scores", "groupName": "Group name", + "groups": "Groups", "groupPopulation": "Size", "groupStatus": "Status", "start": "Start date", diff --git a/frontend/src/assets/lang/app/nl.json b/frontend/src/assets/lang/app/nl.json index 1b0616ee..20760948 100644 --- a/frontend/src/assets/lang/app/nl.json +++ b/frontend/src/assets/lang/app/nl.json @@ -15,6 +15,13 @@ "nl": "Nederlands", "en": "English" } + }, + "footer": { + "home": "Dashboard", + "about": "Help", + "privacy": "Cookies", + "contact": "Contacteer ons", + "rights": "Alle rechten voorbehouden" } }, "views": { @@ -42,15 +49,22 @@ "projects": { "all": "Alle projecten", "coming": "Aankomende deadlines", + "backToCourse": "Terug naar het vak", "deadline": "Deadline", "days": "Vandaag om {hour} | Morgen om {hour} | Over {count} dagen", "ago": "1 dag geleden | {count} dagen geleden", + "chooseGroupMessage": "Kies een groep voor {0}", + "groupScore": "Groepsscore", + "noGroupScore": "Nog geen score", + "publishScores": "Publiceer scores", "groupName": "Groepsnaam", + "noGroupMembers": "Geen groepsleden", "groupPopulation": "Grootte", "groupStatus": "Status", "start": "Startdatum", "submissionStatus": "Indienstatus", "group": "Groep", + "groups": "Groepen", "groupSize": "Individueel | Groepen van {count} personen", "noGroups": "Geen groepen beschikbaar", "groupMembers": "Groepsleden", diff --git a/frontend/src/components/Loading.vue b/frontend/src/components/Loading.vue index eac23369..272c1aa0 100644 --- a/frontend/src/components/Loading.vue +++ b/frontend/src/components/Loading.vue @@ -6,7 +6,7 @@ withDefaults(defineProps<{ height?: string }>(), { height: '4rem', }); -const show = useTimeout(250); +const show = useTimeout(350);