diff --git a/CESIZen_Soutenance.pptx b/CESIZen_Soutenance.pptx new file mode 100644 index 0000000..6c5de94 Binary files /dev/null and b/CESIZen_Soutenance.pptx differ diff --git a/CLAUDE.md b/CLAUDE.md index 03c1c0a..c936d55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Cesizen/ | Configurer les émotions (2 niveaux) | Admin | ✅ | N/A | ### Hiérarchie des émotions (seed) -7 émotions de base (niveau 1) avec 31 sous-émotions (niveau 2) : +7 émotions de base (niveau 1) avec 28 sous-émotions (niveau 2) : Joie, Tristesse, Colère, Peur, Dégoût, Surprise, Amour. ## Grille de priorisation des besoins diff --git a/Plan_Oral_CESIZen.md b/Plan_Oral_CESIZen.md new file mode 100644 index 0000000..54fddc6 --- /dev/null +++ b/Plan_Oral_CESIZen.md @@ -0,0 +1,88 @@ +# Plan d'oral — Soutenance CESIZen + +**Apprenant :** Adam Marzuk · **Bloc :** INFCDAAL1 — Concevoir les solutions logicielles +**Format :** 20 min de présentation + ~10 min de questions/réponses +**Support :** `CESIZen_Soutenance.pptx` (16 slides : 14 + 2 annexes) + +> Objectif : montrer la **démarche** (recueil → analyse → modélisation) autant que le résultat. +> Le jury note aussi la **prise de parole** : posture, voix, regard, gestion du temps. + +--- + +## Déroulé minuté (20 min) + +| Temps | Slide(s) | Sujet | Messages clés à dire | +|---|---|---|---| +| **0:00 – 2:00** (2′) | 1 — 2 | **Introduction** | Se présenter. Annoncer le commanditaire (Ministère) et l'objet : une plateforme grand public de santé mentale. Donner le fil rouge : « je vais dérouler le recueil du besoin, la priorisation, la réponse fonctionnelle, puis la modélisation technique et la conformité RGPD. » Présenter le sommaire (slide 2). | +| **2:00 – 5:00** (3′) | 3 — 4 | **Contexte, enjeux, parties prenantes** | Rappeler l'enjeu sociétal (22 % des Français, SPF 2023) et la stigmatisation. Énoncer les 4 objectifs stratégiques. Présenter les parties prenantes et les 3 acteurs (Visiteur, Utilisateur, Administrateur). Insister : « le besoin a été **reformulé**, pas recopié. » | +| **5:00 – 7:00** (2′) | 5 — 6 | **Besoins & priorisation** | Périmètre : 2 modules obligatoires + Tracker au choix ; modules écartés assumés. Présenter la grille pondérée /15 (5 critères). Conclusion argumentée : « le Tracker cumule 4 fonctionnalités à 9-10/15, c'est le module au choix le plus rentable. » | +| **7:00 – 12:00** (5′) | 7 — 8 — 9 | **Réponse fonctionnelle** | Module Comptes : JWT, RGPD, admin (slide 7 + maquette). Module Informations : feeds, modération, slug auto (slide 8). Module Tracker : wizard 3 étapes, journal, rapports 4 périodes, 7+28 émotions (slide 9 + maquettes). Montrer que chaque écran répond à un besoin précis. | +| **12:00 – 16:00** (4′) | 10 — 11 — 12 | **Modélisation** | Cas d'usage UML (slide 10) : couverture par acteur. MCD Merise (slide 11) : 8 entités, cardinalités, hiérarchie d'émotions auto-référencée. Architecture MVC découplée (slide 12) : 1 API REST Laravel → 2 clients ; justifier le pattern (testabilité, évolutivité, non-duplication). | +| **16:00 – 17:30** (1′30) | 13 | **RGPD & données sensibles** | Données de santé = sensibles (art. 9). Consentement, droits CNIL (export/anonymisation), soft delete. Sécurité : chiffrement AES-256/TLS, JWT, HDS, table audits. | +| **17:30 – 19:00** (1′30) | 14 | **Prototype & perspectives** | Prototype fonctionnel (Laravel + Next.js + Expo + Docker). Ouverture : activer les modules écartés sans réécrire le modèle, Open Data, Mon Espace Santé (FHIR), i18n. | +| **19:00 – 20:00** (1′) | 14 | **Conclusion** | Synthèse : besoin reformulé → périmètre priorisé → réponse fonctionnelle → architecture pérenne → conformité. Remercier, ouvrir les questions. | + +**Repères de rythme :** ~1 slide / 1 min 20. Si retard à 12:00, raccourcir la modélisation (garder MCD + MVC, survoler les cas d'usage). + +--- + +## Préparation aux questions / réponses (~10 min) + +### Recueil & analyse du besoin +**Q1. Comment avez-vous reformulé le besoin plutôt que recopié le sujet ?** +> J'ai structuré le recueil en 4 blocs (contexte, contraintes, livrables, besoins logiciels), ajouté des personas (Claire, Karim, Samia) et une grille de priorisation pondérée qui justifie un périmètre — ce sont des apports personnels, pas dans l'énoncé. + +**Q2. Pourquoi avoir retenu le Tracker et écarté Diagnostic / Cohérence cardiaque / Activités ?** +> La grille /15 le classe en tête (4 fonctionnalités à 9-10). En individuel sur 3 mois, mieux vaut 3 modules aboutis que 6 superficiels. L'architecture découplée permet d'ajouter les autres plus tard sans refonte. + +**Q3. Vos critères de priorisation sont-ils objectifs ?** +> Ils sont explicites et pondérés (Complexité, Valeur Ministère, Valeur Utilisateur, Nécessité, Interdépendance). La part de subjectivité est assumée mais tracée, donc discutable et reproductible. + +### Conception & architecture +**Q4. Pourquoi MVC découplé plutôt qu'un monolithe rendu côté serveur ?** +> Une seule API REST alimente web ET mobile : la logique métier n'est pas dupliquée. Séparation Modèle/Vue/Contrôleur = testabilité et maintenabilité. On peut ajouter un client (TV, vocal) sans toucher au modèle. + +**Q5. Pourquoi Laravel, Next.js, React Native, PostgreSQL ?** +> Laravel : framework MVC mature, sécurité native (CSRF/XSS/SQLi), Eloquent, écosystème JWT. Next.js : SSR/SEO, App Router, accessibilité. React Native/Expo : code partagé, iOS+Android sans double dev natif. PostgreSQL : ACID, JSON, certifiable HDS. + +**Q6. Le Tracker est en relation 1-1 avec l'utilisateur (HasOne) — pourquoi un seul journal ?** +> Le besoin est un journal personnel unique ; le tracker est créé automatiquement à l'inscription. Le modèle reste extensible (passer en 1-N) si un jour on veut plusieurs journaux thématiques. + +**Q7. Comment gérez-vous la hiérarchie d'émotions à 2 niveaux ?** +> Une seule table `emotions` avec auto-référence `parent_id` : niveau 1 = parent_id NULL, niveau 2 = parent_id renseigné. 7 émotions de base + 28 sous-émotions. L'admin la configure via le CRUD émotions. + +### Données & sécurité (RGPD) +**Q8. Les données émotionnelles sont sensibles — comment êtes-vous conforme ?** +> Consentement explicite à l'inscription (case non pré-cochée), finalités documentées, droits CNIL implémentés (accès, rectification, effacement, portabilité via export JSON, anonymisation). Hébergement HDS, chiffrement AES-256 au repos et TLS en transit, table `audits` pour la traçabilité. + +**Q9. Différence entre soft delete et anonymisation ?** +> Soft delete (`deleted_at`) = suppression réversible 30 j puis purge — pour le droit à l'effacement avec fenêtre de rétractation. Anonymisation = on dissocie les données de l'identité (note vidée, contacts supprimés) tout en gardant des statistiques agrégées non identifiantes. + +**Q10. Pourquoi le hard delete sur les saisies du tracker ?** +> Ce sont des données personnelles sensibles : quand l'utilisateur supprime une saisie, il attend une suppression réelle et immédiate, pas une conservation cachée. + +### Sécurité technique +**Q11. Pourquoi JWT et quels risques ?** +> Stateless donc scalable, claims (rôle) embarqués. Risques maîtrisés : expiration courte (60 min) + refresh, signature HS256, HTTPS strict, throttle sur le login (5/min) contre le brute force. Évolution prévue : MFA (TOTP) pour les admins. + +**Q12. Comment tenir 5000 utilisateurs simultanés ?** +> API stateless horizontalement scalable, PostgreSQL avec index, requêtes paginées, cache côté client (TanStack Query). Cibles : API < 300 ms (p95), dashboard < 1,5 s. + +### Questions « piège » fréquentes +**Q13. Qu'est-ce qui est réellement développé vs spécifié ?** +> Un prototype fonctionnel des 3 modules (API + web + mobile) accompagne le dossier ; les spécifications détaillées couvrent les 2 modules obligatoires + le Tracker, conformément à la consigne. + +**Q14. Si c'était à refaire, que changeriez-vous ?** +> J'ajouterais des tests automatisés d'accessibilité RGAA dès le début, et je formaliserais un diagramme de classes en complément du MCD pour la couche métier. + +--- + +## Conseils de prise de parole (la santé mentale… commence par la vôtre) + +- **Respiration** : avant d'entrer, 3 respirations lentes (cohérence cardiaque) — clin d'œil au sujet, ça pose la voix. +- **Posture** : debout, ancré, épaules ouvertes ; ne pas lire les slides, les commenter. +- **Rythme** : marquer une pause après chaque idée clé ; regarder les 3 évaluateurs à tour de rôle. +- **Slides** : peu de texte à l'écran, c'est vous qui portez le contenu ; les schémas sont des appuis. +- **Temps** : repère mental à 12:00 (fin réponse fonctionnelle) ; garder 1 min de marge pour la conclusion. +- **Q/R** : reformuler la question avant de répondre ; si on ne sait pas, l'assumer et proposer une piste. +- **Clôture** : finir sur l'ouverture (perspectives), pas sur une excuse. diff --git a/assets_dossier/mcd.png b/assets_dossier/mcd.png new file mode 100644 index 0000000..b071ae8 Binary files /dev/null and b/assets_dossier/mcd.png differ diff --git a/assets_dossier/mockup_inscription.png b/assets_dossier/mockup_inscription.png new file mode 100644 index 0000000..a8783b5 Binary files /dev/null and b/assets_dossier/mockup_inscription.png differ diff --git a/assets_dossier/mockup_rapports.png b/assets_dossier/mockup_rapports.png new file mode 100644 index 0000000..0797d37 Binary files /dev/null and b/assets_dossier/mockup_rapports.png differ diff --git a/assets_dossier/mockup_wizard.png b/assets_dossier/mockup_wizard.png new file mode 100644 index 0000000..51f6995 Binary files /dev/null and b/assets_dossier/mockup_wizard.png differ diff --git a/assets_dossier/mvc.png b/assets_dossier/mvc.png new file mode 100644 index 0000000..be75a30 Binary files /dev/null and b/assets_dossier/mvc.png differ diff --git a/assets_dossier/seq_login.png b/assets_dossier/seq_login.png new file mode 100644 index 0000000..b5b8c70 Binary files /dev/null and b/assets_dossier/seq_login.png differ diff --git a/assets_dossier/seq_saisie.png b/assets_dossier/seq_saisie.png new file mode 100644 index 0000000..685269b Binary files /dev/null and b/assets_dossier/seq_saisie.png differ diff --git a/assets_dossier/usecase.png b/assets_dossier/usecase.png new file mode 100644 index 0000000..6cad7a2 Binary files /dev/null and b/assets_dossier/usecase.png differ diff --git a/generate_cahier_charges.py b/generate_cahier_charges.py new file mode 100644 index 0000000..24419cc --- /dev/null +++ b/generate_cahier_charges.py @@ -0,0 +1,864 @@ +#!/usr/bin/env python3 +""" +Génération du Cahier des Charges CESIZen — version enrichie pour rendu CESI. + +Couvre toutes les rubriques de la grille d'évaluation INFCDAAL1 : +- Recueil du besoin (contexte, contraintes, livrables, besoins logiciels) +- Critères de priorisation et pondération +- Cahier des charges (analyse, contraintes, opportunités) +- Réponse fonctionnelle 2 modules obligatoires (Comptes, Informations) + 1 au choix (Tracker) +- Modélisation : MCD, MVC, spécifications, RGPD +""" +from fpdf import FPDF + + +# Remplace les caractères Unicode non-Latin-1 par des équivalents. +_UNICODE_FIX = { + '—': '-', # em-dash + '–': '-', # en-dash + '…': '...', # ellipsis + '‘': "'", # left single quote + '’': "'", # right single quote + '“': '"', # left double quote + '”': '"', # right double quote + '→': '->', # right arrow + '←': '<-', # left arrow + '•': '-', # bullet + ' ': ' ', # nbsp + '✅': 'OK', # check mark + '❌': 'X', # cross mark + '⚠': '!', # warning + 'ᵉ': 'e', # superscript e + 'ᵒ': 'o', + '€': 'EUR', + '×': 'x', + '≤': '<=', + '≥': '>=', + '°': 'deg', +} + +def _safe(s): + if not isinstance(s, str): + return s + for k, v in _UNICODE_FIX.items(): + s = s.replace(k, v) + return s + + +# ─── Couleurs CESIZen ────────────────────────────────────────────── +COULEUR_PRIMAIRE = (252, 225, 23) # candlelight (#fce117) +COULEUR_SECONDAIRE = (6, 198, 86) # malachite (#06c656) +COULEUR_BLEU = (0, 70, 130) +COULEUR_BLEU_CLAIR = (230, 240, 250) +COULEUR_GRIS_FONCE = (50, 50, 50) +COULEUR_GRIS_MOYEN = (110, 110, 110) +COULEUR_GRIS_CLAIR = (240, 240, 240) +COULEUR_BLANC = (255, 255, 255) +COULEUR_ROUGE = (200, 40, 40) + + +class CahierCharges(FPDF): + def __init__(self): + super().__init__() + self.set_auto_page_break(auto=True, margin=20) + + def cell(self, w=0, h=0, text='', *args, **kwargs): # noqa + return super().cell(w, h, _safe(text), *args, **kwargs) + + def multi_cell(self, w, h, text='', *args, **kwargs): # noqa + return super().multi_cell(w, h, _safe(text), *args, **kwargs) + + def header(self): + if self.page_no() > 1: + self.set_font('Helvetica', 'I', 8) + self.set_text_color(*COULEUR_GRIS_MOYEN) + self.cell(0, 8, 'CESIZen — Cahier des charges', align='L') + self.cell(0, 8, f'Page {self.page_no()}', align='R', new_x="LMARGIN", new_y="NEXT") + self.set_draw_color(*COULEUR_SECONDAIRE) + self.set_line_width(0.5) + self.line(10, 18, 200, 18) + self.ln(4) + + def footer(self): + self.set_y(-15) + self.set_font('Helvetica', 'I', 7) + self.set_text_color(*COULEUR_GRIS_MOYEN) + self.cell(0, 10, "CESI École d'Ingénieurs — Bloc INFCDAAL1 — Ministère des Solidarités et de la Santé", align='C') + + # ─── Helpers ──────────────────────────────────────────────────── + def h1(self, num, title): + if self.get_y() > 230: + self.add_page() + self.set_font('Helvetica', 'B', 16) + self.set_text_color(*COULEUR_BLEU) + self.set_fill_color(*COULEUR_BLEU_CLAIR) + self.cell(0, 11, f' {num}. {title}', fill=True, new_x="LMARGIN", new_y="NEXT") + self.ln(3) + + def h2(self, title): + if self.get_y() > 250: + self.add_page() + self.set_font('Helvetica', 'B', 12) + self.set_text_color(*COULEUR_SECONDAIRE) + self.cell(0, 8, f' {title}', new_x="LMARGIN", new_y="NEXT") + self.ln(1) + + def h3(self, title): + self.set_font('Helvetica', 'B', 10) + self.set_text_color(*COULEUR_GRIS_FONCE) + self.cell(0, 7, f' {title}', new_x="LMARGIN", new_y="NEXT") + + def paragraph(self, text): + self.set_font('Helvetica', '', 9.5) + self.set_text_color(40, 40, 40) + self.multi_cell(0, 5.2, text) + self.ln(1.5) + + def bullet(self, text, indent=8): + self.set_font('Helvetica', '', 9.5) + self.set_text_color(40, 40, 40) + x_start = self.l_margin + indent + self.set_x(x_start) + # puce + self.set_font('Helvetica', 'B', 9.5) + self.set_text_color(*COULEUR_SECONDAIRE) + self.cell(4, 5, chr(149)) + self.set_font('Helvetica', '', 9.5) + self.set_text_color(40, 40, 40) + self.multi_cell(self.w - self.r_margin - self.get_x(), 5, text) + self.ln(0.5) + + def table(self, headers, rows, col_widths=None, header_bg=None): + if col_widths is None: + page_width = self.w - self.l_margin - self.r_margin + col_widths = [page_width / len(headers)] * len(headers) + header_bg = header_bg or COULEUR_SECONDAIRE + # header + self.set_font('Helvetica', 'B', 8.5) + self.set_fill_color(*header_bg) + self.set_text_color(255, 255, 255) + for i, h in enumerate(headers): + self.cell(col_widths[i], 7, h, border=1, fill=True, align='C') + self.ln() + # rows + self.set_font('Helvetica', '', 8.5) + self.set_text_color(40, 40, 40) + fill = False + for row in rows: + # estimate max height for this row + line_h = 5 + max_lines = 1 + for i, cell_value in enumerate(row): + s = str(cell_value) + # approximate text width per line: each char ~ 1.8mm at size 8.5 + chars_per_line = max(1, int(col_widths[i] / 1.8)) + lines = sum(max(1, (len(w) + chars_per_line - 1) // chars_per_line) + for w in s.split('\n')) + max_lines = max(max_lines, lines) + row_h = line_h * max_lines + 1 + + if self.get_y() + row_h > self.h - 25: + self.add_page() + self.set_font('Helvetica', 'B', 8.5) + self.set_fill_color(*header_bg) + self.set_text_color(255, 255, 255) + for i, h in enumerate(headers): + self.cell(col_widths[i], 7, h, border=1, fill=True, align='C') + self.ln() + self.set_font('Helvetica', '', 8.5) + self.set_text_color(40, 40, 40) + + if fill: + self.set_fill_color(248, 250, 252) + else: + self.set_fill_color(255, 255, 255) + x_init = self.get_x() + y_init = self.get_y() + for i, cell_value in enumerate(row): + self.rect(x_init + sum(col_widths[:i]), y_init, col_widths[i], row_h, 'F' if fill else 'D') + self.set_xy(x_init + sum(col_widths[:i]) + 1, y_init + 0.5) + self.multi_cell(col_widths[i] - 2, line_h, str(cell_value)) + self.set_xy(x_init, y_init + row_h) + fill = not fill + self.ln(2) + + def callout(self, text, color=None): + color = color or COULEUR_SECONDAIRE + self.set_fill_color(*color) + self.set_text_color(255, 255, 255) + self.set_font('Helvetica', 'B', 9.5) + # gentle wrap + self.multi_cell(0, 7, ' ' + text, fill=True) + self.ln(2) + + def block_callout(self, title, body, color=None): + color = color or COULEUR_SECONDAIRE + self.set_fill_color(*color) + self.set_text_color(255, 255, 255) + self.set_font('Helvetica', 'B', 10) + self.cell(0, 7, ' ' + title, fill=True, new_x="LMARGIN", new_y="NEXT") + self.set_text_color(40, 40, 40) + self.set_fill_color(248, 250, 252) + self.set_font('Helvetica', '', 9) + self.multi_cell(0, 5, ' ' + body, fill=True) + self.ln(2) + + def figure(self, path, w, caption=None, x=None, new_page_if_below=None): + """Insère une image centrée (largeur w en mm) avec légende optionnelle. + Saut de page auto si la figure ne tient pas dans l'espace restant.""" + from PIL import Image as _PILImage + iw, ih = _PILImage.open(path).size + disp_h = w * ih / iw + page_w = self.w - self.l_margin - self.r_margin + if x is None: + x = self.l_margin + (page_w - w) / 2 + seuil = new_page_if_below if new_page_if_below is not None else (self.h - 22) + if self.get_y() + disp_h + (6 if caption else 0) > seuil: + self.add_page() + self.image(path, x=x, w=w) + self.ln(1) + if caption: + self.set_font('Helvetica', 'I', 8) + self.set_text_color(*COULEUR_GRIS_MOYEN) + self.cell(0, 4, caption, align='C', new_x="LMARGIN", new_y="NEXT") + self.set_font('Helvetica', '', 9.5) + self.set_text_color(40, 40, 40) + self.ln(2) + + +# ─── Construction du document ───────────────────────────────────────── +# Dossier des schémas générés par generate_diagrams.py +import os as _os +ASSETS = _os.path.join(_os.path.dirname(_os.path.abspath(__file__)), 'assets_dossier') + +pdf = CahierCharges() + +# ─────────────── Page de garde ─────────────── +pdf.add_page() +pdf.ln(40) +pdf.set_font('Helvetica', 'B', 32) +pdf.set_text_color(*COULEUR_SECONDAIRE) +pdf.cell(0, 14, 'CESIZen', align='C', new_x="LMARGIN", new_y="NEXT") +pdf.set_font('Helvetica', 'B', 18) +pdf.set_text_color(*COULEUR_GRIS_FONCE) +pdf.cell(0, 10, "L'application de votre santé mentale", align='C', new_x="LMARGIN", new_y="NEXT") +pdf.ln(10) +pdf.set_font('Helvetica', '', 14) +pdf.set_text_color(100, 100, 100) +pdf.cell(0, 8, 'Cahier des charges', align='C', new_x="LMARGIN", new_y="NEXT") +pdf.ln(40) + +pdf.set_font('Helvetica', '', 10) +pdf.set_text_color(60, 60, 60) +metadata = [ + ('Intitulé du projet', 'CESIZen — Plateforme Web & Mobile de santé mentale'), + ('Commanditaire', 'Ministère des Solidarités et de la Santé'), + ('Apprenant', 'Adam Marzuk — CESI École d\'Ingénieurs'), + ('Bloc', 'INFCDAAL1 — Concevoir les solutions logicielles'), + ('Date', '16 mai 2026'), + ('Version', '2.0'), +] +for lib, val in metadata: + pdf.set_x(40) + pdf.set_font('Helvetica', 'B', 10) + pdf.cell(45, 7, lib) + pdf.set_font('Helvetica', '', 10) + pdf.cell(0, 7, val, new_x="LMARGIN", new_y="NEXT") + +# ─────────────── 1. Glossaire ─────────────── +pdf.add_page() +pdf.h1(1, 'Glossaire') +glossaire = [ + ('MVC', "Modèle-Vue-Contrôleur. Patron d'architecture séparant données, présentation et logique applicative."), + ('MCD', "Modèle Conceptuel de Données (Merise). Représentation abstraite des entités et de leurs relations."), + ('MLD', "Modèle Logique de Données. Traduction du MCD en tables et clés étrangères pour un SGBD relationnel."), + ('JWT', "JSON Web Token. Format de jeton d'authentification stateless porté dans l'en-tête HTTP Authorization."), + ('RGPD', "Règlement Général sur la Protection des Données. Loi européenne (2018) qui encadre le traitement des données personnelles."), + ('RGAA', "Référentiel Général d'Amélioration de l'Accessibilité (norme française d'accessibilité numérique)."), + ('HDS', "Hébergeur de Données de Santé — certification ASIP Santé obligatoire pour héberger des données de santé en France."), + ('Mobile-First', "Conception prioritairement orientée vers les terminaux mobiles, puis étendue au desktop."), + ('Tracker', "Journal de bord émotionnel : suite de saisies datées associant émotion et intensité."), + ('Saisie tracker', "Entrée individuelle du journal : date, émotion, intensité (1-10), note libre."), +] +for terme, definition in glossaire: + pdf.set_font('Helvetica', 'B', 9.5) + pdf.set_text_color(*COULEUR_SECONDAIRE) + pdf.cell(28, 6, terme) + pdf.set_font('Helvetica', '', 9.5) + pdf.set_text_color(40, 40, 40) + pdf.multi_cell(0, 6, definition) + pdf.ln(0.5) + +# ─────────────── 2. Recueil du besoin ─────────────── +pdf.add_page() +pdf.h1(2, 'Recueil du besoin') +pdf.paragraph( + "Le recueil du besoin s'articule autour de quatre blocs structurants demandés par la consigne CESI : " + "(1) le contexte et les objectifs, (2) les contraintes et ressources, (3) les livrables et prestations, " + "et (4) les besoins logiciels reformulés." +) + +# Bloc 1 — Contexte +pdf.h2('2.1 Bloc 1 — Contexte, objectifs et enjeux') +pdf.paragraph( + "Le projet CESIZen est commandité par le Ministère des Solidarités et de la Santé. Il vise à proposer " + "une plateforme grand public d'accompagnement quotidien en santé mentale, axée sur la prévention du stress " + "et la sensibilisation. Le besoin émerge d'un constat sociétal : 22% des Français présentent un trouble " + "de santé mentale au cours de leur vie (Santé Publique France, 2023), mais la stigmatisation et la " + "méconnaissance freinent l'accès au soin." +) +pdf.block_callout( + 'Objectifs stratégiques', + "1. Donner accès à un contenu éducatif fiable sur la santé mentale. " + "2. Permettre à chacun de suivre son humeur dans le temps. " + "3. Diriger les personnes en détresse vers des ressources adaptées (numéros d'urgence). " + "4. Garantir la confidentialité absolue des données émotionnelles (RGPD + HDS).", + color=COULEUR_BLEU +) + +# Bloc 2 — Contraintes & ressources +pdf.h2('2.2 Bloc 2 — Contraintes et ressources catégorisées') +pdf.table( + headers=['Catégorie', 'Contrainte', 'Niveau'], + rows=[ + ['Légale', 'Conformité RGPD (consentement, droits, portabilité, oubli)', 'CRITIQUE'], + ['Légale', 'Hébergement certifié HDS (données de santé)', 'CRITIQUE'], + ['Légale', 'Accessibilité RGAA niveau AA', 'FORTE'], + ['Technique', 'Compatibilité Web modernes (Chrome 100+, Firefox, Safari)', 'FORTE'], + ['Technique', 'Mobile multi-plateformes (iOS 14+, Android 8+)', 'FORTE'], + ['Technique', 'API REST stateless, chiffrement HTTPS strict', 'FORTE'], + ['Organisationnelle', 'Réalisation individuelle, 3 mois calendaires', 'CRITIQUE'], + ['Organisationnelle', "Pas de budget dédié à la création de contenu", 'MOYENNE'], + ['Fonctionnelle', '2 modules obligatoires + 1 au choix (Tracker retenu)', 'CRITIQUE'], + ['Budgétaire', 'Stack open-source uniquement (pas de licence payante)', 'MOYENNE'], + ], + col_widths=[35, 130, 25], +) +pdf.h3('Ressources mobilisées') +pdf.bullet("1 apprenant ingénieur (compétences full-stack PHP/JS/TS).") +pdf.bullet("Stack open-source : Laravel 11, Next.js 16, React Native (Expo), PostgreSQL 16, Docker.") +pdf.bullet("Hébergement cible : Scaleway Healthcare ou OVH Healthcare (HDS).") +pdf.bullet("Outils de gestion : GitHub (versioning), Figma (maquettes), draw.io (UML).") + +# Bloc 3 — Livrables +pdf.h2('2.3 Bloc 3 — Types de prestations et livrables') +pdf.paragraph( + "Le projet livre les éléments suivants au commanditaire :" +) +pdf.table( + headers=['#', 'Livrable', 'Format', 'Pondération'], + rows=[ + ['M1', 'Cahier des charges détaillé', 'PDF', '10%'], + ['M2', "Dossier d'architecture & conception (MCD, UML, MVC)", 'PDF + diagrammes', '15%'], + ['M3', 'Maquettes UI/UX (Web + Mobile)', 'Figma / images', '20%'], + ['M4', 'Code source v1 (API Laravel + Web Next.js + Mobile RN)', 'Dépôt Git', '40%'], + ['M5', "Documentation technique & utilisateur (installation, admin)", 'PDF / Markdown', '15%'], + ], + col_widths=[12, 100, 50, 28], +) + +# Bloc 4 — Besoins logiciels +pdf.h2('2.4 Bloc 4 — Besoins logiciels reformulés') +pdf.paragraph( + "Les besoins fonctionnels sont organisés par module. Les modules retenus pour les spécifications " + "détaillées (cf. section 5) sont en gras :" +) +pdf.table( + headers=['Module', 'Statut', 'Besoins principaux'], + rows=[ + ['Comptes utilisateurs', + 'OBLIGATOIRE', + "Inscription, connexion JWT, gestion profil, reset mot de passe, RGPD, CRUD admin"], + ['Informations', + 'OBLIGATOIRE', + "Affichage pages publiques, modification contenus (admin)"], + ['Tracker d\'émotions', + 'CHOIX', + "Journal de bord, ajout/édition/suppression saisies, rapports périodiques, configuration émotions"], + ['Diagnostics de stress', + 'NON RETENU', + "Module questionnaire — non couvert pour ce livrable"], + ['Exercices de respiration', + 'NON RETENU', + "Cohérence cardiaque — non couvert pour ce livrable"], + ['Activités de détente', + 'NON RETENU', + "Catalogue activités — non couvert pour ce livrable"], + ], + col_widths=[40, 30, 120], +) + +# ─────────────── 3. Critères de priorisation ─────────────── +pdf.add_page() +pdf.h1(3, 'Critères de priorisation et pondération') +pdf.paragraph( + "Face à l'amplitude initiale du besoin, une grille de priorisation pondérée a été établie pour identifier " + "les fonctionnalités à fort impact et à forte valeur ajoutée. Cinq critères ont été retenus, totalisant 15 points." +) +pdf.table( + headers=['Critère', 'Échelle', 'Justification du critère'], + rows=[ + ['Complexité', '0 - 3', "Effort technique / risque d'implémentation (3 = très complexe)."], + ['Valeur Ministère', '0 - 3', "Réponse aux enjeux de santé publique (3 = très alignée)."], + ['Valeur Utilisateur', '0 - 3', "Utilité quotidienne perçue par l'utilisateur final."], + ['Nécessité', '0 - 2', "Caractère obligatoire ou différenciant pour le périmètre."], + ['Interdépendance', '0 - 2', "Nombre de fonctionnalités qui en dépendent."], + ], + col_widths=[40, 25, 125], +) +pdf.h2('3.1 Tableau de priorisation des fonctionnalités') +pdf.paragraph( + "Note : 'C' = Complexité, 'VM' = Valeur Ministère, 'VU' = Valeur Utilisateur, 'N' = Nécessité, " + "'I' = Interdépendance. Total sur 15." +) +prio_rows = [ + ['Afficher le questionnaire diagnostic de stress', '3', '3', '2', '2', '0', '10'], + ["Afficher le journal de bord du tracker d'émotions", '2', '3', '3', '2', '0', '10'], + ['Visualiser un rapport (semaine/mois/trimestre/année)', '3', '2', '3', '1', '1', '10'], + ['Cryptage des données', '3', '2', '1', '2', '1', '9'], + ["Création d'un compte utilisateur", '1', '1', '3', '2', '2', '9'], + ['Cohérence cardiaque', '1', '3', '3', '2', '0', '9'], + ['Catalogue activités détente', '1', '2', '3', '1', '2', '9'], + ['Ajouter / modifier une saisie tracker', '1', '2', '3', '2', '1', '9'], + ['Conformité RGPD', '3', '0', '2', '2', '1', '8'], + ['Réinitialisation mot de passe', '2', '1', '3', '1', '0', '7'], + ['Affichage pages de contenus', '1', '2', '2', '1', '0', '6'], + ['Gestion compte (profil)', '1', '1', '1', '2', '0', '5'], + ['Création comptes admin', '1', '1', '1', '2', '0', '5'], + ['Modification contenus (admin)', '2', '2', '1', '0', '0', '5'], + ['Désactivation / suppression compte', '1', '1', '0', '2', '0', '4'], + ['Supprimer une saisie tracker', '1', '1', '2', '0', '0', '4'], + ['Configurer la liste d\'émotions (admin)', '2', '1', '0', '0', '0', '3'], +] +pdf.table( + headers=['Fonctionnalité', 'C', 'VM', 'VU', 'N', 'I', 'Total'], + rows=prio_rows, + col_widths=[100, 12, 12, 12, 12, 12, 16], +) +pdf.h2('3.2 Conclusion de la priorisation') +pdf.bullet( + "Le module Tracker (journal + rapports + ajout/édition) cumule 4 fonctionnalités notées 9-10/15. " + "C'est le module à plus forte valeur ajoutée pour l'utilisateur, retenu comme module au choix." +) +pdf.bullet( + "Les modules obligatoires (Comptes utilisateurs, Informations) sont en seconde priorité : " + "fondations indispensables mais à valeur ajoutée moins différenciante." +) +pdf.bullet( + "Les modules Diagnostic, Cohérence cardiaque et Activités détente — bien que théoriquement attractifs — " + "ne sont pas implémentés dans le périmètre actuel pour garantir la qualité des modules retenus." +) +pdf.bullet( + "La conformité RGPD (8/15) est traitée transversalement (consentement, soft delete, export, anonymisation)." +) + +# ─────────────── 4. Cahier des charges — Analyse ─────────────── +pdf.add_page() +pdf.h1(4, 'Cahier des charges — Analyse') +pdf.h2('4.1 Acteurs identifiés') +pdf.table( + headers=['Acteur', 'Description', 'Périmètre fonctionnel'], + rows=[ + ['Visiteur anonyme', + "Internaute non authentifié", + "Consulter pages info, créer un compte"], + ['Utilisateur connecté', + "Personne ayant un compte CESIZen actif", + "Gérer profil, journal émotions, rapports, RGPD"], + ['Administrateur', + "Modérateur / professionnel de la plateforme", + "Gérer utilisateurs, contenus, émotions, statistiques"], + ], + col_widths=[35, 60, 90], +) + +pdf.h2('4.2 Personas') +personas = [ + ("Claire — Utilisatrice (Visiteur → Membre)", + "35 ans, responsable marketing à Lille. Mère célibataire de 2 enfants. Stressée par le travail, " + "elle veut un outil mobile rapide pour noter son ressenti dans les transports et lire des conseils le soir."), + ("Karim — Utilisateur (Membre)", + "45 ans, entrepreneur à Bordeaux. Marié, malvoyant. Recherche une application accessible " + "(RGAA AA, contraste, navigation clavier) pour suivre son humeur sans dépendre d'un proche."), + ("Samia — Administratrice", + "42 ans, professionnelle de santé. Modère et publie les ressources informatives. " + "Configure les listes d'émotions, désactive les comptes signalés, consulte les statistiques anonymes globales."), +] +for nom, desc in personas: + pdf.set_font('Helvetica', 'B', 10) + pdf.set_text_color(*COULEUR_SECONDAIRE) + pdf.cell(0, 6, ' ' + nom, new_x="LMARGIN", new_y="NEXT") + pdf.set_font('Helvetica', '', 9.5) + pdf.set_text_color(40, 40, 40) + pdf.multi_cell(0, 5, ' ' + desc) + pdf.ln(1) + +pdf.h2('4.3 Opportunités et pérennité') +pdf.bullet( + "Évolutivité : architecture découplée (API REST + 2 clients web/mobile) permettant l'ajout futur " + "des modules Diagnostic, Respiration, Activités sans réécriture." +) +pdf.bullet( + "Open Data : la base de saisies anonymisées pourra alimenter des études épidémiologiques nationales." +) +pdf.bullet( + "Partenariat institutionnel : intégration future possible avec Mon Espace Santé (DMP) via FHIR." +) +pdf.bullet( + "Internationalisation : l'i18n est anticipée (clés de traduction), permettant un déploiement européen." +) + +# ─────────────── 5. Réponse fonctionnelle ─────────────── +pdf.add_page() +pdf.h1(5, "Réponse fonctionnelle — Spécifications par module") + +# ─── Module 1 — Comptes +pdf.h2("5.1 Module 'Comptes utilisateurs' (obligatoire)") +pdf.h3("Écrans principaux") +pdf.bullet("Page inscription : formulaire (nom, prénom, email, mot de passe + confirmation, case consentement RGPD).") +pdf.bullet("Page connexion : email + mot de passe, lien 'Mot de passe oublié'.") +pdf.bullet("Page profil utilisateur : affichage + édition (nom, prénom, email), changement mot de passe.") +pdf.bullet("Page admin — liste utilisateurs : tableau paginé avec recherche, filtre rôle, action toggle/supprimer.") +pdf.bullet("Page admin — création utilisateur : formulaire avec attribution rôle (membre/admin).") + +pdf.h3("Règles de gestion") +pdf.table( + headers=['Règle', 'Détail'], + rows=[ + ['RG-CU-01', 'Email unique en base — vérification avant insertion.'], + ['RG-CU-02', 'Mot de passe : minimum 8 caractères, hash bcrypt (12 rounds).'], + ['RG-CU-03', "Consentement RGPD obligatoire à l'inscription (case à cocher non-pré-cochée)."], + ['RG-CU-04', "JWT expiration 60 minutes, refresh token 14 jours."], + ['RG-CU-05', "Soft delete sur Utilisateur (champ deleted_at) — conservation 30 jours puis purge."], + ['RG-CU-06', "Compte désactivé ne peut plus se connecter (statut est_actif = false → 403)."], + ['RG-CU-07', "Seul un compte de rôle 'administrateur' peut créer un autre administrateur."], + ], + col_widths=[20, 170], +) +pdf.h3("Endpoints API (extrait)") +pdf.set_font('Courier', '', 8.5) +pdf.set_text_color(40, 40, 40) +endpoints_cu = [ + "POST /api/v1/auth/register Inscription public", + "POST /api/v1/auth/login Connexion (throttle 5/min)", + "GET /api/v1/profil Lecture profil (JWT)", + "PUT /api/v1/profil Mise à jour profil", + "PUT /api/v1/profil/password Changement mot de passe", + "GET /api/v1/profil/export Export RGPD (JSON)", + "POST /api/v1/profil/anonymiser Anonymisation RGPD", + "GET /api/v1/admin/utilisateurs Liste paginée (admin)", + "POST /api/v1/admin/utilisateurs Création (admin)", + "PATCH /api/v1/admin/utilisateurs/{id}/toggle-active", +] +for ep in endpoints_cu: + pdf.cell(0, 5, ep, new_x="LMARGIN", new_y="NEXT") +pdf.set_font('Helvetica', '', 9.5) +pdf.ln(2) +pdf.figure(_os.path.join(ASSETS, 'mockup_inscription.png'), 62, + caption="Maquette - écran d'inscription (consentement RGPD obligatoire)") + +# ─── Module 2 — Informations +pdf.add_page() +pdf.h2("5.2 Module 'Informations' (obligatoire)") +pdf.h3("Écrans principaux") +pdf.bullet("Page publique liste : grille de cartes (titre, extrait 120 caractères, image, date).") +pdf.bullet("Page publique détail : titre, contenu HTML rendu, auteur, date de publication.") +pdf.bullet("Page admin — liste contenus : tableau avec actions modifier / publier / supprimer.") +pdf.bullet("Page admin — formulaire création/édition : titre, slug auto, contenu (éditeur riche), image_url, ordre, case 'publié'.") + +pdf.h3("Règles de gestion") +pdf.table( + headers=['Règle', 'Détail'], + rows=[ + ['RG-INF-01', "Slug unique généré automatiquement à partir du titre (ex: 'Gérer son stress' → 'gerer-son-stress')."], + ['RG-INF-02', "Champ booléen est_publie : false (brouillon, invisible) ou true (publié, visible)."], + ['RG-INF-03', "Ordre numérique pour le tri sur la page liste publique (1 = en haut)."], + ['RG-INF-04', "Auteur enregistré automatiquement (utilisateur authentifié à la création)."], + ['RG-INF-05', "Suppression d'un contenu réservée à l'administrateur (action tracée dans audits)."], + ['RG-INF-06', "Modération obligatoire : un admin valide avant publication."], + ], + col_widths=[20, 170], +) + +# ─── Module 3 — Tracker +pdf.h2("5.3 Module 'Tracker d'émotions' (au choix)") +pdf.paragraph( + "Le module Tracker permet à l'utilisateur d'enregistrer son état émotionnel via un wizard en 3 étapes, " + "de consulter son journal, et de visualiser des rapports analytiques sur 4 périodes (semaine, mois, trimestre, année). " + "Les émotions sont structurées en 2 niveaux (7 émotions de base + 28 sous-émotions précises)." +) +pdf.h3("Wizard d'ajout de saisie (3 étapes)") +pdf.block_callout( + "Étape 1 — Sélection de l'émotion de base (niveau 1)", + "L'utilisateur choisit parmi 7 émotions fondamentales : Joie, Tristesse, Colère, Peur, Dégoût, Surprise, Amour. " + "Chaque émotion est représentée par une couleur et un icône distinctifs.", + color=COULEUR_SECONDAIRE +) +pdf.block_callout( + "Étape 2 — Sous-émotion (niveau 2) + intensité 1-10", + "Selon l'émotion choisie, 4 à 6 sous-émotions plus précises sont proposées (ex: Joie → Sérénité, Enthousiasme, Fierté, Gratitude). " + "L'intensité est ajustée via un slider de 1 (très faible) à 10 (très forte).", + color=COULEUR_BLEU +) +pdf.block_callout( + "Étape 3 — Date + note libre", + "La date est pré-remplie à aujourd'hui (modifiable). Une note libre (0-2000 caractères) permet " + "de contextualiser la saisie. Validation finale → enregistrement.", + color=COULEUR_PRIMAIRE +) +pdf.figure(_os.path.join(ASSETS, 'mockup_wizard.png'), 50, + caption="Maquette - étape 2 du wizard (sous-émotion + slider d'intensité)") + +pdf.h3("Règles de gestion") +pdf.table( + headers=['Règle', 'Détail'], + rows=[ + ['RG-TR-01', "L'utilisateur dispose d'un seul tracker (1-1 avec son compte)."], + ['RG-TR-02', "Intensité obligatoire entre 1 et 10 (entier)."], + ['RG-TR-03', "Une seule saisie par jour autorisée → modification possible (pas de duplicat)."], + ['RG-TR-04', "Émotion obligatoire (référence emotion_id valide, niveau 1 ou 2)."], + ['RG-TR-05', "Note libre limitée à 2000 caractères."], + ['RG-TR-06', "Rapports : période min 7j (semaine), max 365j (année)."], + ['RG-TR-07', "Statistiques : moyenne d'intensité, répartition par émotion, courbe temporelle."], + ['RG-TR-08', "Suppression de saisie : hard delete (pas de soft delete car données personnelles)."], + ], + col_widths=[20, 170], +) +pdf.figure(_os.path.join(ASSETS, 'mockup_rapports.png'), 120, + caption="Maquette - rapport d'émotions par période (statistiques + répartition)") + +# ─────────────── 6. Diagramme cas d'usage ─────────────── +pdf.add_page() +pdf.h1(6, "Schématisation UML — Cas d'usage") +pdf.paragraph( + "Le diagramme ci-dessous présente les cas d'usage couverts par le périmètre actuel. Les modules non retenus " + "(Diagnostic, Respiration, Activités) ne sont volontairement pas représentés." +) +pdf.figure(_os.path.join(ASSETS, 'usecase.png'), 185, + caption="Diagramme de cas d'usage UML - 3 acteurs et fonctionnalités par module") + +# ─────────────── 7. MCD ─────────────── +pdf.add_page() +pdf.h1(7, 'Modèle Conceptuel de Données (MCD)') +pdf.paragraph( + "Le MCD utilise le formalisme Merise (entités, associations, cardinalités). 8 entités principales sont " + "modélisées : Utilisateur, Rôle, Tracker, Émotion, SaisieTracker, Feed, ContactUrgence, Audit." +) +pdf.figure(_os.path.join(ASSETS, 'mcd.png'), 150, + caption="Modèle Conceptuel de Données (Merise) - cardinalités et hiérarchie d'émotions") + +pdf.h2("7.1 Cardinalités principales") +pdf.bullet("Un Rôle est porté par 0..N Utilisateurs. Un Utilisateur a exactement 1 Rôle. [1,1 — 0,N]") +pdf.bullet("Un Utilisateur possède exactement 1 Tracker (créé à l'inscription). Un Tracker appartient à 1 Utilisateur. [1,1 — 1,1]") +pdf.bullet("Un Tracker contient 0..N SaisieTracker. Une SaisieTracker appartient à exactement 1 Tracker. [1,1 — 0,N]") +pdf.bullet("Une Émotion (niveau 1) regroupe 0..N sous-émotions (niveau 2). Auto-référence parent_id. [0,1 — 0,N]") +pdf.bullet("Une Émotion est utilisée dans 0..N SaisieTracker. [1,1 — 0,N]") +pdf.bullet("Un Utilisateur (admin) rédige 0..N Feeds. Un Feed a exactement 1 auteur. [1,1 — 0,N]") + +pdf.h2("7.2 MLD — Traduction en tables") +pdf.set_font('Courier', '', 8) +pdf.set_text_color(40, 40, 40) +mld_text = """\ +roles (id, nom, description) +utilisateurs (id, nom, prenom, email UNIQUE, password, role_id FK→roles, est_actif, + consentement_rgpd, created_at, updated_at, deleted_at) +trackers (id, utilisateur_id FK→utilisateurs, nom, created_at, updated_at) +emotions (id, nom, couleur, icone, niveau, parent_id FK→emotions, est_actif) +saisie_trackers (id, tracker_id FK→trackers, emotion_id FK→emotions, + intensite CHECK BETWEEN 1 AND 10, note, date_saisie, created_at) +feeds (id, titre, slug UNIQUE, contenu, image_url, est_publie, + auteur_id FK→utilisateurs, ordre, created_at, updated_at) +contacts_urgence (id, utilisateur_id FK→utilisateurs, nom, telephone, relation) +audits (id, utilisateur_id FK→utilisateurs, action, table_cible, + enregistrement_id, anciennes_valeurs JSON, nouvelles_valeurs JSON, + ip_address, created_at)""" +for line in mld_text.split('\n'): + pdf.cell(0, 4.6, line, new_x="LMARGIN", new_y="NEXT") +pdf.set_font('Helvetica', '', 9.5) +pdf.ln(2) + +# ─────────────── 8. Architecture MVC ─────────────── +pdf.add_page() +pdf.h1(8, 'Conception technique — Architecture MVC') +pdf.paragraph( + "L'architecture exploite le patron MVC (Modèle-Vue-Contrôleur) en mode découplé : le Backend Laravel " + "expose une API REST JSON qui alimente deux clients (Vue Web Next.js + Vue Mobile React Native)." +) +pdf.figure(_os.path.join(ASSETS, 'mvc.png'), 175, + caption="Architecture MVC découplée - une API REST alimente les vues Web et Mobile") +pdf.h2('8.1 Justification du choix MVC') +pdf.bullet("Séparation stricte modèle / logique / présentation, facilitant les tests unitaires.") +pdf.bullet("Évolutivité : ajout d'un client (TV, assistant vocal) sans toucher au modèle.") +pdf.bullet("Maintenabilité : modification d'un écran n'impacte ni les contrôleurs ni les modèles.") +pdf.bullet("Conformité au cahier des charges (consigne explicite : 'justifier le Design Pattern MVC').") + +pdf.h2('8.2 Répartition des responsabilités') +pdf.table( + headers=['Couche', 'Responsabilité', 'Technologie'], + rows=[ + ['Model', + "Représentation données + relations + règles métier intrinsèques", + "Eloquent ORM (Laravel) + PostgreSQL 16"], + ['Controller', + "Réception requêtes HTTP, validation, application des règles métier, retour JSON", + "Laravel Controllers + FormRequest + Middleware (JWT, role, throttle)"], + ['View — Web', + "Rendu UI desktop & mobile-first, gestion état client, interactions", + "Next.js 16 (App Router) + Zustand + TanStack Query"], + ['View — Mobile', + "Rendu UI native iOS + Android, navigation, SecureStore", + "React Native (Expo 53) + Expo Router + Zustand"], + ], + col_widths=[28, 100, 62], +) + +pdf.h2("8.3 Choix techniques justifiés") +pdf.table( + headers=['Domaine', 'Technologie', 'Justification clé'], + rows=[ + ['Backend', 'Laravel 11 (PHP 8.2)', + "Framework MVC mature, sécurité native (CSRF, XSS, SQL Inj.), Eloquent ORM, écosystème JWT."], + ['Frontend Web', 'Next.js 16 + TS', + "SSR pour SEO, App Router, Mobile-First, accessibilité RGAA via composants standard."], + ['Mobile', 'React Native (Expo)', + "Code partagé avec le web (logique métier), iOS + Android sans bi-développement natif."], + ['BDD', 'PostgreSQL 16', + "ACID, support JSON, intégrité référentielle robuste, certifié HDS chez OVH / Scaleway."], + ['Auth', 'JWT (tymon/jwt-auth)', + "Stateless, scalable, expiration configurable, refresh tokens."], + ['Conteneurisation', 'Docker Compose', + "Reproductibilité environnements (dev / staging / prod), isolation services."], + ['Versioning', 'Git + GitHub', + "CI/CD intégré, branches/PR, conventions équipe."], + ], + col_widths=[28, 35, 127], +) + +# ─────────────── 9. Diagrammes de séquence ─────────────── +pdf.add_page() +pdf.h1(9, "Diagrammes UML — Séquences clés") +pdf.h2("9.1 Authentification JWT (login)") +pdf.figure(_os.path.join(ASSETS, 'seq_login.png'), 165, + caption="Séquence d'authentification - validation, vérification bcrypt et émission du JWT") + +pdf.h2("9.2 Création d'une saisie tracker (wizard 3 étapes)") +pdf.figure(_os.path.join(ASSETS, 'seq_saisie.png'), 165, + caption="Séquence d'ajout d'une saisie via le wizard - validation et persistance") + +# ─────────────── 10. RGPD ─────────────── +pdf.add_page() +pdf.h1(10, "Gestion des données personnelles et sensibles (RGPD)") +pdf.paragraph( + "CESIZen manipule des données sensibles relevant de la santé mentale. La conformité au RGPD " + "(et à la doctrine HDS française) est traitée en profondeur." +) +pdf.h2('10.1 Données collectées et finalités') +pdf.table( + headers=['Donnée', 'Sensibilité', 'Base légale', 'Conservation'], + rows=[ + ['Email, nom, prénom', 'Personnelle', 'Consentement (art. 6.1.a)', 'Durée du compte + 30j'], + ['Mot de passe (hash bcrypt)', 'Sensible (sécurité)', 'Consentement', "Idem"], + ['Saisies émotionnelles (intensité, note, date)', 'Sensible (santé)', 'Consentement explicite', 'Durée du compte'], + ['Logs d\'accès admin', 'Technique', "Intérêt légitime (art. 6.1.f)", '12 mois'], + ['IP & user-agent', 'Technique', 'Sécurité (HDS)', '6 mois'], + ], + col_widths=[55, 35, 50, 45], +) +pdf.h2('10.2 Droits CNIL implémentés') +pdf.table( + headers=['Droit RGPD', 'Article', 'Implémentation CESIZen'], + rows=[ + ['Information', 'Art. 13-14', "Politique de confidentialité accessible (page publique)."], + ['Accès', 'Art. 15', "Endpoint GET /profil retourne toutes les données."], + ['Rectification', 'Art. 16', "Endpoint PUT /profil modifie les champs autorisés."], + ['Effacement (oubli)', 'Art. 17', "Endpoint DELETE /profil — soft delete + purge à 30j."], + ['Portabilité', 'Art. 20', "Endpoint GET /profil/export retourne JSON téléchargeable."], + ['Limitation', 'Art. 18', "Désactivation compte sans suppression (admin)."], + ['Opposition', 'Art. 21', "Désactivation des notifications (en cours)."], + ['Anonymisation', '—', "Endpoint POST /profil/anonymiser dissocie données du compte."], + ], + col_widths=[40, 20, 130], +) +pdf.h2('10.3 Mesures de sécurité techniques') +pdf.bullet("Chiffrement en transit : HTTPS strict (HSTS), TLS 1.3 minimum.") +pdf.bullet("Chiffrement au repos : AES-256 sur le volume PostgreSQL, secrets dans .env hors-versioning.") +pdf.bullet("Hash mot de passe : bcrypt 12 rounds (configurable selon serveur).") +pdf.bullet("Authentification : JWT signé (HS256), expiration 60min, refresh 14j, throttle login 5/min.") +pdf.bullet("Autorisation : middleware role-based (jwt.auth + role:administrateur), rate limit admin 60/min.") +pdf.bullet("Journalisation : table audits trace toute action admin sensible (sans exposer données métier).") +pdf.bullet("Sauvegardes : snapshot quotidien chiffré, rétention 30 jours, restauration testée trimestriellement.") +pdf.bullet("HDS : hébergement chez prestataire certifié (Scaleway Healthcare ou OVH Healthcare).") + +# ─────────────── 11. Risques ─────────────── +pdf.add_page() +pdf.h1(11, "Analyse des risques et mitigation") +pdf.table( + headers=['Risque', 'Impact', 'Probabilité', 'Mitigation'], + rows=[ + ['Fuite données de santé', 'CRITIQUE', 'Faible', + "Chiffrement AES-256, HDS, audits trimestriels, minimisation données."], + ['Dérive contenus (conseil médical dangereux)', 'FORT', 'Moyen', + "Modération admin obligatoire avant publication. Avertissement légal en pied de page."], + ['Perte de données utilisateur', 'FORT', 'Faible', + "Sauvegardes quotidiennes chiffrées, restauration testée, redondance serveur."], + ['Détresse aiguë / risque suicidaire', 'CRITIQUE', 'Faible', + "Page contacts urgence accessible 1 clic (3114, 15, 112). Bandeau permanent en cas de score critique."], + ['Retard de livraison', 'MOYEN', 'Moyen', + "Méthode MoSCoW (focus MVP Must Have). Stack maîtrisée (Laravel/React)."], + ['Compromission compte admin', 'CRITIQUE', 'Faible', + "MFA prévu (TOTP), rate limit, audit log."], + ['Non-accessibilité (RGAA)', 'MOYEN', 'Faible', + "Tests automatisés axe-core en CI, validation manuelle navigation clavier."], + ], + col_widths=[55, 22, 25, 88], +) + +# ─────────────── 12. Exigences non-fonctionnelles ─────────────── +pdf.h1(12, "Exigences non-fonctionnelles") +pdf.table( + headers=['Catégorie', 'Exigence', 'Valeur cible'], + rows=[ + ['Performance', 'Temps de réponse API (95ᵉ percentile)', '< 300ms'], + ['Performance', 'Temps de chargement Dashboard (LCP)', '< 1.5s'], + ['Disponibilité', 'SLA mensuel', '99,5%'], + ['Capacité', 'Utilisateurs simultanés', '5 000 (cible)'], + ['Capacité', 'Saisies stockées', '10 millions (sans dégradation)'], + ['Sécurité', 'Conformité OWASP Top 10', '100% des risques traités'], + ['Sécurité', 'Audit pénétration', 'Annuel (tiers indépendant)'], + ['Accessibilité', 'RGAA niveau', 'AA'], + ['Ergonomie', 'Dark Mode', "Natif iOS / Android / Web"], + ['Internationalisation', 'Langues supportées', 'FR (v1), EN (v2)'], + ], + col_widths=[30, 80, 80], +) + +# ─────────────── 13. Conclusion ─────────────── +pdf.h1(13, "Conclusion") +pdf.paragraph( + "Le cahier des charges CESIZen formalise un projet aligné sur les enjeux de santé publique " + "(prévention de la souffrance psychique, accessibilité grand public), tout en garantissant les " + "exigences strictes de protection des données sensibles (RGPD, HDS)." +) +pdf.paragraph( + "Le périmètre couvre les 2 modules obligatoires (Comptes utilisateurs, Informations) ainsi que le " + "module Tracker d'émotions au choix, avec une grille de priorisation pondérée justifiant ce périmètre. " + "L'architecture MVC découplée (API Laravel + clients Next.js / React Native) garantit l'évolutivité " + "vers les modules futurs (Diagnostic, Respiration, Activités) sans réécriture." +) +pdf.paragraph( + "Un prototype fonctionnel (backend Laravel + frontend Next.js + mobile Expo) accompagne ce dossier " + "et démontre la faisabilité technique des choix présentés." +) + +pdf.ln(6) +pdf.set_font('Helvetica', 'I', 9) +pdf.set_text_color(*COULEUR_GRIS_MOYEN) +pdf.cell(0, 6, 'Sources : consignes CESI INFCDAAL1, Santé Publique France 2023, CNIL — Guide RGPD santé.', + new_x="LMARGIN", new_y="NEXT") + + +# ─── Sortie ────────────────────────────────────────────────────────── +OUTPUT = '/Users/azmog/Desktop/CESI/Cahier_des_charges_CESIZen_v2.pdf' +pdf.output(OUTPUT) +print(f"Cahier des charges généré : {OUTPUT}") +print(f"Nombre de pages : {pdf.page_no()}") diff --git a/generate_diagrams.py b/generate_diagrams.py new file mode 100644 index 0000000..9aec759 --- /dev/null +++ b/generate_diagrams.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +""" +Génération des schémas du dossier CESIZen (MCD, UML cas d'usage, séquences, +architecture MVC, mockups) en PNG, à partir du code réel du projet. + +Rendu avec Pillow uniquement (aucune dépendance externe de diagramme). +Supersampling x2 pour un anti-aliasing propre des traits. + +Sortie : ./assets_dossier/*.png +""" +import os +from PIL import Image, ImageDraw, ImageFont + +# ─── Palette CESIZen ─────────────────────────────────────────────── +GREEN = (6, 198, 86) +GREEN_D = (4, 150, 65) +BLUE = (0, 70, 130) +BLUE_L = (230, 240, 250) +YELLOW = (252, 225, 23) +GREY_D = (50, 50, 50) +GREY = (110, 110, 110) +GREY_L = (235, 238, 242) +GREY_LL = (247, 249, 251) +WHITE = (255, 255, 255) +RED = (200, 40, 40) +INK = (35, 35, 45) + +FONT_REG = '/System/Library/Fonts/Supplemental/Arial.ttf' +FONT_BOLD = '/System/Library/Fonts/Supplemental/Arial Bold.ttf' +FONT_MONO = '/System/Library/Fonts/Menlo.ttc' + +OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'assets_dossier') + + +class Canvas: + """Toile de dessin avec supersampling : on dessine à `scale`x puis on réduit.""" + + def __init__(self, w, h, scale=2, bg=WHITE): + self.w, self.h, self.s = w, h, scale + self.img = Image.new('RGB', (w * scale, h * scale), bg) + self.d = ImageDraw.Draw(self.img) + self._fonts = {} + + def font(self, size, bold=False, mono=False): + key = (size, bold, mono) + if key not in self._fonts: + path = FONT_MONO if mono else (FONT_BOLD if bold else FONT_REG) + self._fonts[key] = ImageFont.truetype(path, size * self.s) + return self._fonts[key] + + # ---- primitives (coordonnées logiques) ---- + def box(self, x, y, w, h, fill=WHITE, outline=BLUE, width=2, radius=10): + s = self.s + self.d.rounded_rectangle( + [x * s, y * s, (x + w) * s, (y + h) * s], + radius=radius * s, fill=fill, outline=outline, width=width * s) + + def rect(self, x, y, w, h, fill=None, outline=None, width=1): + s = self.s + self.d.rectangle([x * s, y * s, (x + w) * s, (y + h) * s], + fill=fill, outline=outline, width=width * s) + + def text(self, x, y, txt, size=13, color=INK, bold=False, anchor='lm', mono=False): + self.d.text((x * self.s, y * self.s), txt, font=self.font(size, bold, mono), + fill=color, anchor=anchor) + + def text_w(self, txt, size, bold=False, mono=False): + """Largeur logique d'un texte.""" + bb = self.d.textbbox((0, 0), txt, font=self.font(size, bold, mono)) + return (bb[2] - bb[0]) / self.s + + def line(self, x0, y0, x1, y1, color=GREY_D, width=2, dash=None): + s = self.s + if dash: + self._dashed(x0, y0, x1, y1, color, width, dash) + else: + self.d.line([x0 * s, y0 * s, x1 * s, y1 * s], fill=color, width=width * s) + + def _dashed(self, x0, y0, x1, y1, color, width, dash): + import math + s = self.s + dx, dy = x1 - x0, y1 - y0 + dist = math.hypot(dx, dy) + if dist == 0: + return + ux, uy = dx / dist, dy / dist + on, off = dash + pos = 0 + while pos < dist: + seg = min(on, dist - pos) + ax, ay = x0 + ux * pos, y0 + uy * pos + bx, by = x0 + ux * (pos + seg), y0 + uy * (pos + seg) + self.d.line([ax * s, ay * s, bx * s, by * s], fill=color, width=width * s) + pos += on + off + + def arrow(self, x0, y0, x1, y1, color=GREY_D, width=2, head=9, dash=None, + double=False): + import math + self.line(x0, y0, x1, y1, color, width, dash) + ang = math.atan2(y1 - y0, x1 - x0) + self._head(x1, y1, ang, color, head) + if double: + self._head(x0, y0, ang + math.pi, color, head) + + def _head(self, x, y, ang, color, head): + import math + s = self.s + a1 = ang + math.radians(150) + a2 = ang - math.radians(150) + p = [(x * s, y * s), + ((x + head * math.cos(a1)) * s, (y + head * math.sin(a1)) * s), + ((x + head * math.cos(a2)) * s, (y + head * math.sin(a2)) * s)] + self.d.polygon(p, fill=color) + + def open_head(self, x, y, ang, color, head=9, width=2): + """Pointe ouverte (flèche async UML).""" + import math + s = self.s + a1 = ang + math.radians(150) + a2 = ang - math.radians(150) + self.d.line([( (x+head*math.cos(a1))*s,(y+head*math.sin(a1))*s),(x*s,y*s)], + fill=color, width=width*s) + self.d.line([( (x+head*math.cos(a2))*s,(y+head*math.sin(a2))*s),(x*s,y*s)], + fill=color, width=width*s) + + def check(self, x, y, size=14, color=GREEN_D, width=3): + """Coche dessinée au trait (Arial n'a pas le glyphe ✓).""" + self.line(x, y + size * 0.55, x + size * 0.38, y + size * 0.92, color, width) + self.line(x + size * 0.38, y + size * 0.92, x + size, y, color, width) + + def ellipse(self, cx, cy, rx, ry, fill=WHITE, outline=BLUE, width=2): + s = self.s + self.d.ellipse([(cx - rx) * s, (cy - ry) * s, (cx + rx) * s, (cy + ry) * s], + fill=fill, outline=outline, width=width * s) + + def polygon(self, pts, fill=None, outline=None, width=1): + s = self.s + self.d.polygon([(p[0] * s, p[1] * s) for p in pts], fill=fill, + outline=outline, width=width * s) + + def pill(self, cx, cy, txt, size=11, fill=GREEN, color=WHITE, pad=8): + w = self.text_w(txt, size, bold=True) + 2 * pad + h = size + 10 + self.box(cx - w / 2, cy - h / 2, w, h, fill=fill, outline=fill, radius=h / 2, width=1) + self.text(cx, cy, txt, size, color, bold=True, anchor='mm') + + def save(self, name): + os.makedirs(OUT_DIR, exist_ok=True) + out = self.img.resize((self.w, self.h), Image.LANCZOS) + path = os.path.join(OUT_DIR, name) + out.save(path, 'PNG') + print(f' ✓ {name} ({self.w}x{self.h})') + return path + + +def title_bar(c, x, y, w, txt, sub=None, fill=BLUE): + """Bandeau de titre pour un schéma.""" + c.box(x, y, w, 40, fill=fill, outline=fill, radius=8) + c.text(x + 16, y + 20, txt, 16, WHITE, bold=True, anchor='lm') + if sub: + c.text(x + w - 16, y + 20, sub, 10, BLUE_L, anchor='rm') + + +# ════════════════════════════════════════════════════════════════════ +# 1. MCD (Merise) — fidèle au schéma réel (8 entités) +# ════════════════════════════════════════════════════════════════════ +def diagram_mcd(): + c = Canvas(1480, 1030) + title_bar(c, 30, 24, 1420, "Modèle Conceptuel de Données (MCD - Merise)", + "# = clé étrangère") + + entities = { + 'Role': (110, 110, 210, ['id', 'nom', 'description']), + 'Utilisateur': (90, 360, 250, ['id', 'nom', 'prenom', 'email', 'password', + 'est_actif', 'consentement_rgpd', 'role_id #', + 'deleted_at']), + 'Tracker': (480, 200, 210, ['id', 'nom', 'utilisateur_id #']), + 'SaisieTracker': (820, 200, 250, ['id', 'intensite', 'note', 'date_saisie', + 'tracker_id #', 'emotion_id #']), + 'Emotion': (1095, 410, 250, ['id', 'nom', 'couleur', 'icone', 'niveau', + 'est_actif', 'parent_id #']), + 'ContactUrgence': (90, 760, 250, ['id', 'nom', 'telephone', 'relation', + 'utilisateur_id #']), + 'Feed': (480, 600, 250, ['id', 'titre', 'slug', 'contenu', 'image_url', + 'est_publie', 'ordre', 'auteur_id #']), + 'Audit': (840, 720, 250, ['id', 'action', 'table_cible', + 'enregistrement_id', 'ip_address', 'created_at', + 'utilisateur_id #']), + } + + line_h = 25 + boxes = {} # name -> (x, y, w, h) + for name, (x, y, w, attrs) in entities.items(): + h = 34 + len(attrs) * line_h + 10 + boxes[name] = (x, y, w, h) + + def edge(name, side): + x, y, w, h = boxes[name] + return { + 'l': (x, y + h / 2), 'r': (x + w, y + h / 2), + 't': (x + w / 2, y), 'b': (x + w / 2, y + h), + 'tr': (x + w, y + 18), 'br': (x + w, y + h - 18), + 'tl': (x, y + 18), 'bl': (x, y + h - 18), + }[side] + + def connect(a, sa, b, sb, verb, card_a, card_b): + p0 = edge(a, sa) + p1 = edge(b, sb) + c.line(p0[0], p0[1], p1[0], p1[1], GREEN_D, 2) + mx, my = (p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2 + # cardinalités à 18% de chaque extrémité + ca = (p0[0] + (p1[0] - p0[0]) * 0.16, p0[1] + (p1[1] - p0[1]) * 0.16) + cb = (p0[0] + (p1[0] - p0[0]) * 0.84, p0[1] + (p1[1] - p0[1]) * 0.84) + for (cx, cy), card in ((ca, card_a), (cb, card_b)): + w = c.text_w(card, 10, bold=True) + 8 + c.box(cx - w / 2, cy - 9, w, 18, fill=WHITE, outline=GREEN_D, width=1, radius=5) + c.text(cx, cy, card, 10, GREEN_D, bold=True, anchor='mm') + c.pill(mx, my, verb, 11, GREEN) + + # liens (cardinalités Merise : (min,max) côté de chaque entité) + connect('Role', 'b', 'Utilisateur', 't', 'possède', '0,n', '1,1') + connect('Utilisateur', 'tr', 'Tracker', 'l', 'dispose', '1,1', '1,1') + connect('Tracker', 'r', 'SaisieTracker', 'l', 'contient', '0,n', '1,1') + connect('SaisieTracker', 'r', 'Emotion', 't', 'porte sur', '1,1', '0,n') + connect('Utilisateur', 'b', 'ContactUrgence', 't', 'déclare', '0,n', '1,1') + connect('Utilisateur', 'br', 'Feed', 'l', 'rédige', '0,n', '1,1') + connect('Utilisateur', 'r', 'Audit', 'tl', 'génère', '0,n', '0,1') + + # auto-référence Emotion (hiérarchie niveau 1 -> 2) + ex, ey, ew, eh = boxes['Emotion'] + c.line(ex + ew, ey + 40, ex + ew + 45, ey + 40, GREEN_D, 2) + c.line(ex + ew + 45, ey + 40, ex + ew + 45, ey - 5, GREEN_D, 2) + c.line(ex + ew + 45, ey - 5, ex + ew / 2, ey - 5, GREEN_D, 2) + c.line(ex + ew / 2, ey - 5, ex + ew / 2, ey, GREEN_D, 2) + c.pill(ex + ew + 45, ey + 18, 'hiérarchie', 10, GREEN) + c.text(ex + ew + 50, ey + 40, '0,n', 9, GREEN_D, bold=True, anchor='lm') + c.text(ex + ew / 2 + 8, ey - 12, '0,1', 9, GREEN_D, bold=True, anchor='lm') + + # dessin des entités (au-dessus des liens) + for name, (x, y, w, attrs) in entities.items(): + bx, by, bw, bh = boxes[name] + c.box(bx, by, bw, bh, fill=WHITE, outline=BLUE, width=2, radius=8) + c.box(bx, by, bw, 34, fill=BLUE, outline=BLUE, radius=8) + c.rect(bx, by + 20, bw, 14, fill=BLUE) + c.text(bx + bw / 2, by + 17, name, 14, WHITE, bold=True, anchor='mm') + for i, a in enumerate(attrs): + ay = by + 34 + 6 + i * line_h + line_h / 2 - 4 + col = BLUE if a == attrs[0] else INK + c.text(bx + 14, ay, a, 11, col, bold=(a == attrs[0] or a.endswith('#'))) + c.line(bx + 6, by + 34 + (i + 1) * line_h, bx + bw - 6, + by + 34 + (i + 1) * line_h, GREY_L, 1) + + c.save('mcd.png') + + +# ════════════════════════════════════════════════════════════════════ +# 2. Diagramme de cas d'usage UML +# ════════════════════════════════════════════════════════════════════ +def stick_figure(c, cx, cy, label, color=BLUE): + c.ellipse(cx, cy - 26, 11, 11, fill=WHITE, outline=color, width=3) + c.line(cx, cy - 15, cx, cy + 12, color, 3) # tronc + c.line(cx - 16, cy - 5, cx + 16, cy - 5, color, 3) # bras + c.line(cx, cy + 12, cx - 14, cy + 34, color, 3) # jambe g + c.line(cx, cy + 12, cx + 14, cy + 34, color, 3) # jambe d + for i, ln in enumerate(label.split('\n')): + c.text(cx, cy + 52 + i * 16, ln, 12, GREY_D, bold=True, anchor='mm') + + +def diagram_usecase(): + c = Canvas(1480, 1020) + title_bar(c, 30, 24, 1420, "Diagramme de cas d'usage (UML)", + "Périmètre : modules retenus") + + # frontière système + c.box(330, 95, 820, 880, fill=GREY_LL, outline=GREY, width=2, radius=14) + c.text(740, 120, "Système CESIZen", 15, GREY, bold=True, anchor='mm') + + # acteurs + stick_figure(c, 130, 300, "Visiteur\nanonyme") + stick_figure(c, 130, 640, "Utilisateur\nconnecté") + stick_figure(c, 1360, 470, "Administrateur", color=GREEN_D) + + modules = [ + ("Module Comptes utilisateurs", GREEN, [ + ("Créer un compte", 470, 200, ['V']), + ("Se connecter / déconnecter", 470, 270, ['U']), + ("Gérer son profil", 740, 200, ['U']), + ("Réinitialiser mot de passe", 740, 270, ['U']), + ("Exporter / anonymiser (RGPD)", 1010, 200, ['U']), + ("Gérer les comptes", 1010, 270, ['A']), + ]), + ("Module Informations", BLUE, [ + ("Consulter les pages info", 470, 430, ['V', 'U']), + ("Gérer les contenus (CRUD)", 1010, 430, ['A']), + ]), + ("Module Tracker d'émotions", YELLOW, [ + ("Consulter son journal", 470, 600, ['U']), + ("Ajouter une saisie (wizard)", 740, 600, ['U']), + ("Modifier / supprimer une saisie", 470, 690, ['U']), + ("Visualiser un rapport", 740, 690, ['U']), + ("Configurer les émotions", 1010, 645, ['A']), + ]), + ] + + actor_pt = {'V': (157, 300), 'U': (157, 640), 'A': (1333, 470)} + # bandes de module + bands = [(165, GREEN, 'Comptes utilisateurs'), + (400, BLUE, 'Informations'), + (560, (180, 150, 20), "Tracker d'émotions")] + for y, col, lab in bands: + c.text(350, y, lab.upper(), 9, col, bold=True, anchor='lm') + + for _, col, ucs in modules: + for (name, cx, cy, actors) in ucs: + for a in actors: + ax, ay = actor_pt[a] + c.line(ax, ay, cx, cy, GREY, 2) + for _, col, ucs in modules: + for (name, cx, cy, actors) in ucs: + w = max(150, c.text_w(name, 11) + 30) + c.ellipse(cx, cy, w / 2, 30, fill=WHITE, outline=col, width=3) + c.text(cx, cy, name, 11, GREY_D, anchor='mm') + + c.save('usecase.png') + + +# ════════════════════════════════════════════════════════════════════ +# 3 & 4. Diagrammes de séquence +# ════════════════════════════════════════════════════════════════════ +def sequence(c, title, sub, lifelines, messages, notes=None): + title_bar(c, 30, 24, c.w - 60, title, sub) + top = 95 + n = len(lifelines) + gap = (c.w - 180) / (n - 1) + xs = {name: 90 + i * gap for i, name in enumerate(lifelines)} + bottom = c.h - 50 + # têtes + lignes de vie + for name, x in xs.items(): + w = max(150, c.text_w(name, 12, bold=True) + 30) + c.box(x - w / 2, top, w, 44, fill=BLUE, outline=BLUE, radius=8) + c.text(x, top + 22, name, 12, WHITE, bold=True, anchor='mm') + c.line(x, top + 44, x, bottom, GREY, 2, dash=(7, 6)) + # messages + y = top + 90 + for msg in messages: + src, dst, label, kind = msg + x0, x1 = xs[src], xs[dst] + if src == dst: # self-call + c.line(x0, y, x0 + 55, y, BLUE, 2) + c.line(x0 + 55, y, x0 + 55, y + 26, BLUE, 2) + c.arrow(x0 + 55, y + 26, x0 + 4, y + 26, BLUE, 2, head=8) + c.text(x0 + 62, y + 13, label, 11, GREY_D, anchor='lm') + y += 52 + continue + color = GREEN_D if kind == 'return' else BLUE + dash = (6, 5) if kind == 'return' else None + c.text((x0 + x1) / 2, y - 13, label, 11, GREY_D, anchor='mm') + c.arrow(x0, y, x1, y, color, 2, head=9, dash=dash) + y += 46 + if notes: + for (nx, ny, txt) in notes: + w = c.text_w(txt, 10) + 22 + c.box(nx - w / 2, ny, w, 26, fill=YELLOW, outline=(200, 180, 20), radius=4) + c.text(nx, ny + 13, txt, 10, GREY_D, anchor='mm') + + +def diagram_seq_login(): + c = Canvas(1320, 760) + sequence( + c, "Séquence - Authentification JWT", "POST /v1/auth/login", + ['Utilisateur', 'Client Web/Mobile', 'API Laravel', 'PostgreSQL'], + [ + ('Utilisateur', 'Client Web/Mobile', 'saisit email + mot de passe', 'call'), + ('Client Web/Mobile', 'API Laravel', 'POST /v1/auth/login', 'call'), + ('API Laravel', 'API Laravel', 'LoginRequest::validate()', 'self'), + ('API Laravel', 'PostgreSQL', 'SELECT utilisateur WHERE email', 'call'), + ('PostgreSQL', 'API Laravel', 'utilisateur + hash', 'return'), + ('API Laravel', 'API Laravel', 'Hash::check (bcrypt) + est_actif', 'self'), + ('API Laravel', 'API Laravel', 'JWTAuth::fromUser() (claims rôle)', 'self'), + ('API Laravel', 'Client Web/Mobile', '200 { utilisateur, token }', 'return'), + ('Client Web/Mobile', 'Utilisateur', 'stocke token + redirige', 'return'), + ], + notes=[(660, 700, "Token JWT : SecureStore (mobile) / localStorage (web), expiration 60 min")] + ) + c.save('seq_login.png') + + +def diagram_seq_saisie(): + c = Canvas(1320, 800) + sequence( + c, "Séquence - Ajout d'une saisie (wizard 3 étapes)", "POST /v1/tracker/saisies", + ['Utilisateur', 'Client (wizard)', 'API Laravel', 'PostgreSQL'], + [ + ('Utilisateur', 'Client (wizard)', 'Étape 1 : émotion de base', 'call'), + ('Utilisateur', 'Client (wizard)', 'Étape 2 : sous-émotion + intensité', 'call'), + ('Utilisateur', 'Client (wizard)', 'Étape 3 : date + note, valider', 'call'), + ('Client (wizard)', 'API Laravel', 'POST /v1/tracker/saisies (Bearer)', 'call'), + ('API Laravel', 'API Laravel', 'jwt.auth + StoreSaisieRequest', 'self'), + ('API Laravel', 'PostgreSQL', 'INSERT saisie_trackers', 'call'), + ('PostgreSQL', 'API Laravel', 'saisie créée', 'return'), + ('API Laravel', 'Client (wizard)', '201 { saisie }', 'return'), + ('Client (wizard)', 'Utilisateur', 'invalide le cache, MAJ journal', 'return'), + ], + notes=[(660, 740, "RG-TR-03 : une seule saisie par jour - sinon modification")] + ) + c.save('seq_saisie.png') + + +# ════════════════════════════════════════════════════════════════════ +# 5. Architecture MVC découplée +# ════════════════════════════════════════════════════════════════════ +def diagram_mvc(): + c = Canvas(1320, 820) + title_bar(c, 30, 24, 1260, "Architecture technique - Design Pattern MVC découplé") + + # Vues (clients) + c.text(230, 110, "VUES (Clients)", 13, GREY, bold=True, anchor='mm') + c.box(70, 140, 320, 110, fill=BLUE_L, outline=BLUE, radius=12) + c.text(230, 170, "Frontend Web", 14, BLUE, bold=True, anchor='mm') + c.text(230, 200, "Next.js 16 + React 19", 11, INK, anchor='mm') + c.text(230, 222, "App Router · Zustand · TanStack Query", 9, GREY, anchor='mm') + + c.box(70, 280, 320, 110, fill=BLUE_L, outline=BLUE, radius=12) + c.text(230, 310, "Application Mobile", 14, BLUE, bold=True, anchor='mm') + c.text(230, 340, "React Native (Expo 53)", 11, INK, anchor='mm') + c.text(230, 362, "Expo Router · SecureStore", 9, GREY, anchor='mm') + + # Contrôleur (API) + c.text(720, 110, "CONTRÔLEUR", 13, GREY, bold=True, anchor='mm') + c.box(560, 140, 320, 250, fill=WHITE, outline=GREEN_D, width=3, radius=12) + c.text(720, 170, "API REST Laravel 11", 14, GREEN_D, bold=True, anchor='mm') + for i, t in enumerate(["Routes /api/v1 (api.php)", + "Middleware : jwt.auth, role", + "FormRequests (validation)", + "Controllers (Auth, Profil,", + " SaisieTracker, Feed, Admin...)", + "Services (RapportService)"]): + c.text(580, 205 + i * 28, "• " + t if not t.startswith(' ') else t, 10, INK, anchor='lm') + + # Modèle + BDD + c.text(1130, 110, "MODÈLE", 13, GREY, bold=True, anchor='mm') + c.box(980, 140, 280, 130, fill=GREY_LL, outline=GREEN_D, width=2, radius=12) + c.text(1120, 168, "Eloquent ORM", 13, GREEN_D, bold=True, anchor='mm') + c.text(1120, 196, "8 modèles métier", 10, INK, anchor='mm') + c.text(1120, 216, "Relations · Scopes · Casts", 9, GREY, anchor='mm') + c.text(1120, 244, "SoftDeletes · JWTSubject", 9, GREY, anchor='mm') + + c.box(980, 300, 280, 90, fill=BLUE, outline=BLUE, radius=12) + c.text(1120, 330, "PostgreSQL 16", 14, WHITE, bold=True, anchor='mm') + c.text(1120, 360, "ACID · JSON · HDS", 10, BLUE_L, anchor='mm') + + # flèches + c.arrow(390, 195, 555, 220, GREY_D, 2, head=10, double=True) + c.arrow(390, 335, 555, 300, GREY_D, 2, head=10, double=True) + c.text(470, 175, "JSON / HTTPS", 10, GREY, bold=True, anchor='mm') + c.arrow(880, 230, 975, 210, GREY_D, 2, head=10, double=True) + c.arrow(1120, 270, 1120, 298, GREY_D, 2, head=10, double=True) + c.text(1120, 286, "SQL", 9, GREY, anchor='mm') + + # bandeau bas : justification + c.box(70, 450, 1190, 320, fill=GREY_LL, outline=GREY_L, radius=12) + c.text(95, 480, "Pourquoi ce découplage ?", 14, GREEN_D, bold=True, anchor='lm') + pts = [ + "Séparation stricte Modèle / Logique / Présentation -> testabilité et maintenabilité.", + "Une seule API REST alimente 2 clients (web + mobile) : logique métier non dupliquée.", + "Évolutivité : ajout d'un client (TV, assistant vocal) ou d'un module sans toucher au modèle.", + "Sécurité centralisée côté contrôleur : validation (FormRequest), authz (middleware role), JWT.", + "Stack 100% open-source, conteneurisée (Docker Compose) : reproductibilité dev / staging / prod.", + ] + for i, p in enumerate(pts): + c.check(106, 518 + i * 42, 15, GREEN_D, 3) + c.text(140, 525 + i * 42, p, 12, INK, anchor='lm') + + c.save('mvc.png') + + +# ════════════════════════════════════════════════════════════════════ +# 6. Mockups / wireframes +# ════════════════════════════════════════════════════════════════════ +def phone_frame(c, x, y, w, h, title): + c.box(x, y, w, h, fill=WHITE, outline=GREY_D, width=3, radius=28) + c.box(x, y, w, 54, fill=BLUE, outline=BLUE, radius=28) + c.rect(x, y + 30, w, 24, fill=BLUE) + c.text(x + w / 2, y + 28, title, 13, WHITE, bold=True, anchor='mm') + c.box(x + w / 2 - 28, y + 8, 56, 7, fill=BLUE_L, outline=BLUE_L, radius=4) + + +def ui_input(c, x, y, w, label, ph): + c.text(x, y, label, 10, GREY, bold=True, anchor='lm') + c.box(x, y + 8, w, 34, fill=GREY_LL, outline=GREY_L, width=1, radius=8) + c.text(x + 10, y + 25, ph, 10, GREY, anchor='lm') + + +def ui_button(c, x, y, w, label, fill=GREEN): + c.box(x, y, w, 40, fill=fill, outline=fill, radius=10) + c.text(x + w / 2, y + 20, label, 12, WHITE, bold=True, anchor='mm') + + +def mockup_inscription(): + c = Canvas(440, 760) + title_bar(c, 14, 14, 412, "Maquette - Inscription", fill=GREEN_D) + x, y, w = 60, 80, 320 + phone_frame(c, x, y, w, 650, "Créer un compte") + ix = x + 24 + ui_input(c, ix, y + 80, w - 48, "Prénom", "Claire") + ui_input(c, ix, y + 145, w - 48, "Nom", "Martin") + ui_input(c, ix, y + 210, w - 48, "Email", "claire@email.fr") + ui_input(c, ix, y + 275, w - 48, "Mot de passe", "••••••••") + ui_input(c, ix, y + 340, w - 48, "Confirmation", "••••••••") + # case RGPD + c.box(ix, y + 410, 22, 22, fill=GREEN, outline=GREEN, radius=5) + c.check(ix + 5, y + 414, 13, WHITE, 3) + c.text(ix + 32, y + 421, "J'accepte la politique de", 10, INK, anchor='lm') + c.text(ix + 32, y + 438, "confidentialité (RGPD)", 10, INK, anchor='lm') + ui_button(c, ix, y + 475, w - 48, "S'inscrire") + c.text(x + w / 2, y + 545, "Déjà un compte ? Se connecter", 10, BLUE, anchor='mm') + c.text(x + w / 2, y + 600, "RG-CU-02 : mot de passe ≥ 8 car. · RG-CU-03 : consentement requis", + 8, GREY, anchor='mm') + c.save('mockup_inscription.png') + + +def mockup_wizard(): + c = Canvas(440, 760) + title_bar(c, 14, 14, 412, "Maquette - Wizard saisie", fill=GREEN_D) + x, y, w = 60, 80, 320 + phone_frame(c, x, y, w, 650, "Comment je me sens ?") + ix = x + 24 + # progression + for i, on in enumerate([True, True, False]): + col = GREEN if on else GREY_L + c.ellipse(ix + 20 + i * 130, y + 85, 14, 14, fill=col, outline=col) + c.text(ix + 20 + i * 130, y + 85, str(i + 1), 11, WHITE, bold=True, anchor='mm') + if i < 2: + c.line(ix + 34 + i * 130, y + 85, ix + 136 + i * 130, y + 85, GREY_L, 3) + c.text(ix, y + 125, "Étape 2/3 · Sous-émotion + intensité", 10, GREY, bold=True, anchor='lm') + # émotion choisie + c.box(ix, y + 145, w - 48, 50, fill=(255, 248, 200), outline=YELLOW, width=2, radius=10) + c.ellipse(ix + 28, y + 170, 13, 13, fill=(255, 215, 0), outline=(225, 185, 0), width=2) + c.text(ix + 52, y + 170, "Joie", 14, GREY_D, bold=True, anchor='lm') + # sous-émotions + subs = ['Sérénité', 'Gratitude', 'Fierté', 'Espoir', 'Bonheur', 'Enthousiasme'] + for i, s in enumerate(subs): + sx = ix + (i % 2) * 140 + sy = y + 215 + (i // 2) * 50 + sel = (i == 0) + c.box(sx, sy, 128, 38, fill=GREEN if sel else WHITE, + outline=GREEN if sel else GREY_L, width=2, radius=10) + c.text(sx + 64, sy + 19, s, 10, WHITE if sel else INK, bold=sel, anchor='mm') + # slider intensité + c.text(ix, y + 390, "Intensité : 7 / 10", 11, GREY_D, bold=True, anchor='lm') + c.box(ix, y + 410, w - 48, 8, fill=GREY_L, outline=GREY_L, radius=4) + c.box(ix, y + 410, (w - 48) * 0.7, 8, fill=GREEN, outline=GREEN, radius=4) + c.ellipse(ix + (w - 48) * 0.7, y + 414, 14, 14, fill=WHITE, outline=GREEN, width=3) + ui_button(c, ix, y + 460, w - 48, "Suivant →") + c.text(x + w / 2, y + 540, "RG-TR-02 : intensité 1-10 · RG-TR-04 : émotion obligatoire", + 8, GREY, anchor='mm') + c.save('mockup_wizard.png') + + +def mockup_rapports(): + c = Canvas(620, 760) + title_bar(c, 14, 14, 592, "Maquette - Rapport d'émotions (Web)", fill=GREEN_D) + x, y, w, h = 40, 80, 540, 650 + # fenêtre navigateur + c.box(x, y, w, h, fill=WHITE, outline=GREY_D, width=3, radius=14) + c.box(x, y, w, 40, fill=GREY_L, outline=GREY_L, radius=14) + c.rect(x, y + 22, w, 18, fill=GREY_L) + for i, col in enumerate([(255, 95, 86), (255, 189, 46), (39, 201, 63)]): + c.ellipse(x + 22 + i * 22, y + 20, 7, 7, fill=col, outline=col) + c.text(x + w / 2, y + 20, "cesizen.fr / rapports", 10, GREY_D, anchor='mm') + ix = x + 28 + c.text(ix, y + 70, "Mon rapport émotionnel", 16, BLUE, bold=True, anchor='lm') + # onglets période + for i, (p, on) in enumerate([('Semaine', False), ('Mois', True), + ('Trimestre', False), ('Année', False)]): + px = ix + i * 110 + c.box(px, y + 95, 100, 32, fill=GREEN if on else GREY_LL, + outline=GREEN if on else GREY_L, width=1, radius=8) + c.text(px + 50, y + 111, p, 11, WHITE if on else GREY_D, bold=on, anchor='mm') + # cartes stats + cards = [("Saisies", "24"), ("Intensité moy.", "6,4"), ("Émotion dom.", "Joie")] + for i, (lab, val) in enumerate(cards): + cx = ix + i * 165 + c.box(cx, y + 145, 150, 80, fill=BLUE_L, outline=BLUE_L, radius=12) + c.text(cx + 75, y + 175, val, 20, BLUE, bold=True, anchor='mm') + c.text(cx + 75, y + 205, lab, 10, GREY, anchor='mm') + # graphe barres + c.text(ix, y + 255, "Répartition par émotion", 12, GREY_D, bold=True, anchor='lm') + bars = [("Joie", 0.85, (255, 200, 0)), ("Peur", 0.45, (153, 50, 204)), + ("Tristesse", 0.35, (65, 105, 225)), ("Colère", 0.25, (220, 20, 60)), + ("Surprise", 0.15, (255, 140, 0))] + bx, by, bw = ix, y + 285, 480 + for i, (lab, v, col) in enumerate(bars): + ry = by + i * 48 + c.text(bx, ry + 14, lab, 10, INK, anchor='lm') + c.box(bx + 90, ry, (bw - 90) * v, 26, fill=col, outline=col, radius=6) + c.text(bx + 96 + (bw - 90) * v, ry + 13, f"{int(v*100)}%", 9, GREY_D, anchor='lm') + c.save('mockup_rapports.png') + + +# ════════════════════════════════════════════════════════════════════ +def main(): + print("Génération des schémas CESIZen ->", OUT_DIR) + diagram_mcd() + diagram_usecase() + diagram_seq_login() + diagram_seq_saisie() + diagram_mvc() + mockup_inscription() + mockup_wizard() + mockup_rapports() + print("Terminé.") + + +if __name__ == '__main__': + main() diff --git a/generate_slides.py b/generate_slides.py new file mode 100644 index 0000000..9d95bfb --- /dev/null +++ b/generate_slides.py @@ -0,0 +1,434 @@ +#!/usr/bin/env python3 +""" +Génère le support de soutenance CESIZen (PowerPoint 16:9 éditable). + +Réutilise les schémas PNG produits par generate_diagrams.py. +Sortie : ./CESIZen_Soutenance.pptx +""" +import os +from PIL import Image +from pptx import Presentation +from pptx.util import Inches, Pt, Emu +from pptx.dml.color import RGBColor +from pptx.enum.text import PP_ALIGN, MSO_ANCHOR +from pptx.enum.shapes import MSO_SHAPE + +# ─── Charte CESIZen ──────────────────────────────────────────────── +GREEN = RGBColor(6, 198, 86) +GREEN_D = RGBColor(4, 150, 65) +BLUE = RGBColor(0, 70, 130) +BLUE_L = RGBColor(230, 240, 250) +YELLOW = RGBColor(252, 225, 23) +GREY_D = RGBColor(50, 50, 50) +GREY = RGBColor(110, 110, 110) +WHITE = RGBColor(255, 255, 255) +INK = RGBColor(35, 35, 45) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ASSETS = os.path.join(HERE, 'assets_dossier') +OUT = os.path.join(HERE, 'CESIZen_Soutenance.pptx') + +prs = Presentation() +prs.slide_width = Inches(13.333) +prs.slide_height = Inches(7.5) +SW, SH = prs.slide_width, prs.slide_height +BLANK = prs.slide_layouts[6] + + +def _no_line(shape): + shape.line.fill.background() + + +def rect(slide, x, y, w, h, color, line=None): + s = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) + s.fill.solid() + s.fill.fore_color.rgb = color + if line is None: + _no_line(s) + else: + s.line.color.rgb = line + s.line.width = Pt(1) + s.shadow.inherit = False + return s + + +def textbox(slide, x, y, w, h, anchor=MSO_ANCHOR.TOP): + tb = slide.shapes.add_textbox(x, y, w, h) + tf = tb.text_frame + tf.word_wrap = True + tf.vertical_anchor = anchor + tf.margin_left = Inches(0.05) + tf.margin_right = Inches(0.05) + tf.margin_top = Inches(0.02) + tf.margin_bottom = Inches(0.02) + return tb, tf + + +def set_par(p, text, size, color, bold=False, align=PP_ALIGN.LEFT, + font='Arial', space_after=8): + p.text = text + p.alignment = align + p.space_after = Pt(space_after) + for r in p.runs: + r.font.size = Pt(size) + r.font.color.rgb = color + r.font.bold = bold + r.font.name = font + + +def footer(slide): + tb, tf = textbox(slide, Inches(0.4), Inches(7.05), Inches(12.5), Inches(0.35)) + set_par(tf.paragraphs[0], + "CESIZen · Bloc INFCDAAL1 — Concevoir les solutions logicielles · Adam Marzuk", + 9, GREY, align=PP_ALIGN.CENTER, space_after=0) + + +def band_title(slide, text, num=None): + rect(slide, 0, 0, SW, Inches(1.05), BLUE) + rect(slide, 0, Inches(1.05), SW, Inches(0.06), GREEN) + label = f"{num}. {text}" if num else text + tb, tf = textbox(slide, Inches(0.5), Inches(0.12), Inches(12.3), Inches(0.85), + anchor=MSO_ANCHOR.MIDDLE) + set_par(tf.paragraphs[0], label, 30, WHITE, bold=True, space_after=0) + + +def bullets(slide, items, x, y, w, h, size=18, gap=10): + tb, tf = textbox(slide, x, y, w, h) + for i, it in enumerate(items): + lvl = 0 + txt = it + if isinstance(it, tuple): + txt, lvl = it + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + prefix = "• " if lvl == 0 else "– " + set_par(p, prefix + txt, size if lvl == 0 else size - 2, + INK if lvl == 0 else GREY_D, bold=False, space_after=gap) + p.level = lvl + if lvl == 1: + p.runs[0].font.color.rgb = GREY + return tb + + +def image_fit(slide, name, x, y, max_w, max_h, caption=None): + path = os.path.join(ASSETS, name) + iw, ih = Image.open(path).size + ar = iw / ih + box_ar = max_w / max_h + if box_ar > ar: + h = max_h + w = int(h * ar) + else: + w = max_w + h = int(w / ar) + left = x + (max_w - w) // 2 + top = y + (max_h - h) // 2 + slide.shapes.add_picture(path, left, top, width=w, height=h) + if caption: + tb, tf = textbox(slide, x, y + max_h + Inches(0.02), max_w, Inches(0.35)) + set_par(tf.paragraphs[0], caption, 11, GREY, align=PP_ALIGN.CENTER, space_after=0) + + +# ════════════════════════════════════════════════════════════════════ +# Slide 1 — Titre +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +rect(s, 0, 0, SW, SH, WHITE) +rect(s, 0, Inches(2.5), SW, Inches(0.08), GREEN) +tb, tf = textbox(s, Inches(1), Inches(0.9), Inches(11.3), Inches(1.5)) +set_par(tf.paragraphs[0], "CESIZen", 60, GREEN, bold=True, align=PP_ALIGN.CENTER, space_after=0) +tb, tf = textbox(s, Inches(1), Inches(2.65), Inches(11.3), Inches(0.9)) +set_par(tf.paragraphs[0], "L'application de votre santé mentale", 26, GREY_D, + align=PP_ALIGN.CENTER, space_after=0) +tb, tf = textbox(s, Inches(1), Inches(3.7), Inches(11.3), Inches(0.7)) +set_par(tf.paragraphs[0], "Cahier des charges & conception logicielle", 18, BLUE, + align=PP_ALIGN.CENTER, space_after=0) +tb, tf = textbox(s, Inches(1), Inches(5.4), Inches(11.3), Inches(1.4)) +for i, line in enumerate([ + "Adam Marzuk — CESI École d'Ingénieurs", + "Bloc INFCDAAL1 — Concevoir les solutions logicielles", + "Commanditaire : Ministère des Solidarités et de la Santé"]): + p = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + set_par(p, line, 15, GREY, align=PP_ALIGN.CENTER, space_after=4) + +# ════════════════════════════════════════════════════════════════════ +# Slide 2 — Sommaire +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Sommaire") +bullets(s, [ + "Contexte, objectifs et enjeux", + "Parties prenantes et acteurs", + "Besoins reformulés et priorisation", + "Réponse fonctionnelle (Comptes · Informations · Tracker)", + "Modélisation : cas d'usage UML, MCD, architecture MVC", + "Données personnelles et sensibles (RGPD)", + "Prototype et perspectives", +], Inches(1.2), Inches(1.5), Inches(11), Inches(5.2), size=22, gap=14) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 3 — Contexte & enjeux +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Contexte, objectifs et enjeux", 1) +bullets(s, [ + "Commanditaire : Ministère des Solidarités et de la Santé.", + "Enjeu : une plateforme grand public pour prévenir le stress et accompagner la santé mentale.", + "Constat : 22 % des Français touchés par un trouble psychique au cours de leur vie (Santé Publique France, 2023).", + "Frein : stigmatisation et méconnaissance limitent l'accès au soin.", +], Inches(0.8), Inches(1.4), Inches(11.7), Inches(2.6), size=18) +# encadré objectifs +rect(s, Inches(0.8), Inches(4.2), Inches(11.7), Inches(2.4), BLUE_L) +rect(s, Inches(0.8), Inches(4.2), Inches(3.4), Inches(0.55), BLUE) +tb, tf = textbox(s, Inches(0.95), Inches(4.25), Inches(3.2), Inches(0.5), MSO_ANCHOR.MIDDLE) +set_par(tf.paragraphs[0], "4 objectifs stratégiques", 15, WHITE, bold=True, space_after=0) +bullets(s, [ + "Informer : contenu éducatif fiable sur la santé mentale.", + "Suivre : permettre à chacun de tracer son humeur dans le temps.", + "Orienter : diriger les personnes en détresse (numéros d'urgence).", + "Protéger : confidentialité absolue des données émotionnelles (RGPD + HDS).", +], Inches(1.0), Inches(4.9), Inches(11.3), Inches(1.6), size=15, gap=4) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 4 — Parties prenantes & acteurs +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Parties prenantes et acteurs", 2) +tb, tf = textbox(s, Inches(0.8), Inches(1.35), Inches(11.7), Inches(0.5)) +set_par(tf.paragraphs[0], "Parties prenantes du projet", 16, GREEN_D, bold=True, space_after=0) +bullets(s, [ + "Commanditaire : Ministère (pilotage, exigences santé publique et RGPD).", + "Bénéficiaires : grand public, personnes en quête de mieux-être psychique.", + "Exploitants : administrateurs et professionnels de santé (modération, contenus).", +], Inches(0.8), Inches(1.85), Inches(11.7), Inches(2.0), size=17) +tb, tf = textbox(s, Inches(0.8), Inches(4.0), Inches(11.7), Inches(0.5)) +set_par(tf.paragraphs[0], "3 acteurs du système", 16, GREEN_D, bold=True, space_after=0) +cards = [("Visiteur anonyme", "Consulte les informations, crée un compte.", GREEN), + ("Utilisateur connecté", "Journal d'émotions, rapports, profil, RGPD.", BLUE), + ("Administrateur", "Gère utilisateurs, contenus et émotions.", GREEN_D)] +for i, (t, d, col) in enumerate(cards): + x = Inches(0.8 + i * 4.0) + rect(s, x, Inches(4.55), Inches(3.7), Inches(1.8), BLUE_L) + rect(s, x, Inches(4.55), Inches(3.7), Inches(0.55), col) + tb, tf = textbox(s, x + Inches(0.1), Inches(4.58), Inches(3.5), Inches(0.5), MSO_ANCHOR.MIDDLE) + set_par(tf.paragraphs[0], t, 14, WHITE, bold=True, space_after=0) + tb, tf = textbox(s, x + Inches(0.15), Inches(5.2), Inches(3.4), Inches(1.0)) + set_par(tf.paragraphs[0], d, 13, INK, space_after=0) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 5 — Besoins & périmètre +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Besoins reformulés et périmètre", 3) +cols = [ + ("2 modules OBLIGATOIRES", GREEN, [ + "Comptes utilisateurs : inscription JWT, profil, reset, admin, RGPD.", + "Informations : pages de contenu santé mentale, CRUD admin.", + ]), + ("1 module AU CHOIX (retenu)", BLUE, [ + "Tracker d'émotions : journal, saisies, rapports périodiques.", + "Hiérarchie : 7 émotions de base + 28 sous-émotions.", + ]), + ("Modules ÉCARTÉS", GREY, [ + "Diagnostic de stress.", + "Cohérence cardiaque.", + "Activités de détente.", + ]), +] +for i, (t, col, items) in enumerate(cols): + x = Inches(0.7 + i * 4.05) + rect(s, x, Inches(1.5), Inches(3.85), Inches(0.6), col) + tb, tf = textbox(s, x + Inches(0.1), Inches(1.53), Inches(3.65), Inches(0.55), MSO_ANCHOR.MIDDLE) + set_par(tf.paragraphs[0], t, 13, WHITE, bold=True, space_after=0) + bullets(s, items, x + Inches(0.05), Inches(2.3), Inches(3.85), Inches(3.5), size=13, gap=8) +tb, tf = textbox(s, Inches(0.7), Inches(6.0), Inches(11.9), Inches(0.8)) +set_par(tf.paragraphs[0], + "Périmètre maîtrisé : 3 modules livrés en qualité plutôt que 6 modules superficiels.", + 15, GREEN_D, bold=True, align=PP_ALIGN.CENTER, space_after=0) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 6 — Priorisation +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Critères de priorisation et pondération", 3) +bullets(s, [ + "5 critères pondérés, total sur 15 : Complexité, Valeur Ministère, Valeur Utilisateur, Nécessité, Interdépendance.", +], Inches(0.8), Inches(1.35), Inches(11.7), Inches(0.8), size=16) +# mini-tableau top priorités +rows = [ + ("Fonctionnalité", "Score /15", True), + ("Journal d'émotions · Rapports · Diagnostic de stress", "10", False), + ("Cryptage · Inscription · Cohérence cardiaque · Ajout tracker", "9", False), + ("Conformité RGPD", "8", False), + ("Réinitialisation mot de passe", "7", False), +] +ty = 2.3 +for i, (a, b, head) in enumerate(rows): + col = BLUE if head else (BLUE_L if i % 2 else WHITE) + rect(s, Inches(0.8), Inches(ty + i * 0.62), Inches(9.4), Inches(0.62), col, + line=RGBColor(210, 220, 230)) + rect(s, Inches(10.2), Inches(ty + i * 0.62), Inches(2.3), Inches(0.62), col, + line=RGBColor(210, 220, 230)) + tb, tf = textbox(s, Inches(0.95), Inches(ty + i * 0.62), Inches(9.1), Inches(0.62), MSO_ANCHOR.MIDDLE) + set_par(tf.paragraphs[0], a, 13, WHITE if head else INK, bold=head, space_after=0) + tb, tf = textbox(s, Inches(10.2), Inches(ty + i * 0.62), Inches(2.3), Inches(0.62), MSO_ANCHOR.MIDDLE) + set_par(tf.paragraphs[0], b, 13, WHITE if head else GREEN_D, bold=True, + align=PP_ALIGN.CENTER, space_after=0) +tb, tf = textbox(s, Inches(0.8), Inches(6.05), Inches(11.9), Inches(0.8)) +set_par(tf.paragraphs[0], + "→ Le Tracker cumule 4 fonctionnalités notées 9-10/15 : module au choix le plus rentable.", + 15, GREEN_D, bold=True, space_after=0) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 7 — Réponse fonctionnelle Comptes +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Réponse fonctionnelle — Comptes utilisateurs", 4) +bullets(s, [ + "Inscription et connexion sécurisées par JWT (tymon/jwt-auth).", + "Consentement RGPD obligatoire, non pré-coché, à l'inscription.", + "Gestion du profil : édition, changement de mot de passe.", + "Espace admin : CRUD utilisateurs, activation/désactivation, rôles.", + "RGPD : export des données (JSON), anonymisation, soft delete + purge 30 j.", + "Mot de passe : bcrypt 12 rounds, min. 8 caractères.", +], Inches(0.8), Inches(1.5), Inches(8.0), Inches(5.2), size=17, gap=12) +image_fit(s, "mockup_inscription.png", Inches(9.1), Inches(1.4), Inches(3.7), Inches(5.2), + caption="Écran d'inscription (mobile)") +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 8 — Réponse fonctionnelle Informations +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Réponse fonctionnelle — Informations", 4) +bullets(s, [ + "Pages de contenu santé mentale (feeds) accessibles au grand public.", + "Page liste (cartes : titre, extrait, image, date) et page détail (HTML rendu).", + "Back-office admin : création / édition / publication / suppression.", + "Slug unique généré automatiquement à partir du titre.", + "Champ booléen est_publie : brouillon (invisible) ou publié (visible).", + "Modération obligatoire : un administrateur valide avant publication.", + "Tri par ordre numérique pour mettre en avant un contenu.", +], Inches(0.8), Inches(1.5), Inches(11.7), Inches(5.0), size=18, gap=12) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 9 — Réponse fonctionnelle Tracker +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Réponse fonctionnelle — Tracker d'émotions", 4) +bullets(s, [ + "Wizard d'ajout en 3 étapes : émotion de base → sous-émotion + intensité → date + note.", + "Journal de bord : consultation, modification, suppression des saisies.", + "Rapports analytiques sur 4 périodes : semaine, mois, trimestre, année.", + "Statistiques : intensité moyenne, émotion dominante, répartition, évolution.", + "Hiérarchie configurable par l'admin : 7 émotions + 28 sous-émotions.", + "Web (graphiques Recharts) et mobile (Expo) synchronisés via l'API.", +], Inches(0.7), Inches(1.45), Inches(7.0), Inches(5.3), size=16, gap=10) +image_fit(s, "mockup_wizard.png", Inches(7.9), Inches(1.35), Inches(2.5), Inches(5.3)) +image_fit(s, "mockup_rapports.png", Inches(10.5), Inches(2.4), Inches(2.7), Inches(3.2)) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 10 — Cas d'usage UML +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Modélisation — Cas d'usage UML", 5) +image_fit(s, "usecase.png", Inches(0.6), Inches(1.3), Inches(12.1), Inches(5.4), + caption="3 acteurs · cas d'usage groupés par module · modules écartés non représentés") +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 11 — MCD +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Modélisation — MCD (Merise)", 5) +image_fit(s, "mcd.png", Inches(0.6), Inches(1.3), Inches(12.1), Inches(5.4), + caption="8 entités · cardinalités Merise · hiérarchie d'émotions (auto-référence)") +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 12 — Architecture MVC +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Modélisation — Architecture MVC", 5) +image_fit(s, "mvc.png", Inches(0.6), Inches(1.25), Inches(8.6), Inches(5.5)) +bullets(s, [ + "API REST Laravel 11 = Contrôleur central.", + "Eloquent ORM + PostgreSQL 16 = Modèle.", + "Next.js (web) + React Native (mobile) = Vues.", + "Une seule logique métier, deux clients.", + "Sécurité centralisée : FormRequest, middleware, JWT.", + "Stack open-source, conteneurisée (Docker).", +], Inches(9.3), Inches(1.5), Inches(3.5), Inches(5.0), size=14, gap=12) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 13 — RGPD & données sensibles +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Données personnelles et sensibles (RGPD)", 6) +bullets(s, [ + "Données de santé mentale = données sensibles (RGPD art. 9).", + "Base légale : consentement explicite ; finalités documentées.", + "Droits CNIL implémentés : accès, rectification, effacement, portabilité (export JSON).", + "Anonymisation et soft delete + purge à 30 jours.", +], Inches(0.8), Inches(1.45), Inches(11.7), Inches(2.4), size=17, gap=10) +rect(s, Inches(0.8), Inches(4.1), Inches(11.7), Inches(2.5), BLUE_L) +rect(s, Inches(0.8), Inches(4.1), Inches(3.6), Inches(0.55), GREEN_D) +tb, tf = textbox(s, Inches(0.95), Inches(4.13), Inches(3.4), Inches(0.5), MSO_ANCHOR.MIDDLE) +set_par(tf.paragraphs[0], "Mesures de sécurité", 15, WHITE, bold=True, space_after=0) +bullets(s, [ + "Chiffrement en transit (HTTPS/TLS 1.3) et au repos (AES-256).", + "Authentification JWT signée, expiration 60 min, throttle login.", + "Hébergement certifié HDS (Scaleway / OVH Healthcare).", + "Journalisation : table audits trace toute action admin sensible.", +], Inches(1.0), Inches(4.8), Inches(11.3), Inches(1.7), size=14, gap=6) +footer(s) + +# ════════════════════════════════════════════════════════════════════ +# Slide 14 — Prototype & perspectives +# ════════════════════════════════════════════════════════════════════ +s = prs.slides.add_slide(BLANK) +band_title(s, "Prototype et perspectives", 7) +tb, tf = textbox(s, Inches(0.8), Inches(1.4), Inches(11.7), Inches(0.5)) +set_par(tf.paragraphs[0], "Un prototype fonctionnel accompagne le dossier", 17, GREEN_D, + bold=True, space_after=0) +bullets(s, [ + "Backend Laravel 11 (API REST JWT) + PostgreSQL 16.", + "Frontend web Next.js 16 et application mobile Expo (iOS / Android).", + "Conteneurisation Docker Compose, dépôt Git.", +], Inches(0.8), Inches(1.9), Inches(11.7), Inches(1.9), size=16) +tb, tf = textbox(s, Inches(0.8), Inches(4.0), Inches(11.7), Inches(0.5)) +set_par(tf.paragraphs[0], "Perspectives d'évolution", 17, GREEN_D, bold=True, space_after=0) +bullets(s, [ + "Activer les modules écartés (Diagnostic, Cohérence cardiaque, Activités) sans réécrire le modèle.", + "Open Data : base anonymisée pour études épidémiologiques.", + "Interopérabilité : Mon Espace Santé (FHIR) ; internationalisation (i18n).", +], Inches(0.8), Inches(4.5), Inches(11.7), Inches(1.7), size=16) +rect(s, 0, Inches(6.6), SW, Inches(0.9), BLUE) +tb, tf = textbox(s, Inches(0.8), Inches(6.62), Inches(11.7), Inches(0.85), MSO_ANCHOR.MIDDLE) +set_par(tf.paragraphs[0], "Merci de votre attention — Questions / Réponses", 20, WHITE, + bold=True, align=PP_ALIGN.CENTER, space_after=0) + +# ════════════════════════════════════════════════════════════════════ +# Annexes (backup Q/R) — séquences +# ════════════════════════════════════════════════════════════════════ +for name, title, cap in [ + ("seq_login.png", "Annexe — Séquence d'authentification JWT", + "Validation · vérification bcrypt · émission du token JWT"), + ("seq_saisie.png", "Annexe — Séquence d'ajout d'une saisie", + "Wizard 3 étapes · validation · persistance"), +]: + s = prs.slides.add_slide(BLANK) + band_title(s, title) + image_fit(s, name, Inches(0.6), Inches(1.3), Inches(12.1), Inches(5.4), caption=cap) + footer(s) + +prs.save(OUT) +print(f"Support de soutenance généré : {OUT}") +print(f"Nombre de slides : {len(prs.slides._sldIdLst)}")