- A29 — `app/services/publish.py` : slugify titré unique (fallback aléatoire),
404 si la page n'existe pas, événements centralisés. Les 3 paires
publish/unpublish déléguent (sharing = front, board, v2) :
· board : mise à jour aveugle → 404 + contrôle de session ajouté
· board : perd `share_mode='anyone'` en bonus, v2 : perd `is_shared=1` —
le share dialog reste l'unique propriétaire de ces drapeaux
· v2 : slug fourni conservé, slug vidé aussi à la dépublication (avant : laissé)
· `/users/me` ×2 et listings collections ×3 = contrats versionnés distincts,
décision documentée (on garde)
- Byproduct sécurité — `GET /api/users/me` (v1) et le contexte de `/accounts`
faisaient `SELECT *` sur users → password_hash / login_attempts / locked_until
exposés → colonnes whitelistées (liste v2)
- A42 (partiel) — 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))`
→ `settings.data_dir` (property : lecture à chaque accès, les tests
monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque
écriture. Reste : client httpx partagé (52 créations, cache par event loop)
tests : test_publish_service_shared_and_safe, test_users_me_no_secret_columns,
test_gitea_cache_evicts_expired
suite **1034/1034** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.5.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
92 lines
3.4 KiB
Python
92 lines
3.4 KiB
Python
"""Publication de pages — A29 : une seule implémentation, les routers déléguent.
|
|
|
|
Les trois surfaces divergeaient avant cette passe :
|
|
|
|
- `/api/pages/{id}/publish` (sharing, consommateur principal — le front) :
|
|
slug `slugify(titre)` unique, 404 si absente, `_require_auth`, aucun drapeau
|
|
- `/board/api/pages/{id}/publish` : slug aléatoire `p-<8>`, mise à jour AVEUGLE
|
|
(pas de 404), `share_mode='anyone'` en bonus, pas de contrôle d'session
|
|
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
|
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
|
|
|
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
|
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
|
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
|
donc pas un partage manuel.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import secrets
|
|
import unicodedata
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from app.db import get_conn
|
|
from app.services.automations import fire_event
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def slugify(title: str) -> str:
|
|
"""URL-safe slug à partir d'un titre (même traitement qu'avant : NFKD)."""
|
|
slug = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode("ascii")
|
|
slug = re.sub(r"[^\w\s-]", "", slug.lower())
|
|
slug = re.sub(r"[-\s]+", "-", slug).strip("-")
|
|
return slug
|
|
|
|
|
|
def _unique_slug(conn, page_id: int, title: str) -> str:
|
|
"""Slug depuis le titre, suffixé -1, -2… si pris ; fallback aléatoire."""
|
|
base = slugify(title) or f"p-{secrets.token_urlsafe(8)}"
|
|
slug, counter = base, 1
|
|
while conn.execute(
|
|
"SELECT id FROM pages WHERE publish_slug=? AND id!=?", (slug, page_id)
|
|
).fetchone():
|
|
slug = f"{base}-{counter}"
|
|
counter += 1
|
|
return slug
|
|
|
|
|
|
def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
|
"""Publie une page. Renvoie ``(slug, title)`` ; 404 si la page n'existe pas."""
|
|
with get_conn() as conn:
|
|
page = conn.execute(
|
|
"SELECT id, title FROM pages WHERE id=?", (page_id,)
|
|
).fetchone()
|
|
if not page:
|
|
raise HTTPException(404, "Page not found")
|
|
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
|
conn.execute(
|
|
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
|
)
|
|
conn.commit()
|
|
return slug, page["title"] or ""
|
|
|
|
|
|
def unpublish(page_id: int) -> None:
|
|
"""Dépublie : 404 si absente, sinon `is_published=0` + slug vidé."""
|
|
with get_conn() as conn:
|
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
|
raise HTTPException(404, "Page not found")
|
|
conn.execute(
|
|
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
async def fire_published(page_id: int, slug: str) -> None:
|
|
"""Événement `page.published` — l'échec d'eventing n'échoue jamais la route."""
|
|
try:
|
|
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
|
except Exception:
|
|
logger.exception("publish page.published")
|
|
|
|
|
|
async def fire_unpublished(page_id: int) -> None:
|
|
try:
|
|
await fire_event("page.unpublished", {"page_id": page_id})
|
|
except Exception:
|
|
logger.exception("publish page.unpublished")
|