diff --git a/backend/app/crud/tasks.py b/backend/app/crud/tasks.py index 7877449104999f6f7071cfe7018a9218fa974960..e3dbd2a326ce328719aaba002bd78212856d6a71 100644 --- a/backend/app/crud/tasks.py +++ b/backend/app/crud/tasks.py @@ -30,3 +30,22 @@ def update_task(db: Session, task: schemas.TaskCreate, task_id: int) -> None: def delete_task(db: Session, task_id: int) -> None: db.query(models.Task).filter(models.Task.id == task_id).delete() db.commit() + + +def create_task_status( + db: Session, task_status: schemas.TaskStatusCreate +) -> models.TaskStatus: + db_task_status = models.TaskStatus(**task_status.model_dump()) + db.add(db_task_status) + db.commit() + db.refresh(db_task_status) + return db_task_status + + +def get_task_status_session(db: Session, session_id: int) -> models.TaskStatus | None: + return ( + db.query(models.TaskStatus) + .filter(models.TaskStatus.session_id == session_id) + .order_by(models.TaskStatus.created_at.desc()) + .first() + ) diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py index c3f7a391aad9b8d66d408a73488459f0f10d1a1b..d36a8e1131f8a88a7d5209bb814d1622dac2cd47 100644 --- a/backend/app/models/tasks.py +++ b/backend/app/models/tasks.py @@ -15,11 +15,11 @@ class Task(Base): class TaskStatus(Base): - __tablename__ = "task_statuses" + __tablename__ = "task_status" 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) + student_id = Column(Integer, ForeignKey("users.id"), nullable=False) tutor_id = Column(Integer, ForeignKey("users.id"), nullable=False) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) + status = Column(String, nullable=False) created_at = Column(DateTime, default=datetime_aware) diff --git a/backend/app/routes/decorators.py b/backend/app/routes/decorators.py index 6cd315b4f26303f90261d449ff3eff78334b1408..e7e944c893b2d48ce30db7b036ab3b6b8c848a8d 100644 --- a/backend/app/routes/decorators.py +++ b/backend/app/routes/decorators.py @@ -19,3 +19,18 @@ def require_admin(error: str): return wrapper return decorator + + +def require_tutor(error: str): + def decorator(func: Callable): + def wrapper(*args, current_user: schemas.User, **kwargs): + if not check_user_level(current_user, schemas.UserType.TUTOR): + raise HTTPException( + status_code=401, + detail=error, + ) + return func(*args, current_user=current_user, **kwargs) + + return wrapper + + return decorator diff --git a/backend/app/routes/tasks.py b/backend/app/routes/tasks.py index 136eb833257bdd55ccd03f8af07f781a1ed281dc..68eede8c07c6c50e024e018371e4c7d02eff2fba 100644 --- a/backend/app/routes/tasks.py +++ b/backend/app/routes/tasks.py @@ -4,7 +4,7 @@ from sqlalchemy.orm import Session import crud import schemas from database import get_db -from routes.decorators import require_admin +from routes.decorators import require_admin, require_tutor taskRouter = APIRouter(prefix="/tasks", tags=["Tasks"]) @@ -45,6 +45,28 @@ def get_tasks( return crud.get_tasks(db, skip) +@require_tutor("You do not have permission to create a task status.") +@taskRouter.post("/status", status_code=status.HTTP_201_CREATED) +def create_task_status( + task_status: schemas.TaskStatusCreate, + db: Session = Depends(get_db), +): + return crud.create_task_status(db, task_status).id + + +@taskRouter.get("/status/sessions/{session_id}", response_model=schemas.TaskStatus) +def get_task_status_session( + session_id: int, + db: Session = Depends(get_db), +): + task_status = crud.get_task_status_session(db, session_id) + if task_status is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Task status not found" + ) + return task_status + + @taskRouter.get("/{task_id}", response_model=schemas.Task) def get_task( task_id: int, diff --git a/backend/app/schemas/tasks.py b/backend/app/schemas/tasks.py index 524dcea453ee2a384ac5d6e04d471768d932785a..71312f86ecc0651a54b09b5260b12aa599fc9196 100644 --- a/backend/app/schemas/tasks.py +++ b/backend/app/schemas/tasks.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, NaiveDatetime class TaskCreate(BaseModel): @@ -11,3 +11,16 @@ class TaskCreate(BaseModel): class Task(TaskCreate): id: int + + +class TaskStatusCreate(BaseModel): + task_id: int + student_id: int + tutor_id: int + session_id: int + status: str + created_at: NaiveDatetime | None = None + + +class TaskStatus(TaskStatusCreate): + id: int diff --git a/frontend/src/lang/fr.json b/frontend/src/lang/fr.json index cc5da60fdc709a805df8eb22b84f36e3ddc8934e..54bdee72414e8556a5189524d1dafa7f97b4eda4 100644 --- a/frontend/src/lang/fr.json +++ b/frontend/src/lang/fr.json @@ -407,7 +407,11 @@ "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)" + "examples": "Exemples (peut être sur plusieurs lignes)", + "create": "Créer une nouvelle tâche", + "taskInProgress": "Tâche en cours", + "taskFinished": "Tâche terminée avec succès", + "statusFail": "Echec du changement de status de la tâche" }, "button": { "create": "Créer", @@ -425,7 +429,9 @@ "updated": "Mis à jour !", "delete": "Supprimer", "remove": "Retirer", - "continue": "Continuer" + "continue": "Continuer", + "select": "Sélectionner", + "finish": "Terminer" }, "utils": { "month": { @@ -510,7 +516,11 @@ "questions": "questions", "tests": "tests", "OrganisationUni": "Organisation/Université", - "Address": "Adresse" + "Address": "Adresse", + "all": "Tous", + "instructions": "Instructions", + "examples": "Exemples", + "tasks": "Tâches" } }, "inputs": { diff --git a/frontend/src/lib/api/tasks.ts b/frontend/src/lib/api/tasks.ts index 614bd502edf0f45cca90e0899890f4b6e4abfbcd..a2ed536fd8f86a1bd6c4a6e7aeb87ef60fbd5f97 100644 --- a/frontend/src/lib/api/tasks.ts +++ b/frontend/src/lib/api/tasks.ts @@ -66,3 +66,34 @@ export async function deleteTaskAPI(fetch: fetchType, task_id: number): Promise< }); return response.ok; } + +export async function sendTaskStatusAPI( + fetch: fetchType, + status: string, + student_id: number, + tutor_id: number, + task_id: number, + session_id: number +): Promise<boolean> { + const response = await fetch(`/api/tasks/status`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + status, + task_id, + student_id, + tutor_id, + session_id + }) + }); + return response.ok; +} + +export async function getTaskStatusFromSessionAPI( + fetch: fetchType, + session_id: number +): Promise<any> { + const response = await fetch(`/api/tasks/status/sessions/${session_id}`); + if (!response.ok) return null; + return await response.json(); +} diff --git a/frontend/src/lib/types/session.ts b/frontend/src/lib/types/session.ts index 254dce9feba8477403447640ed980c6ecc23f72b..7a93aca4f37d449fef1f2e5f478e9ab77f5566f0 100644 --- a/frontend/src/lib/types/session.ts +++ b/frontend/src/lib/types/session.ts @@ -125,6 +125,10 @@ export default class Session { return this._length; } + get student(): User | null { + return this._users.find((u) => u.type === 2) ?? null; + } + usersList(maxLength = 30): string { const users = this._users .filter((u) => u.id != this._user?.id) diff --git a/frontend/src/lib/types/tasks.ts b/frontend/src/lib/types/tasks.ts index ce6129f86bb6f2eacae465f8d55a133476d12e2f..70804a96a6d6f1aad04fd807b69350bf0d2fc0de 100644 --- a/frontend/src/lib/types/tasks.ts +++ b/frontend/src/lib/types/tasks.ts @@ -69,3 +69,79 @@ export default class Task { .filter((task: Task | null): task is Task => task !== null); } } + +// some IDs not resolved because they are not used in the front-end +export class TaskStatus { + private _id: number; + private _task_id: number; + private _student_id: number; + private _tutor_id: number; + private _session_id: number; + private _status: string; + + constructor( + id: number, + task_id: number, + student_id: number, + tutor_id: number, + session_id: number, + status: string + ) { + this._id = id; + this._task_id = task_id; + this._student_id = student_id; + this._tutor_id = tutor_id; + this._session_id = session_id; + this._status = status; + } + + get id(): number { + return this._id; + } + + get task_id(): number { + return this._task_id; + } + + get status(): string { + return this._status; + } + + get student_id(): number { + return this.student_id; + } + + get tutor_id(): number { + return this.tutor_id; + } + + get session_id(): number { + return this.session_id; + } + + static parse(data: any): TaskStatus | null { + if (data === null) { + toastAlert('Failed to parse task status data'); + return null; + } + + return new TaskStatus( + data.id, + data.task_id, + data.student_id, + data.tutor_id, + data.session_id, + data.status + ); + } + + static parseAll(data: any): TaskStatus[] { + if (data === null) { + toastAlert('Failed to parse task status data'); + return []; + } + return data + .map((taskStatus: any) => TaskStatus.parse(taskStatus)) + .filter((taskStatus: TaskStatus | null): taskStatus is TaskStatus => taskStatus !== null); + } +} diff --git a/frontend/src/routes/sessions/[id]/+page.svelte b/frontend/src/routes/sessions/[id]/+page.svelte index a8bd4b6b4c44360b76312a8e207d1d33153fd89a..36ed70bbce0b0004a05995b38b11df8c165c22cd 100644 --- a/frontend/src/routes/sessions/[id]/+page.svelte +++ b/frontend/src/routes/sessions/[id]/+page.svelte @@ -3,11 +3,77 @@ import type { PageData } from './$types.js'; import WeeklySurvey from './WeeklySurvey.svelte'; import Chatbox from './Chatbox.svelte'; + import type Task from '$lib/types/tasks'; + import { toastAlert, toastSuccess } from '$lib/utils/toasts'; + import { sendTaskStatusAPI } from '$lib/api/tasks'; let { data }: { data: PageData } = $props(); let user = data.user!; - let { session, jwt } = data; + let { session, jwt, tasks } = data; let { onlineUsers } = session; + + let level = $state('all'); + let currentTask: Task | null = $state(data.currentTask); + let taskInProgress: boolean = $state(data.currentTask !== null); + + let availableLevels = new Set(tasks.map((task: Task) => task.level)); + + async function startTask() { + const student = session.student; + if (!student || !currentTask) return; + const ok = await sendTaskStatusAPI( + fetch, + 'start', + student.id, + user.id, + currentTask.id, + session.id + ); + if (!ok) { + toastAlert($t('tasks.statusFail')); + return; + } + taskInProgress = true; + } + + async function cancelTask() { + const student = session.student; + if (!student || !currentTask) return; + const ok = await sendTaskStatusAPI( + fetch, + 'cancel', + student.id, + user.id, + currentTask.id, + session.id + ); + if (!ok) { + toastAlert($t('tasks.statusFail')); + return; + } + taskInProgress = false; + currentTask = null; + } + + async function finishTask() { + const student = session.student; + if (!student || !currentTask) return; + const ok = await sendTaskStatusAPI( + fetch, + 'finish', + student.id, + user.id, + currentTask.id, + session.id + ); + if (!ok) { + toastAlert($t('tasks.statusFail')); + return; + } + taskInProgress = false; + currentTask = null; + toastSuccess($t('tasks.taskFinished')); + } </script> <div class="h-full flex flex-col lg:flex-row pt-2 lg:pt-0 bg-gray-50 relative"> @@ -53,11 +119,61 @@ </div> <h2 class="text-lg truncate font-semibold text-gray-700 text-center border-t pt-4 mt-4"> - {$t('utils.words.topics')} + {$t('utils.words.tasks')} </h2> - <p class="text-center truncate text-sm text-neutral-500 italic"> - {$t('session.noTopic')} - </p> + {#if !taskInProgress || !currentTask} + <div class="flex gap-2"> + <select class="select select-bordered w-32" bind:value={level}> + <option value="all">{$t('utils.words.all')}</option> + {#each availableLevels as l} + <option value={l}>{l}</option> + {/each} + </select> + <select class="select select-bordered flex-1 overflow-hidden" bind:value={currentTask}> + {#each availableLevels as l} + {#if level === 'all' || l === level} + <optgroup label={l}> + {#each tasks.filter((task: Task) => task.level === l) as task (task.id)} + <option value={task}>{task.shortTitle}</option> + {/each} + </optgroup> + {/if} + {/each} + </select> + </div> + <button class="btn mt-2 w-full btn-primary" onclick={startTask}> + {$t('button.select')} + </button> + {:else} + <p class="mt-4 font-bold"> + {$t('tasks.taskInProgress')}: + </p> + <p> + {currentTask.shortTitle} + </p> + {#if currentTask.instructions} + <p class="mt-2 font-bold"> + {$t('utils.words.instructions')}: + </p> + <p> + {currentTask.instructions} + </p> + {/if} + <p class="mt-2 font-bold"> + {$t('utils.words.examples')}: + </p> + <p> + {currentTask.examples} + </p> + <div class="flex gap-2 mt-4"> + <button class="btn flex-grow" onclick={cancelTask}> + {$t('button.cancel')} + </button> + <button class="btn btn-primary flex-grow" onclick={finishTask}> + {$t('button.finish')} + </button> + </div> + {/if} </div> <div class="flex flex-row flex-grow col-span-5"> diff --git a/frontend/src/routes/sessions/[id]/+page.ts b/frontend/src/routes/sessions/[id]/+page.ts index 251ba545835504f12d817b4ff4eee7d6bfb406df..08494fa31ac70d77b62e67b4e190657ba44d99de 100644 --- a/frontend/src/routes/sessions/[id]/+page.ts +++ b/frontend/src/routes/sessions/[id]/+page.ts @@ -1,5 +1,7 @@ import { getSessionAPI } from '$lib/api/sessions'; +import { getTasksAPI, getTaskStatusFromSessionAPI } from '$lib/api/tasks'; import Session from '$lib/types/session'; +import Task, { TaskStatus } from '$lib/types/tasks'; import { error, type Load } from '@sveltejs/kit'; export const load: Load = async ({ params, fetch, data }) => { @@ -22,5 +24,19 @@ export const load: Load = async ({ params, fetch, data }) => { await session.loadMessages(fetch); - return { session, jwt }; + const tasksRaw = await getTasksAPI(fetch); + const tasks = Task.parseAll(tasksRaw); + + let currentTask: Task | null = null; + + const sessionTaskStatusRaw = await getTaskStatusFromSessionAPI(fetch, nid); + if (sessionTaskStatusRaw) { + const sessionTaskStatus = TaskStatus.parse(sessionTaskStatusRaw); + if (sessionTaskStatus && sessionTaskStatus.status == 'start') { + const task = tasks.find((task) => task.id === sessionTaskStatus.task_id); + if (task) currentTask = task; + } + } + + return { session, jwt, tasks, currentTask }; };