"""Générateur HTML · projets_master.json → site public (vente.otov7.com).
Livrable Publiciste · Sprint 2 · Semaine 3 (aperçu ; le parser Semaine 2 est le
cœur du sprint courant). Rendu server-side pur stdlib, marque luxury (#4).
Interdits appliqués (AGENT.md §Règles absolues) :
- ❌ Jamais éditer directement l'index publié : on passe TOUJOURS par ce
générateur, qui écrit une sortie complète et horodatée.
- ❌ Zéro prix inventé : un projet sans prix numérique n'affiche PAS de grille,
mais « Prochainement · Détails à venir » (contrainte #6).
- ❌ Rendus IA génériques interdits : on n'affiche que les rendus référencés
dans la faisabilité (liés au projet réel).
"""
from __future__ import annotations
import html
import os
from typing import Any
from urllib.parse import quote
from . import branding
_TEMPLATE = os.path.join(os.path.dirname(__file__), "..", "templates", "site_public.html.tmpl")
_TAGLINE = "Résidences d'exception en République dominicaine · faisabilités 4 volets."
_A_VENIR = "Prochainement · Détails à venir"
def _esc(text: Any) -> str:
return html.escape(str(text), quote=True)
def _fmt_usd(value: float) -> str:
return f"USD {value:,.0f}".replace(",", " ")
def _fmt_dop(value: float) -> str:
return f"DOP {value:,.0f}".replace(",", " ")
def _fmt_m2(value: float) -> str:
txt = f"{value:,.0f}".replace(",", " ") if float(value).is_integer() else f"{value:,.1f}".replace(",", " ")
return f"{txt} m²"
def _prix_depuis(typologies: list[dict[str, Any]]) -> float | None:
"""Prix « à partir de » du projet = min des prix USD numériques présents."""
prices = [t["prix_depuis_usd"] for t in typologies if isinstance(t.get("prix_depuis_usd"), (int, float))]
return min(prices) if prices else None
def _render_typologies(typologies: list[dict[str, Any]]) -> str:
rows = []
for t in typologies:
surface = t.get("surface_totale_m2") or t.get("surface_interieure_m2")
surface_txt = _fmt_m2(surface) if isinstance(surface, (int, float)) else "—"
prix = t.get("prix_depuis_usd")
prix_txt = (
f'{_esc(_fmt_usd(prix))}'
if isinstance(prix, (int, float))
else "—"
)
rows.append(
"
"
f"| {_esc(t.get('nom', ''))} | "
f"{surface_txt} | "
f"{prix_txt} | "
"
"
)
return (
' \n'
" | Typologie | Surface | À partir de |
\n"
+ "\n".join(rows)
+ "\n
"
)
def _render_inclus(inclus: list[str]) -> str:
if not inclus:
return ""
items = "\n".join(f" {_esc(x)}" for x in inclus)
return f' '
def _hero_media(projet: dict[str, Any]) -> str:
rendus = projet.get("rendus", [])
hero = next((r for r in rendus if r.get("hero")), rendus[0] if rendus else None)
if not hero:
return ' '
# Convention de chemin public (miroir de /opt/oto/sites/static/projets/pXX/).
# Le nom de rendu réel peut contenir espaces/apostrophes/accents (« Vue
# d'ensemble.png », « façade nord.webp ») : on percent-encode CHAQUE segment
# dynamique de l'URL (code ET fichier) pour qu'elle reste valide DANS le
# url('…') CSS. Sans cela, une apostrophe — une fois l'attribut style
# HTML-décodé par le navigateur — referme la chaîne CSS et casse
# silencieusement le background-image. quote() est le SEUL encodeur correct
# pour un segment d'URL (html.escape ne l'est pas) : `code` est un identifiant
# contrôlé (P01..P09) aujourd'hui, mais on l'encode par uniformité de
# construction — le contexte, pas la donnée courante, dicte l'encodeur.
# (_esc protège l'attribut HTML ; quote protège le contexte CSS interne.)
code = projet["code"].lower()
src = f"/static/projets/{quote(code)}/{quote(hero['fichier'])}"
style = f"background-image:url('{_esc(src)}')"
return f' '
def render_projet(projet: dict[str, Any]) -> str:
statut = projet.get("statut", "en_developpement")
statut_label = branding.statut_label(statut)
nom = projet.get("nom", projet.get("code", ""))
loc = projet.get("localisation", "")
parts = [f' ']
parts.append(_hero_media(projet))
parts.append(' ')
parts.append(f'
{_esc(statut_label)}')
parts.append(f"
{_esc(nom)}
")
if loc:
parts.append(f'
{_esc(loc)}
')
positionnement = (projet.get("positionnement") or {}).get("fr")
if positionnement:
parts.append(f'
{_esc(positionnement)}
')
typologies = projet.get("typologies", [])
show_prices = statut not in branding.STATUTS_SANS_PRIX and _prix_depuis(typologies) is not None
if show_prices:
parts.append(_render_typologies(typologies))
else:
# Aucun prix inventé — message d'attente (contrainte #6).
parts.append(f'
{_esc(_A_VENIR)}
')
inclus_html = _render_inclus(projet.get("inclus", []))
if inclus_html:
parts.append(inclus_html)
parts.append("
")
parts.append(" ")
return "\n".join(parts)
def render_site(master: dict[str, Any]) -> str:
"""projets_master.json (dict) → page HTML complète (str)."""
with open(_TEMPLATE, encoding="utf-8") as fh:
tmpl = fh.read()
projets_html = "\n".join(render_projet(p) for p in master.get("projets", []))
generated_at = master.get("generated_at", "")
template_version = master.get("template_version", "")
footer = (
f"Contenu généré automatiquement depuis les faisabilités canoniques · "
f"template {_esc(template_version)} · {_esc(generated_at)}. "
"Prix « à partir de » en USD + DOP (Cardnet). Aucune donnée inventée — "
"chaque valeur provient de la faisabilité du projet."
)
replacements = {
"{{TITRE}}": "Helios RD · Résidences d'exception",
"{{META_DESCRIPTION}}": _TAGLINE,
"{{TAGLINE}}": _TAGLINE,
"{{COLOR_BG}}": branding.COLOR_BG,
"{{COLOR_ACCENT}}": branding.COLOR_ACCENT,
"{{COLOR_INK}}": branding.COLOR_INK,
"{{COLOR_MUTED}}": branding.COLOR_MUTED,
"{{FONT_DISPLAY}}": branding.FONT_DISPLAY,
"{{FONT_BODY}}": branding.FONT_BODY,
"{{PROJETS}}": projets_html,
"{{FOOTER}}": footer,
}
for key, val in replacements.items():
tmpl = tmpl.replace(key, val)
return tmpl