fix: traiter le premier lot de tickets

Refs #4 #5 #6 #9 #12 #16 #18 #20
This commit is contained in:
Alexandre NOEL 2026-09-07 23:31:29 +02:00
commit 04cbe54dd1
14 changed files with 87 additions and 12 deletions

View file

@ -2,6 +2,16 @@
Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/).
## [1.0.0-rc.7] — 2026-09-07
### Premier lot de tickets
- création et réparation automatique du cache de géocodage afin d’éviter les erreurs lors de l’enregistrement d’un animal, des paramètres ou d’un logo (`#5`, `#16`) ;
- messages explicites lorsqu’une espèce ou une salle existe déjà (`#9`, `#12`) ;
- retour visuel immédiat sur le choix « Donné » ou « Non donné » pendant une tournée (`#20`) ;
- publication de plusieurs animaux adoptables dans le jeu de démonstration (`#18`) ;
- validation française des adresses électroniques et confirmation des URL HTTPS invalides (`#4`, `#6`).
## [1.0.0-rc.6] — 2026-09-07 ## [1.0.0-rc.6] — 2026-09-07
### Compatibilité des ressources ### Compatibilité des ressources

View file

@ -33,3 +33,7 @@ Ajout d’une bibliothèque d’images pour les contenus publics, simplification
## `1.0.0-rc.6` — Compatibilité des ressources ## `1.0.0-rc.6` — Compatibilité des ressources
Remplacement de la police emoji par un format vectoriel accepté par les navigateurs et renforcement de la publication des médias par le paquet YunoHost. Remplacement de la police emoji par un format vectoriel accepté par les navigateurs et renforcement de la publication des médias par le paquet YunoHost.
## `1.0.0-rc.7` — Premier lot de tickets
Fiabilisation du géocodage et des formulaires de configuration, amélioration des tournées de soins et publication d’animaux adoptables dans le jeu de démonstration.

View file

@ -1 +1 @@
1.0.0-rc.6 1.0.0-rc.7

View file

