fix: traiter le deuxième lot de tickets

Refs #1 #2 #3 #13 #17
This commit is contained in:
Alexandre NOEL 2026-09-07 23:55:59 +02:00
commit 220c0ece82
18 changed files with 95 additions and 22 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.8] — 2026-09-07
### Deuxième lot de tickets
- détection correcte d’ImageMagick installé comme exécutable, même sans extension PHP (`#1`) ;
- validation du format et de la clé de contrôle des numéros RNA et SIRET (`#2`) ;
- classement de l’absence de sauvegarde comme avertissement et non comme erreur bloquante (`#3`) ;
- limites cohérentes pour les noms d’animaux, de salles et de box, côté formulaire et serveur (`#13`) ;
- reformulation de l’explication sur la revue RGPD (`#17`).
## [1.0.0-rc.7] — 2026-09-07 ## [1.0.0-rc.7] — 2026-09-07
### Premier lot de tickets ### Premier lot de tickets

View file

@ -37,3 +37,7 @@ Remplacement de la police emoji par un format vectoriel accepté par les navigat
## `1.0.0-rc.7` — Premier lot de tickets ## `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. 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.
## `1.0.0-rc.8` — Deuxième lot de tickets
Clarification du diagnostic système et de la santé des sauvegardes, validation des identifiants administratifs et encadrement des longueurs de noms.

View file

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

View file

