add site web
build / build (push) Successful in 57s

This commit is contained in:
hcornet committed 2026-09-21 18:27:03 +02:00
1 parent ea531c4e1c
commit 1121efecd5
27 files changed
+659 -11

No files matched your search

+12 -3
View File
@@ -12,6 +12,8 @@ Réécriture en Python (FastAPI + SQLite) de [afuh/rick-and-morty-api](https://g
| `models/` (Mongoose / MongoDB) | `app/models.py` (SQLAlchemy / SQLite) |
| `utils/helpers.js` (messages) | `app/errors.py` |
| lecture seule | + POST / PUT, DELETE protégé par `X-API-Key` |
| `images/` servies sur `/api/character/avatar/N.jpeg` | idem, fichiers envoyés par upload dans `data/avatars` |
| — | site web de consultation (`/`, Jinja2, sans JavaScript) |
| `graphql/` | chantier suivant |
## Endpoints
@@ -24,20 +26,27 @@ Réécriture en Python (FastAPI + SQLite) de [afuh/rick-and-morty-api](https://g
| POST | `/api/{ressource}` — `id` et `created` facultatifs (conservés si fournis) |
| PUT | `/api/{ressource}/{id}` — remplacement complet (`id`/`created` du corps ignorés) |
| DELETE | `/api/{ressource}/{id}` — en-tête `X-API-Key` |
| GET | `/health`, `/docs` |
| PUT | `/api/character/{id}/avatar` — corps brut JPEG/PNG (2 Mo max), `X-API-Key` ; met à jour `image` |
| GET | `/api/character/avatar/{id}.jpeg` (ou `.png`) |
| GET | `/health`, `/hello`, `/docs` |
Filtres (partiels, insensibles à la casse) : character `name status species type gender`,
location `name type dimension`, episode `name episode`.
## Format et relations
Réponses identiques à l'original ; une ressource lue en GET peut être renvoyée telle quelle en POST.
Réponses identiques à l'original, plus un champ `modified` (date de dernière modification, égale à `created` tant que l'objet n'a pas été modifié) ; une ressource lue en GET peut être renvoyée telle quelle en POST.
Les URL (`origin`, `location`, `episode`, `residents`) sont décodées sur `/api/<ressource>/<id>`, quel que soit l'hôte.
- `character.episode` ↔ `episode.characters` : une seule relation. Le champ `characters` d'un épisode en entrée est ignoré.
- `location.residents` : liste saisie telle quelle (ordre conservé), indépendante de `character.location`.
**Ordre d'import (n8n)** : locations → episodes → characters.
**Ordre d'import (n8n)** : locations → episodes → characters → avatars.
## Site web
`/` accueil, `/characters`, `/locations`, `/episodes` (listes paginées + filtres) et leurs fiches `/<ressource>/<id>`.
Rendu serveur sans JavaScript, contenu échappé et en-tête CSP strict (les données sont saisissables via l'API).
## CI/CD
+50
View File
@@ -0,0 +1,50 @@
"""Stockage des avatars : <avatars_dir>/<id>.jpeg ou <id>.png."""
import os
import re
import tempfile
from pathlib import Path
from app.config import get_settings
MAX_BYTES = 2 * 1024 * 1024
_SIGNATURES = {b"\xff\xd8\xff": "jpeg", b"\x89PNG\r\n\x1a\n": "png"}
EXTENSIONS = tuple(_SIGNATURES.values())
FILENAME = re.compile(r"^(\d+)\.(jpeg|png)$")
def directory() -> Path:
path = Path(get_settings().avatars_dir)
path.mkdir(parents=True, exist_ok=True)
return path
def detect_format(content: bytes) -> str | None:
for signature, ext in _SIGNATURES.items():
if content.startswith(signature):
return ext
return None
def save(character_id: int, content: bytes, ext: str) -> str:
"""Écrit l'avatar de façon atomique et retourne le nom de fichier."""
folder = directory()
filename = f"{character_id}.{ext}"
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".upload-")
try:
with os.fdopen(fd, "wb") as handle:
handle.write(content)
os.chmod(tmp, 0o644)
os.replace(tmp, folder / filename)
except BaseException:
Path(tmp).unlink(missing_ok=True)
raise
for other in EXTENSIONS:
if other != ext:
(folder / f"{character_id}.{other}").unlink(missing_ok=True)
return filename
def remove(character_id: int) -> None:
for ext in EXTENSIONS:
(directory() / f"{character_id}.{ext}").unlink(missing_ok=True)
+1
View File
@@ -9,6 +9,7 @@ class Settings(BaseSettings):
api_base_url: str = "https://api-python-001.tips-of-mine.com"
database_url: str = "sqlite:////data/api.db"
admin_api_key: str = ""
avatars_dir: str = "/data/avatars"
@property
def api_root(self) -> str:
+9 -2
View File
@@ -2,9 +2,11 @@
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, Response
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.templating import render
NO_PAGE = "There is nothing here"
BAD_PARAM = "Hey! you must provide an id"
BAD_ARRAY = "Bad... bad array :/"
@@ -15,7 +17,12 @@ NOT_FOUND = {
}
async def _http_error(_request: Request, exc: StarletteHTTPException) -> JSONResponse:
_JSON_PREFIXES = ("/api", "/health", "/hello", "/docs", "/openapi", "/redoc")
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
if exc.status_code == 404 and not request.url.path.startswith(_JSON_PREFIXES):
return render(request, "404.html", {}, status_code=404)
detail = NO_PAGE if exc.status_code == 404 and exc.detail == "Not Found" else exc.detail
return JSONResponse({"error": detail}, status_code=exc.status_code, headers=exc.headers)
+3 -2
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from sqlalchemy.orm import Session
from app import __version__, errors
from app import __version__, errors, web
from app.config import get_settings
from app.database import Base, engine, get_db
from app.routers import characters, episodes, locations
@@ -25,9 +25,10 @@ errors.register(app)
app.include_router(characters.router)
app.include_router(locations.router)
app.include_router(episodes.router)
app.include_router(web.router)
@app.get("/", tags=["api"])
@app.get("/hello", tags=["api"])
def hello() -> dict:
return {"message": "hello"}
+3
View File
@@ -13,6 +13,9 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
# Colonne `edited` (nom de l'original) exposée sous le nom `modified` ; vaut `created`
# tant que l'objet n'a jamais été modifié.
def utcnow() -> datetime:
# Stockage en UTC « naïf » : SQLite ne conserve pas le fuseau.
+59 -1
View File
@@ -1,8 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi.responses import FileResponse
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session, selectinload
from app import avatars
from app.config import get_settings
from app.database import get_db
from app.errors import NO_PAGE
from app.models import Character, Episode, Location, LocationResident, utcnow
from app.routers.common import (
contains,
@@ -88,6 +92,59 @@ def list_characters(
return paginate(db, stmt, Character, page, RESOURCE, filters, character_out, LOAD)
@router.get("/avatar", include_in_schema=False)
def avatar_index():
raise HTTPException(status.HTTP_404_NOT_FOUND, NO_PAGE)
@router.get("/avatar/{filename}", response_class=FileResponse)
def get_avatar(filename: str):
match = avatars.FILENAME.match(filename)
path = avatars.directory() / filename
if match is None or not path.is_file():
raise HTTPException(status.HTTP_404_NOT_FOUND, NO_PAGE)
return FileResponse(
path,
media_type=f"image/{match.group(2)}",
headers={"Cache-Control": "public, max-age=86400"},
)
@router.put(
"/{character_id}/avatar",
response_model=CharacterOut,
dependencies=[Depends(require_admin)],
openapi_extra={
"requestBody": {
"required": True,
"content": {
"image/jpeg": {"schema": {"type": "string", "format": "binary"}},
"image/png": {"schema": {"type": "string", "format": "binary"}},
},
}
},
)
async def upload_avatar(character_id: int, request: Request, db: Session = Depends(get_db)):
"""Envoi brut du fichier (JPEG ou PNG, 2 Mo max) ; met à jour `image`."""
declared = request.headers.get("content-length")
if declared and declared.isdigit() and int(declared) > avatars.MAX_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Image trop volumineuse (2 Mo max)")
content = await request.body()
if len(content) > avatars.MAX_BYTES:
raise HTTPException(status.HTTP_413_CONTENT_TOO_LARGE, "Image trop volumineuse (2 Mo max)")
ext = avatars.detect_format(content)
if ext is None:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Format non supporté (JPEG ou PNG attendu)"
)
character = get_or_404(db, Character, character_id, RESOURCE)
filename = avatars.save(character_id, content, ext)
character.image = f"{get_settings().api_root}/{RESOURCE}/avatar/{filename}"
character.edited = utcnow()
db.commit()
return character_out(db.get(Character, character_id, options=LOAD))
@router.get("/{ids}", response_model=CharacterOut | list[CharacterOut])
def get_characters(ids: str, db: Session = Depends(get_db)):
return get_by_ids(db, Character, ids, RESOURCE, character_out, LOAD)
@@ -123,4 +180,5 @@ def delete_character(character_id: int, db: Session = Depends(get_db)):
db.delete(get_or_404(db, Character, character_id, RESOURCE))
db.execute(delete(LocationResident).where(LocationResident.character_id == character_id))
db.commit()
avatars.remove(character_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+3
View File
@@ -107,6 +107,7 @@ class CharacterOut(BaseModel):
episode: list[str]
url: str
created: str
modified: str
class LocationOut(BaseModel):
@@ -117,6 +118,7 @@ class LocationOut(BaseModel):
residents: list[str]
url: str
created: str
modified: str
class EpisodeOut(BaseModel):
@@ -127,3 +129,4 @@ class EpisodeOut(BaseModel):
characters: list[str]
url: str
created: str
modified: str
+3
View File
@@ -33,6 +33,7 @@ def character_out(c: Character) -> CharacterOut:
episode=[resource_url("episode", i) for i in sorted(e.id for e in c.episodes)],
url=resource_url("character", c.id),
created=iso(c.created),
modified=iso(c.edited or c.created),
)
@@ -48,6 +49,7 @@ def location_out(loc: Location) -> LocationOut:
],
url=resource_url("location", loc.id),
created=iso(loc.created),
modified=iso(loc.edited or loc.created),
)
@@ -60,4 +62,5 @@ def episode_out(e: Episode) -> EpisodeOut:
characters=[resource_url("character", i) for i in sorted(c.id for c in e.characters)],
url=resource_url("episode", e.id),
created=iso(e.created),
modified=iso(e.edited or e.created),
)
+6
View File
@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}Introuvable{% endblock %}
{% block content %}
<h1>There is nothing here</h1>
<p><a href="/">← Accueil</a></p>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% macro character_card(c) -%}
<a class="card" href="/characters/{{ c.id }}">
{% if c.image %}<img src="{{ c.image }}" alt="{{ c.name }}" loading="lazy">{% else %}<img alt="">{% endif %}
<div class="body">
<div class="name">{{ c.name }}</div>
<div class="muted"><span class="dot {{ c.status }}"></span>{{ c.status }} · {{ c.species }}</div>
</div>
</a>
{%- endmacro %}
{% macro pager(page, pages, prev_url, next_url) -%}
{% if pages > 1 %}
<div class="pager">
{% if prev_url %}<a href="{{ prev_url }}">← Précédent</a>{% endif %}
<span class="muted">Page {{ page }} / {{ pages }}</span>
{% if next_url %}<a href="{{ next_url }}">Suivant →</a>{% endif %}
</div>
{% endif %}
{%- endmacro %}
+56
View File
@@ -0,0 +1,56 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Rick and Morty{% endblock %} · api-python-001</title>
<style>
:root{--bg:#f6f7f9;--card:#fff;--text:#1d2330;--muted:#667085;--line:#e3e6eb;--accent:#0b7a75;--alive:#2e9e44;--dead:#d64545;--unknown:#98a2b3}
@media (prefers-color-scheme:dark){:root{--bg:#14171c;--card:#1d2129;--text:#e6e8ec;--muted:#98a2b3;--line:#2c313b;--accent:#4fc3bd}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--text);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
header{background:var(--card);border-bottom:1px solid var(--line)}
.bar{max-width:1100px;margin:auto;padding:12px 16px;display:flex;gap:20px;align-items:center;flex-wrap:wrap}
.brand{font-weight:700;color:var(--text)}
nav{display:flex;gap:16px;flex-wrap:wrap}
main{max-width:1100px;margin:auto;padding:20px 16px 40px}
h1{font-size:1.6rem;margin:.2em 0 .6em}
.muted{color:var(--muted)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:14px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden;display:block;color:var(--text)}
.card:hover{border-color:var(--accent);text-decoration:none}
.card img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:var(--line)}
.card .body{padding:8px 10px}
.card .name{font-weight:600}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:5px;background:var(--unknown)}
.dot.Alive{background:var(--alive)}.dot.Dead{background:var(--dead)}
table{width:100%;border-collapse:collapse;background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--line)}
th{font-size:.85rem;color:var(--muted);font-weight:600}
form.filters{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
form.filters input,form.filters select{padding:6px 8px;border:1px solid var(--line);border-radius:6px;background:var(--card);color:var(--text)}
form.filters button{padding:6px 14px;border:0;border-radius:6px;background:var(--accent);color:#fff;cursor:pointer}
.pager{display:flex;gap:12px;align-items:center;justify-content:center;margin-top:20px}
.detail{display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start}
.detail img{width:260px;max-width:100%;border-radius:10px;border:1px solid var(--line)}
dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 16px;margin:0}
dt{color:var(--muted)}dd{margin:0}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:14px}
.stats .card{padding:18px}.stats .n{font-size:2rem;font-weight:700}
h2{font-size:1.2rem;margin:28px 0 12px}
</style>
</head>
<body>
<header><div class="bar">
<a class="brand" href="/">Rick and Morty</a>
<nav>
<a href="/characters">Characters</a>
<a href="/locations">Locations</a>
<a href="/episodes">Episodes</a>
<a href="/docs">API</a>
</nav>
</div></header>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}{{ c.name }}{% endblock %}
{% block content %}
<p><a href="/characters">← Characters</a></p>
<div class="detail">
{% if c.image %}<img src="{{ c.image }}" alt="{{ c.name }}">{% endif %}
<div>
<h1>{{ c.name }}</h1>
<dl>
<dt>Statut</dt><dd><span class="dot {{ c.status }}"></span>{{ c.status }}</dd>
<dt>Espèce</dt><dd>{{ c.species or "—" }}</dd>
<dt>Type</dt><dd>{{ c.type or "—" }}</dd>
<dt>Genre</dt><dd>{{ c.gender }}</dd>
<dt>Origine</dt><dd>{% if c.origin.url %}<a href="/locations/{{ c.origin.url | id_from }}">{{ c.origin.name }}</a>{% else %}{{ c.origin.name }}{% endif %}</dd>
<dt>Localisation</dt><dd>{% if c.location.url %}<a href="/locations/{{ c.location.url | id_from }}">{{ c.location.name }}</a>{% else %}{{ c.location.name }}{% endif %}</dd>
<dt>Créé</dt><dd>{{ c.created }}</dd>
<dt>Modifié</dt><dd>{{ c.modified }}</dd>
<dt>API</dt><dd><a href="{{ c.url }}">{{ c.url }}</a></dd>
</dl>
</div>
</div>
<h2>Episodes ({{ episodes | length }})</h2>
{% if episodes %}
<table>
<tr><th>Code</th><th>Titre</th><th>Diffusion</th></tr>
{% for e in episodes %}<tr><td>{{ e.episode }}</td><td><a href="/episodes/{{ e.id }}">{{ e.name }}</a></td><td>{{ e.air_date }}</td></tr>{% endfor %}
</table>
{% else %}<p class="muted">Aucun.</p>{% endif %}
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% from "_macros.html" import character_card, pager %}
{% block title %}Characters{% endblock %}
{% block content %}
<h1>Characters <span class="muted">({{ count }})</span></h1>
<form class="filters" method="get">
<input name="name" placeholder="Nom" value="{{ filters.name }}">
<select name="status">
<option value="">Statut</option>
{% for s in ["Alive", "Dead", "unknown"] %}<option{% if filters.status == s %} selected{% endif %}>{{ s }}</option>{% endfor %}
</select>
<input name="species" placeholder="Espèce" value="{{ filters.species }}">
<select name="gender">
<option value="">Genre</option>
{% for g in ["Female", "Male", "Genderless", "unknown"] %}<option{% if filters.gender == g %} selected{% endif %}>{{ g }}</option>{% endfor %}
</select>
<button>Filtrer</button> <a href="/characters">Réinitialiser</a>
</form>
{% if results %}
<div class="grid">{% for c in results %}{{ character_card(c) }}{% endfor %}</div>
{{ pager(page, pages, prev_url, next_url) }}
{% else %}<p class="muted">Aucun résultat.</p>{% endif %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% from "_macros.html" import character_card %}
{% block title %}{{ e.episode }} · {{ e.name }}{% endblock %}
{% block content %}
<p><a href="/episodes">← Episodes</a></p>
<h1>{{ e.episode }} · {{ e.name }}</h1>
<dl>
<dt>Diffusion</dt><dd>{{ e.air_date or "—" }}</dd>
<dt>Créé</dt><dd>{{ e.created }}</dd>
<dt>Modifié</dt><dd>{{ e.modified }}</dd>
<dt>API</dt><dd><a href="{{ e.url }}">{{ e.url }}</a></dd>
</dl>
<h2>Characters ({{ cast | length }})</h2>
{% if cast %}<div class="grid">{% for c in cast %}{{ character_card(c) }}{% endfor %}</div>
{% else %}<p class="muted">Aucun.</p>{% endif %}
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% from "_macros.html" import pager %}
{% block title %}Episodes{% endblock %}
{% block content %}
<h1>Episodes <span class="muted">({{ count }})</span></h1>
<form class="filters" method="get">
<input name="name" placeholder="Titre" value="{{ filters.name }}">
<input name="episode" placeholder="Code (S01E01)" value="{{ filters.episode }}">
<button>Filtrer</button> <a href="/episodes">Réinitialiser</a>
</form>
{% if results %}
<table>
<tr><th>Code</th><th>Titre</th><th>Diffusion</th><th>Characters</th></tr>
{% for e in results %}<tr><td>{{ e.episode }}</td><td><a href="/episodes/{{ e.id }}">{{ e.name }}</a></td><td>{{ e.air_date }}</td><td>{{ e.characters | length }}</td></tr>{% endfor %}
</table>
{{ pager(page, pages, prev_url, next_url) }}
{% else %}<p class="muted">Aucun résultat.</p>{% endif %}
{% endblock %}
+10
View File
@@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block content %}
<h1>Rick and Morty</h1>
<p class="muted">Consultation des données de l'API. L'API REST est disponible sous <a href="/api">/api</a> et documentée sur <a href="/docs">/docs</a>.</p>
<div class="stats">
<a class="card" href="/characters"><div class="n">{{ counts.characters }}</div>Characters</a>
<a class="card" href="/locations"><div class="n">{{ counts.locations }}</div>Locations</a>
<a class="card" href="/episodes"><div class="n">{{ counts.episodes }}</div>Episodes</a>
</div>
{% endblock %}
+17
View File
@@ -0,0 +1,17 @@
{% extends "base.html" %}
{% from "_macros.html" import character_card %}
{% block title %}{{ loc.name }}{% endblock %}
{% block content %}
<p><a href="/locations">← Locations</a></p>
<h1>{{ loc.name }}</h1>
<dl>
<dt>Type</dt><dd>{{ loc.type or "—" }}</dd>
<dt>Dimension</dt><dd>{{ loc.dimension or "—" }}</dd>
<dt>Créé</dt><dd>{{ loc.created }}</dd>
<dt>Modifié</dt><dd>{{ loc.modified }}</dd>
<dt>API</dt><dd><a href="{{ loc.url }}">{{ loc.url }}</a></dd>
</dl>
<h2>Residents ({{ residents | length }})</h2>
{% if residents %}<div class="grid">{% for c in residents %}{{ character_card(c) }}{% endfor %}</div>
{% else %}<p class="muted">Aucun.</p>{% endif %}
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% from "_macros.html" import pager %}
{% block title %}Locations{% endblock %}
{% block content %}
<h1>Locations <span class="muted">({{ count }})</span></h1>
<form class="filters" method="get">
<input name="name" placeholder="Nom" value="{{ filters.name }}">
<input name="type" placeholder="Type" value="{{ filters.type }}">
<input name="dimension" placeholder="Dimension" value="{{ filters.dimension }}">
<button>Filtrer</button> <a href="/locations">Réinitialiser</a>
</form>
{% if results %}
<table>
<tr><th>Nom</th><th>Type</th><th>Dimension</th><th>Residents</th></tr>
{% for l in results %}<tr><td><a href="/locations/{{ l.id }}">{{ l.name }}</a></td><td>{{ l.type }}</td><td>{{ l.dimension }}</td><td>{{ l.residents | length }}</td></tr>{% endfor %}
</table>
{{ pager(page, pages, prev_url, next_url) }}
{% else %}<p class="muted">Aucun résultat.</p>{% endif %}
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
from pathlib import Path
from fastapi import Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory=Path(__file__).parent / "templates")
# Les données sont saisissables via l'API : échappement automatique Jinja2 + CSP stricte.
CSP = (
"default-src 'self'; img-src 'self' https: data:; style-src 'self' 'unsafe-inline'; "
"script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
)
def id_from(url: str) -> int | None:
tail = url.rstrip("/").rsplit("/", 1)[-1] if url else ""
return int(tail) if tail.isdigit() else None
templates.env.filters["id_from"] = id_from
def render(request: Request, name: str, context: dict, status_code: int = 200) -> HTMLResponse:
return templates.TemplateResponse(
request,
name,
context,
status_code=status_code,
headers={"Content-Security-Policy": CSP, "X-Content-Type-Options": "nosniff"},
)
+139
View File
@@ -0,0 +1,139 @@
"""Site web de consultation (HTML rendu côté serveur, sans JavaScript)."""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import Character, Episode, Location
from app.routers import characters, episodes, locations
from app.routers.common import parse_page
from app.templating import id_from, render
router = APIRouter(include_in_schema=False)
def _by_ids(fetch, db: Session, urls: list[str]) -> list:
ids = [str(i) for i in (id_from(u) for u in urls) if i is not None]
return fetch(f"[{','.join(ids)}]", db) if ids else []
def _page(request: Request, fetch, page: str | None, **filters):
try:
data = fetch(page=page, **filters)
except HTTPException:
data = None
current = parse_page(page)
pages = data.info.pages if data else 0
def link(n: int) -> str:
return str(request.url.include_query_params(page=n))
return {
"results": data.results if data else [],
"count": data.info.count if data else 0,
"page": current,
"pages": pages,
"prev_url": link(current - 1) if data and data.info.prev else None,
"next_url": link(current + 1) if data and data.info.next else None,
"filters": {k.rstrip("_"): v or "" for k, v in filters.items() if k != "db"},
}
def _not_found(request: Request) -> HTMLResponse:
return render(request, "404.html", {}, status_code=404)
@router.get("/", response_class=HTMLResponse)
def home(request: Request, db: Session = Depends(get_db)):
counts = {
name: db.scalar(select(func.count()).select_from(model)) or 0
for name, model in (
("characters", Character),
("locations", Location),
("episodes", Episode),
)
}
return render(request, "index.html", {"counts": counts})
@router.get("/characters", response_class=HTMLResponse)
def characters_page(
request: Request,
page: str | None = None,
name: str | None = None,
status: str | None = None,
species: str | None = None,
gender: str | None = None,
db: Session = Depends(get_db),
):
context = _page(
request,
characters.list_characters,
page,
name=name,
status_=status,
species=species,
type_=None,
gender=gender,
db=db,
)
return render(request, "characters.html", context)
@router.get("/characters/{character_id}", response_class=HTMLResponse)
def character_page(request: Request, character_id: int, db: Session = Depends(get_db)):
try:
character = characters.get_characters(str(character_id), db)
except HTTPException:
return _not_found(request)
eps = _by_ids(episodes.get_episodes, db, character.episode)
return render(request, "character.html", {"c": character, "episodes": eps})
@router.get("/locations", response_class=HTMLResponse)
def locations_page(
request: Request,
page: str | None = None,
name: str | None = None,
type: str | None = None, # nom du filtre de l'original
dimension: str | None = None,
db: Session = Depends(get_db),
):
context = _page(
request, locations.list_locations, page, name=name, type_=type, dimension=dimension, db=db
)
return render(request, "locations.html", context)
@router.get("/locations/{location_id}", response_class=HTMLResponse)
def location_page(request: Request, location_id: int, db: Session = Depends(get_db)):
try:
location = locations.get_locations(str(location_id), db)
except HTTPException:
return _not_found(request)
residents = _by_ids(characters.get_characters, db, location.residents)
return render(request, "location.html", {"loc": location, "residents": residents})
@router.get("/episodes", response_class=HTMLResponse)
def episodes_page(
request: Request,
page: str | None = None,
name: str | None = None,
episode: str | None = None,
db: Session = Depends(get_db),
):
context = _page(request, episodes.list_episodes, page, name=name, episode=episode, db=db)
return render(request, "episodes.html", context)
@router.get("/episodes/{episode_id}", response_class=HTMLResponse)
def episode_page(request: Request, episode_id: int, db: Session = Depends(get_db)):
try:
episode = episodes.get_episodes(str(episode_id), db)
except HTTPException:
return _not_found(request)
cast = _by_ids(characters.get_characters, db, episode.characters)
return render(request, "episode.html", {"e": episode, "cast": cast})
+1
View File
@@ -3,3 +3,4 @@ uvicorn==0.46.0
SQLAlchemy==2.0.54
pydantic==2.13.3
pydantic-settings==2.14.0
Jinja2==3.1.6
+1
View File
@@ -8,6 +8,7 @@ _TMP = tempfile.mkdtemp(prefix="api-python-001-")
os.environ["DATABASE_URL"] = f"sqlite:///{_TMP}/test.db"
os.environ["ADMIN_API_KEY"] = "test-admin-key"
os.environ["API_BASE_URL"] = "https://api.test"
os.environ["AVATARS_DIR"] = f"{_TMP}/avatars"
from fastapi.testclient import TestClient # noqa: E402
+56
View File
@@ -0,0 +1,56 @@
from pathlib import Path
from app.config import get_settings
from tests.conftest import ADMIN
JPEG = b"\xff\xd8\xff\xe0" + b"0" * 100
PNG = b"\x89PNG\r\n\x1a\n" + b"0" * 100
def _upload(client, character_id, content, headers=ADMIN):
return client.put(
f"/api/character/{character_id}/avatar",
content=content,
headers={"Content-Type": "image/jpeg"} | headers,
)
def test_upload_requires_key(seeded):
assert _upload(seeded, 1, JPEG, headers={}).status_code == 401
def test_upload_updates_image_and_serves_file(seeded):
r = _upload(seeded, 1, JPEG)
assert r.status_code == 200, r.text
assert r.json()["image"] == "https://api.test/api/character/avatar/1.jpeg"
assert r.json()["modified"] > r.json()["created"]
got = seeded.get("/api/character/avatar/1.jpeg")
assert got.status_code == 200
assert got.headers["content-type"] == "image/jpeg"
assert got.content == JPEG
def test_upload_png_replaces_jpeg(seeded):
_upload(seeded, 1, JPEG)
r = _upload(seeded, 1, PNG)
assert r.json()["image"].endswith("/avatar/1.png")
assert seeded.get("/api/character/avatar/1.jpeg").status_code == 404
assert seeded.get("/api/character/avatar/1.png").status_code == 200
def test_upload_rejects_bad_input(seeded):
assert _upload(seeded, 1, b"<svg></svg>").status_code == 415
assert _upload(seeded, 1, JPEG + b"0" * (2 * 1024 * 1024)).status_code == 413
assert _upload(seeded, 99, JPEG).status_code == 404
def test_avatar_paths(seeded):
assert seeded.get("/api/character/avatar").json() == {"error": "There is nothing here"}
assert seeded.get("/api/character/avatar/99.jpeg").status_code == 404
assert seeded.get("/api/character/avatar/..%2Fapi.db").status_code == 404
def test_delete_character_removes_avatar(seeded):
_upload(seeded, 2, JPEG)
assert seeded.delete("/api/character/2", headers=ADMIN).status_code == 204
assert not (Path(get_settings().avatars_dir) / "2.jpeg").exists()
+23 -3
View File
@@ -2,7 +2,7 @@ API = "https://api.test/api"
def test_hello_and_index(client):
assert client.get("/").json() == {"message": "hello"}
assert client.get("/hello").json() == {"message": "hello"}
assert client.get("/api").json() == {
"characters": f"{API}/character",
"locations": f"{API}/location",
@@ -27,7 +27,9 @@ def test_character_same_format_as_original(seeded):
"episode",
"url",
"created",
"modified",
]
assert rick["modified"] == rick["created"] # jamais modifié
assert rick["origin"] == {"name": "Earth (C-137)", "url": f"{API}/location/1"}
assert rick["location"] == {"name": "Citadel of Ricks", "url": f"{API}/location/3"}
assert rick["episode"] == [f"{API}/episode/1", f"{API}/episode/2"]
@@ -40,13 +42,31 @@ def test_character_same_format_as_original(seeded):
def test_residents_are_stored_not_derived(seeded):
# Rick est « à » la Citadelle mais n'en fait pas partie des residents saisis
citadel = seeded.get("/api/location/3").json()
assert list(citadel) == ["id", "name", "type", "dimension", "residents", "url", "created"]
assert list(citadel) == [
"id",
"name",
"type",
"dimension",
"residents",
"url",
"created",
"modified",
]
assert citadel["residents"] == [f"{API}/character/2", f"{API}/character/8"]
def test_episode_characters_follow_character_episode(seeded):
pilot = seeded.get("/api/episode/1").json()
assert list(pilot) == ["id", "name", "air_date", "episode", "characters", "url", "created"]
assert list(pilot) == [
"id",
"name",
"air_date",
"episode",
"characters",
"url",
"created",
"modified",
]
assert pilot["characters"] == [f"{API}/character/1", f"{API}/character/2"]
assert pilot["air_date"] == "December 2, 2013"
+51
View File
@@ -0,0 +1,51 @@
from tests.conftest import ADMIN
def test_home(seeded):
r = seeded.get("/")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
assert "script-src 'none'" in r.headers["content-security-policy"]
assert ">3</div>Characters" in r.text
def test_characters_list_and_filters(seeded):
r = seeded.get("/characters?gender=Female")
assert "Summer Smith" in r.text
assert "Rick Sanchez" not in r.text
assert "Aucun résultat" in seeded.get("/characters?name=zzz").text
def test_detail_pages_link_objects(seeded):
rick = seeded.get("/characters/1").text
assert 'href="/locations/3"' in rick
assert 'href="/episodes/2"' in rick
citadel = seeded.get("/locations/3").text
assert "Morty Smith" in citadel
pilot = seeded.get("/episodes/1").text
assert "Rick Sanchez" in pilot and "Morty Smith" in pilot
def test_pagination_links(client):
for i in range(1, 26):
client.post("/api/location", json={"name": f"Planet {i}"})
page1 = client.get("/locations?name=planet").text
assert "page=2" in page1 and "Page 1 / 2" in page1
def test_html_404_but_json_for_api(seeded):
r = seeded.get("/characters/999")
assert r.status_code == 404 and "There is nothing here" in r.text
assert seeded.get("/nowhere").headers["content-type"].startswith("text/html")
assert seeded.get("/api/nowhere").json() == {"error": "There is nothing here"}
def test_user_content_is_escaped(seeded):
seeded.post(
"/api/character",
json={"id": 50, "name": "<script>alert(1)</script>", "status": "Alive", "gender": "Male"},
)
page = seeded.get("/characters/50").text
assert "<script>alert(1)</script>" not in page
assert "&lt;script&gt;" in page
assert seeded.delete("/api/character/50", headers=ADMIN).status_code == 204
+1
View File
@@ -34,6 +34,7 @@ def test_put_moves_character(seeded):
r = seeded.put("/api/character/1", json=rick)
assert r.status_code == 200
assert r.json()["created"] == "2017-11-04T18:48:46.250Z"
assert r.json()["modified"] > r.json()["created"]
assert r.json()["location"]["name"] == "Earth (C-137)"
assert seeded.put("/api/character/99", json=rick).status_code == 404