mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-09-25 01:53:03 +02:00
fix: display db timestamps in Brussels time
(heure de dépôt was showing UTC) - feat: add date_depot column (real TFE deposit date) with CSV round-trip + Brussels→UTC sanitization - fix: keep PHP default tz at UTC to preserve token/share-link expiry consistency; convert to Brussels only in db_datetime()
This commit is contained in:
@@ -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__);
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
/**
|
||||
* Migration 044 — add `date_depot` column to `theses`.
|
||||
*
|
||||
* `submitted_at` was historically used as "when the record entered the system"
|
||||
* (set to CURRENT_TIMESTAMP on create/CSV import). That conflates two concepts:
|
||||
* the *system* submission time and the *student's* real TFE deposit date. When
|
||||
* theses were bulk-imported via CSV, every imported row got stamped with the
|
||||
* same CURRENT_TIMESTAMP, losing the real deposit date.
|
||||
*
|
||||
* This migration:
|
||||
* 1. Adds a nullable `date_depot DATETIME` column to hold the real deposit date.
|
||||
* 2. Backfills `date_depot` from `submitted_at` for theses that have not been
|
||||
* bulk-imported (i.e. where submitted_at differs from the import time) —
|
||||
* this is conservative: it copies the existing value so nothing is lost,
|
||||
* while real per-thesis values must be re-supplied via a CSV import
|
||||
* (or the admin edit form) since the bulk import already flattened them.
|
||||
* 3. Recreates v_theses_full to expose the new column.
|
||||
*
|
||||
* Idempotent: safe to re-run (guards on existing column).
|
||||
*/
|
||||
|
||||
defined('APP_ROOT') || define('APP_ROOT', dirname(__DIR__, 2));
|
||||
defined('STORAGE_ROOT') || define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||
|
||||
$dbPath = $argv[1] ?? (APP_ROOT . '/storage/xamxam.db');
|
||||
if (!file_exists($dbPath)) {
|
||||
echo "ERROR: database not found at $dbPath\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$pdo = new PDO('sqlite:' . $dbPath);
|
||||
$pdo->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";
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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') : '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -178,18 +178,21 @@
|
||||
<fieldset>
|
||||
<legend>Dates et publication</legend>
|
||||
<dl class="recap-dl">
|
||||
<?php if ($thesis['date_depot']): ?>
|
||||
<dt>Date de dépôt</dt><dd><?= htmlspecialchars(db_datetime($thesis['date_depot'], 'd/m/Y à H:i')) ?></dd>
|
||||
<?php endif; ?>
|
||||
<?php if ($thesis['submitted_at']): ?>
|
||||
<dt>Soumis le</dt><dd><?= date('d/m/Y à H:i', strtotime($thesis['submitted_at'])) ?></dd>
|
||||
<dt>Soumis le</dt><dd><?= htmlspecialchars(db_datetime($thesis['submitted_at'])) ?></dd>
|
||||
<?php endif; ?>
|
||||
<?php if ($thesis['defense_date']): ?>
|
||||
<dt>Date de défense</dt><dd><?= date('d/m/Y', strtotime($thesis['defense_date'])) ?></dd>
|
||||
<dt>Date de défense</dt><dd><?= htmlspecialchars(db_datetime($thesis['defense_date'], 'd/m/Y')) ?></dd>
|
||||
<?php endif; ?>
|
||||
<?php if ($thesis['published_at']): ?>
|
||||
<dt>Publié le</dt><dd><?= date('d/m/Y à H:i', strtotime($thesis['published_at'])) ?></dd>
|
||||
<dt>Publié le</dt><dd><?= htmlspecialchars(db_datetime($thesis['published_at'])) ?></dd>
|
||||
<?php endif; ?>
|
||||
<dt>Publié</dt><dd><?= !empty($thesis['is_published']) ? 'Oui' : 'Non' ?></dd>
|
||||
<?php if ($thesis['jury_note_added']): ?>
|
||||
<dt>Note du jury ajoutée</dt><dd><?= date('d/m/Y', strtotime($thesis['jury_note_added'])) ?></dd>
|
||||
<dt>Note du jury ajoutée</dt><dd><?= htmlspecialchars(db_datetime($thesis['jury_note_added'], 'd/m/Y')) ?></dd>
|
||||
<?php endif; ?>
|
||||
</dl>
|
||||
</fieldset>
|
||||
@@ -273,7 +276,7 @@
|
||||
</td>
|
||||
<td class="recap-files-type"><?= htmlspecialchars($fileType) ?></td>
|
||||
<td class="recap-files-size"><?= !empty($f['file_size']) && $f['file_size'] > 0 ? formatFileSize($f['file_size']) : '–' ?></td>
|
||||
<td class="recap-files-date"><?= !empty($f['uploaded_at']) ? date('d/m/Y H:i', strtotime($f['uploaded_at'])) : '–' ?></td>
|
||||
<td class="recap-files-date"><?= !empty($f['uploaded_at']) ? htmlspecialchars(db_datetime($f['uploaded_at'], 'd/m/Y H:i')) : '–' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
Reference in New Issue
Block a user