From 20b2f6828f157b74ec32515769ad1dfceea4a5c7 Mon Sep 17 00:00:00 2001 From: Brieuc Dubois <git@bhasher.com> Date: Fri, 11 Apr 2025 17:22:04 +0200 Subject: [PATCH] Tasks creation and management --- backend/app/crud/__init__.py | 1 + backend/app/crud/tasks.py | 32 ++++++ backend/app/main.py | 2 + backend/app/models/__init__.py | 1 + backend/app/models/tasks.py | 25 +++++ backend/app/routes/tasks.py | 58 ++++++++++ backend/app/routes/tests.py | 1 - backend/app/schemas/__init__.py | 1 + backend/app/schemas/tasks.py | 13 +++ frontend/src/lang/fr.json | 12 ++- frontend/src/lib/api/tasks.ts | 68 ++++++++++++ .../lib/components/studies/StudyForm.svelte | 1 - frontend/src/lib/types/tasks.ts | 71 ++++++++++++ frontend/src/routes/Header.svelte | 5 + frontend/src/routes/admin/tasks/+page.svelte | 39 +++++++ frontend/src/routes/admin/tasks/+page.ts | 11 ++ .../src/routes/admin/tasks/TaskForm.svelte | 101 ++++++++++++++++++ .../routes/admin/tasks/[id]/+page.server.ts | 49 +++++++++ .../src/routes/admin/tasks/[id]/+page.svelte | 8 ++ frontend/src/routes/admin/tasks/[id]/+page.ts | 20 ++++ .../routes/admin/tasks/new/+page.server.ts | 40 +++++++ .../src/routes/admin/tasks/new/+page.svelte | 7 ++ 22 files changed, 563 insertions(+), 3 deletions(-) create mode 100644 backend/app/crud/tasks.py create mode 100644 backend/app/models/tasks.py create mode 100644 backend/app/routes/tasks.py create mode 100644 backend/app/schemas/tasks.py create mode 100644 frontend/src/lib/api/tasks.ts create mode 100644 frontend/src/lib/types/tasks.ts create mode 100644 frontend/src/routes/admin/tasks/+page.svelte create mode 100644 frontend/src/routes/admin/tasks/+page.ts create mode 100644 frontend/src/routes/admin/tasks/TaskForm.svelte create mode 100644 frontend/src/routes/admin/tasks/[id]/+page.server.ts create mode 100644 frontend/src/routes/admin/tasks/[id]/+page.svelte create mode 100644 frontend/src/routes/admin/tasks/[id]/+page.ts create mode 100644 frontend/src/routes/admin/tasks/new/+page.server.ts create mode 100644 frontend/src/routes/admin/tasks/new/+page.svelte diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 69ccf364..b8a8c2a8 100644 --- a/backend/app/crud/__init__.py +++ b/backend/app/crud/__init__.py @@ -9,6 +9,7 @@ from hashing import Hasher from crud.tests import * from crud.studies import * +from crud.tasks import * def get_user(db: Session, user_id: int): diff --git a/backend/app/crud/tasks.py b/backend/app/crud/tasks.py new file mode 100644 index 00000000..78774491 --- /dev/null +++ b/backend/app/crud/tasks.py @@ -0,0 +1,32 @@ +from sqlalchemy.orm import Session + +import models +import schemas + + +def get_tasks(db: Session, skip: int = 0): + return db.query(models.Task).offset(skip).all() + + +def get_task(db: Session, task_id: int): + return db.query(models.Task).filter(models.Task.id == task_id).first() + + +def create_task(db: Session, task: schemas.TaskCreate) -> models.Task: + db_task = models.Task(**task.model_dump()) + db.add(db_task) + db.commit() + db.refresh(db_task) + return db_task + + +def update_task(db: Session, task: schemas.TaskCreate, task_id: int) -> None: + db.query(models.Task).filter(models.Task.id == task_id).update( + {**task.model_dump(exclude_unset=True)} + ) + db.commit() + + +def delete_task(db: Session, task_id: int) -> None: + db.query(models.Task).filter(models.Task.id == task_id).delete() + db.commit() diff --git a/backend/app/main.py b/backend/app/main.py index 8aee55ca..fec8ab0a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ import config from security import jwt_cookie, get_jwt_user from routes.tests import testRouter from routes.studies import studiesRouter +from routes.tasks import taskRouter websocket_users = defaultdict(lambda: defaultdict(set)) websocket_users_global = defaultdict(set) @@ -1064,5 +1065,6 @@ v1Router.include_router(studyRouter) v1Router.include_router(websocketRouter) v1Router.include_router(testRouter) v1Router.include_router(studiesRouter) +v1Router.include_router(taskRouter) apiRouter.include_router(v1Router) app.include_router(apiRouter) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 131af455..206737aa 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -18,6 +18,7 @@ from utils import datetime_aware from models.studies import * from models.tests import * +from models.tasks import * class UserType(Enum): diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py new file mode 100644 index 00000000..c3f7a391 --- /dev/null +++ b/backend/app/models/tasks.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Integer, String +from utils import datetime_aware +from database import Base + + +class Task(Base): + __tablename__ = "tasks" + + id = Column(Integer, primary_key=True, index=True) + level = Column(String, nullable=False) + shortTitle = Column(String, nullable=False) + instructions = Column(String, nullable=True) + learnerInstructions = Column(String, nullable=True) + examples = Column(String, nullable=False) + + +class TaskStatus(Base): + __tablename__ = "task_statuses" + id = Column(Integer, primary_key=True, index=True) + task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(String, nullable=False) + tutor_id = Column(Integer, ForeignKey("users.id"), nullable=False) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) + created_at = Column(DateTime, default=datetime_aware) diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py new file mode 100644 index 00000000..136eb833 --- /dev/null +++ b/backend/app/routes/tasks.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +import crud +import schemas +from database import get_db +from routes.decorators import require_admin + +taskRouter = APIRouter(prefix="/tasks", tags=["Tasks"]) + + +@require_admin("You do not have permission to create a task.") +@taskRouter.post("", status_code=status.HTTP_201_CREATED) +def create_task( + task: schemas.TaskCreate, + db: Session = Depends(get_db), +): + return crud.create_task(db, task).id + + +@require_admin("You do not have permission to edit a task.") +@taskRouter.put("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +def update_task( + task_id: int, + task: schemas.TaskCreate, + db: Session = Depends(get_db), +): + return crud.update_task(db, task, task_id) + + +@require_admin("You do not have permission to delete a task.") +@taskRouter.delete("/{task_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_task( + task_id: int, + db: Session = Depends(get_db), +): + return crud.delete_task(db, task_id) + + +@taskRouter.get("", response_model=list[schemas.Task]) +def get_tasks( + skip: int = 0, + db: Session = Depends(get_db), +): + return crud.get_tasks(db, skip) + + +@taskRouter.get("/{task_id}", response_model=schemas.Task) +def get_task( + task_id: int, + db: Session = Depends(get_db), +): + task = crud.get_task(db, task_id) + if task is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + return task diff --git a/backend/app/routes/tests.py b/backend/app/routes/tests.py index f310d546..b19275d8 100644 --- a/backend/app/routes/tests.py +++ b/backend/app/routes/tests.py @@ -1,6 +1,5 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from starlette.status import HTTP_200_OK import crud import schemas diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index cfc63180..7affcd05 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, NaiveDatetime from schemas.studies import * from schemas.tests import * from schemas.users import * +from schemas.tasks import * class LoginData(BaseModel): diff --git a/backend/app/schemas/tasks.py b/backend/app/schemas/tasks.py new file mode 100644 index 00000000..524dcea4 --- /dev/null +++ b/backend/app/schemas/tasks.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel + + +class TaskCreate(BaseModel): + level: str + shortTitle: str + instructions: str + learnerInstructions: str + examples: str + + +class Task(TaskCreate): + id: int diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index 1cade0cc..cc5da60f 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -12,7 +12,8 @@ "admin": { "users": "Utilisateurs", "sessions": "Sessions", - "studies": "Études" + "studies": "Études", + "tasks": "Tâches" } }, "chatbox": { @@ -399,6 +400,15 @@ "taskTests": "Tests de langue", "typingTests": "Tests de frappe" }, + "tasks": { + "createTitle": "Créer une nouvelle tâche", + "editTitle": "Modifier la tâche", + "level": "Niveau CEFR", + "shortTitle": "Nom de la tâche (max 5 mots)", + "instructions": "Instructions le tuteur (peut être sur plusieurs lignes)", + "learnerInstructions": "Instructions pour l'apprenant (peut être sur plusieurs lignes)", + "examples": "Exemples (peut être sur plusieurs lignes)" + }, "button": { "create": "Créer", "submit": "Envoyer", diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts new file mode 100644 index 00000000..614bd502 --- /dev/null +++ b/frontend/src/lib/api/tasks.ts @@ -0,0 +1,68 @@ +import type { fetchType } from '$lib/utils/types'; + +export async function createTaskAPI( + fetch: fetchType, + level: string, + shortTitle: string, + instructions: string, + learnerInstructions: string, + examples: string +): Promise<number | undefined> { + const response = await fetch(`/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level, + shortTitle, + instructions, + learnerInstructions, + examples + }) + }); + + if (!response.ok) return; + + return parseInt(await response.text()); +} + +export async function getTasksAPI(fetch: fetchType) { + const response = await fetch('/api/tasks'); + if (!response.ok) return null; + return await response.json(); +} + +export async function getTaskAPI(fetch: fetchType, task_id: number) { + const response = await fetch(`/api/tasks/${task_id}`); + if (!response.ok) return null; + return await response.json(); +} + +export async function updateTaskAPI( + fetch: fetchType, + task_id: number, + level: string, + shortTitle: string, + instructions: string, + learnerInstructions: string, + examples: string +): Promise<boolean> { + const response = await fetch(`/api/tasks/${task_id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + level, + shortTitle, + instructions, + learnerInstructions, + examples + }) + }); + return response.ok; +} + +export async function deleteTaskAPI(fetch: fetchType, task_id: number): Promise<boolean> { + const response = await fetch(`/api/tasks/${task_id}`, { + method: 'DELETE' + }); + return response.ok; +} diff --git a/frontend/src/lib/components/studies/StudyForm.svelte b/frontend/src/lib/components/studies/StudyForm.svelte index 9cd142be..4ecba1e3 100644 --- a/frontend/src/lib/components/studies/StudyForm.svelte +++ b/frontend/src/lib/components/studies/StudyForm.svelte @@ -16,7 +16,6 @@ study = $bindable(), possibleTests, mode, - data, form }: { study: Study | null; diff --git a/frontend/src/lib/types/tasks.ts b/frontend/src/lib/types/tasks.ts new file mode 100644 index 00000000..ce6129f8 --- /dev/null +++ b/frontend/src/lib/types/tasks.ts @@ -0,0 +1,71 @@ +import { toastAlert } from '$lib/utils/toasts'; + +export default class Task { + private _id: number; + private _level: string; + private _shortTitle: string; + private _instructions: string; + private _learnerInstructions: string; + private _examples: string; + + constructor( + id: number, + level: string, + shortTitle: string, + instructions: string, + learnerInstructions: string, + examples: string + ) { + this._id = id; + this._level = level; + this._shortTitle = shortTitle; + this._instructions = instructions; + this._learnerInstructions = learnerInstructions; + this._examples = examples; + } + + get id(): number { + return this._id; + } + get level(): string { + return this._level; + } + get shortTitle(): string { + return this._shortTitle; + } + get instructions(): string { + return this._instructions; + } + get learnerInstructions(): string { + return this._learnerInstructions; + } + get examples(): string { + return this._examples; + } + + static parse(data: any): Task | null { + if (data === null) { + toastAlert('Failed to parse tasks data'); + return null; + } + + return new Task( + data.id, + data.level, + data.shortTitle, + data.instructions, + data.learnerInstructions, + data.examples + ); + } + + static parseAll(data: any): Task[] { + if (data === null) { + toastAlert('Failed to parse tasks data'); + return []; + } + return data + .map((task: any) => Task.parse(task)) + .filter((task: Task | null): task is Task => task !== null); + } +} diff --git a/frontend/src/routes/Header.svelte b/frontend/src/routes/Header.svelte index 876faf2f..dbb17277 100644 --- a/frontend/src/routes/Header.svelte +++ b/frontend/src/routes/Header.svelte @@ -93,6 +93,11 @@ {$t('header.admin.studies')} </a> </li> + <li> + <a data-sveltekit-reload href="/admin/tasks"> + {$t('header.admin.tasks')} + </a> + </li> </ul> </details> </li> diff --git a/frontend/src/routes/admin/tasks/+page.svelte b/frontend/src/routes/admin/tasks/+page.svelte new file mode 100644 index 00000000..d503e175 --- /dev/null +++ b/frontend/src/routes/admin/tasks/+page.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import { t } from '$lib/services/i18n'; + import type { PageData } from './$types'; + import Task from '$lib/types/tasks'; + + const { data }: { data: PageData } = $props(); + + let tasks: Task[] = $state(data.tasks); +</script> + +<h1 class="text-xl font-bold m-5 text-center"> + {$t('header.admin.tasks')} +</h1> + +<table class="table max-w-5xl mx-auto text-center"> + <thead> + <tr> + <th>#</th> + <th>{$t('tasks.level')}</th> + <th>{$t('tasks.shortTitle')}</th> + </tr> + </thead> + <tbody> + {#each tasks as task (task.id)} + <tr + class="hover:bg-gray-100 hover:cursor-pointer" + onclick={() => (window.location.href = `/admin/tasks/${task.id}`)} + > + <td>{task.id}</td> + <td>{task.level}</td> + <td>{task.shortTitle}</td> + </tr> + {/each} + </tbody> +</table> + +<div class="mt-8 w-[64rem] mx-auto"> + <a class="button" href="/admin/tasks/new">{$t('tasks.create')}</a> +</div> diff --git a/frontend/src/routes/admin/tasks/+page.ts b/frontend/src/routes/admin/tasks/+page.ts new file mode 100644 index 00000000..732612b1 --- /dev/null +++ b/frontend/src/routes/admin/tasks/+page.ts @@ -0,0 +1,11 @@ +import { getTasksAPI } from '$lib/api/tasks'; +import Task from '$lib/types/tasks'; +import { type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch }) => { + const tasks = Task.parseAll(await getTasksAPI(fetch)); + + return { + tasks + }; +}; diff --git a/frontend/src/routes/admin/tasks/TaskForm.svelte b/frontend/src/routes/admin/tasks/TaskForm.svelte new file mode 100644 index 00000000..42c0fb29 --- /dev/null +++ b/frontend/src/routes/admin/tasks/TaskForm.svelte @@ -0,0 +1,101 @@ +<script lang="ts"> + import { deleteTaskAPI } from '$lib/api/tasks'; + import { t } from '$lib/services/i18n'; + import type { Task } from '$lib/types/tasks'; + import autosize from 'svelte-autosize'; + import { ShoppingBag } from 'svelte-hero-icons'; + + let { form, task }: { form: FormData; task: Task | null } = $props(); + + async function deleteTask() { + if (!task) return; + await deleteTaskAPI(fetch, task.id); + document.location.href = '/admin/tasks'; + } +</script> + +<div class="mx-auto w-full max-w-5xl px-4"> + <h2 class="text-xl font-bold m-5 text-center"> + {$t(task === null ? 'tasks.createTitle' : 'tasks.editTitle')} + </h2> + + {#if form?.message} + <div class="alert alert-error shadow-lg mb-4"> + {form?.message} + </div> + {/if} + + <form method="post"> + <label class="label" for="level">{$t('tasks.level')} *</label> + <select + class="select select-bordered w-full" + id="level" + name="level" + required + value={task?.level} + > + <option value="A1">A1</option> + <option value="A2">A2</option> + <option value="B1">B1</option> + <option value="B2">B2</option> + <option value="C1">C1</option> + </select> + + <label class="label" for="shortTitle">{$t('tasks.shortTitle')} *</label> + <input + class="input w-full" + type="text" + id="shortTitle" + name="shortTitle" + required + value={task?.shortTitle} + /> + + <label class="label" for="instructions">{$t('tasks.instructions')}</label> + <textarea + use:autosize + class="input w-full" + id="instructions" + name="instructions" + value={task?.instructions} + ></textarea> + + <label class="label" for="learnerInstructions">{$t('tasks.learnerInstructions')}</label> + <textarea + use:autosize + class="input w-full" + id="learnerInstructions" + name="learnerInstructions" + value={task?.learnerInstructions} + ></textarea> + + <label class="label" for="examples">{$t('tasks.examples')} *</label> + <textarea + use:autosize + class="input w-full" + id="examples" + name="examples" + required + value={task?.examples} + ></textarea> + + <div class="mt-4 mb-6"> + <input type="hidden" name="id" value={task ? task.id : ''} /> + <button type="submit" class="button"> + {$t(task === null ? 'button.create' : 'button.update')} + </button> + <a class="btn btn-outline float-end ml-2" href="/admin/tasks"> + {$t('button.cancel')} + </a> + {#if task} + <button + type="button" + class="btn btn-error btn-outline float-end" + onclick={() => confirm($t('tasks.deleteConfirm')) && deleteTask()} + > + {$t('button.delete')} + </button> + {/if} + </div> + </form> +</div> diff --git a/frontend/src/routes/admin/tasks/[id]/+page.server.ts b/frontend/src/routes/admin/tasks/[id]/+page.server.ts new file mode 100644 index 00000000..5ea11ed7 --- /dev/null +++ b/frontend/src/routes/admin/tasks/[id]/+page.server.ts @@ -0,0 +1,49 @@ +import { updateTaskAPI } from '$lib/api/tasks'; +import { redirect, type Actions } from '@sveltejs/kit'; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + + const idStr = formData.get('id')?.toString(); + const level = formData.get('level')?.toString(); + const shortTitle = formData.get('shortTitle')?.toString(); + + const instructions = formData.get('instructions')?.toString() || ''; + + const learnerInstructions = formData.get('learnerInstructions')?.toString() || ''; + + const examples = formData.get('examples')?.toString(); + + if (!level || !shortTitle || !examples || !idStr) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + const id = parseInt(idStr, 10); + if (isNaN(id)) { + return { + message: 'Invalid request: Invalid task ID' + }; + } + + const ok = await updateTaskAPI( + fetch, + id, + level, + shortTitle, + instructions, + learnerInstructions, + examples + ); + + if (!ok) { + return { + message: 'Invalid request: Failed to update task' + }; + } + + return redirect(303, `/admin/tasks`); + } +}; diff --git a/frontend/src/routes/admin/tasks/[id]/+page.svelte b/frontend/src/routes/admin/tasks/[id]/+page.svelte new file mode 100644 index 00000000..08c2e686 --- /dev/null +++ b/frontend/src/routes/admin/tasks/[id]/+page.svelte @@ -0,0 +1,8 @@ +<script lang="ts"> + import type { PageData } from './$types'; + import TaskForm from '../TaskForm.svelte'; + + let { form, data }: { form: FormData; data: PageData } = $props(); +</script> + +<TaskForm {form} task={data.task} /> diff --git a/frontend/src/routes/admin/tasks/[id]/+page.ts b/frontend/src/routes/admin/tasks/[id]/+page.ts new file mode 100644 index 00000000..d0502807 --- /dev/null +++ b/frontend/src/routes/admin/tasks/[id]/+page.ts @@ -0,0 +1,20 @@ +import { getTaskAPI } from '$lib/api/tasks'; +import Task from '$lib/types/tasks'; +import { error, type Load } from '@sveltejs/kit'; + +export const load: Load = async ({ fetch, params }) => { + const idStr = params.id; + if (!idStr) { + return error(400, 'Invalid request: Missing task ID'); + } + const id = parseInt(idStr, 10); + + const task = Task.parse(await getTaskAPI(fetch, id)); + if (!task) { + return error(404, 'Task not found'); + } + + return { + task + }; +}; diff --git a/frontend/src/routes/admin/tasks/new/+page.server.ts b/frontend/src/routes/admin/tasks/new/+page.server.ts new file mode 100644 index 00000000..263ac17c --- /dev/null +++ b/frontend/src/routes/admin/tasks/new/+page.server.ts @@ -0,0 +1,40 @@ +import { createTaskAPI } from '$lib/api/tasks'; +import { redirect, type Actions } from '@sveltejs/kit'; + +export const actions: Actions = { + default: async ({ request, fetch }) => { + const formData = await request.formData(); + + const level = formData.get('level')?.toString(); + const shortTitle = formData.get('shortTitle')?.toString(); + + const instructions = formData.get('instructions')?.toString() || ''; + + const learnerInstructions = formData.get('learnerInstructions')?.toString() || ''; + + const examples = formData.get('examples')?.toString(); + + if (!level || !shortTitle || !examples) { + return { + message: 'Invalid request: Missing required fields' + }; + } + + const id = await createTaskAPI( + fetch, + level, + shortTitle, + instructions, + learnerInstructions, + examples + ); + + if (id === undefined) { + return { + message: 'Invalid request: Failed to create task' + }; + } + + return redirect(303, `/admin/tasks`); + } +}; diff --git a/frontend/src/routes/admin/tasks/new/+page.svelte b/frontend/src/routes/admin/tasks/new/+page.svelte new file mode 100644 index 00000000..2998be34 --- /dev/null +++ b/frontend/src/routes/admin/tasks/new/+page.svelte @@ -0,0 +1,7 @@ +<script lang="ts"> + import TaskForm from '../TaskForm.svelte'; + + let { form }: { form: FormData } = $props(); +</script> + +<TaskForm {form} task={null} /> -- GitLab