Apparence
03. Architecture applicative
Comment le code est découpé, où passent les frontières, et les choix de conception qui contraignent le travail futur.
Deux applications, deux origines
Le SPA et l'API sont servis sur deux noms d'hôte distincts, donc sur deux origines au sens du navigateur. Ce n'est pas un accident de configuration mais une décision, et elle a un coût qu'il faut connaître :
- l'URL de l'API est compilée dans le build du SPA (
VITE_API_BASE_URL) : changer d'environnement demande de reconstruire l'image du frontend, pas seulement de redémarrer un conteneur ; - l'origine du front doit être déclarée côté serveur, dans
DJANGO_CORS_ALLOWED_ORIGINSetDJANGO_ALLOWED_HOSTS. Un oubli se manifeste comme une erreur CORS dans le navigateur, dont la cause réelle est ailleurs.
Ce qu'on achète en échange : les deux applications se déploient, se dimensionnent et se journalisent séparément, et le back-office Django n'est jamais servi depuis l'origine du SPA — une session d'administration compromise n'emporte pas l'application, et l'inverse non plus.
Le backend : un domaine par application Django
backend/config/ ne contient que le câblage du projet — réglages, URL racine, sonde de santé, application Celery. Le domaine vit sous backend/apps/ :
| Application | Responsabilité |
|---|---|
accounts | Authentification : jeton, compte courant, réinitialisation de mot de passe |
roles | Habilitations : rôles, attributions datées par établissement |
establishment | Référentiel de l'organisation |
contract_types | Référentiel des types de contrat |
employee | Dossier du personnel : salariés, contrats, affectations |
events | Évènements et formations : types, responsables, participants et détection des conflits |
counters | Catalogue, soldes, acquisitions et journal transactionnel des absences/temps |
holidays | Référentiel des jours fériés et sa synchronisation |
data_imports | Collecte SFTP, parsing CEGI, import atomique et journal de reprise |
rules_engine | Règles de temps de travail administrables, évaluées par le noyau tempo |
notifications | Alertes persistées dans l'application et remise optionnelle aux appareils |
common | Ce qui est transversal et non métier : fenêtres de validité, amorçage, transport des courriels |
devtools | Outillage de développement : introspection alimentant la documentation |
Les routes que chaque application monte sont en annexe C.
Décision : le catalogue et le moteur restent dans counters. CounterType porte les métadonnées administrables et les soldes le référencent avec une suppression protégée. Les règles automatiques ne dépendent que des codes stables CT et AN, avec droits CA/ANC importés de la paie, et ne passent pas par le moteur de règles tempo (ci-dessous). Seul le repos compensateur de nuit (R-F05) crédite un compteur à partir d'un mouvement que ce moteur calcule. Le service construit et applique les mouvements dans des transactions courtes ; la tâche Celery et la commande de reprise sont deux appelants minces du même chemin. Une écriture d'audit défaillante annule avec elle le journal technique et le compteur.
Décision : le workflow reste propriétaire de ses sources. absences écrit et versionne l'absence, puis appelle explicitement counters.integration dans la même transaction ; aucun signal Django ne cache cet effet. Le futur module de temps réalisé appellera le même service avec son identifiant et sa version. Aucun endpoint public ne permet d'injecter un mouvement brut. Le catalogue, les mouvements append-only et le workflow restent ainsi trois responsabilités séparées.
Décision : un conflit évènement-absence réutilise notifications. Le service du domaine events détecte le recouvrement, mais ne crée ni table d'alerte parallèle ni second canal. Il appelle le service de notification existant depuis les écritures explicites de absences, dans leur transaction, de sorte qu'une absence refusée par les compteurs ne laisse pas une alerte sur une opération annulée.
Décision : l'intégration CEGI est une frontière applicative dédiée.data_imports contient le connecteur SFTP, le parseur et l'orchestration, puis écrit par les modèles des domaines employee, contract_types et establishment. Le transport est derrière une interface remplacée par une fausse implémentation dans les tests ; la logique métier ne dépend donc pas d'un serveur SFTP réel. Celery Beat déclenche la collecte périodique et le worker exécute la tâche comme les autres travaux planifiés du projet.
Décision : Microsoft Graph est le transport de tous les courriels de production. Les domaines utilisent un aidant commun qui rend une paire de templates Django, avec le texte comme corps principal et le HTML comme alternative. Le même module sérialise ensuite le message en MIME, obtient un jeton applicatif avec MSAL et le remet à Graph. Cette frontière garde la composition des courriels de réinitialisation et d'alerte d'import indépendante de Microsoft 365, tout en conservant le backend mémoire de Django dans les tests.
Décision : roles réutilise auth.Group de Django comme rôle. Un modèle Role propre aurait dupliqué une table qui existe déjà, qui porte ses permissions et qui est administrable. Ce que Django ne fournit pas, c'est l'attribution datée et cantonnée à des établissements : c'est le seul modèle ajouté (RoleAssignment), une table de liaison qui porte des dates et une liste d'établissements.
Décision : les cinq référentiels sont montés à la racine de /api/. Monter chaque application sous son propre préfixe aurait donné /api/establishments/establishments/. Le routeur utilisé est SimpleRouter et non DefaultRouter, pour qu'aucune application n'installe une vue « racine d'API » sur le /api/ que toutes partagent.
Décision : pas de jeton de rafraîchissement. L'authentification repose sur un jeton d'accès seul, de durée de vie longue mais bornée. Une session se termine donc par une reconnexion, sans mécanisme de rotation à maintenir. Le § 05 dit ce que ce choix implique.
**Décision : la fiche salarié (apps.employee.Employee) n'a plus d'écran d'admin Django à elle — elle est un inline imbriqué sur la fiche auth.User (contrats, affectations, enfants et personnes à prévenir imbriqués un niveau plus bas encore), pour qu'un compte et sa fiche s'éditent sur une seule page. L'admin Django ne supporte qu'un seul niveau d'inline nativement ; la dépendance django-nested-admin fournit celui-ci. Même mouvement pour apps.postes.Poste : plus d'écran non plus — c'est une bibliothèque personnelle d'un manager, déjà consultable et administrée depuis l'application (Bibliothèque ▸ Postes), et l'admin n'avait là qu'un rôle de support qui ne justifiait plus un écran à part. Les deux modèles restent enregistrés dans l'admin (has_module_permission renvoyant toujours faux, pour l'absence de menu) : autocomplete_fields ailleurs — contrats, affectations, compteurs, planning, trames — l'exige, un admin cible devant exister pour que la recherche fonctionne (admin.E039). C'est un registre technique, pas un écran ; la page qui reste accessible par son URL directe l'est aux mêmes comptes qui pouvaient déjà l'ouvrir avant ce changement.
Le contrat (apps.employee.Contract) suit le même mouvement mais sans ce compromis : rien d'autre ne cible Contract par autocomplete_fields (seuls ses propres FK, ContractType/Establishment, le sont ailleurs), donc admin.E039 n'a rien à dire et ContractAdmin est simplement supprimé — ContractInline, imbriqué sous la fiche salarié, porte désormais chacun de ses champs, y compris ceux — période d'essai, annualisation, forfait jours, travail de nuit, astreinte, dimanches et jours fériés prévisionnels — qui n'existaient auparavant que sur l'écran désormais disparu.
Le moteur de règles de temps de travail (APAJH-181)
Décision : les règles de temps de travail sont évaluées par tempo, un noyau embarqué dans package/tempo, et stockées comme données dans la table Rule. Les durées maximales, repos minimaux, amplitudes et moyennes du Code du travail, de la CCN-66 et des accords sont des seuils que le métier veut régler sans redéploiement, et la grille du planning, le tableau de bord et l'éditeur de trames doivent en donner la même lecture. Une règle est donc une ligne Rule (famille, paramètres datés, population, message), éditable dans l'administration Django ; apps.rules_engine convertit les journées et le contrat en faits, et tempo rend les constats.
Pourquoi un package séparé, hors de backend/. tempo n'importe ni Django ni rien de l'application : sa seule dépendance est pydantic. Ses familles de règles se testent sans base de données (près de 600 tests), et un autre hôte pourrait le réutiliser. Le prix : il s'installe dans l'image backend par un contexte de build Docker additionnel, et il a sa propre configuration de lint et de typage, que make tempo-check et le job CI tempo exécutent.
Ce qui passe par le moteur, et ce qui n'y passe pas. Passe par tempo toute règle qui est une fonction des heures travaillées d'une personne sur une période. Restent en Python les règles qui lisent autre chose : les délais de prévenance (date de l'écriture), les durées et fréquences d'une absence, les rappels de justificatifs et de congés, le plafond annuel d'astreintes et les contrôles de la modulation (solde d'annualisation). Leurs seuils sont des constantes nommées, non réglables dans l'administration. Le détail et les familles tempo qui pourraient un jour les reprendre sont dans backend/apps/rules_engine/README.md.
Les règles s'installent par migration. La migration de données rules_engine.0002 crée le jeu de règles à chaque migrate : un déploiement ne tourne jamais avec une table vide, qui ferait disparaître toutes les alertes sans erreur. Modifier une règle livrée passe par une nouvelle migration ; une modification faite dans l'administration reste locale à l'environnement.
Une seule population hors convention. Un contrat dont le type porte outside_collective_agreement (l'agent polyvalent) ne relève que des règles restreintes à ce drapeau ; jamais du libellé du type, qu'un administrateur peut renommer.
Le frontend : atomic design
Les composants sont classés par responsabilité, du plus inerte au plus lié à l'application : atoms/ (aucune logique métier), molecules/, organisms/ (qui peuvent appeler des hooks), templates/ (mise en page), pages/ (niveau route).
Trois règles tiennent le découpage :
- L'accès aux données passe par
src/api/(axios) puissrc/hooks/(TanStack Query). Un composant n'appelle jamais axios directement. Ce n'est pas une préférence de style : la mise en cache, l'invalidation et la gestion d'erreur vivent dans les hooks, et un appel direct les contourne toutes les trois silencieusement. - L'état client partagé est dans
src/store/(zustand), et se limite aujourd'hui à l'authentification. Tout le reste est de l'état serveur, donc du ressort de TanStack Query. - Le responsive est écrit en CSS, pas en JavaScript. Les styles de base visent le mobile et le bureau est ajouté par-dessus au point de rupture 768 px. Une mise en page pilotée par JavaScript provoque un saut visuel au premier rendu.
Le SPA est une PWA (vite-plugin-pwa, mise à jour automatique) : installable, avec un manifeste et un service worker. Il n'y a pas de mode hors ligne fonctionnel — le service worker sert le shell applicatif, pas les données.
Aucune bibliothèque d'internationalisation
Manque délibéré. Les chaînes visibles par l'utilisateur sont écrites en français directement dans le composant ou le sérialiseur. Il n'y a ni fichier de traduction, ni bibliothèque d'i18n, et il n'en sera pas ajouté tant que l'application reste monolingue.
La raison est le coût comparé : une couche d'i18n impose une clé par chaîne, un fichier de ressources à tenir en parallèle du code et une indirection à chaque libellé, contre un bénéfice nul pour une association qui travaille dans une seule langue. Le jour où une seconde langue est demandée, le travail consiste à extraire les chaînes — mécanique, et outillé — plutôt qu'à avoir maintenu une abstraction pendant des années sans l'utiliser.
Ce que cela impose en contrepartie : le code source est en anglais — identifiants, commentaires, messages de commit — et seules les chaînes destinées à l'utilisateur sont en français. La frontière est explicite, sans quoi les deux langues se mélangent dans le même fichier sans règle.
Ce que la documentation lit dans le code
Une contrainte de structure mérite d'être connue, parce qu'elle explique un découpage qui surprend : le conteneur backend ne monte que ./backend. Un test pytest ne voit donc ni docs/, ni frontend/, ni l'historique git.
D'où trois étages pour la documentation générée :
manage.py dump_docs_factsintrospecte Django et écrit des faits en JSON sur la sortie standard — aucun accès à la base, aucun fichier écrit ;- les moteurs de rendu, en Node sur l'hôte, lisent ces faits et ce que seul l'hôte voit (git,
docker-compose.yml,.env.example, les suites de tests) ; - les invariants documentaires tournent au même endroit, puisqu'ils croisent les deux.
Le français vit dans les moteurs de rendu ; Python n'émet que des données. Les règles sont dans references/documentation.md.
Fraîcheur du domaine Compteurs (APAJH-61/81)
GET /api/auth/me/ expose employee_id, l'identifiant du dossier salarié du compte connecté, ou null lorsqu'aucun dossier n'est associé. Cet identifiant est distinct de celui du compte et reste en lecture seule. Il permet aux pages personnelles d'interroger les API partagées sans charger l'annuaire ; les permissions serveur continuent de contrôler chaque lecture (APAJH-82).
Le relevé et les synthèses utilisent la racine commune des clés TanStack Query Compteurs. Les mutations concernées invalident cette racine. Les requêtes des relevés, de la liste des soldes et de l'onglet Compteurs relisent systématiquement au montage et au retour au premier plan, même pendant les 30 secondes de fraîcheur du cache. Il n'y a pas de rafraîchissement périodique entre sessions.
Frontière des droits à congés paie
data_imports distingue les flux personnes/contrats et congés à partir de leurs en-têtes CSV. Le fichier congés est indivisible ; les salariés sont verrouillés dans l'ordre de leurs identifiants, comme les absences. PayrollLeaveEntitlement porte le dernier instantané et PayrollLeaveChange ses variations effectives. La finalisation remplace seulement LeaveCounter.acquired, sans journal de crédit additionnel. Le dernier succès personnes/contrats reste la référence du tableau de bord.
Consultation des EVP — APAJH-91
apps.payroll est une lecture des sources GTA, sans nouveau modèle de données ni tâche Celery. GET /api/payroll/options/ renvoie les établissements autorisés ; GET /api/payroll/?month=AAAA-MM&establishment=ID&search=texte&page=1 renvoie des lignes par contrat, paginées par 25. Le mois est obligatoire, les autres filtres facultatifs. Un établissement non autorisé est refusé ; sans filtre établissement, la lecture reste limitée au périmètre de la permission. Toute écriture sur ces routes est refusée.
Les sources sont chargées pour la page de contrats sélectionnée. Le calendrier existant résout les dates de télétravail et la priorité des absences ; le décompte existant donne les quantités des absences validées. Une astreinte qui commence la veille et finit pendant le mois est incluse ; ses bornes sont limitées au mois/contrat et sa durée tient compte des changements d'heure. Les évènements sont limités aux participants et à l'établissement du contrat, ou aux évènements transversaux.
Les réponses portent Cache-Control: no-store. Le cache SPA est isolé par utilisateur et filtres ; montage, retour au premier plan et bouton Actualiser relisent les données. Pendant un changement de filtre ou une relecture, les anciennes lignes et leur détail sont retirés. Aucun journal d'export, fichier ou secret d'export n'est conservé.