1 parent
913093f3ae
commit
9ab0db2ae9
13 files changed
+437
-18
No files matched your search
@@ -29,6 +29,8 @@ Réécriture en Python (FastAPI + SQLite) de [afuh/rick-and-morty-api](https://g
|
||||
| PUT | `/api/episode/{id}/characters/{character_id}` — corps `{"role": "major"\|"minor"\|"mentioned"\|null, "died": false}` : rattache le personnage si besoin et fixe son rôle |
|
||||
| PUT | `/api/episode/{id}/locations/{location_id}` — relie l'épisode à un lieu où il se déroule (sans effet si déjà lié) |
|
||||
| DELETE | `/api/episode/{id}/locations/{location_id}` — retire ce lien (`X-API-Key`), le lieu est conservé |
|
||||
| PUT | `/api/location/{id}/parents/{parent_id}` — place le lieu dans un autre (plusieurs parents possibles, sans effet si déjà lié) |
|
||||
| DELETE | `/api/location/{id}/parents/{parent_id}` — retire ce lien (`X-API-Key`), les lieux sont conservés |
|
||||
| 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` |
|
||||
@@ -49,6 +51,22 @@ Les URL (`origin`, `location`, `episode`, `residents`) sont décodées sur `/api
|
||||
Un PUT de character conserve les rôles des épisodes qu'il garde.
|
||||
- Lieux d'un épisode : relation épisode ↔ location saisie via l'API ; champs ajoutés en fin d'objet :
|
||||
`locations` (episode) et `episodes` (location).
|
||||
- Hiérarchie des lieux : un lieu peut être contenu dans plusieurs lieux (ex. une seule « Smith Residence »
|
||||
sur plusieurs planètes de plusieurs dimensions). Champs ajoutés en fin d'objet : `parents` et `children` (location).
|
||||
En entrée, `parents` (URL) remplace les parents ; absent, ils sont conservés ; `children` est ignoré.
|
||||
Un parent doit être d'un niveau strictement inférieur (niveaux sautables), sinon 422 ; cycles refusés :
|
||||
|
||||
| Niveau | Types |
|
||||
|---|---|
|
||||
| 0 | Multiverse |
|
||||
| 1 | Dimension, Reality, Non-Diegetic Alternative Reality |
|
||||
| 2 | Planet, Dwarf planet (Celestial Dwarf), Asteroid, Star, Cluster, Quadrant, Quasar, Space, Space station, Spacecraft, Death Star, Artificially generated world |
|
||||
| 3 | Country, State, City, Fantasy town, Woods, Lake, Mount, Liquid |
|
||||
| 4 | Residence, Building, School, Restaurant, Arcade, Spa, Resort, Daycare, Customs, Police Department, Convention, Lair, Company, Theme park, Acid Plant, Menagerie, Base |
|
||||
| libre | tout autre type (Microverse, Dream, TV, unknown…) : aucune contrainte de niveau |
|
||||
|
||||
`dimension` est calculée : noms des lieux de niveau 1 parmi le lieu et ses ancêtres (séparés par « , »),
|
||||
sinon la valeur saisie. Le filtre `?dimension=` porte sur cette valeur calculée. Table dans `app/hierarchy.py`.
|
||||
- Migration : les colonnes ajoutées après la mise en production sont créées au démarrage (`app/migrations.py`).
|
||||
|
||||
**Ordre d'import (n8n)** : locations → episodes → characters → avatars.
|
||||
@@ -56,7 +74,7 @@ Les URL (`origin`, `location`, `episode`, `residents`) sont décodées sur `/api
|
||||
## Site web
|
||||
|
||||
`/` accueil, `/characters`, `/locations`, `/episodes` (listes paginées + filtres) et leurs fiches `/<ressource>/<id>`.
|
||||
Fiche épisode : lieux de l'épisode, puis personnages rangés en Major / Minor / Mentioned / Dead / Non classés ; fiche personnage : rôle par épisode ; fiche location : épisodes où elle apparaît.
|
||||
Fiche épisode : lieux de l'épisode en arbre indenté (lieux englobants grisés), puis personnages rangés en Major / Minor / Mentioned / Dead / Non classés ; fiche personnage : rôle par épisode ; fiche location : lieux parents et contenus, épisodes où elle apparaît.
|
||||
Bascule `?vue=images` / `?vue=liste` sur la liste des characters, les residents d'une location et la fiche épisode.
|
||||
Rendu serveur sans JavaScript, contenu échappé et en-tête CSP strict (les données sont saisissables via l'API).
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Hiérarchie des lieux : niveaux par type, contrôles des liens, dimension calculée.
|
||||
|
||||
Un lieu peut avoir plusieurs parents. Un parent doit être d'un niveau strictement
|
||||
inférieur (des niveaux peuvent être sautés) ; les types « libres » (absents du tableau)
|
||||
peuvent contenir ou être contenus par n'importe quel lieu. Les cycles sont refusés.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Location, location_parent
|
||||
|
||||
_LEVELS: dict[int, tuple[str, ...]] = {
|
||||
0: ("Multiverse",),
|
||||
1: ("Dimension", "Reality", "Non-Diegetic Alternative Reality"),
|
||||
2: (
|
||||
"Planet",
|
||||
"Dwarf planet (Celestial Dwarf)",
|
||||
"Asteroid",
|
||||
"Star",
|
||||
"Cluster",
|
||||
"Quadrant",
|
||||
"Quasar",
|
||||
"Space",
|
||||
"Space station",
|
||||
"Spacecraft",
|
||||
"Death Star",
|
||||
"Artificially generated world",
|
||||
),
|
||||
3: ("Country", "State", "City", "Fantasy town", "Woods", "Lake", "Mount", "Liquid"),
|
||||
4: (
|
||||
"Residence",
|
||||
"Building",
|
||||
"School",
|
||||
"Restaurant",
|
||||
"Arcade",
|
||||
"Spa",
|
||||
"Resort",
|
||||
"Daycare",
|
||||
"Customs",
|
||||
"Police Department",
|
||||
"Convention",
|
||||
"Lair",
|
||||
"Company",
|
||||
"Theme park",
|
||||
"Acid Plant",
|
||||
"Menagerie",
|
||||
"Base",
|
||||
),
|
||||
}
|
||||
LEVELS: dict[str, int] = {t.lower(): lvl for lvl, types in _LEVELS.items() for t in types}
|
||||
DIMENSION_LEVEL = 1
|
||||
|
||||
|
||||
def level(type_: str) -> int | None:
|
||||
"""Niveau du type (insensible à la casse), None pour un type libre."""
|
||||
return LEVELS.get(type_.strip().lower())
|
||||
|
||||
|
||||
def _conflict(message: str) -> HTTPException:
|
||||
return HTTPException(422, message)
|
||||
|
||||
|
||||
def _level_error(child_type: str, parent_type: str) -> str | None:
|
||||
child, parent = level(child_type), level(parent_type)
|
||||
if child is None or parent is None or parent < child:
|
||||
return None
|
||||
return f"a {child_type} cannot be inside a {parent_type}"
|
||||
|
||||
|
||||
def ancestors(location: Location) -> Iterator[Location]:
|
||||
"""Tous les ancêtres (parcours en largeur, chacun une seule fois)."""
|
||||
seen, queue = {location.id}, list(location.parents)
|
||||
while queue:
|
||||
current = queue.pop(0)
|
||||
if current.id in seen:
|
||||
continue
|
||||
seen.add(current.id)
|
||||
yield current
|
||||
queue.extend(current.parents)
|
||||
|
||||
|
||||
def check_link(child: Location, parent: Location) -> None:
|
||||
if child.id == parent.id:
|
||||
raise _conflict("a location cannot be inside itself")
|
||||
if error := _level_error(child.type, parent.type):
|
||||
raise _conflict(error)
|
||||
if any(a.id == child.id for a in ancestors(parent)):
|
||||
raise _conflict(f"cycle: {parent.name} is already inside {child.name}")
|
||||
|
||||
|
||||
def check_type(location: Location, new_type: str, parents: list[Location]) -> None:
|
||||
"""Le type (éventuellement nouveau) doit rester compatible avec parents et enfants."""
|
||||
for parent in parents:
|
||||
if error := _level_error(new_type, parent.type):
|
||||
raise _conflict(f"{error} ({parent.name})")
|
||||
for child in location.children:
|
||||
if error := _level_error(child.type, new_type):
|
||||
raise _conflict(f"{error} ({child.name})")
|
||||
|
||||
|
||||
def dimension_of(location: Location) -> str:
|
||||
"""Dimension(s) déduite(s) de l'arbre, sinon la valeur saisie."""
|
||||
names = {a.name for a in (location, *ancestors(location)) if level(a.type) == DIMENSION_LEVEL}
|
||||
return ", ".join(sorted(names)) if names else location.dimension
|
||||
|
||||
|
||||
def ids_in_dimension(db: Session, value: str) -> list[int]:
|
||||
"""Identifiants des lieux dont la dimension calculée contient `value` (casse ignorée).
|
||||
|
||||
Graphe complet chargé en mémoire : quelques centaines de lieux, deux requêtes.
|
||||
"""
|
||||
rows = db.execute(select(Location.id, Location.name, Location.type, Location.dimension)).all()
|
||||
parents: dict[int, list[int]] = {}
|
||||
for child_id, parent_id in db.execute(select(location_parent)).all():
|
||||
parents.setdefault(child_id, []).append(parent_id)
|
||||
info = {r.id: r for r in rows}
|
||||
wanted = value.strip().lower()
|
||||
result = []
|
||||
for row in rows:
|
||||
seen, queue, names = set(), [row.id], set()
|
||||
while queue:
|
||||
current = queue.pop()
|
||||
if current in seen or current not in info:
|
||||
continue
|
||||
seen.add(current)
|
||||
if level(info[current].type) == DIMENSION_LEVEL:
|
||||
names.add(info[current].name)
|
||||
queue.extend(parents.get(current, []))
|
||||
computed = ", ".join(sorted(names)) if names else row.dimension
|
||||
if wanted in computed.lower():
|
||||
result.append(row.id)
|
||||
return result
|
||||
@@ -5,6 +5,8 @@
|
||||
le rôle du personnage dans l'épisode (major / minor / mentioned, vide = non classé)
|
||||
et un indicateur de décès dans cet épisode.
|
||||
- episode <-> location : lieux où se déroule l'épisode (saisis via l'API), lus des deux côtés.
|
||||
- location <-> location (parents / children) : hiérarchie à parents multiples, règles de
|
||||
niveau par type dans `app/hierarchy.py`.
|
||||
- location.residents : liste enregistrée telle quelle, indépendante de
|
||||
`character.location` (comme l'original : « ceux qui y sont passés »).
|
||||
"""
|
||||
@@ -43,6 +45,18 @@ episode_location = Table(
|
||||
)
|
||||
|
||||
|
||||
# Hiérarchie des lieux : un lieu peut être contenu dans plusieurs lieux (ex. une même
|
||||
# « Smith Residence » sur plusieurs planètes, elles-mêmes dans plusieurs dimensions).
|
||||
location_parent = Table(
|
||||
"location_parent",
|
||||
Base.metadata,
|
||||
Column("location_id", ForeignKey("locations.id", ondelete="CASCADE"), primary_key=True),
|
||||
Column(
|
||||
"parent_id", ForeignKey("locations.id", ondelete="CASCADE"), primary_key=True, index=True
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CharacterEpisode(Base):
|
||||
"""Lien personnage ↔ épisode, avec rôle et décès (lecture et mise à jour du rôle)."""
|
||||
|
||||
@@ -72,6 +86,20 @@ class Location(Base):
|
||||
episodes: Mapped[list["Episode"]] = relationship(
|
||||
secondary=episode_location, back_populates="locations", order_by="Episode.id"
|
||||
)
|
||||
parents: Mapped[list["Location"]] = relationship(
|
||||
secondary=location_parent,
|
||||
primaryjoin=lambda: Location.id == location_parent.c.location_id,
|
||||
secondaryjoin=lambda: Location.id == location_parent.c.parent_id,
|
||||
back_populates="children",
|
||||
order_by="Location.id",
|
||||
)
|
||||
children: Mapped[list["Location"]] = relationship(
|
||||
secondary=location_parent,
|
||||
primaryjoin=lambda: Location.id == location_parent.c.parent_id,
|
||||
secondaryjoin=lambda: Location.id == location_parent.c.location_id,
|
||||
back_populates="parents",
|
||||
order_by="Location.id",
|
||||
)
|
||||
|
||||
|
||||
class Character(Base):
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from fastapi import APIRouter, Depends, Query, Response, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
|
||||
from app.database import get_db
|
||||
from app.hierarchy import check_link, check_type, ids_in_dimension
|
||||
from app.models import Location, LocationResident, utcnow
|
||||
from app.routers.common import (
|
||||
contains,
|
||||
@@ -18,15 +19,37 @@ from app.serializers import location_out
|
||||
from app.urls import id_from_url
|
||||
|
||||
RESOURCE = "location"
|
||||
LOAD = (selectinload(Location.residents), selectinload(Location.episodes))
|
||||
LOAD = (
|
||||
selectinload(Location.residents),
|
||||
selectinload(Location.episodes),
|
||||
selectinload(Location.parents),
|
||||
selectinload(Location.children),
|
||||
)
|
||||
|
||||
router = APIRouter(prefix=f"/api/{RESOURCE}", tags=[RESOURCE])
|
||||
|
||||
|
||||
def _apply(location: Location, payload: LocationIn) -> None:
|
||||
def _parents(db: Session, urls: list[str]) -> list[Location]:
|
||||
parents = []
|
||||
for url in dict.fromkeys(urls):
|
||||
parent_id = id_from_url(url, RESOURCE)
|
||||
parent = db.get(Location, parent_id) if parent_id else None
|
||||
if parent is None:
|
||||
raise HTTPException(422, f"parent location not found: {url}")
|
||||
parents.append(parent)
|
||||
return parents
|
||||
|
||||
|
||||
def _apply(db: Session, location: Location, payload: LocationIn) -> None:
|
||||
parents = location.parents if payload.parents is None else _parents(db, payload.parents)
|
||||
check_type(location, payload.type, parents)
|
||||
location.name = payload.name
|
||||
location.type = payload.type
|
||||
location.dimension = payload.dimension
|
||||
for parent in parents:
|
||||
check_link(location, parent)
|
||||
if payload.parents is not None:
|
||||
location.parents = parents
|
||||
ids = [i for i in dict.fromkeys(id_from_url(u, "character") for u in payload.residents) if i]
|
||||
existing = {r.character_id: r for r in location.residents}
|
||||
residents = []
|
||||
@@ -50,10 +73,11 @@ def list_locations(
|
||||
for column, value in (
|
||||
(Location.name, name),
|
||||
(Location.type, type_),
|
||||
(Location.dimension, dimension),
|
||||
):
|
||||
if value:
|
||||
stmt = stmt.where(contains(column, value))
|
||||
if dimension: # dimension calculée depuis l'arbre des lieux
|
||||
stmt = stmt.where(Location.id.in_(ids_in_dimension(db, dimension)))
|
||||
filters = {"name": name, "type": type_, "dimension": dimension}
|
||||
return paginate(db, stmt, Location, page, RESOURCE, filters, location_out, LOAD)
|
||||
|
||||
@@ -67,21 +91,55 @@ def get_locations(ids: str, db: Session = Depends(get_db)):
|
||||
def create_location(payload: LocationIn, db: Session = Depends(get_db)):
|
||||
ensure_free_id(db, Location, payload.id, RESOURCE)
|
||||
location = Location(id=payload.id)
|
||||
_apply(location, payload)
|
||||
_apply(db, location, payload)
|
||||
if payload.created:
|
||||
location.created = to_utc_naive(payload.created)
|
||||
db.add(location)
|
||||
db.commit()
|
||||
return location_out(db.get(Location, location.id, options=LOAD))
|
||||
return _reloaded(db, location.id)
|
||||
|
||||
|
||||
@router.put("/{location_id}", response_model=LocationOut)
|
||||
def update_location(location_id: int, payload: LocationIn, db: Session = Depends(get_db)):
|
||||
location = get_or_404(db, Location, location_id, RESOURCE)
|
||||
_apply(location, payload)
|
||||
_apply(db, location, payload)
|
||||
location.edited = utcnow()
|
||||
db.commit()
|
||||
return location_out(db.get(Location, location.id, options=LOAD))
|
||||
return _reloaded(db, location.id)
|
||||
|
||||
|
||||
def _reloaded(db: Session, location_id: int) -> LocationOut:
|
||||
db.expire_all()
|
||||
return location_out(db.get(Location, location_id, options=LOAD))
|
||||
|
||||
|
||||
@router.put("/{location_id}/parents/{parent_id}", response_model=LocationOut)
|
||||
def add_parent(location_id: int, parent_id: int, db: Session = Depends(get_db)):
|
||||
"""Place le lieu dans `parent_id` (il peut en avoir plusieurs) ; sans effet si déjà lié."""
|
||||
location = get_or_404(db, Location, location_id, RESOURCE)
|
||||
parent = get_or_404(db, Location, parent_id, RESOURCE)
|
||||
if parent not in location.parents:
|
||||
check_link(location, parent)
|
||||
location.parents.append(parent)
|
||||
location.edited = parent.edited = utcnow()
|
||||
db.commit()
|
||||
return _reloaded(db, location_id)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}/parents/{parent_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(require_admin)],
|
||||
)
|
||||
def remove_parent(location_id: int, parent_id: int, db: Session = Depends(get_db)):
|
||||
"""Retire le lien lieu ↔ parent (aucun des deux lieux n'est supprimé)."""
|
||||
location = get_or_404(db, Location, location_id, RESOURCE)
|
||||
parent = get_or_404(db, Location, parent_id, RESOURCE)
|
||||
if parent in location.parents:
|
||||
location.parents.remove(parent)
|
||||
location.edited = parent.edited = utcnow()
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete(
|
||||
|
||||
@@ -86,6 +86,11 @@ class LocationIn(BaseModel):
|
||||
dimension: str = Field(default="", max_length=255)
|
||||
residents: list[str] = Field(default_factory=list, description="URL des characters")
|
||||
created: datetime | None = Field(default=None, description="Ignoré en PUT")
|
||||
parents: list[str] | None = Field(
|
||||
default=None,
|
||||
description="URL des lieux contenant celui-ci ; absent = parents inchangés",
|
||||
)
|
||||
children: list[str] = Field(default_factory=list, description="Ignoré : déduit des parents")
|
||||
|
||||
|
||||
class EpisodeIn(BaseModel):
|
||||
@@ -150,6 +155,8 @@ class LocationOut(BaseModel):
|
||||
created: str
|
||||
modified: str
|
||||
episodes: list[str]
|
||||
parents: list[str]
|
||||
children: list[str]
|
||||
|
||||
|
||||
class EpisodeOut(BaseModel):
|
||||
|
||||
+4
-1
@@ -1,5 +1,6 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.hierarchy import dimension_of
|
||||
from app.models import Character, Episode, Location
|
||||
from app.schemas import (
|
||||
CharacterOut,
|
||||
@@ -55,7 +56,7 @@ def location_out(loc: Location) -> LocationOut:
|
||||
id=loc.id,
|
||||
name=loc.name,
|
||||
type=loc.type,
|
||||
dimension=loc.dimension,
|
||||
dimension=dimension_of(loc),
|
||||
residents=[
|
||||
resource_url("character", r.character_id)
|
||||
for r in sorted(loc.residents, key=lambda r: r.position)
|
||||
@@ -64,6 +65,8 @@ def location_out(loc: Location) -> LocationOut:
|
||||
created=iso(loc.created),
|
||||
modified=iso(loc.edited or loc.created),
|
||||
episodes=[resource_url("episode", i) for i in sorted(e.id for e in loc.episodes)],
|
||||
parents=[resource_url("location", i) for i in sorted(p.id for p in loc.parents)],
|
||||
children=[resource_url("location", i) for i in sorted(c.id for c in loc.children)],
|
||||
)
|
||||
|
||||
|
||||
|
||||
+38
-2
@@ -63,6 +63,34 @@ def _view(request: Request) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _location_tree(db: Session, linked: list) -> list[dict]:
|
||||
"""Arbre indenté des lieux de l'épisode, avec leurs ancêtres (grisés s'ils ne sont
|
||||
pas eux-mêmes liés). Un lieu à plusieurs parents apparaît sous chacun d'eux."""
|
||||
nodes = {loc.id: loc for loc in linked}
|
||||
pending = {i for loc in linked for i in map(id_from, loc.parents)} - nodes.keys()
|
||||
while pending:
|
||||
fetched = _by_ids(locations.get_locations, db, [f"/{i}" for i in sorted(pending)])
|
||||
nodes |= {loc.id: loc for loc in fetched}
|
||||
pending = {i for loc in fetched for i in map(id_from, loc.parents)} - nodes.keys()
|
||||
children: dict[int, list] = {}
|
||||
for loc in nodes.values():
|
||||
for parent_id in map(id_from, loc.parents):
|
||||
children.setdefault(parent_id, []).append(loc)
|
||||
linked_ids = {loc.id for loc in linked}
|
||||
|
||||
def branch(loc, path: frozenset) -> dict:
|
||||
kids = sorted(children.get(loc.id, []), key=lambda c: (c.name.lower(), c.id))
|
||||
return {
|
||||
"loc": loc,
|
||||
"linked": loc.id in linked_ids,
|
||||
"children": [branch(c, path | {c.id}) for c in kids if c.id not in path],
|
||||
}
|
||||
|
||||
roots = [loc for loc in nodes.values() if not any(id_from(u) in nodes for u in loc.parents)]
|
||||
roots.sort(key=lambda r: (r.name.lower(), r.id))
|
||||
return [branch(r, frozenset({r.id})) for r in roots]
|
||||
|
||||
|
||||
def _not_found(request: Request) -> HTMLResponse:
|
||||
return render(request, "404.html", {}, status_code=404)
|
||||
|
||||
@@ -139,7 +167,13 @@ def location_page(request: Request, location_id: int, db: Session = Depends(get_
|
||||
return _not_found(request)
|
||||
residents = _by_ids(characters.get_characters, db, location.residents)
|
||||
eps = _by_ids(episodes.get_episodes, db, location.episodes)
|
||||
context = {"loc": location, "residents": residents, "episodes": eps}
|
||||
context = {
|
||||
"loc": location,
|
||||
"residents": residents,
|
||||
"episodes": eps,
|
||||
"parents": _by_ids(locations.get_locations, db, location.parents),
|
||||
"children": _by_ids(locations.get_locations, db, location.children),
|
||||
}
|
||||
return render(request, "location.html", context | _view(request))
|
||||
|
||||
|
||||
@@ -171,10 +205,12 @@ def episode_page(request: Request, episode_id: int, db: Session = Depends(get_db
|
||||
}
|
||||
for key, title in SECTIONS
|
||||
]
|
||||
places = _by_ids(locations.get_locations, db, episode.locations)
|
||||
context = {
|
||||
"e": episode,
|
||||
"total": len(cast),
|
||||
"sections": [s for s in sections if s["characters"]],
|
||||
"places": _by_ids(locations.get_locations, db, episode.locations),
|
||||
"places": places,
|
||||
"tree": _location_tree(db, places),
|
||||
}
|
||||
return render(request, "episode.html", context | _view(request))
|
||||
@@ -0,0 +1,106 @@
|
||||
from app.hierarchy import level
|
||||
from tests.conftest import ADMIN
|
||||
|
||||
API = "https://api.test/api"
|
||||
|
||||
|
||||
def loc(i: int) -> str:
|
||||
return f"{API}/location/{i}"
|
||||
|
||||
|
||||
def create(client, name: str, type_: str, **extra) -> int:
|
||||
r = client.post("/api/location", json={"name": name, "type": type_, **extra})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def test_levels_are_case_insensitive():
|
||||
assert level("planet") == level("Planet") == 2
|
||||
assert level("Microverse") is None
|
||||
|
||||
|
||||
def test_multi_parent_and_computed_dimension(seeded):
|
||||
# seeded : 1 = Earth (C-137) Planet « Dimension C-137 », 3 = Citadel of Ricks
|
||||
c137 = create(seeded, "Dimension C-137", "Dimension")
|
||||
replacement = create(seeded, "Replacement Dimension", "Dimension")
|
||||
earth_r = create(seeded, "Earth (Replacement Dimension)", "Planet", parents=[loc(replacement)])
|
||||
house = create(seeded, "Smith Residence", "Residence", parents=[loc(1), loc(earth_r)])
|
||||
r = seeded.put(f"/api/location/1/parents/{c137}")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["parents"] == [loc(c137)]
|
||||
assert r.json()["children"] == [loc(house)]
|
||||
|
||||
smith = seeded.get(f"/api/location/{house}").json()
|
||||
assert smith["parents"] == [loc(1), loc(earth_r)]
|
||||
assert smith["dimension"] == "Dimension C-137, Replacement Dimension"
|
||||
assert seeded.get(f"/api/location/{c137}").json()["dimension"] == "Dimension C-137"
|
||||
# sans ancêtre de type Dimension : valeur saisie conservée
|
||||
assert seeded.get("/api/location/3").json()["dimension"] == "unknown"
|
||||
|
||||
found = seeded.get("/api/location?dimension=replacement").json()["results"]
|
||||
assert {x["id"] for x in found} == {replacement, earth_r, house}
|
||||
|
||||
|
||||
def test_level_rules(seeded):
|
||||
city = create(seeded, "Earth City", "City", parents=[loc(1)])
|
||||
# un parent doit être de niveau strictement inférieur
|
||||
r = seeded.put(f"/api/location/1/parents/{city}")
|
||||
assert r.status_code == 422
|
||||
assert r.json() == {"error": "a Planet cannot be inside a City"}
|
||||
assert seeded.put("/api/location/1/parents/3").status_code == 422 # même niveau
|
||||
assert seeded.put("/api/location/1/parents/1").status_code == 422
|
||||
# type libre : aucune contrainte de niveau, mais pas de cycle
|
||||
micro = create(seeded, "Microverse", "Microverse", parents=[loc(city)])
|
||||
assert seeded.put(f"/api/location/1/parents/{micro}").status_code == 422 # cycle
|
||||
assert seeded.put(f"/api/location/3/parents/{micro}").status_code == 200
|
||||
|
||||
|
||||
def test_put_keeps_or_replaces_parents(seeded):
|
||||
dim = create(seeded, "Dimension C-137", "Dimension")
|
||||
seeded.put(f"/api/location/1/parents/{dim}")
|
||||
body = {"name": "Earth (C-137)", "type": "Planet", "dimension": "x"}
|
||||
assert seeded.put("/api/location/1", json=body).json()["parents"] == [loc(dim)]
|
||||
# changement de type incompatible avec le parent existant
|
||||
r = seeded.put("/api/location/1", json=body | {"type": "Multiverse"})
|
||||
assert r.status_code == 422
|
||||
r = seeded.put("/api/location/1", json=body | {"parents": []})
|
||||
assert r.json()["parents"] == [] and r.json()["dimension"] == "x"
|
||||
r = seeded.put("/api/location/1", json=body | {"parents": [f"{API}/location/999"]})
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_get_can_be_posted_back(seeded):
|
||||
dim = create(seeded, "Dimension C-137", "Dimension")
|
||||
seeded.put(f"/api/location/1/parents/{dim}")
|
||||
earth = seeded.get("/api/location/1").json()
|
||||
seeded.delete("/api/location/1", headers=ADMIN)
|
||||
r = seeded.post("/api/location", json=earth)
|
||||
assert r.status_code == 201, r.text
|
||||
assert r.json()["parents"] == [loc(dim)]
|
||||
|
||||
|
||||
def test_unlink_and_delete(seeded):
|
||||
dim = create(seeded, "Dimension C-137", "Dimension")
|
||||
seeded.put(f"/api/location/1/parents/{dim}")
|
||||
assert seeded.delete(f"/api/location/1/parents/{dim}").status_code == 401
|
||||
assert seeded.delete(f"/api/location/1/parents/{dim}", headers=ADMIN).status_code == 204
|
||||
assert seeded.get("/api/location/1").json()["parents"] == []
|
||||
seeded.put(f"/api/location/1/parents/{dim}")
|
||||
assert seeded.delete(f"/api/location/{dim}", headers=ADMIN).status_code == 204
|
||||
assert seeded.get("/api/location/1").json()["parents"] == []
|
||||
|
||||
|
||||
def test_episode_page_tree(seeded):
|
||||
dim = create(seeded, "Dimension C-137", "Dimension")
|
||||
other = create(seeded, "Earth (Replacement Dimension)", "Planet")
|
||||
house = create(seeded, "Smith Residence", "Residence", parents=[loc(1), loc(other)])
|
||||
seeded.put(f"/api/location/1/parents/{dim}")
|
||||
seeded.put(f"/api/episode/1/locations/{house}")
|
||||
html = seeded.get("/episodes/1").text
|
||||
assert "Locations (1)" in html
|
||||
# la résidence apparaît sous chacune de ses planètes, les ancêtres sont grisés
|
||||
assert html.count(f'href="/locations/{house}"') == 2
|
||||
assert html.index(f'href="/locations/{dim}"') < html.index('href="/locations/1"')
|
||||
assert html.count('class="ghost"') == 3
|
||||
page = seeded.get(f"/locations/{house}").text
|
||||
assert "Contenu dans (2)" in page and "Contient (0)" in page
|
||||
@@ -53,6 +53,8 @@ def test_residents_are_stored_not_derived(seeded):
|
||||
"created",
|
||||
"modified",
|
||||
"episodes",
|
||||
"parents",
|
||||
"children",
|
||||
]
|
||||
assert citadel["residents"] == [f"{API}/character/2", f"{API}/character/8"]
|
||||
|
||||
|
||||
@@ -44,3 +44,21 @@
|
||||
<div class="grid">{% for c in chars %}{{ character_card(c) }}{% endfor %}</div>
|
||||
{% endif %}
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro location_tree(nodes) -%}
|
||||
<ul class="tree">
|
||||
{% for n in nodes %}
|
||||
<li{% if not n.linked %} class="ghost"{% endif %}>
|
||||
<a href="/locations/{{ n.loc.id }}">{{ n.loc.name }}</a> <span class="muted">{{ n.loc.type or "—" }}</span>
|
||||
{% if n.children %}{{ location_tree(n.children) }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro locations_table(locs) -%}
|
||||
<table>
|
||||
<tr><th>Nom</th><th>Type</th><th>Dimension</th></tr>
|
||||
{% for l in locs %}<tr><td><a href="/locations/{{ l.id }}">{{ l.name }}</a></td><td>{{ l.type or "—" }}</td><td>{{ l.dimension or "—" }}</td></tr>{% endfor %}
|
||||
</table>
|
||||
{%- endmacro %}
|
||||
@@ -48,6 +48,11 @@ table.list td{padding:5px 10px}
|
||||
td.mini{width:44px}td.mini img{width:36px;height:36px;border-radius:50%;object-fit:cover;display:block}
|
||||
.tag{display:inline-block;padding:1px 8px;border-radius:10px;font-size:.8rem;background:var(--line)}
|
||||
.tag.dead{background:var(--dead);color:#fff}
|
||||
ul.tree{list-style:none;margin:0;padding:10px 14px;background:var(--card);border:1px solid var(--line);border-radius:10px}
|
||||
ul.tree ul{list-style:none;margin:2px 0 2px 7px;padding-left:18px;border-left:1px dashed var(--muted)}
|
||||
ul.tree li{padding:2px 0}
|
||||
ul.tree li.ghost>a{color:var(--muted)}
|
||||
.small{font-size:.85rem}
|
||||
@media (max-width:600px){table.list .wide{display:none}}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
+3
-5
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import characters_view, view_switch %}
|
||||
{% from "_macros.html" import characters_view, location_tree, view_switch %}
|
||||
{% block title %}{{ e.episode }} · {{ e.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<p><a href="/episodes">← Episodes</a></p>
|
||||
@@ -12,10 +12,8 @@
|
||||
</dl>
|
||||
<h2>Locations ({{ places | length }})</h2>
|
||||
{% if places %}
|
||||
<table>
|
||||
<tr><th>Nom</th><th>Type</th><th>Dimension</th></tr>
|
||||
{% for l in places %}<tr><td><a href="/locations/{{ l.id }}">{{ l.name }}</a></td><td>{{ l.type or "—" }}</td><td>{{ l.dimension or "—" }}</td></tr>{% endfor %}
|
||||
</table>
|
||||
{{ location_tree(tree) }}
|
||||
<p class="muted small">En gris : lieux englobants non rattachés à l'épisode.</p>
|
||||
{% else %}<p class="muted">Aucune.</p>{% endif %}
|
||||
<div class="head">
|
||||
<h2>Characters ({{ total }})</h2>
|
||||
|
||||
+5
-1
@@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import characters_view, view_switch %}
|
||||
{% from "_macros.html" import characters_view, locations_table, view_switch %}
|
||||
{% block title %}{{ loc.name }}{% endblock %}
|
||||
{% block content %}
|
||||
<p><a href="/locations">← Locations</a></p>
|
||||
@@ -11,6 +11,10 @@
|
||||
<dt>Modifié</dt><dd>{{ loc.modified }}</dd>
|
||||
<dt>API</dt><dd><a href="{{ loc.url }}">{{ loc.url }}</a></dd>
|
||||
</dl>
|
||||
<h2>Contenu dans ({{ parents | length }})</h2>
|
||||
{% if parents %}{{ locations_table(parents) }}{% else %}<p class="muted">Aucun.</p>{% endif %}
|
||||
<h2>Contient ({{ children | length }})</h2>
|
||||
{% if children %}{{ locations_table(children) }}{% else %}<p class="muted">Aucun.</p>{% endif %}
|
||||
<h2>Episodes ({{ episodes | length }})</h2>
|
||||
{% if episodes %}
|
||||
<table>
|
||||
|
||||
Reference in new issue
Block a user