From 9ab0db2ae996d5c3e152b12859ebfe1094686a2d Mon Sep 17 00:00:00 2001 From: hcornet Date: Tue, 22 Sep 2026 13:34:17 +0200 Subject: [PATCH] location in location --- README.md | 20 +++++- app/hierarchy.py | 136 +++++++++++++++++++++++++++++++++++++++ app/models.py | 28 ++++++++ app/routers/locations.py | 74 ++++++++++++++++++--- app/schemas.py | 7 ++ app/serializers.py | 5 +- app/web.py | 40 +++++++++++- tests/test_hierarchy.py | 106 ++++++++++++++++++++++++++++++ tests/test_read.py | 2 + web/_macros.html | 18 ++++++ web/base.html | 5 ++ web/episode.html | 8 +-- web/location.html | 6 +- 13 files changed, 437 insertions(+), 18 deletions(-) create mode 100644 app/hierarchy.py create mode 100644 tests/test_hierarchy.py diff --git a/README.md b/README.md index c766600..e58286a 100644 --- a/README.md +++ b/README.md @@ -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 `//`. -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). diff --git a/app/hierarchy.py b/app/hierarchy.py new file mode 100644 index 0000000..7ded034 --- /dev/null +++ b/app/hierarchy.py @@ -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 diff --git a/app/models.py b/app/models.py index 50f25da..9c73dfa 100644 --- a/app/models.py +++ b/app/models.py @@ -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): diff --git a/app/routers/locations.py b/app/routers/locations.py index 04a82cb..01797d0 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -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( diff --git a/app/schemas.py b/app/schemas.py index 404422f..d7b0f0a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/app/serializers.py b/app/serializers.py index 15ba162..0f83128 100644 --- a/app/serializers.py +++ b/app/serializers.py @@ -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)], ) diff --git a/app/web.py b/app/web.py index 3498da2..930271a 100644 --- a/app/web.py +++ b/app/web.py @@ -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)) diff --git a/tests/test_hierarchy.py b/tests/test_hierarchy.py new file mode 100644 index 0000000..52f8ebc --- /dev/null +++ b/tests/test_hierarchy.py @@ -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 diff --git a/tests/test_read.py b/tests/test_read.py index f30efff..b382186 100644 --- a/tests/test_read.py +++ b/tests/test_read.py @@ -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"] diff --git a/web/_macros.html b/web/_macros.html index 7998e70..c59294d 100644 --- a/web/_macros.html +++ b/web/_macros.html @@ -44,3 +44,21 @@
{% for c in chars %}{{ character_card(c) }}{% endfor %}
{% endif %} {%- endmacro %} + +{% macro location_tree(nodes) -%} +
    + {% for n in nodes %} + + {{ n.loc.name }} {{ n.loc.type or "—" }} + {% if n.children %}{{ location_tree(n.children) }}{% endif %} + + {% endfor %} +
+{%- endmacro %} + +{% macro locations_table(locs) -%} + + + {% for l in locs %}{% endfor %} +
NomTypeDimension
{{ l.name }}{{ l.type or "—" }}{{ l.dimension or "—" }}
+{%- endmacro %} diff --git a/web/base.html b/web/base.html index 85376bb..67a3d4e 100644 --- a/web/base.html +++ b/web/base.html @@ -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}} diff --git a/web/episode.html b/web/episode.html index bf07520..bda1a36 100644 --- a/web/episode.html +++ b/web/episode.html @@ -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 %}

← Episodes

@@ -12,10 +12,8 @@

Locations ({{ places | length }})

{% if places %} - - - {% for l in places %}{% endfor %} -
NomTypeDimension
{{ l.name }}{{ l.type or "—" }}{{ l.dimension or "—" }}
+{{ location_tree(tree) }} +

En gris : lieux englobants non rattachés à l'épisode.

{% else %}

Aucune.

{% endif %}

Characters ({{ total }})

diff --git a/web/location.html b/web/location.html index ce17aca..0b204d7 100644 --- a/web/location.html +++ b/web/location.html @@ -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 %}

← Locations

@@ -11,6 +11,10 @@
Modifié
{{ loc.modified }}
API
{{ loc.url }}
+

Contenu dans ({{ parents | length }})

+{% if parents %}{{ locations_table(parents) }}{% else %}

Aucun.

{% endif %} +

Contient ({{ children | length }})

+{% if children %}{{ locations_table(children) }}{% else %}

Aucun.

{% endif %}

Episodes ({{ episodes | length }})

{% if episodes %}