location in location
build / build (push) Successful in 1m6s

This commit is contained in:
hcornet committed 2026-09-22 13:34:17 +02:00
1 parent 913093f3ae
commit 9ab0db2ae9
13 files changed
+437 -18

No files matched your search

+19 -1
View File
@@ -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).
+136
View File
@@ -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
+28
View File
@@ -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):
+66 -8
View File
@@ -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(
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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))
+106
View File
@@ -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
+2
View File
@@ -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"]
+18
View File
@@ -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 %}
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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>