418 lines
23 KiB
Python
418 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Génère le support de soutenance CESIZen — Activité 3 « Déployer et sécuriser »
|
||
(PowerPoint 16:9 éditable). Réutilise la charte du support du Bloc 1.
|
||
Sortie : ./CESIZen_Soutenance_Bloc3.pptx
|
||
Schémas : ./assets_bloc3/*.png
|
||
"""
|
||
import os
|
||
from PIL import Image
|
||
from pptx import Presentation
|
||
from pptx.util import Inches, Pt
|
||
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)
|
||
ORANGE = RGBColor(228, 128, 28)
|
||
RED = RGBColor(200, 16, 46)
|
||
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_bloc3')
|
||
OUT = os.path.join(HERE, 'CESIZen_Soutenance_Bloc3.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 INFCDAAL3 — Déployer et sécuriser les applications informatiques · 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, 28, 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)
|
||
|
||
|
||
def cards(slide, items, y, h=1.8, top=0.55):
|
||
"""items = list of (titre, description, couleur)"""
|
||
n = len(items)
|
||
total_w = 12.0
|
||
gap = 0.25
|
||
cw = (total_w - gap * (n - 1)) / n
|
||
for i, (t, d, col) in enumerate(items):
|
||
x = Inches(0.7 + i * (cw + gap))
|
||
rect(slide, x, Inches(y), Inches(cw), Inches(h), BLUE_L)
|
||
rect(slide, x, Inches(y), Inches(cw), Inches(top), col)
|
||
tb, tf = textbox(slide, x + Inches(0.1), Inches(y + 0.03), Inches(cw - 0.2),
|
||
Inches(top), MSO_ANCHOR.MIDDLE)
|
||
set_par(tf.paragraphs[0], t, 13, WHITE, bold=True, space_after=0, align=PP_ALIGN.CENTER)
|
||
tb, tf = textbox(slide, x + Inches(0.15), Inches(y + top + 0.1), Inches(cw - 0.3),
|
||
Inches(h - top - 0.15))
|
||
for j, line in enumerate(d):
|
||
p = tf.paragraphs[0] if j == 0 else tf.add_paragraph()
|
||
set_par(p, "• " + line, 11.5, INK, space_after=5)
|
||
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# 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.62), Inches(11.3), Inches(0.9))
|
||
set_par(tf.paragraphs[0], "L'application de votre santé mentale", 24, GREY_D,
|
||
align=PP_ALIGN.CENTER, space_after=0)
|
||
tb, tf = textbox(s, Inches(1), Inches(3.65), Inches(11.3), Inches(0.9))
|
||
set_par(tf.paragraphs[0], "Activité 3 — Déployer et sécuriser les applications informatiques",
|
||
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 INFCDAAL3 — Déployer et sécuriser les applications informatiques",
|
||
"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 et périmètre de l'Activité 3",
|
||
"Plan de déploiement : architecture et environnements",
|
||
"Versioning et intégration/livraison continues (Gitea · Coolify)",
|
||
"Plan de maintenance : ticketing, engagements de service, veille",
|
||
"Plan de sécurisation : vulnérabilités, risques, actions",
|
||
"Données personnelles, RGPD et gestion de crise",
|
||
"Démonstration des outils et conclusion",
|
||
], Inches(1.2), Inches(1.5), Inches(11), Inches(5.2), size=22, gap=13)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 3 — Contexte & périmètre
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Contexte et périmètre de l'Activité 3", 1)
|
||
bullets(s, [
|
||
"CESIZen : plateforme grand public de santé mentale (Ministère).",
|
||
"Après la conception (Act. 1) et le développement/test (Act. 2), l'Activité 3 traite du déploiement et de la sécurisation.",
|
||
"Prototype déployé : API Laravel + web Next.js + mobile Expo + PostgreSQL.",
|
||
], Inches(0.8), Inches(1.4), Inches(11.7), Inches(2.0), size=18)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(3.7), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "3 livrables", 16, GREEN_D, bold=True, space_after=0)
|
||
cards(s, [
|
||
("Plan de déploiement", ["Environnements", "Versioning", "Automatisations"], GREEN),
|
||
("Plan de maintenance", ["Ticketing Gitea", "SLA & méthodologie", "Veille techno"], BLUE),
|
||
("Plan de sécurisation", ["Risques & criticité", "Actions correctives", "RGPD & crise"], GREEN_D),
|
||
], y=4.3, h=2.0)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 4 — Architecture technique
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Architecture technique de la solution", 2)
|
||
bullets(s, [
|
||
"Architecture découplée : une API REST centrale, deux clients (web + mobile).",
|
||
("Back-end : Laravel 12 (PHP 8.4) — API REST JSON, authentification JWT.", 1),
|
||
("Base de données : PostgreSQL 16 (ACID) ; Redis en développement.", 1),
|
||
("Front-end web : Next.js 16 / React 19, design system de l'État (DSFR).", 1),
|
||
("Mobile : React Native / Expo 54 (iOS + Android, Mobile First).", 1),
|
||
"Conteneurisation Docker : environnements reproductibles et isolés.",
|
||
"Une seule logique métier → non-duplication, testabilité, évolutivité.",
|
||
], Inches(0.8), Inches(1.5), Inches(11.7), Inches(5.0), size=17, gap=9)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 5 — Plan de déploiement : 3 environnements + chaîne
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Plan de déploiement — environnements et chaîne CI/CD", 2)
|
||
image_fit(s, "archi_deploiement.png", Inches(0.5), Inches(1.25), Inches(12.3), Inches(5.5),
|
||
caption="Développement (local) → Test/Intégration (Gitea Actions) → Production (Coolify, HTTPS)")
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 6 — Versioning & CI/CD
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Versioning et intégration/livraison continues", 3)
|
||
bullets(s, [
|
||
"Gitea : dépôt Git, ticketing et CI/CD réunis dans un outil souverain auto-hébergé.",
|
||
"Stratégie de branches : main (prod, protégée) · develop · feature/fix/hotfix.",
|
||
"Conventions : Conventional Commits, revue par pull request, tags de version.",
|
||
], Inches(0.8), Inches(1.4), Inches(11.7), Inches(1.9), size=17)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(3.55), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "Deux automatisations complémentaires", 16, GREEN_D, bold=True, space_after=0)
|
||
cards(s, [
|
||
("Intégration continue — Gitea Actions", [
|
||
"29 tests à chaque push",
|
||
"Lint (Pint, ESLint)",
|
||
"Merge bloqué si CI rouge"], ORANGE),
|
||
("Déploiement continu — Coolify", [
|
||
"Webhook sur merge main",
|
||
"Build images + migrations",
|
||
"HTTPS, health checks, restart"], BLUE),
|
||
], y=4.15, h=2.1)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 7 — Maintenance : ticketing
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Plan de maintenance — ticketing et cycle de vie", 4)
|
||
image_fit(s, "cycle_ticket.png", Inches(0.5), Inches(1.25), Inches(12.3), Inches(5.5),
|
||
caption="Gitea Issues : gabarits incident/évolution, labels, jalons, Kanban · SLA du contrat")
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 8 — Maintenance : méthodo + veille
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Maintenance corrective, évolutive et veille", 4)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(1.3), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "Méthodologie", 16, GREEN_D, bold=True, space_after=0)
|
||
bullets(s, [
|
||
"Corrective : ticket → qualification (gravité) → SLA → correction → revue (PR + CI) → clôture validée.",
|
||
"Évolutive : analyse (documentation, délai, coût) → validation client → planification par version.",
|
||
"Pilotage : tableaux de bord Gitea (Kanban, jalons, indicateurs de délais).",
|
||
], Inches(0.8), Inches(1.8), Inches(11.7), Inches(2.3), size=16, gap=9)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(4.2), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "Veille technologique (pérennité)", 16, GREEN_D, bold=True, space_after=0)
|
||
bullets(s, [
|
||
"Sécurité : CVE, CERT-FR, OWASP, alertes de dépendances (composer/npm audit en CI).",
|
||
"Frameworks & dépendances : notes de version, robot de mises à jour testées par la CI.",
|
||
"Réglementation : CNIL, ANSSI (RGPD, HDS).",
|
||
], Inches(0.8), Inches(4.7), Inches(11.7), Inches(1.9), size=16, gap=9)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 9 — Sécurisation : vulnérabilités OWASP
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Plan de sécurisation — vulnérabilités (OWASP)", 5)
|
||
bullets(s, [
|
||
"Contrôle d'accès : RBAC par middleware, garde-fous admin.",
|
||
"Cryptographie : HTTPS/TLS, bcrypt (coût 12), mots de passe masqués et hors logs.",
|
||
"Injection : ORM paramétré (Eloquent) + validation par Form Requests.",
|
||
"Authentification : JWT signé, TTL 60 min, blacklist, rate limiting différencié.",
|
||
"Journalisation : piste d'audit immuable (IP, anciennes/nouvelles valeurs).",
|
||
"Configuration : APP_DEBUG=false, secrets hors dépôt, CORS en liste blanche.",
|
||
], Inches(0.8), Inches(1.5), Inches(11.7), Inches(5.0), size=17, gap=11)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 10 — Matrice de risques
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Sécurisation — matrice de criticité des risques", 5)
|
||
image_fit(s, "matrice_risques.png", Inches(2.4), Inches(1.25), Inches(8.5), Inches(5.5),
|
||
caption="Criticité = gravité × probabilité · priorisation des actions (R1, R7, R2 prioritaires)")
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 11 — Actions préventives / correctives
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Sécurisation — actions préventives et correctives", 5)
|
||
cards(s, [
|
||
("Prévention", [
|
||
"Validation & ORM (injection)",
|
||
"MFA admin, mots de passe robustes",
|
||
"audit dépendances en CI",
|
||
"Sauvegardes automatisées"], GREEN_D),
|
||
("Chiffrement", [
|
||
"HTTPS/TLS en transit",
|
||
"bcrypt + AES (APP_KEY)",
|
||
"Notes de santé chiffrées (prévu)",
|
||
"JWT signés"], BLUE),
|
||
("Correction", [
|
||
"Correctif prioritaire + test",
|
||
"Révocation / blacklist token",
|
||
"Redéploiement automatisé",
|
||
"Plan de reprise (PRA)"], ORANGE),
|
||
], y=1.6, h=4.6, top=0.6)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 12 — RGPD
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Données personnelles et RGPD", 6)
|
||
bullets(s, [
|
||
"Données de santé mentale = données sensibles (RGPD art. 9).",
|
||
"Consentement explicite obligatoire à l'inscription, tracé en base.",
|
||
"Droits implémentés : accès et portabilité (export JSON), effacement (anonymisation + soft delete).",
|
||
"Minimisation des données ; hébergement UE, aucun transfert hors UE ; cible HDS en production.",
|
||
], Inches(0.8), Inches(1.45), Inches(11.7), Inches(2.4), size=17, gap=10)
|
||
rect(s, Inches(0.8), Inches(4.25), Inches(11.7), Inches(2.3), BLUE_L)
|
||
rect(s, Inches(0.8), Inches(4.25), Inches(3.6), Inches(0.55), GREEN_D)
|
||
tb, tf = textbox(s, Inches(0.95), Inches(4.28), Inches(3.4), Inches(0.5), MSO_ANCHOR.MIDDLE)
|
||
set_par(tf.paragraphs[0], "Traçabilité (art. 32)", 15, WHITE, bold=True, space_after=0)
|
||
bullets(s, [
|
||
"Piste d'audit immuable : action, IP, anciennes/nouvelles valeurs — le mot de passe n'est jamais journalisé.",
|
||
"Chiffrement en transit, hachage bcrypt, contrôle d'accès par rôle.",
|
||
"Export et anonymisation testés automatiquement (tests RGPD).",
|
||
], Inches(1.0), Inches(4.95), Inches(11.3), Inches(1.6), size=14, gap=6)
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 13 — Gestion de crise & escalade
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Gestion de crise et escalade d'information", 6)
|
||
levels = [
|
||
("N1 — Détection", "Astreinte technique", "Qualifier, contenir, ouvrir un ticket sécurité", GREEN),
|
||
("N2 — Confirmation", "Responsable technique", "Isoler, préserver les preuves, évaluer l'impact", YELLOW),
|
||
("N3 — Crise", "Chef de projet + DPO", "Notification CNIL < 72 h, information des personnes", ORANGE),
|
||
("N4 — Direction", "Direction + Ministère", "Communication officielle, coordination ANSSI", RED),
|
||
]
|
||
y = 1.55
|
||
for titre, resp, act, col in levels:
|
||
rect(s, Inches(0.8), Inches(y), Inches(11.7), Inches(1.15), BLUE_L)
|
||
rect(s, Inches(0.8), Inches(y), Inches(0.16), Inches(1.15), col)
|
||
tb, tf = textbox(s, Inches(1.1), Inches(y + 0.05), Inches(3.0), Inches(1.05), MSO_ANCHOR.MIDDLE)
|
||
set_par(tf.paragraphs[0], titre, 15, col if col != YELLOW else GREY_D, bold=True, space_after=2)
|
||
p = tf.add_paragraph(); set_par(p, resp, 12, GREY_D, space_after=0)
|
||
tb, tf = textbox(s, Inches(4.3), Inches(y + 0.05), Inches(8.0), Inches(1.05), MSO_ANCHOR.MIDDLE)
|
||
set_par(tf.paragraphs[0], act, 14, INK, space_after=0)
|
||
y += 1.32
|
||
footer(s)
|
||
|
||
# ════════════════════════════════════════════════════════════════════
|
||
# Slide 14 — Démo & conclusion
|
||
# ════════════════════════════════════════════════════════════════════
|
||
s = prs.slides.add_slide(BLANK)
|
||
band_title(s, "Démonstration et conclusion", 7)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(1.35), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "Démonstration des outils (en direct)", 17, GREEN_D, bold=True, space_after=0)
|
||
bullets(s, [
|
||
"Versioning : arbre des commits, branches et tags sur Gitea.",
|
||
"Intégration continue : pull request → 29 tests verts (Gitea Actions).",
|
||
"Ticketing : création d'une issue (gabarit incident), labels, jalon, Kanban.",
|
||
"Déploiement : merge → Coolify → site en ligne (cesizen.cleanows.fr).",
|
||
], Inches(0.8), Inches(1.85), Inches(11.7), Inches(2.4), size=16, gap=9)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(4.35), Inches(11.7), Inches(0.5))
|
||
set_par(tf.paragraphs[0], "En synthèse", 17, GREEN_D, bold=True, space_after=0)
|
||
bullets(s, [
|
||
"Chaîne de déploiement complète, outillée et réellement en production.",
|
||
"Maintenance cadrée (ticketing, SLA, veille) et sécurité analysée par les risques + RGPD.",
|
||
], Inches(0.8), Inches(4.85), Inches(11.7), Inches(1.4), size=16, gap=8)
|
||
rect(s, 0, Inches(6.55), SW, Inches(0.95), BLUE)
|
||
tb, tf = textbox(s, Inches(0.8), Inches(6.57), Inches(11.7), Inches(0.9), 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)
|
||
|
||
# ─── Numéros de page (toutes les slides sauf la page de titre) ─────
|
||
for idx, slide in enumerate(prs.slides):
|
||
if idx == 0:
|
||
continue # pas de numéro sur la slide de titre
|
||
tb, tf = textbox(slide, Inches(12.4), Inches(7.05), Inches(0.7), Inches(0.35),
|
||
anchor=MSO_ANCHOR.MIDDLE)
|
||
set_par(tf.paragraphs[0], str(idx + 1), 10, GREY, bold=True,
|
||
align=PP_ALIGN.RIGHT, space_after=0)
|
||
|
||
prs.save(OUT)
|
||
print(f"Support de soutenance généré : {OUT}")
|
||
print(f"Nombre de slides : {len(prs.slides._sldIdLst)}")
|