Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,17 @@ NO_LIMITS=true
DEBUG_ENDPOINTS=true
ACTIVITY_SHOW_AUTHORIZED_PHOTOS=false
WORKER_URL=http://localhost:8056

# Panoramax federation container (optional — compose profile "panoramax").
# Password for the dedicated read-mostly DB role; on fresh clusters the role is
# created by initdb, on existing ones run backend/scripts/provision_panoramax_role.sh.
# PANORAMAX_DB_PASSWORD=change-me
# Public canonical URL registered in the meta-catalog. Its last characters must
# not be '/', 'a', 'p' or 'i' (the catalog's canonical_url() rstrip("/api")s the
# whole string) — any *.hillview.cz host is fine since 'z' isn't in that set.
# PANORAMAX_BASE_URL=https://cc.geovisio.hillview.cz
# Where "/" redirects humans arriving from the catalog's rel=via link
# PANORAMAX_VIEWER_URL=https://hillview.cz
PICS_URL=http://localhost:9999/

# Storage pools (optional). FILE_POOLS is a JSON array describing every location
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ Each subdirectory has its own `CLAUDE.md` with detailed instructions:
- **[Terrain Data Licensing](docs/terrain-data-licensing.md)**: DEM/OSM licence obligations for terrain renders (required notices, pre-launch checklist)
- **[Native Android Auth](docs/native-auth.md)**: Credential Manager + Google ID-token login — concepts, security reasoning, and where everything lives
- **[Zoom view print view](docs/zoomview-print.md)**: ⋮ → Print view + Ctrl+P — share-link QR in the middle, why the viewer freezes instead of re-rendering at print time, the replaced-element canvas gotcha
- **[Panoramax Federation](docs/panoramax-federation.md)**: The `backend/panoramax/` read API + sequencer serving CC photos to the Panoramax federation (harvester contract, deployment, registration)

## Common Issues & Solutions

Expand Down
185 changes: 185 additions & 0 deletions backend/api/app/alembic/versions/030_add_panoramax_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Add the `panoramax` PG schema: synthesized sequences for the Panoramax federation.

Hillview joins the Panoramax federation by serving a GeoVisio-compatible read
API (backend/panoramax/) whose "collections" are sequences synthesized from
users' photos by per-owner time-gap session splitting. This migration is purely
additive to the existing schema: the only touch on existing tables is an AFTER
UPDATE trigger on photos (+ FKs from the new tables).

Design constraints (from the meta-catalog harvester, see docs/panoramax-federation.md):
- Sequence ids must be real UUIDs — the meta-catalog casts `content->>'id'` to
UUID primary-key columns.
- Tombstones are never hard-deleted: a sequence that loses all members flips to
status='deleted' and must keep being served (the harvester's incremental sync
lists `status IN ('deleted','ready') AND updated > <ts>` — the updated_at bump
is the only channel through which deletions propagate to the catalog).
- owner_id is ON DELETE SET NULL so tombstones survive account deletion.
- UNIQUE(photo_id) on membership: a photo belongs to at most one sequence, which
stays correct across future scopes because scopes partition by license.
- The (scope, updated_at) index serves the harvester's incremental crawl filter.
- Membership triggers fire on cascaded deletes too (PG fires row triggers on the
referencing table when a photos hard-delete cascades), so photo hard-deletes
bump/tombstone sequences without any app-side code.

