"""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' " \n" + "\n".join(rows) + "\n
TypologieSurfaceÀ partir de
" ) 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