diff --git a/.gitignore b/.gitignore index cccba5e..efdd03a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ app/storage/test.db *.db *.db-wal *.db-shm +*.db.gz app/.env ### Logs ### diff --git a/.ignore b/.ignore index fc13db9..1038b19 100644 --- a/.ignore +++ b/.ignore @@ -3,3 +3,4 @@ nginx src/cache/rate_limit *.db-wal *.db-shm +*.db.gz diff --git a/app/bootstrap.php b/app/bootstrap.php index a4c60f1..fdf3872 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -10,6 +10,42 @@ $autoloadPath = file_exists(__DIR__ . '/vendor/autoload.php') : __DIR__ . '/../vendor/autoload.php'; require_once $autoloadPath; +// The site serves the ERG (Brussels), but the server + SQLite timestamps run in +// UTC (CURRENT_TIMESTAMP). We intentionally do NOT change the PHP default +// timezone: code that writes timestamps via date()/time() (e.g. one-time +// tokens, share-link expiries) must stay wall-clock consistent with SQLite's +// CURRENT_TIMESTAMP (UTC), otherwise expiries drift by the UTC↔Brussels offset. +// Conversion to Brussels is handled explicitly at the display layer via +// db_datetime() below. + +/** + * Format a database-stored datetime for display in Brussels local time. + * + * SQLite timestamps (submitted_at, created_at, published_at, …) are written + * with CURRENT_TIMESTAMP (= UTC). This parses the value as UTC and converts it + * to Europe/Brussels (UTC+1 winter / UTC+2 summer) — independent of the PHP + * default timezone, so it never affects DB write/read consistency elsewhere. + */ +function db_datetime(string $raw, string $format = 'd/m/Y à H:i'): string +{ + if ($raw === '') { + return ''; + } + + $dt = DateTime::createFromFormat('Y-m-d H:i:s', $raw, new DateTimeZone('UTC')); + if ($dt === false) { + // Non-ISO fallback: parse the naive string as UTC. + $ts = strtotime($raw . ' UTC'); + if ($ts === false) { + return $raw; + } + $dt = (new DateTime('@' . $ts))->setTimezone(new DateTimeZone('UTC')); + } + + $dt->setTimezone(new DateTimeZone('Europe/Brussels')); + return $dt->format($format); +} + // Define application root define('APP_ROOT', __DIR__); diff --git a/app/migrations/applied/044_add_date_depot.php b/app/migrations/applied/044_add_date_depot.php new file mode 100644 index 0000000..caf2c08 --- /dev/null +++ b/app/migrations/applied/044_add_date_depot.php @@ -0,0 +1,129 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$pdo->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + +// 1. Add the column if missing. +$cols = array_column($pdo->query("PRAGMA table_info(theses)")->fetchAll(), 'name'); +if (!in_array('date_depot', $cols, true)) { + $pdo->exec("ALTER TABLE theses ADD COLUMN date_depot DATETIME"); + echo "Added column theses.date_depot\n"; +} else { + echo "Column theses.date_depot already present\n"; +} + +// 2. Backfill: for any thesis with a submitted_at but no date_depot, copy it. +// (Conservative — preserves the value so display still works; the real +// deposit date must be supplied via a later CSV import for bulk-imported rows.) +$updated = $pdo->exec( + "UPDATE theses SET date_depot = submitted_at WHERE date_depot IS NULL AND submitted_at IS NOT NULL" +); +echo "Backfilled date_depot from submitted_at for {$updated} row(s)\n"; + +// 3. Recreate v_theses_full so it exposes date_depot. +$pdo->exec("DROP VIEW IF EXISTS v_theses_full"); +$pdo->exec( + "CREATE VIEW v_theses_full AS + SELECT + t.id, + t.identifier, + t.title, + t.subtitle, + t.year, + t.is_doctoral, + t.objet, + o.name as orientation, + ap.name as ap_program, + ft.name as finality_type, + t.synopsis, + t.context_note, + t.duration_pages, + t.duration_minutes, + t.has_annexes, + at.name as access_type, + lt.name as license_type, + t.license_id, + t.license_custom, + t.access_type_id, + t.jury_points, + t.submitted_at, + t.date_depot, + t.defense_date, + t.published_at, + t.is_published, + t.baiu_link, + t.exemplaire_baiu, + t.exemplaire_erg, + t.cc2r, + t.remarks, + t.jury_note_added, + t.contact_visible, + GROUP_CONCAT(DISTINCT a.name ORDER BY a.name ASC) 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' AND ts.is_ulb = 0 THEN s.name END) as jury_promoteurs, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'promoteur' AND ts.is_ulb = 1 THEN s.name END) as jury_promoteurs_ulb, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 0 THEN s.name END) as jury_lecteurs_internes, + GROUP_CONCAT(DISTINCT CASE WHEN ts.role = 'lecteur' AND ts.is_external = 1 THEN s.name END) as jury_lecteurs_externes, + GROUP_CONCAT(DISTINCT l.name) as languages, + GROUP_CONCAT(DISTINCT fmt.name) as formats, + GROUP_CONCAT(DISTINCT tg.name) as keywords, + (SELECT a2.email FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_interne, + (SELECT a2.show_contact FROM authors a2 JOIN thesis_authors ta2 ON a2.id = ta2.author_id WHERE ta2.thesis_id = t.id ORDER BY ta2.author_order LIMIT 1) as contact_public + 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_tags tt ON t.id = tt.thesis_id + LEFT JOIN tags tg ON tt.tag_id = tg.id + GROUP BY t.id;" +); +echo "Recreated v_theses_full with date_depot\n"; + +// v_theses_public is SELECT * FROM v_theses_full, so it inherits the new column +// automatically; re-assert it in case the old view is still cached. +$pdo->exec("DROP VIEW IF EXISTS v_theses_public"); +$pdo->exec("CREATE VIEW IF NOT EXISTS v_theses_public AS SELECT * FROM v_theses_full WHERE is_published = 1;"); +echo "Recreated v_theses_public\n"; + +echo "Migration 044 complete.\n"; diff --git a/app/public/admin/index.php b/app/public/admin/index.php index 0f8d943..3a34a84 100644 --- a/app/public/admin/index.php +++ b/app/public/admin/index.php @@ -143,6 +143,51 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { return ($raw === 'oui' || $raw === '1' || $raw === 'yes' || $raw === 'true') ? 1 : 0; }; + // Helper: parse a bulk-import date cell into a UTC 'Y-m-d H:i:s' string. + // The cell is authored in Brussels local time (e.g. "12/05/2026", + // "12/05/2026 14:30", ISO "2026-05-12"). We interpret it in the + // Europe/Brussels timezone and convert to UTC for storage, matching + // the rest of the DB (SQLite CURRENT_TIMESTAMP) and the db_datetime() + // display helper which converts UTC → Brussels on read. + $parseBulkDate = function(string $raw): ?string { + $raw = trim($raw); + if ($raw === '') return null; + + // Try a set of formats (d/m/Y first — the CSV convention). + // Date-only formats (no time component) are anchored to midnight + // local time; PHP otherwise fills a missing time with *now*. + $formats = [ + 'd/m/Y H:i:s', 'd/m/Y H:i', 'd/m/Y', + 'Y-m-d H:i:s', 'Y-m-d H:i', 'Y-m-d', + 'd.m.Y H:i', 'd.m.Y', + ]; + foreach ($formats as $fmt) { + $dt = DateTime::createFromFormat($fmt, $raw, new DateTimeZone('Europe/Brussels')); + if ($dt === false) { + continue; + } + // Reject partial parses (e.g. "12/05/20xx" residual garbage). + $errors = DateTime::getLastErrors(); + if ($errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0)) { + continue; + } + // Anchor date-only values to midnight local time. + if (!str_contains($fmt, 'H') && !str_contains($fmt, ':')) { + $dt->setTime(0, 0, 0); + } + $dt->setTimezone(new DateTimeZone('UTC')); + return $dt->format('Y-m-d H:i:s'); + } + + // ISO fallback via strtotime (assumes Brussels for naive strings). + $ts = strtotime($raw . ' Europe/Brussels'); + if ($ts !== false) { + return gmdate('Y-m-d H:i:s', $ts); + } + + return null; + }; + // Code → canonical name (legacy short-code CSV format) $orientationCodeMap = [ 'SC'=>'Sculpture','VI'=>'Vidéographie','CA'=>"Cinéma d'animation", @@ -305,6 +350,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { $contactVisible = $cell($row, 'contact-visible'); // second "contact", key won't match header → positional $objetRaw = $cell($row, 'objet'); $objet = in_array($objetRaw, ['tfe', 'thèse', 'frart'], true) ? $objetRaw : 'tfe'; + $dateDepotRaw = $cell($row, 'dépôt'); + $dateDepot = parseBulkDate($dateDepotRaw); // Brussels-local → UTC (Y-m-d H:i:s) or null if ($title === '' || $year === 0) { $missing = []; @@ -366,8 +413,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { cc2r, exemplaire_baiu, exemplaire_erg, objet, contact_visible, duration_pages, duration_minutes, has_annexes, - is_published, status, submitted_at - ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP) + is_published, status, submitted_at, date_depot + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,0,'active',CURRENT_TIMESTAMP,?) "); $s->execute([ !empty($identifier) ? $identifier : null, $title, @@ -389,6 +436,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['csv_file'])) { $durationPages, $durationMinutes, $hasAnnexes, + $dateDepot, ]); $thesisId = $importPdo->lastInsertId(); diff --git a/app/src/Controllers/ExportController.php b/app/src/Controllers/ExportController.php index 8762d29..de0dbd8 100644 --- a/app/src/Controllers/ExportController.php +++ b/app/src/Controllers/ExportController.php @@ -290,6 +290,7 @@ class ExportController ['Licence personnalisée', 'license_custom', 'licence-perso'], ['Contact visible', 'contact_visible', 'contact-visible'], ['Objet', 'objet', 'objet'], + ['Date de dépôt', 'date_depot', 'dépôt'], ]; /** @@ -336,6 +337,7 @@ class ExportController 'Licence personnalisée', 'Contact visible', 'Objet', + 'Date de dépôt', ]; /** @@ -461,6 +463,7 @@ class ExportController $t['license_custom'] ?? '', $t['contact_visible'] ?? '', $t['objet'] ?? '', + !empty($t['date_depot']) ? db_datetime((string)$t['date_depot'], 'd/m/Y H:i') : '', ]; } diff --git a/app/src/Database.php b/app/src/Database.php index c96e0d7..cd1aba2 100644 --- a/app/src/Database.php +++ b/app/src/Database.php @@ -2722,7 +2722,8 @@ class Database t.duration_pages, t.duration_minutes, t.has_annexes, - t.objet + t.objet, + t.date_depot 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 diff --git a/app/storage/backups/db-2026-05-11T01-03-26.db.gz b/app/storage/backups/db-2026-05-11T01-03-26.db.gz deleted file mode 100644 index c846d23..0000000 Binary files a/app/storage/backups/db-2026-05-11T01-03-26.db.gz and /dev/null differ diff --git a/app/storage/schema.sql b/app/storage/schema.sql index ad6b458..f6048d2 100644 --- a/app/storage/schema.sql +++ b/app/storage/schema.sql @@ -99,6 +99,7 @@ CREATE TABLE IF NOT EXISTS theses ( jury_points DECIMAL(4,2), jury_note_added BOOLEAN DEFAULT 0, submitted_at DATETIME, + date_depot DATETIME, -- real TFE deposit date (bulk-import source, Brussels-local parse → UTC storage) defense_date DATETIME, published_at DATETIME, is_published BOOLEAN DEFAULT 0, @@ -447,6 +448,7 @@ SELECT t.access_type_id, t.jury_points, t.submitted_at, + t.date_depot, t.defense_date, t.published_at, t.is_published, diff --git a/app/templates/admin/recapitulatif.php b/app/templates/admin/recapitulatif.php index b8e7ba8..516df01 100644 --- a/app/templates/admin/recapitulatif.php +++ b/app/templates/admin/recapitulatif.php @@ -178,18 +178,21 @@
@@ -273,7 +276,7 @@