From cefceb046c7a81b335de32ee0f4882b71f7895ff Mon Sep 17 00:00:00 2001 From: Pontoporeia Date: Tue, 24 Mar 2026 13:25:23 +0100 Subject: [PATCH] feat: jury composition + banner image upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migration 004: thesis_supervisors.role + is_external; view adds jury_president/jury_promoteurs/jury_lecteurs - migration 005: theses.banner_path; view exposes t.banner_path and t.license_id - Database: getThesisJury(), setThesisJury(), setBannerPath() - admin/add.php: jury fieldset (président/promoteur/lecteurs + externe checkboxes, JS add/remove rows); banner file input - admin/edit.php: jury fieldset pre-populated from DB; banner preview + remove checkbox + upload; multipart form - admin/actions/formulaire.php: parse jury fields → setThesisJury(); banner upload to banners/ - tfe.php: three conditional jury rows (président·e, promoteur·ice, lecteur·ices) - schema.sql: updated thesis_supervisors, theses, v_theses_full, v_theses_public definitions - admin.css: fieldset, jury-row, jury-entry, btn-remove styles --- TODO.md | 114 +++--------- public/admin/actions/formulaire.php | 60 ++++-- public/admin/add.php | 81 ++++++-- public/admin/edit.php | 176 ++++++++++++++++-- public/assets/admin.css | 57 ++++++ public/tfe.php | 20 +- src/Database.php | 54 ++++++ .../f528764d624db129b32c21fbca0cb8d6.json | 2 +- storage/migrations/004_jury_roles.sql | 66 +++++++ storage/migrations/005_add_banner.sql | 65 +++++++ storage/schema.sql | 12 +- storage/test.db | Bin 221184 -> 221184 bytes 12 files changed, 569 insertions(+), 138 deletions(-) create mode 100644 storage/migrations/004_jury_roles.sql create mode 100644 storage/migrations/005_add_banner.sql diff --git a/TODO.md b/TODO.md index 8f0f64a..b7c4934 100644 --- a/TODO.md +++ b/TODO.md @@ -113,8 +113,7 @@ no seed data yet. #### 3f — View update -- [ ] **`storage/schema.sql`** — update `v_theses_full` to expose `t.license_id` raw FK - (edit form currently queries theses directly — lower priority) +- [x] **`storage/schema.sql`** — `v_theses_full` now exposes `t.license_id` raw FK (done as part of 004/005 view rebuild) #### 3g — TFE public page @@ -133,90 +132,43 @@ internal/external). Current state: `supervisors` table + `thesis_supervisors` junction with a bare `supervisor_order` integer. No role distinction. -- [ ] **`storage/migrations/004_jury_roles.sql`**: +- [x] **`storage/migrations/004_jury_roles.sql`**: - `ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'` — role values: `'president'`, `'promoteur'`, `'lecteur'` - `ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0` — 1 = external, 0 = internal (replaces the old "promoteur externe" free-text field) - No data loss: existing rows get `role = 'promoteur'`, `is_external = 0` -- [ ] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors` +- [x] **`storage/schema.sql`** — add the two new columns to `thesis_supervisors` definition; update `v_theses_full` to expose jury members grouped by role: - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='president' THEN s.name END) as jury_president` - - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as promoteurs` - - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as lecteurs` + - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='promoteur' THEN s.name END) as jury_promoteurs` + - `GROUP_CONCAT(DISTINCT CASE WHEN ts.role='lecteur' THEN s.name END) as jury_lecteurs` - Keep the existing `supervisors` column (all names) for backwards compat - Migration SQL must DROP + CREATE the view #### 4b — `src/Database.php` -- [ ] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with - their role and is_external flag: - ```sql - SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order - FROM thesis_supervisors ts - JOIN supervisors s ON s.id = ts.supervisor_id - WHERE ts.thesis_id = ? - ORDER BY ts.supervisor_order - ``` -- [ ] `setThesisJury(int $thesisId, array $juryMembers): void` — within a transaction: - 1. `DELETE FROM thesis_supervisors WHERE thesis_id = ?` - 2. For each member `[name, role, is_external]`: - - `findOrCreateSupervisor($name)` - - `INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order)` +- [x] `getThesisJury(int $thesisId): array` — fetch all supervisors for a thesis with + their role and is_external flag +- [x] `setThesisJury(int $thesisId, array $juryMembers): void` — delete + re-insert #### 4c — Add form (`public/admin/add.php`) -Remove the following existing fields: -- [ ] Remove `promoteurice` (single internal promoter text input) -- [ ] Remove `promoteurice_externe` (single external promoter text input) - -Add a new **"Composition du jury"** fieldset section: -- [ ] **Président·e** — single text input `name="jury_president"` (one person, always internal) -- [ ] **Promoteur·ice** — single text input `name="jury_promoteur"` with checkbox - `name="jury_promoteur_ext"` value="1" → marks as external -- [ ] **Lecteur·ices** — dynamic list of up to N entries; each row has: - - text input `name="jury_lecteurs[]"` (person name) - - checkbox `name="jury_lecteurs_ext[]"` with matching index → "Externe" - - "Ajouter un·e lecteur·ice" button (JS adds a new row) - - "Supprimer" button per row (JS removes row) - - Minimum 0 lecteurs (field is optional) +- [x] Remove `promoteurice` and `promoteurice_externe` fields +- [x] Add **"Composition du jury"** fieldset: président·e, promoteur·ice + externe checkbox, dynamic lecteur·ices list with JS add/remove #### 4d — Add action (`public/admin/actions/formulaire.php`) -- [ ] Remove parsing of `$_POST['promoteurice']` and `$_POST['promoteurice_externe']` -- [ ] Parse new jury fields: - ```php - $juryMembers = []; - if (!empty($_POST['jury_president'])) { - $juryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0]; - } - if (!empty($_POST['jury_promoteur'])) { - $juryMembers[] = ['name' => trim($_POST['jury_promoteur']), 'role' => 'promoteur', - 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0]; - } - foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) { - if (!empty(trim($name))) { - $juryMembers[] = ['name' => trim($name), 'role' => 'lecteur', - 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0]; - } - } - ``` -- [ ] Replace the supervisor insertion loop with `$db->setThesisJury($thesisId, $juryMembers)` +- [x] Removed old promoteurice parsing; parse jury fields; call `$db->setThesisJury()` #### 4e — Edit form (`public/admin/edit.php`) -- [ ] Load `$jury = $db->getThesisJury($thesisId)` alongside other data -- [ ] Replace old `promoteurice` text input with the same **"Composition du jury"** - fieldset as in add.php; pre-populate fields from `$jury` array -- [ ] In POST handler: remove old supervisor logic; call `$db->setThesisJury()` +- [x] Load `$jury = $db->getThesisJury($thesisId)`; jury fieldset pre-populated from DB +- [x] POST handler calls `$db->setThesisJury()` #### 4f — TFE public page (`public/tfe.php`) -- [ ] Replace "Promoteur·ice interne :" single meta row with three conditional rows: - - "Président·e du jury :" — `$data['jury_president']` (from updated view) - - "Promoteur·ice :" — `$data['promoteurs']` (from updated view) - - "Lecteur·ices :" — `$data['lecteurs']` (from updated view) - - Each row hidden if null/empty +- [x] Three conditional jury rows: Président·e, Promoteur·ice, Lecteur·ices --- @@ -229,48 +181,26 @@ home grid (wider, shorter aspect ratio). #### 5a — Schema / DB -- [ ] **`storage/migrations/005_add_banner.sql`**: - - `ALTER TABLE theses ADD COLUMN banner_path TEXT` — stores path relative to - `STORAGE_ROOT`, same convention as `thesis_files.file_path`; NULL if no banner -- [ ] **`storage/schema.sql`** — add `banner_path TEXT` column to `theses` definition; - update `v_theses_full` to `SELECT t.banner_path` +- [x] **`storage/migrations/005_add_banner.sql`** — `ALTER TABLE theses ADD COLUMN banner_path TEXT` +- [x] **`storage/schema.sql`** — `banner_path TEXT` in `theses`; `t.banner_path` in view #### 5b — Add form (`public/admin/add.php`) -- [ ] Add "Image bannière (page d'accueil)" file input row: - ```html - - ``` -- [ ] Hint: "JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB." -- [ ] Placed after the existing "Image de couverture" row +- [x] "Image bannière" file input added after couverture row #### 5c — Add action (`public/admin/actions/formulaire.php`) -- [ ] Process `$_FILES['banner']` similarly to `$_FILES['couverture']`: - - Allowed MIME: `image/jpeg`, `image/png`, `image/webp` - - Max size: 5 MB - - Save to `STORAGE_ROOT . "/banners/"` with a random hex filename - - Path stored as `"banners/" . $safeFileName` -- [ ] After thesis INSERT: `UPDATE theses SET banner_path = ? WHERE id = ?` - (or include `banner_path` in the initial INSERT column list) +- [x] Banner upload: MIME check, 5 MB cap, save to `banners/`, call `setBannerPath()` #### 5d — Edit form (`public/admin/edit.php`) -- [ ] Display existing banner as `` preview if `$thesis['banner_path']` is set - (served via `/media.php?path=…`) -- [ ] Add file input `name="banner"` to replace/upload new banner -- [ ] Add checkbox `name="remove_banner"` to clear the current banner -- [ ] In POST handler: - - If `remove_banner` checked: `UPDATE theses SET banner_path = NULL WHERE id = ?`; - also unlink the file - - If new file uploaded: process as in 5c; update `banner_path` +- [x] Banner preview img shown; remove_banner checkbox; new banner upload input +- [x] POST handler: unlinks old file on remove; processes new upload via `setBannerPath()` #### 5e — `src/Database.php` -- [ ] `getThesis()` and `getThesisById()` — already return all columns from the view; - after adding `banner_path` to the view, they automatically expose it — verify -- [ ] `getPublishedTheses()` — same: view-sourced, automatic after view update -- [ ] Optionally add `setBannerPath(int $thesisId, ?string $path): void` for clarity +- [x] `banner_path` exposed via view — verified; `getThesisById()` / `getPublishedTheses()` pick it up automatically +- [x] `setBannerPath(int $thesisId, ?string $path): void` added --- diff --git a/public/admin/actions/formulaire.php b/public/admin/actions/formulaire.php index 241a16c..f0b02fb 100644 --- a/public/admin/actions/formulaire.php +++ b/public/admin/actions/formulaire.php @@ -84,9 +84,22 @@ try { $problematique = sanitize_string($_POST["problématique"] ?? ''); $durationInfo = sanitize_string($_POST["duration_info"] ?? ''); - // Supervisor(s) - $promoteuriceRaw = sanitize_string($_POST["promoteurice"] ?? ''); - $supervisorNames = !empty($promoteuriceRaw) ? array_map('trim', explode(',', $promoteuriceRaw)) : []; + // Jury members + $juryMembers = []; + if (!empty(trim($_POST['jury_president'] ?? ''))) { + $juryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0]; + } + if (!empty(trim($_POST['jury_promoteur'] ?? ''))) { + $juryMembers[] = ['name' => trim($_POST['jury_promoteur']), 'role' => 'promoteur', + 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0]; + } + foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) { + $name = trim($name); + if ($name !== '') { + $juryMembers[] = ['name' => $name, 'role' => 'lecteur', + 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0]; + } + } // Keywords (max 10) $tagRaw = sanitize_string($_POST["tag"] ?? ''); @@ -119,6 +132,7 @@ try { // File uploads $couverture = $_FILES["couverture"] ?? null; + $bannerFile = $_FILES["banner"] ?? null; $files = $_FILES["files"] ?? null; // ===== CREATE OR FIND AUTHOR ===== @@ -164,14 +178,8 @@ try { $stmt = $pdo->prepare("INSERT INTO thesis_authors (thesis_id, author_id, author_order) VALUES (?, ?, 1)"); $stmt->execute([$thesisId, $authorId]); - // ===== LINK SUPERVISORS TO THESIS ===== - foreach ($supervisorNames as $index => $supervisorName) { - if (!empty($supervisorName)) { - $supervisorId = $db->findOrCreateSupervisor($supervisorName); - $stmt = $pdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?, ?, ?)"); - $stmt->execute([$thesisId, $supervisorId, $index + 1]); - } - } + // ===== LINK JURY TO THESIS ===== + $db->setThesisJury($thesisId, $juryMembers); // ===== LINK LANGUAGES TO THESIS ===== foreach ($languageIds as $languageId) { @@ -201,7 +209,8 @@ try { // Create necessary directories — outside the webroot (security items #3 & #4). // Files are served through /media.php, never directly via a URL path. $uploadBaseDir = STORAGE_ROOT . "/theses/{$annee}/{$identifier}/"; - $coverDir = STORAGE_ROOT . "/covers/"; + $coverDir = STORAGE_ROOT . "/covers/"; + $bannerDir = STORAGE_ROOT . "/banners/"; if (!file_exists($uploadBaseDir)) { mkdir($uploadBaseDir, 0755, true); @@ -209,6 +218,9 @@ try { if (!file_exists($coverDir)) { mkdir($coverDir, 0755, true); } + if (!file_exists($bannerDir)) { + mkdir($bannerDir, 0755, true); + } // Define security constraints $allowedMimeTypes = ['image/jpeg', 'image/png', 'application/pdf', 'video/mp4', 'application/zip']; @@ -252,6 +264,30 @@ try { } } + // Process banner image + if ($bannerFile && isset($bannerFile["error"]) && $bannerFile["error"] === UPLOAD_ERR_OK) { + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($bannerFile["tmp_name"]); + $fileExtension = strtolower(pathinfo($bannerFile["name"], PATHINFO_EXTENSION)); + $allowedBannerMimes = ['image/jpeg', 'image/png', 'image/webp']; + $allowedBannerExts = ['jpg', 'jpeg', 'png', 'webp']; + $maxBannerSize = 5 * 1024 * 1024; // 5 MB + + if (in_array($mimeType, $allowedBannerMimes) && in_array($fileExtension, $allowedBannerExts) + && $bannerFile["size"] <= $maxBannerSize) { + $randomName = bin2hex(random_bytes(16)); + $safeFileName = $randomName . "." . $fileExtension; + $targetFile = $bannerDir . $safeFileName; + if (move_uploaded_file($bannerFile["tmp_name"], $targetFile)) { + chmod($targetFile, 0644); + $db->setBannerPath($thesisId, "banners/" . $safeFileName); + error_log("Banner image uploaded: " . $safeFileName); + } + } else { + error_log("Invalid or oversized banner image: " . $bannerFile["name"]); + } + } + // Process thesis files if ($files && is_array($files["name"])) { for ($i = 0; $i < count($files["name"]); $i++) { diff --git a/public/admin/add.php b/public/admin/add.php index e9b0675..a853187 100644 --- a/public/admin/add.php +++ b/public/admin/add.php @@ -79,19 +79,67 @@ function wasSelected($key, $value) { value=""> - -
- - -
+ +
+ Composition du jury - -
- - -
+ +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ +
+ +
+ + + +
+
+ +
+
+
@@ -234,6 +282,15 @@ function wasSelected($key, $value) {
+ +
+ +
+ +

JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.

+
+
+
diff --git a/public/admin/edit.php b/public/admin/edit.php index 7c7aa1b..94e50d3 100644 --- a/public/admin/edit.php +++ b/public/admin/edit.php @@ -84,19 +84,23 @@ try { } } - // Update supervisors - $pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]); - $supervisorsRaw = trim($_POST['promoteurice'] ?? ''); - if (!empty($supervisorsRaw)) { - $supervisors = array_map('trim', explode(',', $supervisorsRaw)); - foreach ($supervisors as $index => $supervisorName) { - if (!empty($supervisorName)) { - $supervisorId = $db->findOrCreateSupervisor($supervisorName); - $stmt = $pdo->prepare("INSERT INTO thesis_supervisors (thesis_id, supervisor_id, supervisor_order) VALUES (?, ?, ?)"); - $stmt->execute([$thesisId, $supervisorId, $index + 1]); - } + // Update jury + $editJuryMembers = []; + if (!empty(trim($_POST['jury_president'] ?? ''))) { + $editJuryMembers[] = ['name' => trim($_POST['jury_president']), 'role' => 'president', 'is_external' => 0]; + } + if (!empty(trim($_POST['jury_promoteur'] ?? ''))) { + $editJuryMembers[] = ['name' => trim($_POST['jury_promoteur']), 'role' => 'promoteur', + 'is_external' => isset($_POST['jury_promoteur_ext']) ? 1 : 0]; + } + foreach ($_POST['jury_lecteurs'] ?? [] as $i => $name) { + $name = trim($name); + if ($name !== '') { + $editJuryMembers[] = ['name' => $name, 'role' => 'lecteur', + 'is_external' => isset($_POST['jury_lecteurs_ext'][$i]) ? 1 : 0]; } } + $db->setThesisJury($thesisId, $editJuryMembers); // Update languages $pdo->prepare("DELETE FROM thesis_languages WHERE thesis_id = ?")->execute([$thesisId]); @@ -134,6 +138,38 @@ try { } $db->commit(); + + // Handle banner upload/removal (after commit, outside transaction) + $bannerDir = defined('STORAGE_ROOT') ? STORAGE_ROOT . "/banners/" : null; + if ($bannerDir && !file_exists($bannerDir)) { + mkdir($bannerDir, 0755, true); + } + if (isset($_POST['remove_banner'])) { + // Unlink existing banner file if present + $currentBannerPath = $pdo->query("SELECT banner_path FROM theses WHERE id = $thesisId")->fetchColumn(); + if ($currentBannerPath && $bannerDir) { + $absPath = STORAGE_ROOT . '/' . $currentBannerPath; + if (file_exists($absPath)) unlink($absPath); + } + $db->setBannerPath($thesisId, null); + } elseif (isset($_FILES['banner']) && $_FILES['banner']['error'] === UPLOAD_ERR_OK && $bannerDir) { + $bannerFile = $_FILES['banner']; + $finfo = new finfo(FILEINFO_MIME_TYPE); + $mimeType = $finfo->file($bannerFile["tmp_name"]); + $fileExtension = strtolower(pathinfo($bannerFile["name"], PATHINFO_EXTENSION)); + $allowedBannerMimes = ['image/jpeg', 'image/png', 'image/webp']; + $allowedBannerExts = ['jpg', 'jpeg', 'png', 'webp']; + if (in_array($mimeType, $allowedBannerMimes) && in_array($fileExtension, $allowedBannerExts) + && $bannerFile["size"] <= 5 * 1024 * 1024) { + $randomName = bin2hex(random_bytes(16)); + $safeFileName = $randomName . '.' . $fileExtension; + if (move_uploaded_file($bannerFile["tmp_name"], $bannerDir . $safeFileName)) { + chmod($bannerDir . $safeFileName, 0644); + $db->setBannerPath($thesisId, "banners/" . $safeFileName); + } + } + } + $success = "TFE mis à jour avec succès!"; // Regenerate CSRF token @@ -162,6 +198,9 @@ try { $stmt->execute([$thesisId]); $currentFormats = $stmt->fetchAll(PDO::FETCH_COLUMN); + // Load jury + $jury = $db->getThesisJury($thesisId); + // Load reference data $orientations = $db->getAllOrientations(); $apPrograms = $db->getAllAPPrograms(); @@ -195,7 +234,7 @@ try {
-
+
@@ -251,11 +290,95 @@ try {
-
- - -
+ + +
+ Composition du jury + +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+
+ +
+ + + +
+ + $lm): ?> +
+ + + +
+ + +
+ +
+
+
+
@@ -334,6 +457,25 @@ try { value="">
+ +
+ +
+ +
+ Bannière actuelle + +
+ + +

JPG, PNG ou WEBP. Format paysage recommandé (4:1). Max 5 MB.

+
+
+
- +
- Promoteur·ice interne : - + Président·e du jury : + +
+ + + +
+ Promoteur·ice : + +
+ + + +
+ Lecteur·ices : +
diff --git a/src/Database.php b/src/Database.php index b303718..1980fcc 100644 --- a/src/Database.php +++ b/src/Database.php @@ -731,6 +731,60 @@ class Database { return $this->getLicenseTypes(); } + // ======================================================================== + // JURY METHODS + // ======================================================================== + + /** + * Fetch all jury members for a thesis, with role and is_external flag. + */ + public function getThesisJury(int $thesisId): array { + $stmt = $this->pdo->prepare(" + SELECT s.id, s.name, ts.role, ts.is_external, ts.supervisor_order + FROM thesis_supervisors ts + JOIN supervisors s ON s.id = ts.supervisor_id + WHERE ts.thesis_id = ? + ORDER BY ts.supervisor_order + "); + $stmt->execute([$thesisId]); + return $stmt->fetchAll(); + } + + /** + * Replace the entire jury for a thesis in a single transaction. + * $juryMembers: array of ['name' => string, 'role' => string, 'is_external' => int] + */ + public function setThesisJury(int $thesisId, array $juryMembers): void { + $this->pdo->prepare("DELETE FROM thesis_supervisors WHERE thesis_id = ?")->execute([$thesisId]); + $stmt = $this->pdo->prepare(" + INSERT INTO thesis_supervisors (thesis_id, supervisor_id, role, is_external, supervisor_order) + VALUES (?, ?, ?, ?, ?) + "); + foreach ($juryMembers as $order => $member) { + $name = trim($member['name'] ?? ''); + if ($name === '') continue; + $supervisorId = $this->findOrCreateSupervisor($name); + $role = in_array($member['role'], ['president', 'promoteur', 'lecteur']) + ? $member['role'] : 'promoteur'; + $isExternal = isset($member['is_external']) ? (int)$member['is_external'] : 0; + $stmt->execute([$thesisId, $supervisorId, $role, $isExternal, $order + 1]); + } + } + + // ======================================================================== + // BANNER METHODS + // ======================================================================== + + /** + * Set (or clear) the banner_path for a thesis. + */ + public function setBannerPath(int $thesisId, ?string $path): void { + $stmt = $this->pdo->prepare( + "UPDATE theses SET banner_path = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?" + ); + $stmt->execute([$path, $thesisId]); + } + /** * Insert a thesis file record */ diff --git a/src/cache/rate_limit/f528764d624db129b32c21fbca0cb8d6.json b/src/cache/rate_limit/f528764d624db129b32c21fbca0cb8d6.json index accc126..1eafea7 100644 --- a/src/cache/rate_limit/f528764d624db129b32c21fbca0cb8d6.json +++ b/src/cache/rate_limit/f528764d624db129b32c21fbca0cb8d6.json @@ -1 +1 @@ -[1772461679,1772461686] \ No newline at end of file +[1774354709] \ No newline at end of file diff --git a/storage/migrations/004_jury_roles.sql b/storage/migrations/004_jury_roles.sql new file mode 100644 index 0000000..1ef62f6 --- /dev/null +++ b/storage/migrations/004_jury_roles.sql @@ -0,0 +1,66 @@ +-- Migration 004: Add jury role and is_external columns to thesis_supervisors +-- Existing rows get role = 'promoteur', is_external = 0 (no data loss) + +ALTER TABLE thesis_supervisors ADD COLUMN role TEXT NOT NULL DEFAULT 'promoteur'; +ALTER TABLE thesis_supervisors ADD COLUMN is_external INTEGER NOT NULL DEFAULT 0; + +-- Recreate v_theses_full to include jury role columns +DROP VIEW IF EXISTS v_theses_full; + +CREATE VIEW v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + t.duration_minutes, + t.duration_pages, + t.file_size_info, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + GROUP_CONCAT(DISTINCT a.name) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' THEN s.name END) as jury_lecteurs, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT k.keyword) as keywords +FROM theses t +LEFT JOIN orientations o ON t.orientation_id = o.id +LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_keywords tk ON t.id = tk.thesis_id +LEFT JOIN keywords k ON tk.keyword_id = k.id +GROUP BY t.id; + +-- Recreate public view +DROP VIEW IF EXISTS v_theses_public; + +CREATE VIEW v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/storage/migrations/005_add_banner.sql b/storage/migrations/005_add_banner.sql new file mode 100644 index 0000000..614b593 --- /dev/null +++ b/storage/migrations/005_add_banner.sql @@ -0,0 +1,65 @@ +-- Migration 005: Add banner_path column to theses for home page card thumbnails + +ALTER TABLE theses ADD COLUMN banner_path TEXT; + +-- Recreate v_theses_full to include banner_path +DROP VIEW IF EXISTS v_theses_full; + +CREATE VIEW v_theses_full AS +SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + t.duration_minutes, + t.duration_pages, + t.file_size_info, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.jury_points, + t.submitted_at, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.banner_path, + GROUP_CONCAT(DISTINCT a.name) as authors, + GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' THEN s.name END) as jury_lecteurs, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT k.keyword) as keywords +FROM theses t +LEFT JOIN orientations o ON t.orientation_id = o.id +LEFT JOIN ap_programs ap ON t.ap_program_id = ap.id +LEFT JOIN finality_types ft ON t.finality_id = ft.id +LEFT JOIN access_types at ON t.access_type_id = at.id +LEFT JOIN license_types lt ON t.license_id = lt.id +LEFT JOIN thesis_authors ta ON t.id = ta.thesis_id +LEFT JOIN authors a ON ta.author_id = a.id +LEFT JOIN thesis_supervisors ts ON t.id = ts.thesis_id +LEFT JOIN supervisors s ON ts.supervisor_id = s.id +LEFT JOIN thesis_languages tl ON t.id = tl.thesis_id +LEFT JOIN languages l ON tl.language_id = l.id +LEFT JOIN thesis_formats tf ON t.id = tf.thesis_id +LEFT JOIN format_types fmt ON tf.format_id = fmt.id +LEFT JOIN thesis_keywords tk ON t.id = tk.thesis_id +LEFT JOIN keywords k ON tk.keyword_id = k.id +GROUP BY t.id; + +-- Recreate public view +DROP VIEW IF EXISTS v_theses_public; + +CREATE VIEW v_theses_public AS +SELECT * FROM v_theses_full +WHERE is_published = 1; diff --git a/storage/schema.sql b/storage/schema.sql index 2325f02..b5eb6de 100644 --- a/storage/schema.sql +++ b/storage/schema.sql @@ -191,6 +191,9 @@ CREATE TABLE IF NOT EXISTS theses ( -- External links baiu_link TEXT, -- Link to institutional repository + -- Home page card banner (optional, landscape image) + banner_path TEXT, -- path relative to STORAGE_ROOT (e.g. "banners/abc.jpg") + -- Timestamps created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -217,11 +220,13 @@ CREATE TABLE IF NOT EXISTS thesis_authors ( FOREIGN KEY (author_id) REFERENCES authors(id) ON DELETE CASCADE ); --- Supervisors per thesis (can have multiple promoters) +-- Supervisors per thesis (jury: president, promoteur, lecteurs) CREATE TABLE IF NOT EXISTS thesis_supervisors ( thesis_id INTEGER NOT NULL, supervisor_id INTEGER NOT NULL, supervisor_order INTEGER DEFAULT 1, + role TEXT NOT NULL DEFAULT 'promoteur', -- 'president'|'promoteur'|'lecteur' + is_external INTEGER NOT NULL DEFAULT 0, -- 0 = internal, 1 = external PRIMARY KEY (thesis_id, supervisor_id), FOREIGN KEY (thesis_id) REFERENCES theses(id) ON DELETE CASCADE, FOREIGN KEY (supervisor_id) REFERENCES supervisors(id) ON DELETE CASCADE @@ -360,14 +365,19 @@ SELECT t.file_size_info, at.name as access_type, lt.name as license_type, + t.license_id, t.jury_points, t.submitted_at, t.defense_date, t.published_at, t.is_published, t.baiu_link, + t.banner_path, GROUP_CONCAT(DISTINCT a.name) as authors, GROUP_CONCAT(DISTINCT s.name) as supervisors, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'president' THEN s.name END) as jury_president, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' THEN s.name END) as jury_lecteurs, GROUP_CONCAT(DISTINCT l.name) as languages, GROUP_CONCAT(DISTINCT fmt.name) as formats, GROUP_CONCAT(DISTINCT k.keyword) as keywords diff --git a/storage/test.db b/storage/test.db index 188282815edd83a464d1b8ba866a3867b0c0891d..707201cf05bd07c10ba5ff2d070a593b66c462e6 100644 GIT binary patch delta 643 zcmZoTz}s+ucY?Iw3I+xSGa!Zmy@@);tScDwCO+7hGLfHAzxf;g_HX=*+zFB_&I}xk z>I@t?>|Lx!*nJt*`FAt@;ICq7+AdYV$jivp>?gr4ZfMBZ=04rGgmHU-LQ#HBszQis zM2Lc)KM;rd_$auzx;cjWgea&N6y@jUm!y^!sp}|Y7RRSnl%y8rCFUr2`h~c7`+%-*j*VLH#XKXZa-4W*vQDlzJ0oTIb#6}&?j3bHg4qV z;}&5Tmy~3jUAX;VIpZ2uL4~Bmyu8$+_=3ce46wVl=hQN~GqTxBvxx`mPCr=BD8GGv zJ!9$yHW7vu47`7)a~UwHaPZslZs0XxpD?kpX!{BSrcN#L3pGFS1I=pngi`U*+r8fRz6U{{534_7~hl43n@0NR2AE44T?B{i=^T|prPBv-7L zmzbNX;Ogh1sgPK#kX2e#84p(kGDyY+he5DV$7>K&Q8Cyg2dpOLq$Y!7PXP#UItij^ dJDU{qDrTlC^Xbas%vIYr$TIgaE>mE3002qh#UlU! delta 304 zcmZoTz}s+ucY?IwTm}XPGa!Zm<%v4RtaBOkx|Lx!*nL@?`FAt@;ICrbwq2@#k(Y7fgo}*Ze-tr#GjcW6NwA9>8Zx%3Pd6-N z+&+C`8Kdm<&?3eq+mDtqHZU^rZJzF4&RD?JTPeaWE-A@4+jo0e1>+jl?Iv}MZj3BI zryQTyc$m#znoT@dclyD4M)~dY>lsrwun9A)VBp&~oy&kpg^e$RcLV!`iH#-OR~RsL zaxpRYZ*1&l-u}my$%%2B0~5;yK2~-`27WuRy1DF%8yorAnW~JZ|Bz*_;_4AFVi#3c w=a`k>DYD_1?E5Y0Hf+zYybcN