getting started with backend and uploader

This commit is contained in:
dreamer committed 2026-10-13 16:54:05 +02:00
commit 1e1000268a
50 files changed
+2237

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
*.db
*.venv/
*.env
*cache*
+21
View File
@@ -0,0 +1,21 @@
# FDH View
## Description
Filmhuis Den Haag schedule viewer using FastAPI and SQLModel.
Ingests a ScreenWriter XML file with theater screening schedule.
Backend has an API with a main ticketing booth overview and theater hall specific endpoints.
The endpoint serves the 10 upcoming screenings for the day. The frontend automatically refreshes to update its view with the current information.
## Run
`poetry run uvicorn fdhview.main:app --reload`
## Endpoints
* GET `/admin` - upload form protected with Basic Auth.
* POST `/admin/upload` - upload endpoint protected with Basic Auth
* GET `/api/screenings` - serves JSON with upcoming 10 films for today
+45
View File
@@ -0,0 +1,45 @@
# ADR-001 Initial Architecture
Date: 2026-10-13
## Context
Filmhuis Den Haag (FDH) has a display above the ticket counter that shows ticket pricing information and upcoming screenings with ticket sales. Currently it's running a proprietary system that is leased from a third party. Since FDH already has all feature and ticket information available, as part of a weekly ScreenWriter XML, the desire is to have their own solution to replace the proprietary system.
It needs to display both ticket pricing info and upcoming features with information like the start and end times, KijkWijzer ratings, theater room, and other useful data.
## Decision
A FastAPI backend will be able to ingest the ScreenWriter XML and collect all feature information into a simple database. Subsequently for ticketing info a JSON endpoint will serve the next 10 upcoming features. A simple frontend page can automatically refresh every couple minutes in order to display the latest valid information.
When a new XML is uploaded it replaces all information in the database with the new data. This way the backend only holds currently relevant information.
The upload endpoints will be secured using a basic auth password.
For each screening at least the following information is stored:
* unique session id - int, as reference
* screen - which screen/room is it being displayed
* start time - timestamp
* end time - timestamp
* duration - in seconds
* title - str
* rating - int
* seats_total - int, called seats_avaialble in XML
* seats_sold - int
* en_subs - bool, if the feature has english subtitles
* intro - bool, if there is an introductory talk
* outro - bool, if there is an after talk
* kijkwijzer - list of kijkwijzer ids
* tags - any additional tags that may be useful
## MVP Definition
An authenticated user needs to be able to log into the upload page and provide an XML with data. Any (parsing) errors will be displayed to the user if there are issues.
A simple browser client connected to a display can then open a html page that will automatically refresh every couple minutes with updated/current information.
## Future Improvements
* add display endpoints for each of the theater rooms with current and upcoming feature information
* be able to synchronize with a ticketing backend without the user having to upload an XML
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse, JSONResponse
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.api.deps import check_auth, get_session
from fdhview.services.screenwriter.importer import import_sessions
from fdhview.services.screenwriter.parser import XmlParseError, parse_screenwriter_xml
BASE_DIR = Path(__file__).resolve().parent.parent
router = APIRouter(
prefix="/admin",
tags=["Admin"],
dependencies=[Depends(check_auth)]
)
@router.get("", response_class=FileResponse)
async def upload_form():
return FileResponse(BASE_DIR / "html" / "upload.html")
@router.post("/upload", response_class=JSONResponse)
async def upload_file(
file: UploadFile = File(...),
session: AsyncSession = Depends(get_session)
):
if not file.filename or not file.filename.lower().endswith(".xml"):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="File must be an XML file"
)
contents = await file.read()
if len(contents) > 10 * 1024 * 1024:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="File too large"
)
try:
aampos = parse_screenwriter_xml(contents)
except XmlParseError as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(e)
)
await import_sessions(aampos, session=session)
return JSONResponse(content={"filename": file.filename, "size": len(contents)})
+25
View File
@@ -0,0 +1,25 @@
import secrets
from collections.abc import AsyncGenerator
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.core.config import settings
from fdhview.core.database import async_session
security = HTTPBasic()
async def get_session() -> AsyncGenerator[AsyncSession]:
async with async_session() as session:
yield session
def check_auth(credentials: HTTPBasicCredentials = Depends(security)):
if not secrets.compare_digest(credentials.password.encode(), settings.api_password.encode()):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.api.deps import get_session
from fdhview.core.config import settings
from fdhview.models.screening import Screening
from fdhview.schemas.screening import ScreeningRead
router = APIRouter(prefix="/screenings", tags=["Screenings"])
@router.get(
"",
response_model=list[ScreeningRead],
summary="Get upcoming screenings for the infodesk"
)
async def get_infodesk_screenings(
session: AsyncSession = Depends(get_session)
) -> list[ScreeningRead]:
now = datetime.now(tz=ZoneInfo(settings.timezone))
query = (
select(Screening)
.where(Screening.start > now)
.order_by(col(Screening.start))
.limit(10)
)
screenings = await session.exec(query)
return [ScreeningRead.model_validate(s) for s in screenings.all()]
@router.get(
"/prices",
response_model=dict,
summary="Get pricing information"
)
async def get_screening_prices() -> dict:
return settings.prices
View File
Whitespace-only changes.
+29
View File
@@ -0,0 +1,29 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
debug: bool = False
api_password: str = "testertje"
sql_file_name: str = "database.db"
timezone: str = "Europe/Amsterdam"
prices: dict = {
"prijzen": [
{"label": "Standaard", "price": "€ 12,50"},
{"label": "Dagtarief (ma–vr tot 16u)", "price": "€ 11,00"},
{"label": "Cineville", "price": "Gratis"},
{"label": "Lange film (>180 min)", "price": "€ 15,00"},
{"label": "70MM film", "price": "€ 15,00"},
],
"kortingsprijzen": [
{"label": "Jongeren (t/m 26 jaar)", "price": "€ 10,00"},
{"label": "Student Nights", "price": "€ 5,00"},
{"label": "CJP", "price": "€ 10,00"},
{"label": "Vrienden", "price": "€ 9,50"},
{"label": "Kinderfilm (t/m 11 jaar)", "price": "€ 8,00"},
{"label": "Ooievaarspas", "price": "€ 6,25"},
{"label": "De Volharding", "price": "€ 9,00"},
],
}
settings = Settings()
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.core.config import settings
sqlite_url = f"sqlite+aiosqlite:///{settings.sql_file_name}"
engine = create_async_engine(sqlite_url, echo=settings.debug)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
+43
View File
@@ -0,0 +1,43 @@
<html>
<head>
<title>Upload Schedule</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css">
</head>
<body style="font-family: sans-serif; max-width: 480px; margin: 40px auto;">
<img src="/static/Logo.svg" alt="Logo" style="filter: invert(1);">
<br>
<br>
<h1>Upload a schedule</h1>
<form id="upload-form">
<input type="file" name="file" required>
<button type="submit">Upload</button>
</form>
<p id="message"></p>
<script>
const form = document.getElementById("upload-form");
const message = document.getElementById("message");
form.onsubmit = async (e) => {
e.preventDefault();
message.textContent = "Uploading...";
try {
const res = await fetch("/admin/upload", { method: "POST", body: new FormData(form) });
const data = await res.json();
if (res.ok) {
message.style.color = "green";
message.textContent = `Uploaded ${data.filename} (${data.size} bytes).`;
} else {
message.style.color = "red";
message.textContent = data.detail;
}
} catch {
message.style.color = "red";
message.textContent = "Upload failed (network error).";
}
};
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fdhview.api.admin import router as admin_router
from fdhview.api.screenings import router as screenings_router
from fdhview.core.database import init_db
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Runs on startup: ensure DB tables exist"""
await init_db()
yield
app = FastAPI(title="Filmhuis Den Haag Signage", lifespan=lifespan)
BASE_DIR = Path(__file__).resolve().parent
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
app.include_router(admin_router)
app.include_router(screenings_router, prefix="/api")
View File
Whitespace-only changes.
+28
View File
@@ -0,0 +1,28 @@
from enum import Enum
class KijkWijzer(str, Enum):
ZES = "6"
NEGEN = "9"
TWAALF = "12"
VEERTIEN = "14"
ZESTIEN = "16"
ACHTTIEN = "18"
ALLE = "al"
ANGST = "angst"
DISCRIMINATIE = "discriminatie"
DRUGS = "drugs-en-alcohol"
GROF = "taal"
GEDRAG = "gevaarlijk-gedrag"
GEWELD = "geweld"
SEKS = "seks"
class Screen(str, Enum):
ONE = "1"
TWO = "2"
THREE = "3"
FOUR = "4"
FIVE = "5"
SIX = "6"
TEN = "10"
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
import sqlalchemy as sa
from pydantic import field_serializer, field_validator
from sqlmodel import Field, SQLModel
from fdhview.models.enums import KijkWijzer, Screen
class Screening(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
session: int
screen: Screen = Field(default=Screen.ONE, sa_column=sa.Column(sa.Enum(Screen)))
complex_ident: str
start: datetime
end: datetime
duration: int
title: str
rating: int
seats_total: int
seats_sold: int
en_subs: bool = False
intro: bool = False
outro: bool = False
kijkwijzer: list[KijkWijzer] = Field(default_factory=list, sa_type=sa.JSON)
tags: list[str] = Field(default_factory=list, sa_type=sa.JSON)
@field_validator("kijkwijzer", mode="before")
@classmethod
def _coerce(cls, v):
return [r.value if isinstance(r, KijkWijzer) else r for r in v]
@field_serializer("kijkwijzer")
def _serialize(self, v: list[KijkWijzer]) -> list[str]:
return [r.value if isinstance(r, KijkWijzer) else r for r in v]
+60
View File
@@ -0,0 +1,60 @@
from fdhview.models.enums import KijkWijzer, Screen
screens = {
'DenHaag_zaal_1': Screen.ONE,
'DenHaag_zaal_2': Screen.TWO,
'DenHaag_zaal_3': Screen.THREE,
'DenHaag_zaal_4': Screen.FOUR,
'DenHaag_zaal_5': Screen.FIVE,
'DenHaag_zaal_6': Screen.SIX,
'Filmhuis Den Haag 10 (buitenbios)': Screen.TEN
}
kijkwijzer = {
'vanaf 6 jaar': KijkWijzer.ZES,
'vanaf 9 jaar': KijkWijzer.NEGEN,
'vanaf 12 jaar': KijkWijzer.TWAALF,
'vanaf 14 jaar': KijkWijzer.VEERTIEN,
'vanaf 16 jaar': KijkWijzer.ZESTIEN,
'vanaf 18 jaar': KijkWijzer.ACHTTIEN,
'Alle leeftijden': KijkWijzer.ALLE,
'Angst': KijkWijzer.ANGST,
'Discriminatie': KijkWijzer.DISCRIMINATIE,
'Drugs- en/of alcoholmisbruik': KijkWijzer.DRUGS,
'Grof taalgebruik': KijkWijzer.GROF,
'Gevaarlijk gedrag': KijkWijzer.GEDRAG,
'Geweld': KijkWijzer.GEWELD,
'Seks': KijkWijzer.SEKS
}
extra_tags = [
'2D',
'Action',
'Adventure',
'Animation',
'Biographie',
'Comedy',
'Crime',
'Documentary',
'Drama',
'EN subs',
'Family film',
'Family',
'Fantasy',
'Festival',
'Horror',
'Kids film',
'Kids',
'LGBTQ+',
'Met muziek',
'Romantic',
'Science fiction',
'Sciencefiction',
'Thriller',
'Voorfilm',
'War',
'with after talk',
'with introduction'
]
View File
Whitespace-only changes.
+21
View File
@@ -0,0 +1,21 @@
from datetime import datetime
from sqlmodel import SQLModel
from fdhview.models.enums import KijkWijzer, Screen
class ScreeningRead(SQLModel):
screen: Screen
start: datetime
end: datetime
duration: int
title: str
rating: int
seats_total: int
seats_sold: int
en_subs: bool = False
intro: bool = False
outro: bool = False
kijkwijzer: list[KijkWijzer] = []
tags: list[str] = []
View File
Whitespace-only changes.
Whitespace-only changes.
+96
View File
@@ -0,0 +1,96 @@
from sqlmodel import delete
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.models.enums import Screen
from fdhview.models.screening import KijkWijzer, Screening
from fdhview.models.tags import kijkwijzer, screens
from fdhview.services.screenwriter.schema import AAMPos
# Delimiters used to attach notes to titles
DASH_DELIMITERS = (" - ", " – ", " — ", "- ")
# Explicit list of known screening notes (exact matches only)
KNOWN_SCREENING_NOTES = frozenset({
# Subtitles
"en subs",
"en sub",
"nl subs",
"nl sub",
# Events & talks
"met inleiding",
"met nagesprek",
"met inleiding en nagesprek",
"met voorfilms en nagesprek",
# Combined notes
"en subs met inleiding",
"en subs en nagesprek",
})
def _clean_title(title: str) -> str:
"""Peel off trailing presentation notes using an explicit blacklist."""
for dash in DASH_DELIMITERS:
while dash in title:
prefix, _, suffix = title.rpartition(dash)
# Exact string check (case-insensitive and trimmed)
if suffix.strip().lower() in KNOWN_SCREENING_NOTES:
title = prefix.strip()
else:
break
return title.strip()
async def import_sessions(data: AAMPos, session: AsyncSession):
# purge old rows
await session.exec(delete(Screening))
# add new screenings from data
for screening in data.sessions.root:
kijk: list[KijkWijzer] = []
for k in kijkwijzer:
if k in screening.attributes:
kijk.append(kijkwijzer[k])
screening.attributes.remove(k)
if "EN subs" in screening.attributes:
en_subs = True
screening.attributes.remove("EN subs")
else:
en_subs = False
if "with introduction" in screening.attributes:
intro = True
screening.attributes.remove("with introduction")
else:
intro = False
if "with after talk" in screening.attributes:
outro = True
screening.attributes.remove("with after talk")
else:
outro = False
new_screening = Screening(
session=screening.session_id,
screen=screens.get(screening.screen_identifier, Screen.ONE),
complex_ident=screening.complex_identifier,
start=screening.start,
end=screening.end,
duration=screening.feature_duration,
title=_clean_title(screening.title),
rating=screening.rating,
seats_total=screening.seats_available,
seats_sold=screening.seats_sold,
en_subs=en_subs,
intro=intro,
outro=outro,
kijkwijzer=kijk,
tags=screening.attributes
)
session.add(new_screening)
await session.commit()
+17
View File
@@ -0,0 +1,17 @@
from xml.etree import ElementTree as ET
from pydantic import ValidationError
from fdhview.services.screenwriter.schema import AAMPos
class XmlParseError(Exception):
"""Raised when an XML is malformed or violates the ScreenWriter schema."""
def parse_screenwriter_xml(content: bytes | str) -> AAMPos:
"""Parser raw ScreenWriter XML into validated Pydantic models."""
try:
return AAMPos.from_xml(content)
except (ValidationError, ET.ParseError) as e:
raise XmlParseError(f"Failed to parse ScreenWriter XML: {e}") from e
+34
View File
@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Annotated
from zoneinfo import ZoneInfo
from pydantic import BeforeValidator
from pydantic_xml import BaseXmlModel, RootXmlModel, element, wrapped
from fdhview.core.config import settings
DT = Annotated[datetime, BeforeValidator(lambda v: datetime.strptime(v, "%Y%m%d%H%M").replace(
tzinfo=ZoneInfo(settings.timezone)
))]
class Session(BaseXmlModel, tag='session'):
session_id: int = element(tag='id')
screen_identifier: str = element()
complex_identifier: str = element()
start: DT = element()
end: DT = element()
feature_duration: int = element()
title: str = element()
rating: int = element()
seats_available: int = element()
seats_sold: int = element()
attributes: list[str] = wrapped('session_attributes', element(tag='session_attribute'))
class Sessions(RootXmlModel, tag='sessions'):
root: list[Session]
class AAMPos(BaseXmlModel, tag='AAMPos'):
sessions: Sessions = element()
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+19
View File
@@ -0,0 +1,19 @@
<svg width="180" height="68" viewBox="0 0 180 68" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M158.687 19.1935C158.687 22.8129 160.895 24.7871 164.098 24.7871C166.638 24.7871 168.625 23.1419 168.625 20.729C168.625 17.9871 166.086 17 164.208 16.3419C159.128 14.5871 155.043 13.0516 155.043 7.56774C155.043 2.96129 159.018 0 163.656 0C168.625 0 172.49 3.50968 172.49 8.66452H167.963C167.963 5.92258 166.417 4.05806 163.767 4.05806C161.558 4.05806 159.57 5.26452 159.57 7.56774C159.57 9.76129 161.558 10.8581 163.656 11.6258C167.963 13.1613 173.153 15.0258 173.153 20.2903C173.153 25.9935 168.846 28.9548 163.656 28.9548C158.576 28.9548 154.159 25.4452 154.159 19.3032H158.687V19.1935Z" fill="white"/>
<path d="M38.0981 0.658203H33.3496V28.1872H38.0981V0.658203Z" fill="white"/>
<path d="M56.9819 24.1292V28.1872H43.8408V0.658203H48.3684V24.1292H56.9819Z" fill="white"/>
<path d="M87.2393 28.1872H82.7117V11.1872L75.7547 28.1872H72.3313L65.3743 11.1872V28.1872H60.8467V0.658203H65.2639L74.0982 21.1679L82.9326 0.658203H87.2393V28.1872Z" fill="white"/>
<path d="M109.104 16.5614H97.6194V28.1872H93.0918V0.658203H97.6194V12.5034H109.104V0.658203H113.632V28.1872H109.104V16.5614Z" fill="white"/>
<path d="M119.264 0.658203H123.791V18.9743C123.791 22.155 125.889 24.7872 129.202 24.7872C132.515 24.7872 134.724 22.155 134.724 18.9743V0.658203H139.251V18.9743C139.251 24.3485 135.276 28.8453 129.202 28.8453C123.239 28.8453 119.264 24.3485 119.264 18.9743V0.658203Z" fill="white"/>
<path d="M149.522 0.658203H144.773V28.1872H149.522V0.658203Z" fill="white"/>
<path d="M8.94479 39.8135C18.1104 39.8135 23.3006 46.6135 23.3006 53.6328C23.3006 60.5425 18.1104 67.4522 8.94479 67.4522H0V39.8135H8.94479ZM4.52761 43.8715V63.2844H8.94479C15.1288 63.2844 18.6626 58.4586 18.6626 53.5232C18.6626 48.5877 15.1288 43.7619 8.94479 43.7619H4.52761V43.8715Z" fill="white"/>
<path d="M32.1351 43.8715V51.6586H43.5093V55.7167H32.1351V63.2844H43.5093V67.3425H27.4971V39.8135H43.5093V43.8715H32.1351Z" fill="white"/>
<path d="M64.6011 59.4457V39.8135H69.1287V67.3425H64.1594L53.2269 47.7102V67.3425H48.5889V39.8135H53.6686L64.6011 59.4457Z" fill="white"/>
<path d="M95.9634 55.7167H84.5892V67.3425H79.9512V39.8135H84.5892V51.6586H95.9634V39.8135H100.601V67.3425H95.9634V55.7167Z" fill="white"/>
<path d="M120.921 67.3425L119.706 63.8328H109.325L108.111 67.3425H103.362L112.97 39.8135H116.172L125.779 67.3425H120.921ZM114.516 47.3812L110.651 59.8844H118.27L114.516 47.3812Z" fill="white"/>
<path d="M144.332 67.3425L143.117 63.8328H132.737L131.522 67.3425H126.773L136.381 39.8135H139.583L149.191 67.3425H144.332ZM137.927 47.3812L134.062 59.8844H141.681L137.927 47.3812Z" fill="white"/>
<path d="M166.859 53.5223H180V57.5804H176.908C175.693 63.7223 171.166 67.9998 163.436 67.9998C154.381 67.9998 149.19 60.9804 149.19 53.5223C149.19 46.0643 154.712 39.0449 163.436 39.0449C169.951 39.0449 174.81 43.103 175.252 47.9288H170.614C170.172 45.1869 167.301 43.103 163.436 43.103C157.473 43.103 153.828 48.1481 153.828 53.5223C153.828 58.8965 157.141 63.9417 163.988 63.9417C168.736 63.9417 171.497 60.9804 172.049 57.5804H166.749V53.5223H166.859Z" fill="white"/>
<path d="M4.52761 0.658203H0V28.1872H4.52761V0.658203Z" fill="white"/>
<path d="M23.0801 0.658203H9.16602V5.15498H23.0801V0.658203Z" fill="white"/>
<path d="M27.6077 9.76172H9.16602V14.2585H27.6077V9.76172Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+69
View File
@@ -0,0 +1,69 @@
:root {
--bg: #dfe2e9;
--bg-side: #d6dae2;
--ink: #111318;
--muted: #8a8f99;
--rule: #9aa0aa;
--badge: #101318;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
font-family: "Poppins", "Futura", sans-serif;
background: var(--bg);
color: var(--ink);
overflow: hidden;
}
.screen { display: grid; grid-template-columns: 1fr 340px; height: 100vh; }
/* ---------- left: film list ---------- */
.programme { padding: 28px 40px 40px 48px; display: flex; flex-direction: column; }
.programme h1 {
font-size: 92px; font-weight: 600; letter-spacing: 6px;
text-transform: uppercase; line-height: 1; margin-bottom: 18px;
}
.film {
display: grid;
grid-template-columns: 90px 60px 1fr 170px;
gap: 12px; align-items: start;
padding: 16px 0 18px;
border-top: 1px solid var(--rule);
}
.times { line-height: 1.25; }
.times .start { font-size: 20px; font-weight: 600; }
.times .end { font-size: 20px; color: var(--muted); }
.icons { display: flex; gap: 6px; padding-top: 3px; }
.icons .dot {
width: 26px; height: 26px; border-radius: 50%;
background: var(--badge); color: #fff;
font-size: 13px; font-weight: 600;
display: flex; align-items: center; justify-content: center;
}
.info { padding-top: 3px; }
.info .title { font-size: 22px; font-weight: 500; }
.info .meta {
margin-top: 14px; font-size: 13px; font-weight: 600;
letter-spacing: 1.5px; text-transform: uppercase;
display: flex; gap: 18px; align-items: center;
}
.pill {
background: #b9bfc9; border-radius: 14px;
padding: 4px 14px; font-size: 12px; font-weight: 600;
letter-spacing: 1px; text-transform: uppercase;
}
.badge-last {
justify-self: end; align-self: start; margin-top: 3px;
background: var(--badge); color: #fff;
font-size: 11px; font-weight: 600; letter-spacing: 1.5px;
padding: 6px 12px; text-transform: uppercase; white-space: nowrap;
}
/* ---------- right: sidebar ---------- */
.sidebar { background: var(--bg-side); padding: 30px 36px; display: flex; flex-direction: column; }
.sidebar .top { display: flex; justify-content: space-between; align-items: flex-start; }
.sidebar .date { font-size: 44px; font-weight: 600; letter-spacing: 4px; }
.sidebar .logo { font-size: 13px; font-weight: 600; letter-spacing: 1px; text-align: right; line-height: 1.3; text-transform: uppercase; }
.sidebar h2 { margin-top: 56px; font-size: 20px; font-weight: 600; margin-bottom: 14px; }
.price-row { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 9px; font-size: 17px; }
.price-row .price { white-space: nowrap; }
.sidebar h3 { margin-top: 34px; font-size: 17px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; margin-bottom: 14px; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Generated
+1181
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
[project]
name = "fdhview"
version = "0.1.0"
description = "Filmhuis Den Haag schedule viewer"
authors = [
{name = "Your Name",email = "you@example.com"}
]
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"fastapi (>=0.141.1,<0.142.0)",
"pydantic-xml (>=2.21.1,<3.0.0)",
"sqlmodel (>=0.0.42,<0.0.43)",
"uvicorn (>=0.53.0,<0.54.0)",
"python-multipart (>=0.0.32,<0.0.33)",
"pydantic-settings (>=2.15.0,<3.0.0)",
"aiosqlite (>=0.22.1,<0.23.0)"
]
[dependency-groups]
dev = [
"pytest (>=9.1.1,<10.0.0)",
"mypy (>=2.3.1,<3.0.0)",
"ruff (>=0.16.6,<0.17.0)",
"httpx2 (>=2.13.1,<3.0.0)",
"pytest-asyncio (>=1.4.0,<2.0.0)",
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.ruff.lint.flake8-bugbear]
extend-immutable-calls = ["fastapi.Depends", "fastapi.File", "fastapi.Query", "fastapi.Body", "fastapi.Header", "fastapi.Path"]
+30
View File
@@ -0,0 +1,30 @@
from pathlib import Path
from fdhview.services.screenwriter.schema import AAMPos
def read_xml():
# xml = Path('tests/data/ScreenWriter_202608181146.xml').read_text()
xml = Path('tests/data/minimal.xml').read_text()
data = AAMPos.from_xml(xml)
unique_tags = set()
unique_screen = set()
for session in data.sessions.root:
for tag in session.attributes:
unique_tags.add(tag)
print(f'Unique tags: {unique_tags}')
for session in data.sessions.root:
unique_screen.add(session.screen_identifier)
print(f'Unique screen: {unique_screen}')
for session in data.sessions.root:
print(f'Session start: {session.start}, end: {session.end}')
read_xml()
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
import asyncio
from collections.abc import AsyncGenerator, Iterator
import pytest
import pytest_asyncio
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlmodel import SQLModel
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.api.deps import get_session
from fdhview.main import app
test_engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
test_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
@pytest.fixture(autouse=True)
def setup_test_db() -> Iterator[None]:
"""Creates tables before each test and drops them after."""
async def _setup():
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.create_all)
async def _teardown():
async with test_engine.begin() as conn:
await conn.run_sync(SQLModel.metadata.drop_all)
asyncio.run(_setup())
yield
asyncio.run(_teardown())
@pytest.fixture
def client() -> Iterator[TestClient]:
"""Test client that uses the in-memory database."""
async def override_get_session():
async with test_session_maker() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
with TestClient(app) as test_client:
yield test_client
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession]:
"""Async session for tests that need to seed or query data directly."""
async with test_session_maker() as session:
yield session
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<AAMPos xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="pos_schema.xsd">
<sessions>
<session>
<id>12065371</id>
<screen_identifier>DenHaag_zaal_3</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608201400</start>
<end>202608201534</end>
<feature_duration>5280</feature_duration>
<title>Mulan - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>40</seats_available>
<seats_sold>fourteen</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Angst</session_attribute>
<session_attribute>Animation</session_attribute>
<session_attribute>Geweld</session_attribute>
<session_attribute>vanaf 6 jaar</session_attribute>
</session_attributes>
</session>
<session>
<id>12065374</id>
<screen_identifier>DenHaag_zaal_1</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608212030</start>
<end>202608212339</end>
<feature_duration>10980</feature_duration>
<title>Apocalypse Now: The Final Cut - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>98</seats_available>
<seats_sold>56</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Geweld</session_attribute>
<session_attribute>Grof taalgebruik</session_attribute>
<session_attribute>vanaf 16 jaar</session_attribute>
<session_attribute>War</session_attribute>
</session_attributes>
</session>
<session>
<id>12065375</id>
<screen_identifier>DenHaag_zaal_3</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608211815</start>
<end>202608211957</end>
<feature_duration>5760</feature_duration>
<title>Hearts of Darkness: A Filmmaker's Apocalypse - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>40</seats_available>
<seats_sold>23</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Documentary</session_attribute>
</session_attributes>
</session>
</sessions>
</AAMPos>
+59
View File
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<AAMPos xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="pos_schema.xsd">
<sessions>
<session>
<id>12065371</id>
<screen_identifier>DenHaag_zaal_3</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608201400</start>
<end>202608201534</end>
<feature_duration>5280</feature_duration>
<title>Mulan - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>40</seats_available>
<seats_sold>14</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Angst</session_attribute>
<session_attribute>Animation</session_attribute>
<session_attribute>Geweld</session_attribute>
<session_attribute>vanaf 6 jaar</session_attribute>
</session_attributes>
</session>
<session>
<id>12065374</id>
<screen_identifier>DenHaag_zaal_1</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608212030</start>
<end>202608212339</end>
<feature_duration>10980</feature_duration>
<title>Apocalypse Now: The Final Cut - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>98</seats_available>
<seats_sold>56</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Geweld</session_attribute>
<session_attribute>Grof taalgebruik</session_attribute>
<session_attribute>vanaf 16 jaar</session_attribute>
<session_attribute>War</session_attribute>
</session_attributes>
</session>
<session>
<id>12065375</id>
<screen_identifier>DenHaag_zaal_3</screen_identifier>
<complex_identifier>Filmhuis Den Haag</complex_identifier>
<start>202608211815</start>
<end>202608211957</end>
<feature_duration>5760</feature_duration>
<title>Hearts of Darkness: A Filmmaker's Apocalypse - EPIC SUMMER</title>
<rating>0</rating>
<seats_available>40</seats_available>
<seats_sold>23</seats_sold>
<session_attributes>
<session_attribute>2D</session_attribute>
<session_attribute>Documentary</session_attribute>
</session_attributes>
</session>
</sessions>
</AAMPos>
+38
View File
@@ -0,0 +1,38 @@
from pathlib import Path
from fastapi.testclient import TestClient
from fdhview.core.config import settings
AUTH = ("username", settings.api_password)
def test_upload_requires_auth(client: TestClient):
response = client.post("/admin/upload")
assert response.status_code == 401
def test_upload_requests_wrong_ext(client: TestClient):
response = client.post(
"/admin/upload",
auth=AUTH,
files={"file": ("test.txt", b"not xml", "text/plain")})
assert response.status_code == 422
def test_upload_requests_bad_xml(client: TestClient):
response = client.post(
"/admin/upload",
auth=AUTH,
files={"file": ("test.xml", b"not xml", "application/xml")})
assert response.status_code == 422
def test_upload_succeeds_with_valid_xml(client: TestClient):
minimal_xml = Path("tests/data/minimal.xml").read_bytes()
response = client.post(
"/admin/upload",
auth=AUTH,
files={"file": ("minimal.xml", minimal_xml, "application/xml")})
assert response.status_code == 200
assert response.json()['filename'] == "minimal.xml"
+49
View File
@@ -0,0 +1,49 @@
from datetime import datetime
from pathlib import Path
import pytest
from fdhview.services.screenwriter.importer import _clean_title
from fdhview.services.screenwriter.schema import AAMPos
def test_xml():
xml = Path('tests/data/minimal.xml').read_text()
data = AAMPos.from_xml(xml)
sessions = data.sessions.root
assert isinstance(sessions[1].start, datetime)
assert isinstance(sessions[1].end, datetime)
assert len(sessions[1].attributes) == 5
assert len(sessions[2].attributes) == 2
def test_broken_xml():
xml = Path('tests/data/broken.xml').read_text()
with pytest.raises(ValueError):
AAMPos.from_xml(xml)
def test_clean_title():
# Basic cases
assert _clean_title("Good Movie - EN subs") == "Good Movie"
assert _clean_title("Good Movie- EN subs") == "Good Movie"
assert _clean_title("Good Movie - en subs") == "Good Movie"
assert _clean_title("Good Movie - NL sub") == "Good Movie"
# Presentation / talk variations
assert _clean_title("Good Movie - met inleiding") == "Good Movie"
assert _clean_title("Good Movie - met inleiding en nagesprek") == "Good Movie"
assert _clean_title("Good Movie – met nagesprek") == "Good Movie"
assert _clean_title("Good Movie - met voorfilms en nagesprek") == "Good Movie"
assert _clean_title("Good Movie - EN subs met inleiding") == "Good Movie"
# Stacked suffixes
assert _clean_title("Good Movie - met inleiding - EN subs") == "Good Movie"
# Titles that should NOT be modified
assert _clean_title("Good Movie") == "Good Movie"
assert _clean_title("De la Comédie-Française") == "De la Comédie-Française"
assert _clean_title("Dikkie Dik (re-release)") == "Dikkie Dik (re-release)"
assert _clean_title("Aguirre - Epic Summer - EN subs") == "Aguirre - Epic Summer"
+53
View File
@@ -0,0 +1,53 @@
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import pytest
from sqlmodel.ext.asyncio.session import AsyncSession
from fdhview.core.config import settings
from fdhview.models.enums import Screen
from fdhview.models.screening import Screening
@pytest.mark.asyncio
async def test_infodesk_filters_past(
client, db_session: AsyncSession
):
now = datetime.now(tz=ZoneInfo(settings.timezone))
# Past film (ended 15 mins ago)
past = Screening(
session=1, screen=Screen.ONE, complex_ident="FDH",
start=now - timedelta(hours=2), end=now - timedelta(minutes=15),
duration=105, title="Past Film", rating=0, seats_total=100, seats_sold=10
)
# Currently running film (started 30 mins ago, ends in 60 mins)
running = Screening(
session=2, screen=Screen.TWO, complex_ident="FDH",
start=now - timedelta(minutes=30), end=now + timedelta(minutes=60),
duration=90, title="Running Film", rating=0, seats_total=100, seats_sold=50
)
# Upcoming film (starts in 2 hours)
upcoming = Screening(
session=3, screen=Screen.THREE, complex_ident="FDH",
start=now + timedelta(hours=2), end=now + timedelta(hours=4),
duration=120, title="Upcoming Film", rating=0, seats_total=100, seats_sold=20
)
db_session.add_all([past, running, upcoming])
await db_session.commit()
response = client.get("/api/screenings")
assert response.status_code == 200
titles = [s["title"] for s in response.json()]
assert "Past Film" not in titles
assert "Running Film" not in titles
assert "Upcoming Film" in titles
@pytest.mark.asyncio
async def test_get_screening_prices(client):
response = client.get("/api/screenings/prices")
assert response.status_code == 200
assert response.json() == settings.prices