From f590e518a49dce8eae05e06532eeddf3a32ab93b Mon Sep 17 00:00:00 2001 From: drieshuybens Date: Tue, 21 May 2024 14:10:51 +0200 Subject: [PATCH 1/4] Edit project (#196) * paar fixes alvast * almost * files uploaden werkt * formatting fixes * bugfixes + added date fields in db * formatting * background container gone + no instructors found fix * format * basic * temp * begint ergens op te trekken * update werkt! * small change * looking good * edit fixes (almost there) * files werken * edit (improvements) * updates * filestructure visible * werkt * af? * remove empty alembic revisions * docker terug ok * alert * route fix * linter * add publish_date to mock projects * reroute * bijna * requirements * editfiles werkt * AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA * few fixes * small time fix * i18n * format * formatting * final * final * vage fixes * vage fixes 2 * tijd update nu ook * test fix * format * schema optional added * fix voor tests not found * bram requests xoxo * whoops * Update frontend/src/components/RequirementsInput.vue Co-authored-by: Pieter Janin * Update frontend/src/components/RequirementsInput.vue Co-authored-by: Pieter Janin * Update frontend/src/queries/Project.ts Co-authored-by: Pieter Janin * Update frontend/src/queries/Project.ts Co-authored-by: Pieter Janin * Update frontend/src/components/RequirementsInput.vue Co-authored-by: Pieter Janin * Update frontend/src/services/project.ts Co-authored-by: Pieter Janin * Update frontend/src/components/project/DatePicker.vue Co-authored-by: Pieter Janin * fixes * loading fix * Update frontend/src/components/project/DatePicker.vue Co-authored-by: Pieter Janin * fixes vooral cleanup * fixes vooral cleanup * fixes vooral cleanup * Revert "upgrade @vue/test-utils (#181)" This reverts commit 87ed14205bbd85eb57565562496ac64ef72b969f. * luxon weg? * luxon echt weg? * luxon echt echt weg? * renaming + unused gone * some small fixes * bramfixes --------- Co-authored-by: Bram Reyniers Co-authored-by: Pieter Janin --- backend/README.md | 2 +- backend/src/project/dependencies.py | 2 - backend/src/project/router.py | 9 +- backend/src/project/schemas.py | 5 +- backend/src/project/service.py | 9 +- backend/src/subject/service.py | 3 +- backend/tests/test_docker.py | 1 + backend/tests/test_project.py | 27 +- backend/tests/test_submission.py | 1 + .../components/form_elements/FilesInput.vue | 14 + .../src/components/project/DatePicker.vue | 56 ++- .../components/project/DisplayTestFiles.vue | 91 ++++ .../src/components/project/ProjectSideBar.vue | 2 +- .../components/project/RadiobuttonList.vue | 74 +-- .../components/project/RequirementsInput.vue | 107 ++++ frontend/src/i18n/locales/en.ts | 17 +- frontend/src/i18n/locales/nl.ts | 18 + frontend/src/models/Project.ts | 19 +- frontend/src/queries/Project.ts | 74 ++- frontend/src/router/index.ts | 6 + frontend/src/services/project.ts | 30 ++ frontend/src/views/CreateProjectView.vue | 475 +++++++++++++----- frontend/src/views/ProjectView.vue | 1 + 23 files changed, 831 insertions(+), 212 deletions(-) create mode 100644 frontend/src/components/project/DisplayTestFiles.vue create mode 100644 frontend/src/components/project/RequirementsInput.vue diff --git a/backend/README.md b/backend/README.md index 52991678..84355101 100644 --- a/backend/README.md +++ b/backend/README.md @@ -6,7 +6,7 @@ ```sh # Create a python virtual environment -python -m venv venv +python3.12 -m venv venv # Activate the environment source venv/bin/activate # Install dependencies diff --git a/backend/src/project/dependencies.py b/backend/src/project/dependencies.py index d6d86da8..0acc939b 100644 --- a/backend/src/project/dependencies.py +++ b/backend/src/project/dependencies.py @@ -22,8 +22,6 @@ async def retrieve_project(project_id: int, async def retrieve_test_files_uuid(project: Project = Depends(retrieve_project)): - if project.test_files_uuid is None: - raise TestsNotFound return project.test_files_uuid diff --git a/backend/src/project/router.py b/backend/src/project/router.py index 5f2729c4..8561a5fc 100644 --- a/backend/src/project/router.py +++ b/backend/src/project/router.py @@ -1,4 +1,4 @@ -from typing import Sequence, List +from typing import Sequence, List, Optional from docker import DockerClient from fastapi import APIRouter, Depends, UploadFile, BackgroundTasks @@ -96,7 +96,9 @@ async def get_submissions_dump(project_id: int, db: AsyncSession = Depends(get_a @router.get("/{project_id}/test_files") -async def get_test_files(test_files_uuid: str = Depends(retrieve_test_files_uuid)): +async def get_test_files(test_files_uuid: Optional[str] = Depends(retrieve_test_files_uuid)): + if not test_files_uuid: + return [] return get_files_from_dir(tests_path(test_files_uuid)) @@ -116,7 +118,8 @@ async def put_test_files( if not using_default_docker_image(uuid): # build custom docker image if dockerfile is present - background_tasks.add_task(build_docker_image, tests_path(uuid), uuid, client) + background_tasks.add_task( + build_docker_image, tests_path(uuid), uuid, client) return await update_test_files(db, project.id, uuid) diff --git a/backend/src/project/schemas.py b/backend/src/project/schemas.py index e4b70412..995c3396 100644 --- a/backend/src/project/schemas.py +++ b/backend/src/project/schemas.py @@ -19,6 +19,8 @@ class ProjectBase(BaseModel): is_visible: bool = Field(default=True) capacity: int = Field(gt=0) requirements: List[Requirement] = [] + enroll_deadline: Optional[datetime] + publish_date: datetime class ProjectCreate(ProjectBase): @@ -55,7 +57,8 @@ class ProjectUpdate(BaseModel): deadline: Optional[datetime] = None description: Optional[str] = None requirements: Optional[List[Requirement]] = None - is_visible: Optional[bool] = None + enroll_deadline: Optional[datetime] = None + publish_date: Optional[datetime] = None @field_validator("deadline") def validate_deadline(cls, value: datetime) -> datetime: diff --git a/backend/src/project/service.py b/backend/src/project/service.py index 78397ccb..5ba96760 100644 --- a/backend/src/project/service.py +++ b/backend/src/project/service.py @@ -18,7 +18,8 @@ async def create_project(db: AsyncSession, project_in: ProjectCreate) -> Project description=project_in.description, is_visible=project_in.is_visible, capacity=project_in.capacity, - requirements=[Requirement(**r.model_dump()) for r in project_in.requirements], + requirements=[Requirement(**r.model_dump()) + for r in project_in.requirements], ) db.add(new_project) await db.commit() @@ -77,10 +78,12 @@ async def update_project( project.name = project_update.name if project_update.deadline is not None: project.deadline = project_update.deadline + if project_update.publish_date is not None: + project.publish_date = project_update.publish_date + if project_update.enroll_deadline is not None: + project.enroll_deadline = project.enroll_deadline if project_update.description is not None: project.description = project_update.description - if project_update.is_visible is not None: - project.is_visible = project_update.is_visible if project_update.requirements is not None: await delete_requirements_for_project(db, project_id) project.requirements = [Requirement(**r.model_dump()) diff --git a/backend/src/subject/service.py b/backend/src/subject/service.py index c2d9786e..5f5e517b 100644 --- a/backend/src/subject/service.py +++ b/backend/src/subject/service.py @@ -62,7 +62,8 @@ async def is_instructor(db: AsyncSession, subject_id: int, uid: str) -> bool: async def create_subject(db: AsyncSession, subject: SubjectCreate) -> Subject: - db_subject = Subject(name=subject.name, academic_year=subject.academic_year) + db_subject = Subject( + name=subject.name, academic_year=subject.academic_year) db.add(db_subject) await db.commit() await db.refresh(db_subject) diff --git a/backend/tests/test_docker.py b/backend/tests/test_docker.py index 96fbcc4b..430d8cf0 100644 --- a/backend/tests/test_docker.py +++ b/backend/tests/test_docker.py @@ -25,6 +25,7 @@ "capacity": 1, "requirements": [{"mandatory": "true", "value": "*.py"}], "test_files": [], + "publish_date": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), } group_data = {"team_name": "test group", "project_id": 0} diff --git a/backend/tests/test_project.py b/backend/tests/test_project.py index 277b68df..559295e6 100644 --- a/backend/tests/test_project.py +++ b/backend/tests/test_project.py @@ -16,9 +16,9 @@ "deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), "description": "test", "enroll_deadline": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), - "is_visible": True, "capacity": 1, "requirements": [{"mandatory": "false", "value": "*.pdf"}], + "publish_date": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), } @@ -111,28 +111,3 @@ async def test_patch_project(client: AsyncClient, db: AsyncSession, project_id: assert response.status_code == 200 response = await client.get(f"/api/projects/{project_id}") assert response.json()["description"] == "new description" - - -@pytest.mark.asyncio -async def test_is_visible_project(client: AsyncClient, db: AsyncSession, project_id: int): - - response = await client.get(f"/api/projects/{project_id}") - subject_id = response.json()["subject_id"] - - await set_admin(db, "test", True) - await client.patch(f"/api/projects/{project_id}", json={"is_visible": False}) - await set_admin(db, "test", False) - - response = await client.get(f"/api/projects/{project_id}") - assert response.status_code == 404 # Not found as project is not visible - - response = await client.get(f"/api/subjects/{subject_id}/projects") - assert len(response.json()["projects"]) == 0 - - # Now privileged get request - await make_instructor(subject_id, "test", db, client) - response = await client.get(f"/api/projects/{project_id}") - assert response.status_code == 200 - - response = await client.get(f"/api/subjects/{subject_id}/projects") - assert len(response.json()["projects"]) == 1 diff --git a/backend/tests/test_submission.py b/backend/tests/test_submission.py index 24b8fe2d..361cc62c 100644 --- a/backend/tests/test_submission.py +++ b/backend/tests/test_submission.py @@ -27,6 +27,7 @@ "is_visible": True, "capacity": 1, "requirements": [{"mandatory": "true", "value": "*.py"}, {"mandatory": "false", "value": "*.pdf"}], + "publish_date": future_date.strftime("%Y-%m-%dT%H:%M:%SZ"), } group_data = {"team_name": "test group", "project_id": 0} diff --git a/frontend/src/components/form_elements/FilesInput.vue b/frontend/src/components/form_elements/FilesInput.vue index e8f8bf0b..a44d8405 100644 --- a/frontend/src/components/form_elements/FilesInput.vue +++ b/frontend/src/components/form_elements/FilesInput.vue @@ -62,4 +62,18 @@ function onDeleteClick(index: number) { .files { margin-top: 15px; } +.custom-alert .alert-text { + white-space: nowrap; /* Prevents the text from wrapping */ + overflow: hidden; /* Prevents overflow of text outside the alert box */ + text-overflow: ellipsis; /* Adds an ellipsis if the text overflows */ +} + +.custom-alert a { + display: inline; /* Ensures the link is in line with other text */ + white-space: normal; /* Allows normal wrapping inside the link if needed */ +} + +.custom-alert { + margin-bottom: 15px; /* Added spacing between the alert and the button */ +} diff --git a/frontend/src/components/project/DatePicker.vue b/frontend/src/components/project/DatePicker.vue index d5b04c22..0e539148 100644 --- a/frontend/src/components/project/DatePicker.vue +++ b/frontend/src/components/project/DatePicker.vue @@ -16,30 +16,66 @@ > + diff --git a/frontend/src/components/project/ProjectSideBar.vue b/frontend/src/components/project/ProjectSideBar.vue index 01a2239f..2550f0f6 100644 --- a/frontend/src/components/project/ProjectSideBar.vue +++ b/frontend/src/components/project/ProjectSideBar.vue @@ -24,7 +24,7 @@ class="group-button" :email="subject!.email" > - + {{ $t("project.edit") }} diff --git a/frontend/src/components/project/RadiobuttonList.vue b/frontend/src/components/project/RadiobuttonList.vue index 771cd63a..6df6cf28 100644 --- a/frontend/src/components/project/RadiobuttonList.vue +++ b/frontend/src/components/project/RadiobuttonList.vue @@ -12,55 +12,65 @@ - + :modelValue="enrollDate" + @update:modelValue="handleDateChange" + label="Deadline" + /> diff --git a/frontend/src/components/project/RequirementsInput.vue b/frontend/src/components/project/RequirementsInput.vue new file mode 100644 index 00000000..e518783d --- /dev/null +++ b/frontend/src/components/project/RequirementsInput.vue @@ -0,0 +1,107 @@ + + + + diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 254ca7a8..b28fd7d0 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -33,6 +33,7 @@ export default { new_submission: "Submit new", status_submission: "Submission is: {status}", no_submission_files: "No submissions found", + files_disclaimer: "For info on the usage of testfiles please visit our", }, submission: { status: "Submission status:", @@ -42,7 +43,7 @@ export default { files: "Files", download_info: "Click on filename to download", after_deadline: "After deadline", - submissions_title: "Submissions for project {project}", + submissions_title: "Submissions:", no_submissions: "No submissions yet", teacher_submissions_info: "This page contains a list of the latest submission of each group for this project.", @@ -73,6 +74,20 @@ export default { unmet_reqs_warning: "Your submission did not satisfy all file requirements.", to_subject: "To subject", to_groups: "To groups", + selected_subject: "Selected subject", + group_warning: "Once the project has been created groups can no longer be edited", + group_toggle: "Group project", + random: "Random groups", + student_groups: "Student chosen groups", + enroll_deadline: "Enroll deadline", + publish_date: "Publish date", + no_files: "No project files found", + requirement: "Add mandatory or forbidden extension (ie: assignment.pdf)", + invalid_format: + "Please enter a valid file type, including the extension (e.g., 'image.png')", + files_will_be_overwritten: "By uploading another file current files will be overwritten.", + testfiles: "Testfiles", + requirements_disclaimer: "For info on the usage of requirements please visit our", to_project: "To Project", }, navigation: { diff --git a/frontend/src/i18n/locales/nl.ts b/frontend/src/i18n/locales/nl.ts index d7bace25..872bb494 100644 --- a/frontend/src/i18n/locales/nl.ts +++ b/frontend/src/i18n/locales/nl.ts @@ -32,6 +32,8 @@ export default { new_submission: "Nieuwe indiening", status_submission: "Indiening is: {status}", no_submission_files: "Geen indieningen gevonden", + files_usage_note: "Info voor het gebruik van testbestanden", + files_disclaimer: "Voor info over het gebruik van testbestanden, bezoek onze ", }, submission: { status: "Status indiening:", @@ -72,6 +74,22 @@ export default { unmet_reqs_warning: "Opgelet: je indiening voldoet niet aan alle bestandsvereisten.", to_subject: "Naar vak", to_groups: "Naar groepen", + selected_subject: "Gekozen vak", + group_warning: + "Eens het project aangemaakt is is het niet meer mogelijk de groepen aan te passen", + group_toggle: "Groepswerk", + random: "Willekeurige groepen", + student_groups: "Studenten gekozen groepen", + enroll_deadline: "Inschrijvingsdeadline", + publish_date: "Publiceringsdatum", + no_files: "Geen bestanden voor het project teruggevonden", + requirement: "Voeg verplichte of verboden extensie toe (bv: verslag.pdf)", + invalid_format: + "Voer een geldig bestandstype in, inclusief de extensie (bv. 'afbeelding.png')", + files_will_be_overwritten: + "Bij het uploaden van een nieuw testbestand zullen alle huidige testbestanden verwijderd worden.", + testfiles: "Testbestanden", + requirements_disclaimer: "Voor info over het gebruik van bestandsvereisten, bezoek onze ", to_project: "Naar project", }, navigation: { diff --git a/frontend/src/models/Project.ts b/frontend/src/models/Project.ts index 2661a600..62b16227 100644 --- a/frontend/src/models/Project.ts +++ b/frontend/src/models/Project.ts @@ -2,12 +2,13 @@ export default interface Project { id: number; name: string; deadline: Date; - // groupProjectType: string; - // selectedTeachers: string[]; // Assuming you store only teacher IDs subject_id: number; requirements: Requirement[]; description: string; capacity: number; + enroll_deadline: Date; + publish_date: Date; + test_files_uuid: string; } export interface UserProjectList { @@ -18,12 +19,13 @@ export interface UserProjectList { export interface ProjectForm { name: string; deadline: Date; - description: string; subject_id: number; - test_files_uuid: string; - is_visible: boolean; + requirements: Requirement[]; + description: string; capacity: number; - requirements: []; + enroll_deadline: Date; + publish_date: Date; + test_files_uuid: string; } export interface Deadline { @@ -46,3 +48,8 @@ export enum FilterOptions { Active = "Active", Completed = "Completed", } + +export interface Requirement { + mandatory: boolean; + value: string; +} diff --git a/frontend/src/queries/Project.ts b/frontend/src/queries/Project.ts index 64599239..9d27d23f 100644 --- a/frontend/src/queries/Project.ts +++ b/frontend/src/queries/Project.ts @@ -2,9 +2,16 @@ import { computed, toValue } from "vue"; import type { MaybeRefOrGetter } from "vue"; import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query"; import type { UseMutationReturnType, UseQueryReturnType } from "@tanstack/vue-query"; -import type Project from "@/models/Project"; import type { ProjectForm, UserProjectList } from "@/models/Project"; -import { getProject, createProject, getProjects } from "@/services/project"; +import type Project from "@/models/Project"; +import { + getProject, + createProject, + getProjects, + uploadTestFiles, + updateProject, + fetchTestFiles, +} from "@/services/project"; function PROJECT_QUERY_KEY(projectId: number): (string | number)[] { return ["project", projectId]; @@ -17,6 +24,12 @@ function PROJECTS_QUERY_KEY(): string[] { /** * Query composable for fetching a project by id */ + +function TEST_FILES_QUERY_KEY(projectId: number): (string | number)[] { + return ["testFiles", projectId]; +} + +// Hook for fetching project details export function useProjectQuery( projectId: MaybeRefOrGetter ): UseQueryReturnType { @@ -58,3 +71,60 @@ export function useCreateProjectMutation(): UseMutationReturnType< }, }); } + +export function useUpdateProjectMutation(): UseMutationReturnType< + Project, + Error, + { projectId: number; projectData: Partial }, + void +> { + const queryClient = useQueryClient(); + return useMutation< + Project, + Error, + { projectId: number; projectData: Partial }, + void + >({ + mutationFn: ({ projectId, projectData }) => updateProject(projectId, projectData), + + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: PROJECT_QUERY_KEY(variables.projectId) }); + console.log("Project updated successfully."); + }, + + onError: (error) => { + console.error("Project update failed", error); + alert("Failed to update project. Please try again."); + }, + }); +} + +export function useTestFilesQuery( + projectId: MaybeRefOrGetter +): UseQueryReturnType { + return useQuery({ + queryKey: TEST_FILES_QUERY_KEY(toValue(projectId)!), + queryFn: () => fetchTestFiles(toValue(projectId)!), + enabled: () => !!toValue(projectId), // Only fetch when a projectId is provided + }); +} +// Hook for uploading files to a project +export function useUploadTestFilesMutation(): UseMutationReturnType< + void, // Type of data returned on success + Error, // Type of error + { projectId: number; formData: FormData }, + void +> { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ projectId, formData }) => uploadTestFiles(projectId, formData), + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ queryKey: PROJECT_QUERY_KEY(projectId) }); + console.log("Files uploaded successfully"); + }, + onError: (error) => { + console.error("File upload failed", error); + alert("Could not upload files. Please try again."); + }, + }); +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 3c613489..69c2a8d1 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -94,6 +94,12 @@ const router = createRouter({ component: () => import("../views/CreateProjectView.vue"), props: (route) => ({ subjectId: Number(route.params.subjectId) }), }, + { + path: "/project/:projectId(\\d+)/edit", + name: "edit-project", + component: () => import("../views/CreateProjectView.vue"), // Ensure this is correct + props: (route) => ({ projectId: Number(route.params.projectId), isEditMode: true }), + }, { path: "/subjects/register/:uuid", name: "registerSubject", diff --git a/frontend/src/services/project.ts b/frontend/src/services/project.ts index 162ba72b..310ce65f 100644 --- a/frontend/src/services/project.ts +++ b/frontend/src/services/project.ts @@ -1,6 +1,7 @@ import type Project from "@/models/Project"; import type { ProjectForm, UserProjectList } from "@/models/Project"; import { authorized_fetch } from "@/services"; +import type Submission from "@/models/Submission"; function initProjectDate(project: Project): Project { return { ...project, deadline: new Date(project.deadline) }; @@ -48,3 +49,32 @@ export async function createProject(projectData: ProjectForm): Promise { }); return response.id; } + +export async function updateProject( + projectId: number, + projectData: Partial +): Promise { + return await authorized_fetch(`/api/projects/${projectId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(projectData), + }); +} + +// Function to upload test files to a specific project +export async function uploadTestFiles(projectId: number, formData: FormData): Promise { + await authorized_fetch( + `/api/projects/${projectId}/test_files`, + { + method: "PUT", + body: formData, + }, + { omitContentType: true } + ); +} + +export async function fetchTestFiles(projectId: number): Promise { + return authorized_fetch(`/api/projects/${projectId}/test_files`, { + method: "GET", + }); +} diff --git a/frontend/src/views/CreateProjectView.vue b/frontend/src/views/CreateProjectView.vue index cabab9a4..52bf5817 100644 --- a/frontend/src/views/CreateProjectView.vue +++ b/frontend/src/views/CreateProjectView.vue @@ -1,198 +1,421 @@ diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue index ab6fae72..e041c9c5 100644 --- a/frontend/src/views/ProjectView.vue +++ b/frontend/src/views/ProjectView.vue @@ -30,6 +30,7 @@