@ -266,6 +266,11 @@ final class SettingsController
$color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? '')) $color = preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($new['color'] ?? ''))
? (string) $new['color'] ? (string) $new['color']
: '#6c757d'; : '#6c757d';
$duplicate = $db->prepare('SELECT 1 FROM shelter_rooms WHERE code=? OR name=? COLLATE NOCASE');
$duplicate->execute([$code, $newName]);
if ($duplicate->fetchColumn()) {
throw new RuntimeException(t('rooms.duplicate'));
}
$db->prepare( $db->prepare(
'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)', 'INSERT INTO shelter_rooms(code,name,room_type,status_code,color,bulk_validation,sort_order) VALUES(?,?,?,?,?,?,?)',
)->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]); )->execute([$code, $newName, $type, $status, $color, isset($new['bulk_validation']) ? 1 : 0, 100]);
@ -351,6 +356,13 @@ final class SettingsController
exit(); exit();
} }
try { try {
$duplicate = $db->prepare(
'SELECT 1 FROM ref_species WHERE id<>:id AND (code=:code OR name=:name COLLATE NOCASE)',
);
$duplicate->execute([':id' => $id, ':code' => $code, ':name' => $name]);
if ($duplicate->fetchColumn()) {
throw new RuntimeException(t('settings.species_duplicate'));
}
if ($id > 0) { if ($id > 0) {
$old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id'); $old = $db->prepare('SELECT code,is_system FROM ref_species WHERE id=:id');
$old->execute([':id' => $id]); $old->execute([':id' => $id]);

View file

@ -375,6 +375,16 @@ final class DemoDataService
$animalIds[$name] = (int) $db->lastInsertId(); $animalIds[$name] = (int) $db->lastInsertId();
} }
$publish = $db->prepare(
"UPDATE animals SET website_published=1,website_description=? WHERE id=? AND adoption_availability='available'",
);
foreach (['Moka', 'Pixel', 'Caramel', 'Plume', 'Lune', 'Soleil'] as $name) {
$publish->execute([
'Fiche fictive publiée pour découvrir le fonctionnement du site public de démonstration.',
$animalIds[$name],
]);
}
$db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([ $db->prepare('INSERT INTO litters(mother_id,birth_date,notes) VALUES(?,?,?)')->execute([
$animalIds['Plume'], $animalIds['Plume'],
$animals[6][3], $animals[6][3],

View file

@ -9,7 +9,12 @@ final class GeoService
return null; return null;
} }
// Cache DB // Garde-fou pour les bases anciennes dans lesquelles la table de cache
// n'avait jamais été créée par une migration dédiée.
$db->exec(
"CREATE TABLE IF NOT EXISTS geocode_cache (q TEXT PRIMARY KEY, lat REAL, lng REAL, raw_json TEXT, fetched_at TEXT NOT NULL DEFAULT (datetime('now')))",
);
$st = $db->prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1'); $st = $db->prepare('SELECT lat,lng FROM geocode_cache WHERE q=:q LIMIT 1');
$st->execute([':q' => $q]); $st->execute([':q' => $q]);
$row = $st->fetch(PDO::FETCH_ASSOC); $row = $st->fetch(PDO::FETCH_ASSOC);
@ -17,7 +22,6 @@ final class GeoService
return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']]; return ['lat' => (float) $row['lat'], 'lng' => (float) $row['lng']];
} }
// Nominatim (OpenStreetMap) — respecter l'usage: User-Agent + requêtes raisonnables
$url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q); $url = 'https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=' . rawurlencode($q);
$ch = curl_init($url); $ch = curl_init($url);
@ -43,7 +47,6 @@ final class GeoService
$json = json_decode($body, true); $json = json_decode($body, true);
if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) { if (!is_array($json) || empty($json[0]['lat']) || empty($json[0]['lon'])) {
// Stocker "vide" en cache évite de spammer
$db->prepare( $db->prepare(
"INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))", "INSERT OR REPLACE INTO geocode_cache(q,lat,lng,raw_json,fetched_at) VALUES(:q,NULL,NULL,:raw,datetime('now'))",
)->execute([':q' => $q, ':raw' => $body]); )->execute([':q' => $q, ':raw' => $body]);

View file

@ -186,6 +186,7 @@
function frenchMessage(field) { function frenchMessage(field) {
if (field.validity.valueMissing) return <?= json_encode(t('auth.required'), JSON_UNESCAPED_UNICODE) ?> ; if (field.validity.valueMissing) return <?= json_encode(t('auth.required'), JSON_UNESCAPED_UNICODE) ?> ;
if (field.validity.typeMismatch) return <?= json_encode(t('settings.invalid_email'), JSON_UNESCAPED_UNICODE) ?> ;
if (field.validity.tooShort) return <?= json_encode(t('auth.password_min', ['count' => '__COUNT__']), if (field.validity.tooShort) return <?= json_encode(t('auth.password_min', ['count' => '__COUNT__']),
JSON_UNESCAPED_UNICODE) ?> .replace('__COUNT__', field.minLength); JSON_UNESCAPED_UNICODE) ?> .replace('__COUNT__', field.minLength);
if (field.validity.patternMismatch) return <?= json_encode(t('auth.invalid_format'), if (field.validity.patternMismatch) return <?= json_encode(t('auth.invalid_format'),

View file

@ -134,6 +134,17 @@ $fieldOptions = [
border-color: #206bc4 border-color: #206bc4
} }
.care-treatment-choice:has(input:checked) {
color: #fff;
border-color: var(--tblr-primary);
background: var(--tblr-primary)
}
.care-treatment-choice.btn-outline-danger:has(input:checked) {
border-color: var(--tblr-danger);
background: var(--tblr-danger)
}
@media(max-width:800px) { @media(max-width:800px) {
.care-box { .care-box {
grid-column: span 6 !important grid-column: span 6 !important
@ -341,12 +352,12 @@ $fieldOptions = [
class="card card-sm bg-purple-lt mb-2"> class="card card-sm bg-purple-lt mb-2">
<div class="card-body py-2"><strong>💊 <?= h($t['medication_name']) ?></strong> <div class="card-body py-2"><strong>💊 <?= h($t['medication_name']) ?></strong>
<div class="small mb-2"><?= h($t['dose_text']) ?></div> <div class="small mb-2"><?= h($t['dose_text']) ?></div>
<div class="btn-group w-100"><label class="btn btn-outline-success"><input <div class="btn-group w-100"><label class="btn btn-outline-success care-treatment-choice"><input
type="radio" class="btn-check" name="treatments[<?= (int)$t['id'] ?>]" type="radio" class="btn-check" name="treatments[<?= (int)$t['id'] ?>]"
value="given" value="given"
<?= ($administrations[(int)$t['id']] ?? '') === 'given' ? 'checked' : '' ?>> <?= ($administrations[(int)$t['id']] ?? '') === 'given' ? 'checked' : '' ?>>
<?= h(t('care.given')) ?></label><label <?= h(t('care.given')) ?></label><label
class="btn btn-outline-danger"><input type="radio" class="btn-check" class="btn btn-outline-danger care-treatment-choice"><input type="radio" class="btn-check"
name="treatments[<?= (int)$t['id'] ?>]" value="skipped" name="treatments[<?= (int)$t['id'] ?>]" value="skipped"
<?= ($administrations[(int)$t['id']] ?? '') === 'skipped' ? 'checked' : '' ?>> <?= ($administrations[(int)$t['id']] ?? '') === 'skipped' ? 'checked' : '' ?>>
<?= h(t('care.not_given')) ?></label></div> <?= h(t('care.not_given')) ?></label></div>

View file

@ -31,10 +31,10 @@
<div class="col-lg-6"> <div class="col-lg-6">
<section class="card h-100"> <section class="card h-100">
<div class="card-body"> <div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.6</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.7</span>
<h3>Compatibilité des ressources</h3> <h3>Premier lot de tickets</h3>
<p class="text-muted">Police emoji compatible avec les navigateurs et publication des médias <p class="text-muted">Géocodage fiabilisé, doublons expliqués, tournées plus lisibles et animaux
renforcée pour les installations YunoHost.</p> adoptables publiés dans la démonstration.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -5,7 +5,7 @@
</div> </div>
<?php if ($saved): ?><div class="alert alert-success">✅ <?= h(t('settings.saved')) ?></div><?php endif; ?> <?php if ($saved): ?><div class="alert alert-success">✅ <?= h(t('settings.saved')) ?></div><?php endif; ?>
<?php if ($error !== ''): ?><div class="alert alert-danger">⚠️ <?= h($error) ?></div><?php endif; ?> <?php if ($error !== ''): ?><div class="alert alert-danger">⚠️ <?= h($error) ?></div><?php endif; ?>
<form method="post" action="/settings/general/save" enctype="multipart/form-data"> <form method="post" action="/settings/general/save" enctype="multipart/form-data" novalidate>
<div class="card mb-3 overflow-hidden"> <div class="card mb-3 overflow-hidden">
<div class="card-status-top bg-primary"></div> <div class="card-status-top bg-primary"></div>
<div class="card-header"> <div class="card-header">
@ -37,7 +37,9 @@
</div> </div>
<div class="col-md-3"><label class="form-label"><?= h(t('settings.email')) ?></label><input <div class="col-md-3"><label class="form-label"><?= h(t('settings.email')) ?></label><input
class="form-control" type="email" name="association_email" class="form-control" type="email" name="association_email"
value="<?= h($settings['association_email']) ?>"></div> value="<?= h($settings['association_email']) ?>"
oninvalid="this.setCustomValidity(<?= h(json_encode(t('settings.invalid_email'))) ?>)"
oninput="this.setCustomValidity('')"></div>
<div class="col-12"> <div class="col-12">
<hr class="my-1"> <hr class="my-1">
</div> </div>

View file

@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS geocode_cache (
q TEXT PRIMARY KEY,
lat REAL,
lng REAL,
raw_json TEXT,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);

View file

@ -121,4 +121,5 @@ return [
'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.', 'search.title'=>'Global search','search.placeholder'=>'Animal, contact, appointment or invoice…','search.action'=>'Search','search.hint'=>'Enter at least two characters.','search.none'=>'No accessible result.','search.open'=>'Open global search','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animals','search.group.directory'=>'Directory','search.group.agenda'=>'Calendar','search.group.accounting'=>'Accounting','search.loading'=>'Searching…','search.all_results'=>'Show all results','search.error'=>'Search is temporarily unavailable.',
'public_site.settings_heading'=>'Public website','public_site.theme_custom'=>'Custom','public_site.site_name'=>'Website name','public_site.custom_css'=>'Custom CSS','public_site.custom_css_help'=>'Used by the custom theme. Imports and external resources are blocked.','public_site.invalid_css'=>'The stylesheet contains a forbidden instruction.','public_site.routing_management_help'=>'Management stays at / and the public website at /site.','public_site.publish_animal'=>'Publish this animal on the public website','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','lab_reference.add_species'=>'Add a species to lab reports','lab_reference.copy_from'=>'Copy reference ranges from','lab_reference.target_species'=>'New species','lab_reference.copy_help'=>'Ranges are copied as a starting point and remain editable.','lab_reference.invalid_species'=>'Choose two different valid species.','lab_reference.nothing_copied'=>'This species already has these reference ranges.','statistics.hair_types'=>'Hair types','animals.filtered_by'=>'Filter: :value','service.icad.home_unavailable'=>'The I-CAD website is unavailable','service.icad.search_unavailable'=>'I-CAD search is unavailable','service.icad.record_unavailable'=>'The I-CAD record is unavailable', 'public_site.settings_heading'=>'Public website','public_site.theme_custom'=>'Custom','public_site.site_name'=>'Website name','public_site.custom_css'=>'Custom CSS','public_site.custom_css_help'=>'Used by the custom theme. Imports and external resources are blocked.','public_site.invalid_css'=>'The stylesheet contains a forbidden instruction.','public_site.routing_management_help'=>'Management stays at / and the public website at /site.','public_site.publish_animal'=>'Publish this animal on the public website','public_site.publish_animal_help'=>'The profile will only be visible when the animal is also available for adoption. Only public photos and the text below are used.','lab_reference.add_species'=>'Add a species to lab reports','lab_reference.copy_from'=>'Copy reference ranges from','lab_reference.target_species'=>'New species','lab_reference.copy_help'=>'Ranges are copied as a starting point and remain editable.','lab_reference.invalid_species'=>'Choose two different valid species.','lab_reference.nothing_copied'=>'This species already has these reference ranges.','statistics.hair_types'=>'Hair types','animals.filtered_by'=>'Filter: :value','service.icad.home_unavailable'=>'The I-CAD website is unavailable','service.icad.search_unavailable'=>'I-CAD search is unavailable','service.icad.record_unavailable'=>'The I-CAD record is unavailable',
'public_site.image_library'=>'Image library','public_site.image_file'=>'Image to add','public_site.image_upload'=>'Upload image','public_site.image_help'=>'Upload an image, place the cursor in a text field, then choose “Insert”. Accepted formats: JPEG, PNG and WebP.','public_site.image_none'=>'No image uploaded.','public_site.image_insert'=>'Insert','public_site.image_invalid'=>'Choose a valid image.', 'public_site.image_library'=>'Image library','public_site.image_file'=>'Image to add','public_site.image_upload'=>'Upload image','public_site.image_help'=>'Upload an image, place the cursor in a text field, then choose “Insert”. Accepted formats: JPEG, PNG and WebP.','public_site.image_none'=>'No image uploaded.','public_site.image_insert'=>'Insert','public_site.image_invalid'=>'Choose a valid image.',
'rooms.duplicate'=>'A room with this name or code already exists.','settings.species_duplicate'=>'A species with this name or code already exists.',
]; ];

View file

@ -121,4 +121,5 @@ return [
'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.', 'search.title'=>'Recherche globale','search.placeholder'=>'Animal, contact, rendez-vous ou facture…','search.action'=>'Rechercher','search.hint'=>'Saisissez au moins deux caractères.','search.none'=>'Aucun résultat accessible.','search.open'=>'Ouvrir la recherche globale','search.shortcut'=>'Ctrl K','search.group.animals'=>'Animaux','search.group.directory'=>'Annuaire','search.group.agenda'=>'Agenda','search.group.accounting'=>'Comptabilité','search.loading'=>'Recherche…','search.all_results'=>'Afficher tous les résultats','search.error'=>'La recherche est momentanément indisponible.',
'public_site.settings_heading'=>'Site public','public_site.theme_custom'=>'Personnalisé','public_site.site_name'=>'Nom du site','public_site.custom_css'=>'CSS personnalisé','public_site.custom_css_help'=>'Actif avec le thème personnalisé. Les imports et les ressources externes sont bloqués.','public_site.invalid_css'=>'La feuille de style contient une instruction interdite.','public_site.routing_management_help'=>'La gestion reste sur / et le site public sur /site.','public_site.publish_animal'=>'Publier cet animal sur le site public','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','lab_reference.add_species'=>'Ajouter une espèce aux bilans','lab_reference.copy_from'=>'Copier les références de','lab_reference.target_species'=>'Nouvelle espèce','lab_reference.copy_help'=>'Les intervalles sont copiés comme point de départ, puis restent modifiables.','lab_reference.invalid_species'=>'Choisissez deux espèces différentes et valides.','lab_reference.nothing_copied'=>'Cette espèce possède déjà ces références.','statistics.hair_types'=>'Types de poil','animals.filtered_by'=>'Filtre : :value','service.icad.home_unavailable'=>'Le site I-CAD est indisponible','service.icad.search_unavailable'=>'La recherche I-CAD est indisponible','service.icad.record_unavailable'=>'La fiche I-CAD est indisponible', 'public_site.settings_heading'=>'Site public','public_site.theme_custom'=>'Personnalisé','public_site.site_name'=>'Nom du site','public_site.custom_css'=>'CSS personnalisé','public_site.custom_css_help'=>'Actif avec le thème personnalisé. Les imports et les ressources externes sont bloqués.','public_site.invalid_css'=>'La feuille de style contient une instruction interdite.','public_site.routing_management_help'=>'La gestion reste sur / et le site public sur /site.','public_site.publish_animal'=>'Publier cet animal sur le site public','public_site.publish_animal_help'=>'La fiche ne sera visible que si l’animal est également disponible à l’adoption. Seules les photos publiques et le texte ci-dessous seront utilisés.','lab_reference.add_species'=>'Ajouter une espèce aux bilans','lab_reference.copy_from'=>'Copier les références de','lab_reference.target_species'=>'Nouvelle espèce','lab_reference.copy_help'=>'Les intervalles sont copiés comme point de départ, puis restent modifiables.','lab_reference.invalid_species'=>'Choisissez deux espèces différentes et valides.','lab_reference.nothing_copied'=>'Cette espèce possède déjà ces références.','statistics.hair_types'=>'Types de poil','animals.filtered_by'=>'Filtre : :value','service.icad.home_unavailable'=>'Le site I-CAD est indisponible','service.icad.search_unavailable'=>'La recherche I-CAD est indisponible','service.icad.record_unavailable'=>'La fiche I-CAD est indisponible',
'public_site.image_library'=>'Bibliothèque d’images','public_site.image_file'=>'Image à ajouter','public_site.image_upload'=>'Ajouter l’image','public_site.image_help'=>'Ajoutez une image, placez le curseur dans un texte puis choisissez « Insérer ». Formats acceptés : JPEG, PNG et WebP.','public_site.image_none'=>'Aucune image ajoutée.','public_site.image_insert'=>'Insérer','public_site.image_invalid'=>'Choisissez une image valide.', 'public_site.image_library'=>'Bibliothèque d’images','public_site.image_file'=>'Image à ajouter','public_site.image_upload'=>'Ajouter l’image','public_site.image_help'=>'Ajoutez une image, placez le curseur dans un texte puis choisissez « Insérer ». Formats acceptés : JPEG, PNG et WebP.','public_site.image_none'=>'Aucune image ajoutée.','public_site.image_insert'=>'Insérer','public_site.image_invalid'=>'Choisissez une image valide.',
'rooms.duplicate'=>'Une salle portant ce nom ou ce code existe déjà.','settings.species_duplicate'=>'Une espèce portant ce nom ou ce code existe déjà.',
]; ];

View file

@ -499,6 +499,19 @@ try {
); );
} }
$assert(is_file($root . '/public/media/animals/' . $twix . '/twix.webp'), 'Photo de Twix absente'); $assert(is_file($root . '/public/media/animals/' . $twix . '/twix.webp'), 'Photo de Twix absente');
$assert(
(int) $db
->query(
"SELECT COUNT(*) FROM animals WHERE website_published=1 AND adoption_availability='available' AND archived_at IS NULL",
)
->fetchColumn() >= 1,
'Aucun animal de démonstration publié sur le site public',
);
$assert(
(int) $db->query("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='geocode_cache'")
->fetchColumn() === 1,
'Cache de géocodage absent',
);
DemoDataService::clearFiles(); DemoDataService::clearFiles();
}); });
} finally { } finally {