diff --git a/backend/app/crud/__init__.py b/backend/app/crud/__init__.py index 69ccf364cfcad47854f4841912cc47f8d4c85a19..b8a8c2a8b60dc83c89d65e489b6133a8930a4b86 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 0000000000000000000000000000000000000000..7877449104999f6f7071cfe7018a9218fa974960 --- /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 8aee55ca3f218e73b1ba5419e992dc095099a514..fec8ab0a205a0481b0613d7b703e0ff1aee429ae 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 131af45538f6e3c6e8ea62551bd1fee65989250a..206737aa04b7e86d0731a9197ee30c6be555b80e 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 0000000000000000000000000000000000000000..c3f7a391aad9b8d66d408a73488459f0f10d1a1b --- /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 0000000000000000000000000000000000000000..136eb833257bdd55ccd03f8af07f781a1ed281dc --- /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 f310d546cfad88c57636aad32c2d14aaa6ac4c26..b19275d8bf53b4e849d942a7f720191bf3d896d5 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 cfc6318044d0b78b7fe60a9ea8dc68cc7fdba32f..7affcd05d54f939700969e9fe9e00de67c05d520 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 0000000000000000000000000000000000000000..524dcea453ee2a384ac5d6e04d471768d932785a --- /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 1cade0ccb2b5c789800e88ddf29d6b70a3c0ab38..cc5da60fdc709a805df8eb22b84f36e3ddc8934e 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 0000000000000000000000000000000000000000..614bd502edf0f45cca90e0899890f4b6e4abfbcd --- /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 9cd142beacab75f6f6a645bf5936573211a0f82c..4ecba1e3e2d1e417632e2ba64d27bb5655e7c9d2 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 0000000000000000000000000000000000000000..ce6129f86bb6f2eacae465f8d55a133476d12e2f --- /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 876faf2f741150f97c9aaa4e5e0bb02f82717092..dbb172773b765caaeaec429c1998c2ce5b2e6bfa 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 0000000000000000000000000000000000000000..d503e175ea5c28f0d8c8ee775eba2b906bfb1698 --- /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 0000000000000000000000000000000000000000..732612b1e902bf254b14aa93f792d86cfa0ace1e --- /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 0000000000000000000000000000000000000000..42c0fb295579e0109f86340d197ef76d8da4ad39 --- /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 0000000000000000000000000000000000000000..5ea11ed7010df5467a0ef6635c4220f319f1cb23 --- /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 0000000000000000000000000000000000000000..08c2e686a3f917cf16f8339c4a21f0462cd461bd --- /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 0000000000000000000000000000000000000000..d05028075f04922cf32e5f12ea4f8586062e936c --- /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 0000000000000000000000000000000000000000..263ac17c3fb9723fc38e621f4742dfbdfed30ad4 --- /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 0000000000000000000000000000000000000000..2998be34552faff8ff983a2ee7f161c6df9684d1 --- /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} />