docs: ajoute les livrables de soutenance et corrige le nombre de sous-émotions

- generate_diagrams.py : schémas PNG (MCD Merise, cas d'usage UML, séquences
  login/saisie, architecture MVC, 3 maquettes) générés avec Pillow
- generate_cahier_charges.py : intègre les schémas et corrige les écarts
  code/dossier (28 sous-émotions, table audits, est_publie)
- generate_slides.py : support de soutenance .pptx (16 slides) via python-pptx
- Plan_Oral_CESIZen.md : déroulé minuté 20 min + préparation Q/R
- assets_dossier/ : schémas sources
- CLAUDE.md : 31 -> 28 sous-émotions (aligné sur le seed réel)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Azmog
2026-06-16 17:19:30 +02:00
parent 2ec2a78469
commit e8b49c6b94
14 changed files with 2003 additions and 1 deletions
Binary file not shown.
+1 -1
View File
@@ -48,7 +48,7 @@ Cesizen/
| Configurer les émotions (2 niveaux) | Admin | ✅ | N/A | | Configurer les émotions (2 niveaux) | Admin | ✅ | N/A |
### Hiérarchie des émotions (seed) ### 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. Joie, Tristesse, Colère, Peur, Dégoût, Surprise, Amour.
## Grille de priorisation des besoins ## Grille de priorisation des besoins
+88
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

+864
View File
@@ -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()}")
+616
View File
@@ -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()
+434
View File
@@ -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)}")