NOT here by design: backfill (the sequencer's first run does it) and role
creation (panoramax_ro is provisioning/initdb territory, not alembic — see
docker/postgres/). Grants ARE applied here, guarded on the role's existence,
because on a fresh cluster the schema doesn't exist yet at initdb time.

Revision ID: 030_add_panoramax_schema
Revises: 029_share_links
Create Date: 2026-08-05

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

revision: str = '030_add_panoramax_schema'
down_revision: Union[str, None] = '029_share_links'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("CREATE SCHEMA IF NOT EXISTS panoramax")

op.execute("""
CREATE TABLE panoramax.sequences (
id UUID PRIMARY KEY,
scope VARCHAR NOT NULL,
status VARCHAR NOT NULL DEFAULT 'ready'
CONSTRAINT sequences_status_check CHECK (status IN ('ready', 'deleted')),
owner_id VARCHAR REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
""")
# Incremental-crawl index: the harvester filters by scope-wide status +
# `updated > <ts>`; scope leads so a second instance scope stays cheap.
op.execute("""
CREATE INDEX ix_panoramax_sequences_scope_updated
ON panoramax.sequences (scope, updated_at)
""")
op.execute("""
CREATE INDEX ix_panoramax_sequences_owner
ON panoramax.sequences (owner_id)
""")

# PK is photo_id (globally unique membership). The (sequence_id, rank)
# uniqueness is DEFERRABLE so the sequencer can renumber ranks within a
# transaction without transient collisions.
op.execute("""
CREATE TABLE panoramax.sequence_photos (
photo_id VARCHAR PRIMARY KEY REFERENCES photos(id) ON DELETE CASCADE,
sequence_id UUID NOT NULL REFERENCES panoramax.sequences(id) ON DELETE CASCADE,
rank INTEGER NOT NULL,
CONSTRAINT sequence_photos_rank_unique UNIQUE (sequence_id, rank)
DEFERRABLE INITIALLY DEFERRED
)
""")
op.execute("""
CREATE INDEX ix_panoramax_sequence_photos_seq_rank
ON panoramax.sequence_photos (sequence_id, rank)
""")

# Any change to a member photo that alters what the federation sees
# (visibility, license, position, heading, capture time, derivatives,
# title/description, processing state, soft-delete) bumps the owning
# sequence's updated_at so the harvester re-crawls that collection.
# geometry is compared as text (exact EWKB hex; PostGIS `=` is bbox
# equality) and sizes as text (json has no equality operator).
op.execute("""
CREATE FUNCTION panoramax.bump_sequence_on_photo_change() RETURNS trigger AS $$
BEGIN
IF (OLD.deleted IS DISTINCT FROM NEW.deleted
OR OLD.is_public IS DISTINCT FROM NEW.is_public
OR OLD.legal_rights IS DISTINCT FROM NEW.legal_rights
OR OLD.geometry::text IS DISTINCT FROM NEW.geometry::text
OR OLD.compass_angle IS DISTINCT FROM NEW.compass_angle
OR OLD.captured_at IS DISTINCT FROM NEW.captured_at
OR OLD.effective_at IS DISTINCT FROM NEW.effective_at
OR OLD.sizes::text IS DISTINCT FROM NEW.sizes::text
OR OLD.title IS DISTINCT FROM NEW.title
OR OLD.description IS DISTINCT FROM NEW.description
OR OLD.processing_status IS DISTINCT FROM NEW.processing_status) THEN
UPDATE panoramax.sequences s
SET updated_at = now()
FROM panoramax.sequence_photos sp
WHERE sp.photo_id = NEW.id AND s.id = sp.sequence_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
""")
op.execute("""
CREATE TRIGGER panoramax_photo_change_trg
AFTER UPDATE ON photos
FOR EACH ROW EXECUTE FUNCTION panoramax.bump_sequence_on_photo_change();
""")

# Membership changes bump the sequence, and a sequence emptied by deletes is
# tombstoned (status='deleted'), never removed. Covers the sequencer's own
# writes AND cascaded deletes from photos/users hard-deletes.
op.execute("""
CREATE FUNCTION panoramax.bump_sequence_on_membership() RETURNS trigger AS $$
BEGIN
IF TG_OP IN ('INSERT', 'UPDATE') THEN
-- a sequence gaining a member is live by definition: revive
-- tombstones the sequencer repopulates, and bump updated_at
UPDATE panoramax.sequences
SET updated_at = now(), status = 'ready'
WHERE id = NEW.sequence_id;
END IF;
IF TG_OP IN ('UPDATE', 'DELETE')
AND (TG_OP = 'DELETE' OR OLD.sequence_id IS DISTINCT FROM NEW.sequence_id) THEN
UPDATE panoramax.sequences
SET updated_at = now()
WHERE id = OLD.sequence_id;
UPDATE panoramax.sequences s
SET status = 'deleted', updated_at = now()
WHERE s.id = OLD.sequence_id
AND s.status <> 'deleted'
AND NOT EXISTS (
SELECT 1 FROM panoramax.sequence_photos sp
WHERE sp.sequence_id = OLD.sequence_id
);
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
""")
op.execute("""
CREATE TRIGGER panoramax_membership_trg
AFTER INSERT OR UPDATE OR DELETE ON panoramax.sequence_photos
FOR EACH ROW EXECUTE FUNCTION panoramax.bump_sequence_on_membership();
""")

# Grants for the dedicated read-mostly role, applied only if provisioning
# already created it (fresh clusters: docker/postgres/initdb.d creates the
# role before the api container ever runs alembic; existing deployments:
# scripts/provision_panoramax_role.sh, which re-applies these grants itself).
op.execute("""
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'panoramax_ro') THEN
GRANT USAGE ON SCHEMA public TO panoramax_ro;
GRANT SELECT ON photos, users, photo_ratings, flagged_photos
TO panoramax_ro;
GRANT USAGE ON SCHEMA panoramax TO panoramax_ro;
GRANT SELECT, INSERT, UPDATE, DELETE
ON panoramax.sequences, panoramax.sequence_photos TO panoramax_ro;
END IF;
END $$;
""")


def downgrade() -> None:
op.execute("DROP TRIGGER IF EXISTS panoramax_photo_change_trg ON photos")
op.execute("DROP FUNCTION IF EXISTS panoramax.bump_sequence_on_photo_change()")
op.execute("DROP TRIGGER IF EXISTS panoramax_membership_trg ON panoramax.sequence_photos")
op.execute("DROP FUNCTION IF EXISTS panoramax.bump_sequence_on_membership()")
op.execute("DROP TABLE IF EXISTS panoramax.sequence_photos")
op.execute("DROP TABLE IF EXISTS panoramax.sequences")
op.execute("DROP SCHEMA IF EXISTS panoramax")
40 changes: 40 additions & 0 deletions backend/panoramax/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Panoramax federation read API. Build context is backend/ (like api and
# worker), same uv-export pattern as worker/Dockerfile.
FROM python:3.12-slim

RUN apt-get update && apt-get install -y \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.11.5 /uv /bin/uv

WORKDIR /app

# Workspace root + member pyproject files needed to resolve this package
COPY pyproject.toml uv.lock /app/
COPY common/pyproject.toml /app/common/
COPY panoramax/pyproject.toml /app/panoramax/

RUN uv export --frozen --no-hashes --no-emit-project --package hillview-panoramax | \
grep -v "sys_platform == 'darwin'" | \
sed "s/ ; sys_platform != 'darwin'//" > /tmp/requirements.txt && \
uv pip install --system --no-deps -r /tmp/requirements.txt && \
rm /tmp/requirements.txt

RUN groupadd --gid 1001 panoramax && \
useradd --create-home --shell /bin/bash --uid 1001 --gid 1001 panoramax

COPY panoramax/app /app/app

RUN python -m compileall -b /app/app && chown -R panoramax:panoramax /app

USER panoramax
WORKDIR /app/app

ENV PYTHONPATH="/app/app:/app"

# 0.0.0.0, not "::": under uvloop (no --reload) a "::" bind is IPv6-only,
# which dead-ends the bridge-mode port mapping (docker-proxy connects over
# IPv4). External IPv6 is Caddy's job.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8058"]
Empty file.
137 changes: 137 additions & 0 deletions backend/panoramax/app/cql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""CQL2-text `filter` parameter of /api/collections.

Parsing is delegated to pygeofilter — the same library the reference GeoVisio
server uses for its own `filter` parameters (geovisio/utils/cql2.py) — so the
grammar is the real CQL2 grammar, not a home-grown approximation. What we do
here is walk the resulting AST and accept only the subset we can execute:

status IN ('deleted','ready') AND updated > '2026-01-01T00:00:00Z'

which is the one shape the meta-catalog harvester sends (harvest.py
get_collections), plus small variations (`status = '...'`, `>=`, clauses in
any order, `TIMESTAMP('...')` literals, parentheses). Anything else — other
attributes, OR/NOT, other operators, non-literal operands — is rejected with
a FilterParseError so a client speaking more CQL than we execute fails loudly
instead of silently getting an unfiltered listing.
"""
from dataclasses import dataclass
from datetime import date, datetime, timezone
from typing import Iterator

from pygeofilter import ast
from pygeofilter.parsers.cql2_text import parse as _parse_cql2_text

VALID_STATUSES = {'ready', 'deleted'}


class FilterParseError(ValueError):
pass


@dataclass
class CollectionsFilter:
# None = clause absent (defaults applied by the caller), else the allowed set
statuses: set[str] | None = None
updated_after: datetime | None = None
updated_inclusive: bool = False


def parse_collections_filter(raw: str | None) -> CollectionsFilter:
result = CollectionsFilter()
if raw is None or raw.strip() == '':
return result
try:
tree = _parse_cql2_text(raw)
except Exception as e: # lark UnexpectedToken/UnexpectedCharacters, literal conversion errors, ...
# first line only: lark appends its full expected-token list, which is
# grammar internals, not something a 400 body should carry
reason = (str(e).strip().splitlines() or ['unparseable'])[0]
raise FilterParseError(f"malformed CQL2 filter: {reason}") from e
for clause in _conjuncts(tree):
_apply_clause(clause, result)
return result


def _conjuncts(node: ast.Node) -> Iterator[ast.Node]:
"""Flatten a (left-nested) AND tree into its top-level clauses."""
if isinstance(node, ast.And):
yield from _conjuncts(node.lhs)
yield from _conjuncts(node.rhs)
else:
yield node


def _apply_clause(node: ast.Node, result: CollectionsFilter) -> None:
if isinstance(node, ast.In):
if _attribute(node.lhs) != 'status':
raise FilterParseError(f"unsupported attribute in IN clause: {_describe(node.lhs)}")
if node.not_:
raise FilterParseError("NOT IN is not supported")
_set_statuses(result, {_status_literal(v) for v in node.sub_nodes})
elif isinstance(node, ast.Equal):
if _attribute(node.lhs) != 'status':
raise FilterParseError(f"unsupported attribute in = clause: {_describe(node.lhs)}")
_set_statuses(result, {_status_literal(node.rhs)})
elif isinstance(node, (ast.GreaterThan, ast.GreaterEqual)):
if _attribute(node.lhs) != 'updated':
raise FilterParseError(f"unsupported attribute in comparison: {_describe(node.lhs)}")
if result.updated_after is not None:
raise FilterParseError("duplicate updated clause")
result.updated_after = _timestamp_literal(node.rhs)
result.updated_inclusive = isinstance(node, ast.GreaterEqual)
else:
raise FilterParseError(f"unsupported filter clause: {_describe(node)}")


def _set_statuses(result: CollectionsFilter, statuses: set[str]) -> None:
if result.statuses is not None:
raise FilterParseError("duplicate status clause")
if not statuses:
raise FilterParseError("empty status list")
result.statuses = statuses


def _attribute(node: object) -> str | None:
"""Name of an attribute operand (case-folded), or None for anything else."""
if isinstance(node, ast.Attribute):
return node.name.lower()
return None


def _status_literal(value: object) -> str:
# pygeofilter hands quoted strings through as plain str; an unquoted word
# comes back as an Attribute node, a number as int/float
if not isinstance(value, str):
raise FilterParseError(f"status must be a quoted string literal, got {_describe(value)}")
if value not in VALID_STATUSES:
raise FilterParseError(f"unknown status: {value!r}")
return value


def _timestamp_literal(value: object) -> datetime:
"""A quoted ISO-8601 string (what the harvester sends) or a CQL2
TIMESTAMP('...') literal, which pygeofilter already turns into a datetime."""
if isinstance(value, datetime):
dt = value
elif isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
except ValueError:
raise FilterParseError(f"unparseable timestamp: {value!r}")
else:
# includes date (DATE('...') has no time part, too coarse for a crawl
# cursor) and arithmetic trees such as an unquoted 2026-01-01
raise FilterParseError(f"timestamp must be a quoted string or TIMESTAMP() literal, got {_describe(value)}")
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)


def _describe(node: object) -> str:
if isinstance(node, ast.Attribute):
return f"attribute {node.name!r}"
if isinstance(node, ast.Node):
return type(node).__name__
if isinstance(node, (str, int, float, date, datetime)):
return repr(node)
return type(node).__name__
Loading