1 parent
ea531c4e1c
commit
1121efecd5
27 files changed
+659
-11
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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"}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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})
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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 "<script>" in page
|
||||
assert seeded.delete("/api/character/50", headers=ADMIN).status_code == 204
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user