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>
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 180 KiB |
@@ -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()}")
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||