@ -491,9 +491,9 @@ trait AnimalRecordActions
$felv = array_key_exists('felv', $_POST) ? (isset($_POST['felv']) ? 1 : 0) : null; $felv = array_key_exists('felv', $_POST) ? (isset($_POST['felv']) ? 1 : 0) : null;
// Validations minimales // Validations minimales
if ($name === '') { if ($name === '' || mb_strlen($name) > 150) {
http_response_code(400); http_response_code(400);
echo h(t('error.name_required')); echo h($name === '' ? t('error.name_required') : t('animal.name_too_long'));
return; return;
} }
if ($breed === '' && SpeciesService::isCat($species)) { if ($breed === '' && SpeciesService::isCat($species)) {

View file

@ -68,6 +68,10 @@ final class AuthController
$error = t('install.association_required'); $error = t('install.association_required');
} elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { } elseif ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) {
$error = t('settings.invalid_email'); $error = t('settings.invalid_email');
} elseif (!Ids::validRna((string) ($_POST['association_rna'] ?? ''))) {
$error = t('settings.invalid_rna');
} elseif (!Ids::validSiret((string) ($_POST['association_siret'] ?? ''))) {
$error = t('settings.invalid_siret');
} elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) { } elseif (!preg_match('/^[a-zA-Z0-9._-]{3,40}$/', $username)) {
$error = t('auth.invalid_username'); $error = t('auth.invalid_username');
} elseif ($display === '') { } elseif ($display === '') {

View file

@ -46,6 +46,14 @@ final class SettingsController
echo h(t('settings.invalid_email')); echo h(t('settings.invalid_email'));
return; return;
} }
if (!Ids::validRna($values['association_rna'])) {
header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_rna')));
exit();
}
if (!Ids::validSiret($values['association_siret'])) {
header('Location: /settings/general?error=' . rawurlencode(t('settings.invalid_siret')));
exit();
}
$values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15))); $values['quarantine_days'] = (string) max(1, min(90, (int) ($_POST['quarantine_days'] ?? 15)));
$values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10))); $values['backup_retention'] = (string) max(1, min(50, (int) ($_POST['backup_retention'] ?? 10)));
$values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')]) $values['app_language'] = isset(I18n::LOCALES[(string) ($_POST['app_language'] ?? 'fr')])
@ -230,6 +238,7 @@ final class SettingsController
$color = (string) ($row['color'] ?? '#6c757d'); $color = (string) ($row['color'] ?? '#6c757d');
if ( if (
$name === '' || $name === '' ||
mb_strlen($name) > 100 ||
!in_array($type, ['collective', 'boxes'], true) || !in_array($type, ['collective', 'boxes'], true) ||
!in_array($status, ['refuge', 'soin', 'quarantaine'], true) || !in_array($status, ['refuge', 'soin', 'quarantaine'], true) ||
!preg_match('/^#[0-9a-fA-F]{6}$/', $color) !preg_match('/^#[0-9a-fA-F]{6}$/', $color)
@ -253,6 +262,9 @@ final class SettingsController
$new = (array) ($_POST['new_room'] ?? []); $new = (array) ($_POST['new_room'] ?? []);
$newName = trim((string) ($new['name'] ?? '')); $newName = trim((string) ($new['name'] ?? ''));
if ($newName !== '') { if ($newName !== '') {
if (mb_strlen($newName) > 100) {
throw new RuntimeException(t('rooms.name_too_long'));
}
$code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName)); $code = ShelterRoomService::normalizeCode((string) ($new['code'] ?? $newName));
if (strlen($code) < 2) { if (strlen($code) < 2) {
throw new RuntimeException(t('rooms.invalid')); throw new RuntimeException(t('rooms.invalid'));
@ -297,6 +309,9 @@ final class SettingsController
$known = $existing->fetchAll(PDO::FETCH_ASSOC); $known = $existing->fetchAll(PDO::FETCH_ASSOC);
$kept = []; $kept = [];
foreach ($names as $index => $name) { foreach ($names as $index => $name) {
if (mb_strlen($name) > 100) {
throw new RuntimeException(t('rooms.box_name_too_long'));
}
$position = ($index + 1) * 10; $position = ($index + 1) * 10;
if (isset($known[$index])) { if (isset($known[$index])) {
$id = (int) $known[$index]['id']; $id = (int) $known[$index]['id'];

View file

@ -4,6 +4,29 @@ declare(strict_types=1);
final class Ids final class Ids
{ {
public static function validRna(string $value): bool
{
$value = strtoupper(preg_replace('/\s+/', '', trim($value)) ?? '');
return $value === '' || preg_match('/^W[0-9]{9}$/', $value) === 1;
}
public static function validSiret(string $value): bool
{
$digits = preg_replace('/\D+/', '', trim($value)) ?? '';
if ($digits === '') {
return true;
}
if (strlen($digits) !== 14) {
return false;
}
$sum = 0;
foreach (str_split($digits) as $index => $digit) {
$number = (int) $digit * ($index % 2 === 0 ? 2 : 1);
$sum += $number > 9 ? $number - 9 : $number;
}
return $sum % 10 === 0;
}
public static function slugifyUpper(string $s): string public static function slugifyUpper(string $s): string
{ {
$s = trim($s); $s = trim($s);

View file

@ -6,6 +6,14 @@ final class ImageService
{ {
private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; private const ALLOWED = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
public static function processor(): ?string
{
if (self::magickBinary() !== null) {
return 'ImageMagick';
}
return extension_loaded('gd') ? 'GD' : null;
}
public static function inspect(string $path, int $maxBytes = 20_000_000): array public static function inspect(string $path, int $maxBytes = 20_000_000): array
{ {
if (!is_file($path) || filesize($path) > $maxBytes) { if (!is_file($path) || filesize($path) > $maxBytes) {

View file

@ -57,17 +57,14 @@ final class InstallationService
? 'Connecteurs externes disponibles' ? 'Connecteurs externes disponibles'
: 'Extension cURL absente : I-CAD et géocodage indisponibles', : 'Extension cURL absente : I-CAD et géocodage indisponibles',
); );
$imageProcessor = ImageService::processor();
self::add( self::add(
$checks, $checks,
'image', 'image',
extension_loaded('imagick') || extension_loaded('gd'), $imageProcessor !== null,
'recommended', 'recommended',
'Traitement des images', 'Traitement des images',
extension_loaded('imagick') $imageProcessor !== null ? $imageProcessor . ' disponible' : 'ImageMagick ou GD conseillé',
? 'ImageMagick disponible'
: (extension_loaded('gd')
? 'GD disponible'
: 'ImageMagick ou GD conseillé'),
); );
if ($withFilesystem) { if ($withFilesystem) {
foreach (self::directories() as $key => $path) { foreach (self::directories() as $key => $path) {

View file

@ -74,7 +74,7 @@ final class SystemHealthService
); );
$backups = BackupService::list(); $backups = BackupService::list();
if (!$backups) { if (!$backups) {
$checks[] = self::check('error', 'continuity', t('health.last_backup'), t('health.no_backup')); $checks[] = self::check('warning', 'continuity', t('health.last_backup'), t('health.no_backup'));
} else { } else {
$latest = $backups[0]; $latest = $backups[0];
$date = DateTimeImmutable::createFromFormat( $date = DateTimeImmutable::createFromFormat(

View file

@ -95,7 +95,7 @@ $estMonths = $birth_estimated_months > 0 ? ($birth_estimated_months % 12) : '';
<div class="col-12 col-md-8"> <div class="col-12 col-md-8">
<label class="form-label"><?= h(t('common.name')) ?></label> <label class="form-label"><?= h(t('common.name')) ?></label>
<input class="form-control" name="name" value="<?= h($animal['name'] ?? '') ?>" <input class="form-control" name="name" value="<?= h($animal['name'] ?? '') ?>"
required> maxlength="150" required>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">

View file

@ -109,9 +109,11 @@
value="<?= h($_POST['association_name'] ?? '') ?>" required></div> value="<?= h($_POST['association_name'] ?? '') ?>" required></div>
<div class="col-md-6"><label class="form-label"><?= h(t('settings.rna')) ?></label><input <div class="col-md-6"><label class="form-label"><?= h(t('settings.rna')) ?></label><input
class="form-control" name="association_rna" class="form-control" name="association_rna"
pattern="W[0-9]{9}" maxlength="10"
value="<?= h($_POST['association_rna'] ?? '') ?>"></div> value="<?= h($_POST['association_rna'] ?? '') ?>"></div>
<div class="col-md-6"><label class="form-label"><?= h(t('settings.siret')) ?></label><input <div class="col-md-6"><label class="form-label"><?= h(t('settings.siret')) ?></label><input
class="form-control" name="association_siret" class="form-control" name="association_siret"
inputmode="numeric" maxlength="17"
value="<?= h($_POST['association_siret'] ?? '') ?>"></div> value="<?= h($_POST['association_siret'] ?? '') ?>"></div>
<div class="col-12"><label class="form-label"><?= h(t('settings.address')) ?></label><input <div class="col-12"><label class="form-label"><?= h(t('settings.address')) ?></label><input
class="form-control" name="association_address" class="form-control" name="association_address"

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.7</span> <span class="badge bg-blue-lt mb-2">1.0.0-rc.8</span>
<h3>Premier lot de tickets</h3> <h3>Deuxième lot de tickets</h3>
<p class="text-muted">Géocodage fiabilisé, doublons expliqués, tournées plus lisibles et animaux <p class="text-muted">Diagnostic système clarifié, identifiants administratifs contrôlés et
adoptables publiés dans la démonstration.</p> longueurs des principaux noms encadrées.</p>
</div> </div>
</section> </section>
</div> </div>

View file

@ -19,9 +19,10 @@
required></div> required></div>
<div class="col-md-3"><label class="form-label"><?= h(t('settings.rna')) ?></label><input <div class="col-md-3"><label class="form-label"><?= h(t('settings.rna')) ?></label><input
class="form-control" name="association_rna" value="<?= h($settings['association_rna']) ?>" class="form-control" name="association_rna" value="<?= h($settings['association_rna']) ?>"
placeholder="W…"></div> pattern="W[0-9]{9}" maxlength="10" placeholder="W123456789"></div>
<div class="col-md-3"><label class="form-label"><?= h(t('settings.siret')) ?></label><input <div class="col-md-3"><label class="form-label"><?= h(t('settings.siret')) ?></label><input
class="form-control" name="association_siret" value="<?= h($settings['association_siret']) ?>"> class="form-control" name="association_siret" inputmode="numeric" maxlength="17"
value="<?= h($settings['association_siret']) ?>">
</div> </div>
<div class="col-12"><label class="form-label"><?= h(t('settings.address')) ?></label><input <div class="col-12"><label class="form-label"><?= h(t('settings.address')) ?></label><input
class="form-control" name="association_address" class="form-control" name="association_address"

View file

@ -19,7 +19,7 @@
<div class="card-body row g-3"> <div class="card-body row g-3">
<div class="col-md-8"><label class="form-label"><?= h(t('common.name')) ?></label><input <div class="col-md-8"><label class="form-label"><?= h(t('common.name')) ?></label><input
class="form-control" name="rooms[<?= (int)$room['id'] ?>][name]" class="form-control" name="rooms[<?= (int)$room['id'] ?>][name]"
value="<?= h($room['name']) ?>" required></div> value="<?= h($room['name']) ?>" maxlength="100" required></div>
<div class="col-md-4"><label class="form-label"><?= h(t('rooms.color')) ?></label><input <div class="col-md-4"><label class="form-label"><?= h(t('rooms.color')) ?></label><input
class="form-control form-control-color w-100" type="color" class="form-control form-control-color w-100" type="color"
name="rooms[<?= (int)$room['id'] ?>][color]" value="<?= h($room['color']) ?>"></div> name="rooms[<?= (int)$room['id'] ?>][color]" value="<?= h($room['color']) ?>"></div>
@ -40,7 +40,7 @@
value="<?= (int)$room['sort_order'] ?>"></div> value="<?= (int)$room['sort_order'] ?>"></div>
<div class="col-12"><label class="form-label"><?= h(t('rooms.boxes')) ?></label><textarea <div class="col-12"><label class="form-label"><?= h(t('rooms.boxes')) ?></label><textarea
class="form-control" rows="5" name="rooms[<?= (int)$room['id'] ?>][boxes]" class="form-control" rows="5" name="rooms[<?= (int)$room['id'] ?>][boxes]"
placeholder="<?= h(t('rooms.one_box_per_line')) ?>"><?= h($boxNames) ?></textarea> placeholder="<?= h(t('rooms.one_box_per_line')) ?>" data-line-maxlength="100"><?= h($boxNames) ?></textarea>
<div class="form-hint"><?= h(t('rooms.boxes_help')) ?></div> <div class="form-hint"><?= h(t('rooms.boxes_help')) ?></div>
</div> </div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" <div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox"
@ -57,7 +57,7 @@
</div> </div>
<div class="card-body row g-3"> <div class="card-body row g-3">
<div class="col-md-4"><label class="form-label"><?= h(t('common.name')) ?></label><input <div class="col-md-4"><label class="form-label"><?= h(t('common.name')) ?></label><input
class="form-control" name="new_room[name]"></div> class="form-control" name="new_room[name]" maxlength="100"></div>
<div class="col-md-3"><label class="form-label"><?= h(t('rooms.type')) ?></label><select <div class="col-md-3"><label class="form-label"><?= h(t('rooms.type')) ?></label><select
class="form-select" name="new_room[room_type]"> class="form-select" name="new_room[room_type]">
<option value="collective"><?= h(t('rooms.collective')) ?></option> <option value="collective"><?= h(t('rooms.collective')) ?></option>
@ -74,7 +74,7 @@
value="#6c757d"></div> value="#6c757d"></div>
<div class="col-12"><label class="form-label"><?= h(t('rooms.boxes')) ?></label><textarea <div class="col-12"><label class="form-label"><?= h(t('rooms.boxes')) ?></label><textarea
class="form-control" name="new_room[boxes]" rows="3" class="form-control" name="new_room[boxes]" rows="3"
placeholder="<?= h(t('rooms.one_box_per_line')) ?>"></textarea></div> placeholder="<?= h(t('rooms.one_box_per_line')) ?>" data-line-maxlength="100"></textarea></div>
<div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox" <div class="col-12"><label class="form-check"><input class="form-check-input" type="checkbox"
name="new_room[bulk_validation]"><span name="new_room[bulk_validation]"><span
class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div> class="form-check-label"><?= h(t('rooms.bulk_validation')) ?></span></label></div>

View file

@ -122,4 +122,5 @@ return [
'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.', 'rooms.duplicate'=>'A room with this name or code already exists.','settings.species_duplicate'=>'A species with this name or code already exists.',
'settings.invalid_rna'=>'The RNA number must start with W followed by 9 digits.','settings.invalid_siret'=>'The SIRET number must contain 14 digits and be valid.','rooms.name_too_long'=>'A room name cannot exceed 100 characters.','rooms.box_name_too_long'=>'A box name cannot exceed 100 characters.','animal.name_too_long'=>'An animal name cannot exceed 150 characters.',
]; ];

View file

@ -5,7 +5,7 @@ return [
'error.unexpected_reference'=>'Une erreur interne est survenue. Référence : :reference.', 'error.unexpected_reference'=>'Une erreur interne est survenue. Référence : :reference.',
'auth.remember_device'=>'Rester connecté sur cet appareil','auth.remember_device_help'=>'Une session révocable sera conservée pendant 90 jours. Le mot de passe n’est jamais enregistré.','devices.title'=>'Appareils connectés','devices.intro'=>'Sessions persistantes autorisées pour votre compte. Révoquez immédiatement un appareil perdu ou partagé.','devices.revoke_all'=>'Tout déconnecter','devices.revoke_all_confirm'=>'Déconnecter tous les appareils, y compris celui-ci ?','devices.revoked'=>'Appareil déconnecté.','devices.this_device'=>'Cet appareil','devices.active'=>'Actif','devices.inactive'=>'Révoqué ou expiré','devices.created'=>'Ajouté le :date','devices.last_used'=>'Dernière utilisation : :date','devices.expires'=>'Expiration : :date','devices.revoke'=>'Révoquer','devices.none'=>'Aucun appareil de confiance enregistré.', 'auth.remember_device'=>'Rester connecté sur cet appareil','auth.remember_device_help'=>'Une session révocable sera conservée pendant 90 jours. Le mot de passe n’est jamais enregistré.','devices.title'=>'Appareils connectés','devices.intro'=>'Sessions persistantes autorisées pour votre compte. Révoquez immédiatement un appareil perdu ou partagé.','devices.revoke_all'=>'Tout déconnecter','devices.revoke_all_confirm'=>'Déconnecter tous les appareils, y compris celui-ci ?','devices.revoked'=>'Appareil déconnecté.','devices.this_device'=>'Cet appareil','devices.active'=>'Actif','devices.inactive'=>'Révoqué ou expiré','devices.created'=>'Ajouté le :date','devices.last_used'=>'Dernière utilisation : :date','devices.expires'=>'Expiration : :date','devices.revoke'=>'Révoquer','devices.none'=>'Aucun appareil de confiance enregistré.',
'public_site.to_complete'=>'À compléter','public_site.default_legal'=>"Éditeur du site\n\nLe présent site est édité par :association, association déclarée sous le numéro RNA :rna et identifiée sous le numéro SIRET :siret.\nSiège social : :address\nContact : :email\n\nDirection de la publication\n\nLa direction de la publication est assurée par la représentante ou le représentant légal de l’association. Nom : [À compléter].\n\nHébergement\n\nHébergeur : [raison sociale, adresse et coordonnées à compléter].\n\nPropriété intellectuelle\n\nSauf mention contraire, les textes, photographies et éléments graphiques de ce site appartiennent à l’association ou sont utilisés avec l’autorisation de leurs titulaires. Toute réutilisation doit respecter les droits applicables et l’image des animaux et des personnes présentées.\n\nResponsabilité\n\nL’association s’efforce de fournir des informations exactes et à jour. Les disponibilités et caractéristiques des animaux peuvent évoluer ; leur publication ne vaut pas acceptation automatique d’une adoption.",'public_site.default_privacy'=>"Responsable du traitement\n\nLe responsable du traitement est :association.\nAdresse : :address\nContact pour l’exercice de vos droits : :email\n\nDonnées concernées et finalités\n\nLe site public présente les animaux proposés à l’adoption et les moyens de contacter l’association. Lorsque vous contactez l’association, les informations que vous transmettez peuvent être utilisées pour répondre à votre demande, étudier une adoption, gérer un don ou assurer le suivi administratif correspondant.\n\nDestinataires et conservation\n\nLes données sont accessibles aux membres autorisés de l’association et, lorsque cela est nécessaire, à ses partenaires habilités. Elles ne sont pas vendues. Elles sont conservées pendant la durée nécessaire à la finalité poursuivie et aux obligations légales ou comptables applicables, puis supprimées ou anonymisées selon la politique de l’association.\n\nCookies et services externes\n\nLe site public n’utilise pas de dispositif publicitaire ou de mesure d’audience intégré par Globinours. La carte de contact utilise les tuiles OpenStreetMap ; son affichage entraîne une connexion aux serveurs cartographiques concernés et la transmission de données techniques telles que l’adresse IP. La partie de gestion utilise un cookie de session indispensable à l’authentification.\n\nVos droits\n\nVous pouvez demander l’accès, la rectification, l’effacement ou la limitation de vos données et, lorsque les conditions sont réunies, vous opposer au traitement ou demander leur portabilité. Adressez votre demande à :email. Vous pouvez également saisir l’autorité de contrôle compétente, notamment la CNIL en France.\n\nMise à jour\n\nCette politique doit être adaptée aux traitements réellement effectués par l’association et mise à jour lorsqu’ils évoluent.", 'public_site.to_complete'=>'À compléter','public_site.default_legal'=>"Éditeur du site\n\nLe présent site est édité par :association, association déclarée sous le numéro RNA :rna et identifiée sous le numéro SIRET :siret.\nSiège social : :address\nContact : :email\n\nDirection de la publication\n\nLa direction de la publication est assurée par la représentante ou le représentant légal de l’association. Nom : [À compléter].\n\nHébergement\n\nHébergeur : [raison sociale, adresse et coordonnées à compléter].\n\nPropriété intellectuelle\n\nSauf mention contraire, les textes, photographies et éléments graphiques de ce site appartiennent à l’association ou sont utilisés avec l’autorisation de leurs titulaires. Toute réutilisation doit respecter les droits applicables et l’image des animaux et des personnes présentées.\n\nResponsabilité\n\nL’association s’efforce de fournir des informations exactes et à jour. Les disponibilités et caractéristiques des animaux peuvent évoluer ; leur publication ne vaut pas acceptation automatique d’une adoption.",'public_site.default_privacy'=>"Responsable du traitement\n\nLe responsable du traitement est :association.\nAdresse : :address\nContact pour l’exercice de vos droits : :email\n\nDonnées concernées et finalités\n\nLe site public présente les animaux proposés à l’adoption et les moyens de contacter l’association. Lorsque vous contactez l’association, les informations que vous transmettez peuvent être utilisées pour répondre à votre demande, étudier une adoption, gérer un don ou assurer le suivi administratif correspondant.\n\nDestinataires et conservation\n\nLes données sont accessibles aux membres autorisés de l’association et, lorsque cela est nécessaire, à ses partenaires habilités. Elles ne sont pas vendues. Elles sont conservées pendant la durée nécessaire à la finalité poursuivie et aux obligations légales ou comptables applicables, puis supprimées ou anonymisées selon la politique de l’association.\n\nCookies et services externes\n\nLe site public n’utilise pas de dispositif publicitaire ou de mesure d’audience intégré par Globinours. La carte de contact utilise les tuiles OpenStreetMap ; son affichage entraîne une connexion aux serveurs cartographiques concernés et la transmission de données techniques telles que l’adresse IP. La partie de gestion utilise un cookie de session indispensable à l’authentification.\n\nVos droits\n\nVous pouvez demander l’accès, la rectification, l’effacement ou la limitation de vos données et, lorsque les conditions sont réunies, vous opposer au traitement ou demander leur portabilité. Adressez votre demande à :email. Vous pouvez également saisir l’autorité de contrôle compétente, notamment la CNIL en France.\n\nMise à jour\n\nCette politique doit être adaptée aux traitements réellement effectués par l’association et mise à jour lorsqu’ils évoluent.",
'privacy.heading'=>'Confidentialité et conservation','privacy.no_auto_delete'=>'Aucune suppression automatique : la liste sert à une revue humaine.','privacy.reason'=>'Motif','privacy.none_review'=>'Aucun contact à réexaminer.','privacy.access_register'=>'Registre des accès privés','privacy.contact_not_found'=>'Contact introuvable.','privacy.export_failed'=>'Impossible de produire l’export.','privacy.reason_required'=>'Le motif est obligatoire.','privacy.anonymous_contact'=>'Contact anonymisé', 'privacy.heading'=>'Confidentialité et conservation','privacy.no_auto_delete'=>'Aucune suppression n’est effectuée automatiquement. Cette liste vous aide à identifier les dossiers à examiner.','privacy.reason'=>'Motif','privacy.none_review'=>'Aucun contact à réexaminer.','privacy.access_register'=>'Registre des accès privés','privacy.contact_not_found'=>'Contact introuvable.','privacy.export_failed'=>'Impossible de produire l’export.','privacy.reason_required'=>'Le motif est obligatoire.','privacy.anonymous_contact'=>'Contact anonymisé',
'settings.privacy'=>'Confidentialité / RGPD','privacy.title'=>'Confidentialité et conservation','privacy.intro'=>'Pilotez la conservation des coordonnées, les exports et les accès aux fichiers privés.','privacy.saved'=>'Politique de conservation enregistrée.','privacy.retention'=>'Durée de conservation','privacy.retention_years'=>'Durée après la dernière activité (années)','privacy.retention_help'=>'Cette règle produit une liste de contrôle : aucune donnée n’est supprimée automatiquement.','privacy.review'=>'Contacts à réexaminer','privacy.no_review'=>'Aucun contact ne dépasse actuellement cette durée.','privacy.last_activity'=>'Dernière activité','privacy.export'=>'Exporter','privacy.anonymize'=>'Anonymiser','privacy.anonymize_reason'=>'Motif obligatoire','privacy.anonymize_confirm'=>'Anonymiser ce contact ? Une sauvegarde sera créée avant l’opération.','privacy.access_log'=>'Accès aux fichiers privés','privacy.no_access'=>'Aucun accès enregistré.','privacy.resource'=>'Ressource','privacy.actor'=>'Utilisateur','privacy.action'=>'Action','privacy.date'=>'Date','privacy.anonymized'=>'Contact anonymisé. Sauvegarde : :backup','public_site.legal'=>'Mentions légales','public_site.privacy'=>'Confidentialité','public_site.legal_privacy'=>'Mentions légales et confidentialité','public_site.legal_hint'=>'Ces textes sont publiés sur deux pages dédiées. Faites-les valider selon la situation de votre association.', 'settings.privacy'=>'Confidentialité / RGPD','privacy.title'=>'Confidentialité et conservation','privacy.intro'=>'Pilotez la conservation des coordonnées, les exports et les accès aux fichiers privés.','privacy.saved'=>'Politique de conservation enregistrée.','privacy.retention'=>'Durée de conservation','privacy.retention_years'=>'Durée après la dernière activité (années)','privacy.retention_help'=>'Cette règle produit une liste de contrôle : aucune donnée n’est supprimée automatiquement.','privacy.review'=>'Contacts à réexaminer','privacy.no_review'=>'Aucun contact ne dépasse actuellement cette durée.','privacy.last_activity'=>'Dernière activité','privacy.export'=>'Exporter','privacy.anonymize'=>'Anonymiser','privacy.anonymize_reason'=>'Motif obligatoire','privacy.anonymize_confirm'=>'Anonymiser ce contact ? Une sauvegarde sera créée avant l’opération.','privacy.access_log'=>'Accès aux fichiers privés','privacy.no_access'=>'Aucun accès enregistré.','privacy.resource'=>'Ressource','privacy.actor'=>'Utilisateur','privacy.action'=>'Action','privacy.date'=>'Date','privacy.anonymized'=>'Contact anonymisé. Sauvegarde : :backup','public_site.legal'=>'Mentions légales','public_site.privacy'=>'Confidentialité','public_site.legal_privacy'=>'Mentions légales et confidentialité','public_site.legal_hint'=>'Ces textes sont publiés sur deux pages dédiées. Faites-les valider selon la situation de votre association.',
'settings.group_general'=>'Configuration','settings.group_business'=>'Référentiels métier','settings.group_access'=>'Accès','settings.group_maintenance'=>'Maintenance','public_site.main_address'=>'Page affichée à l’adresse principale','public_site.routing_management'=>'Globinours à la racine','public_site.routing_management_help'=>'La gestion reste sur / et le mini-site sur /site.','public_site.routing_public'=>'Site public à la racine','public_site.routing_public_help'=>'Le site utilise / et l’accès à la gestion se fait par /gestion.','public_site.routing_no_server'=>'Ce choix est géré par Globinours : aucune modification Apache ou Nginx n’est nécessaire.', 'settings.group_general'=>'Configuration','settings.group_business'=>'Référentiels métier','settings.group_access'=>'Accès','settings.group_maintenance'=>'Maintenance','public_site.main_address'=>'Page affichée à l’adresse principale','public_site.routing_management'=>'Globinours à la racine','public_site.routing_management_help'=>'La gestion reste sur / et le mini-site sur /site.','public_site.routing_public'=>'Site public à la racine','public_site.routing_public_help'=>'Le site utilise / et l’accès à la gestion se fait par /gestion.','public_site.routing_no_server'=>'Ce choix est géré par Globinours : aucune modification Apache ou Nginx n’est nécessaire.',
'public_site.published'=>'Publié sur le site','public_site.waiting_publication'=>'Publication en attente de disponibilité', 'public_site.published'=>'Publié sur le site','public_site.waiting_publication'=>'Publication en attente de disponibilité',
@ -122,4 +122,5 @@ return [
'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à.', '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à.',
'settings.invalid_rna'=>'Le numéro RNA doit commencer par W et être suivi de 9 chiffres.','settings.invalid_siret'=>'Le numéro SIRET doit contenir 14 chiffres et être valide.','rooms.name_too_long'=>'Le nom d’une salle ne peut pas dépasser 100 caractères.','rooms.box_name_too_long'=>'Le nom d’un box ne peut pas dépasser 100 caractères.','animal.name_too_long'=>'Le nom d’un animal ne peut pas dépasser 150 caractères.',
]; ];

View file

@ -11,6 +11,7 @@ foreach (
[ [
'DB', 'DB',
'Migrations', 'Migrations',
'Ids',
'I18n', 'I18n',
'AppSettings', 'AppSettings',
'SecurityService', 'SecurityService',
@ -116,6 +117,12 @@ try {
$assert(AppSettings::get('app_language') === 'en'); $assert(AppSettings::get('app_language') === 'en');
I18n::setLocale('fr'); I18n::setLocale('fr');
}); });
$test('Les identifiants RNA et SIRET sont contrôlés', function () use ($assert): void {
$assert(Ids::validRna('W123456789'));
$assert(!Ids::validRna('W1234'));
$assert(Ids::validSiret('73282932000074'));
$assert(!Ids::validSiret('73282932000075'));
});
$test('Les quatre bilans biologiques existent pour chats et chiens', function () use ($db, $assert): void { $test('Les quatre bilans biologiques existent pour chats et chiens', function () use ($db, $assert): void {
$rows = $db $rows = $db
->query( ->query(