91df66852e
Référentiel d'émotions (conformité au sujet) : - 6 émotions de base (suppression d'« Amour ») et 36 sous-émotions - libellés exacts du sujet (Surprise dédupliquée : Étonnement listé 2x) - propagation du décompte 6+36 (CLAUDE.md, plan oral, slides, dossier, mobile) Corrections dossier (grille d'évaluation Bloc INFCDAAL1) : - ajout d'un sommaire - contraintes budget 75 000 EUR et délai 12 mois (chiffres du sujet) - table des parties prenantes et de leur communication (§4.1) - mockup du module Informations (les 3 modules ont désormais une maquette) - cardinalités du MCD agrandies et lisibles - chiffrement reformulé honnêtement (bcrypt aujourd'hui, AES-256 prévu en prod HDS) - dossier maintenu à 20 pages (limite respectée) Vérifs : backend 15 tests, mobile tsc 0, seeder 6+36. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
660 lines
32 KiB
Python
660 lines
32 KiB
Python
#!/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, 13, bold=True) + 12
|
|
c.box(cx - w / 2, cy - 12, w, 24, fill=WHITE, outline=GREEN_D, width=2, radius=6)
|
|
c.text(cx, cy, card, 13, 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 + 52, ey + 40, '0,n', 12, GREEN_D, bold=True, anchor='lm')
|
|
c.text(ex + ew / 2 + 10, ey - 13, '0,1', 12, 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 = ['Fierté', 'Contentement', 'Enchantement', 'Excitation', 'Émerveillement', 'Gratitude']
|
|
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 mockup_informations():
|
|
c = Canvas(620, 760)
|
|
title_bar(c, 14, 14, 592, "Maquette - Informations (pages de contenu)", 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 / informations", 10, GREY_D, anchor='mm')
|
|
ix = x + 28
|
|
c.text(ix, y + 68, "Santé mentale - Ressources", 16, BLUE, bold=True, anchor='lm')
|
|
c.text(ix, y + 92, "Articles et conseils validés par nos professionnels", 10, GREY, anchor='lm')
|
|
# cartes articles (liste publique)
|
|
articles = [
|
|
("Comprendre le stress", ["Mécanismes du stress et signaux", "d'alerte à reconnaître au quotidien."]),
|
|
("5 exercices anti-anxiété", ["Respiration et ancrage pour apaiser", "une montée d'anxiété."]),
|
|
("Bien dormir, mieux vivre", ["L'impact du sommeil sur la santé", "mentale et routines de repos."]),
|
|
("Quand consulter ?", ["Repères pour identifier le moment", "d'aller voir un professionnel."]),
|
|
]
|
|
cy0 = y + 118
|
|
for i, (titre, lignes) in enumerate(articles):
|
|
cardx = ix + (i % 2) * 258
|
|
cardy = cy0 + (i // 2) * 152
|
|
c.box(cardx, cardy, 240, 132, fill=GREY_LL, outline=GREY_L, width=1, radius=12)
|
|
c.box(cardx, cardy, 240, 40, fill=BLUE_L, outline=BLUE_L, radius=12)
|
|
c.rect(cardx, cardy + 22, 240, 18, fill=BLUE_L)
|
|
c.text(cardx + 14, cardy + 20, titre, 11, BLUE, bold=True, anchor='lm')
|
|
for j, ln in enumerate(lignes):
|
|
c.text(cardx + 14, cardy + 62 + j * 19, ln, 9, INK, anchor='lm')
|
|
c.text(cardx + 14, cardy + 112, "12 mai 2026 · Lire ->", 8, GREEN_D, bold=True, anchor='lm')
|
|
# bandeau back-office admin
|
|
ay = cy0 + 320
|
|
c.box(ix, ay, w - 56, 40, fill=(255, 248, 200), outline=YELLOW, width=2, radius=10)
|
|
c.text(ix + 14, ay + 20, "Back-office admin : créer / modifier / publier · slug auto · "
|
|
"modération avant mise en ligne", 9, GREY_D, anchor='lm')
|
|
c.text(x + w / 2, y + h - 14, "RG-INF-01 slug auto · RG-INF-02 brouillon/publié · RG-INF-06 modération",
|
|
8, GREY, anchor='mm')
|
|
c.save('mockup_informations.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_informations()
|
|
mockup_rapports()
|
|
print("Terminé.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|