Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4788ae7723 | |||
| 51f0066e61 | |||
| bfa6c0782a | |||
| 2f21e767f3 | |||
| f6bcf198ee | |||
| b13fafd624 | |||
| 631d050540 | |||
| d48029a407 | |||
| 18dea507d1 | |||
| 99a660485e | |||
| cf6dcf9ce2 | |||
| 22bc536978 | |||
| f2205b93f4 | |||
| b0c7c8570b | |||
| fe9a95c60d | |||
| bd8ee9b647 | |||
| 660130f007 | |||
| 34d30e3134 | |||
| 049545fcc8 | |||
| 3a14fcc4ca | |||
| fc4cb0273e | |||
| 83f83ab641 | |||
| 064bb6ea65 | |||
| fbb9d0195c | |||
| 1f25eb2f21 | |||
| d6e2df4a61 | |||
| a799d101b5 | |||
| 0b9d72c878 | |||
| 2d0635e710 | |||
| 768d1b23d4 | |||
| 11f0f79866 | |||
| b8f5c35045 | |||
| 9e6cf6e5b7 | |||
| 828445a6b3 | |||
| 4921ce0776 | |||
| 6c3a9a89aa | |||
| fccc81a6cc | |||
| a5a79f01a7 | |||
| e2edd4b2f1 | |||
| 9f8dd960f4 | |||
| 00bfe8bfca | |||
| 5123c85397 | |||
| e5a8713293 | |||
| 64388b75bf | |||
| dffd05d303 | |||
| 297cb797d6 | |||
| 03124027fe | |||
| 9e4252ba8f | |||
| 0b5c3b260a | |||
| 03aa9a3ca7 |
@@ -0,0 +1,105 @@
|
|||||||
|
name: build-backend
|
||||||
|
|
||||||
|
# Builds + pushes the backend image to justin/provenance-backend's package area
|
||||||
|
# on Gitea on every merge to main. Servers pull from git.jpaul.io.
|
||||||
|
#
|
||||||
|
# Push goes to the LAN registry endpoint 192.168.0.2:1234 (plain HTTP) to bypass
|
||||||
|
# Cloudflare's request-body limit; pulls use the public git.jpaul.io FQDN. Same
|
||||||
|
# Gitea registry either way. Mirrors the drawbar setup.
|
||||||
|
#
|
||||||
|
# Tag scheme: test-main | test-sha-<long> | <semver from pyproject> | latest (v* tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- '.gitea/workflows/build-backend.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-backend-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version from pyproject.toml
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
v=$(grep -oP '^version = "\K[^"]+' backend/pyproject.toml | head -1)
|
||||||
|
if [ -z "$v" ]; then echo "could not parse version from backend/pyproject.toml"; exit 1; fi
|
||||||
|
echo "semver=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "backend semver: $v"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# LAN registry serves plain HTTP on :1234 (git.jpaul.io is the only TLS
|
||||||
|
# endpoint, via Cloudflare). Treat the LAN endpoint as insecure so
|
||||||
|
# buildkit doesn't try to upgrade the push to HTTPS.
|
||||||
|
config-inline: |
|
||||||
|
[registry."192.168.0.2:1234"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Configure registry credentials for buildx
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.docker
|
||||||
|
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0)
|
||||||
|
cat > ~/.docker/config.json <<EOF
|
||||||
|
{
|
||||||
|
"auths": {
|
||||||
|
"192.168.0.2:1234": {
|
||||||
|
"auth": "$AUTH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: 192.168.0.2:1234/justin/provenance-backend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,prefix=test-
|
||||||
|
type=sha,prefix=test-sha-,format=long
|
||||||
|
type=raw,value=${{ steps.ver.outputs.semver }}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://git.jpaul.io/justin/provenance
|
||||||
|
org.opencontainers.image.version=${{ steps.ver.outputs.semver }}
|
||||||
|
|
||||||
|
- name: Build and push (amd64)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./backend
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- name: Link package to the provenance repo
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
code=$(curl -s -o /tmp/link.out -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://git.jpaul.io/api/v1/packages/justin/container/provenance-backend/-/link/provenance")
|
||||||
|
echo "link -> provenance: HTTP $code"
|
||||||
|
case "$code" in
|
||||||
|
201) echo "OK — newly linked" ;;
|
||||||
|
400|409) echo "OK — already linked" ;;
|
||||||
|
*) cat /tmp/link.out; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
name: build-frontend
|
||||||
|
|
||||||
|
# Builds + pushes the Next.js image to justin/provenance-frontend's package area
|
||||||
|
# on Gitea on every merge to main. Servers pull from git.jpaul.io.
|
||||||
|
#
|
||||||
|
# Push -> LAN registry 192.168.0.2:1234 (plain HTTP); pull -> git.jpaul.io.
|
||||||
|
# Mirrors the drawbar setup; see build-backend.yml for the rationale.
|
||||||
|
#
|
||||||
|
# Tag scheme: test-main | test-sha-<long> | <version from package.json> | latest (v* tags)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ['v*']
|
||||||
|
paths:
|
||||||
|
- 'frontend/**'
|
||||||
|
- '.gitea/workflows/build-frontend.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-frontend-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Extract version from package.json
|
||||||
|
id: ver
|
||||||
|
run: |
|
||||||
|
v=$(grep -oP '"version"\s*:\s*"\K[^"]+' frontend/package.json | head -1)
|
||||||
|
if [ -z "$v" ]; then echo "could not parse version from frontend/package.json"; exit 1; fi
|
||||||
|
echo "semver=$v" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "frontend version: $v"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
with:
|
||||||
|
# See build-backend.yml for why these flags are needed.
|
||||||
|
config-inline: |
|
||||||
|
[registry."192.168.0.2:1234"]
|
||||||
|
http = true
|
||||||
|
insecure = true
|
||||||
|
|
||||||
|
- name: Configure registry credentials for buildx
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ github.actor }}
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.docker
|
||||||
|
AUTH=$(printf '%s:%s' "$REGISTRY_USER" "$REGISTRY_TOKEN" | base64 -w0)
|
||||||
|
cat > ~/.docker/config.json <<EOF
|
||||||
|
{
|
||||||
|
"auths": {
|
||||||
|
"192.168.0.2:1234": {
|
||||||
|
"auth": "$AUTH"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Compute tags
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: 192.168.0.2:1234/justin/provenance-frontend
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch,prefix=test-
|
||||||
|
type=sha,prefix=test-sha-,format=long
|
||||||
|
type=raw,value=${{ steps.ver.outputs.semver }}
|
||||||
|
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.source=https://git.jpaul.io/justin/provenance
|
||||||
|
org.opencontainers.image.version=${{ steps.ver.outputs.semver }}
|
||||||
|
|
||||||
|
- name: Build and push (amd64)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ./frontend
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
|
- name: Link package to the provenance repo
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
run: |
|
||||||
|
code=$(curl -s -o /tmp/link.out -w "%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"https://git.jpaul.io/api/v1/packages/justin/container/provenance-frontend/-/link/provenance")
|
||||||
|
echo "link -> provenance: HTTP $code"
|
||||||
|
case "$code" in
|
||||||
|
201) echo "OK — newly linked" ;;
|
||||||
|
400|409) echo "OK — already linked" ;;
|
||||||
|
*) cat /tmp/link.out; exit 1 ;;
|
||||||
|
esac
|
||||||
@@ -25,6 +25,11 @@ target/
|
|||||||
dist/
|
dist/
|
||||||
build/
|
build/
|
||||||
|
|
||||||
|
# Tooling caches
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
|
|||||||
- **Object storage:** S3-compatible (MinIO for self-host).
|
- **Object storage:** S3-compatible (MinIO for self-host).
|
||||||
- **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required).
|
- **Edge:** Caddy reverse proxy; optional Cloudflare Tunnel (preferred ingress, never required).
|
||||||
- **Email:** operator-configured SMTP.
|
- **Email:** operator-configured SMTP.
|
||||||
- **CI/CD:** Gitea Actions on `git.jpaul.io` build container images to the Gitea registry; servers pull to deploy.
|
- **CI/CD:** Gitea Actions build per-component images. **Push** to the LAN registry `192.168.0.2:1234` (plain HTTP, bypasses Cloudflare's body limit); **pull** via the public `git.jpaul.io` FQDN. Servers pull to deploy — no host build. Mirrors the drawbar setup; see [[gitea-lan-push-fqdn-pull]].
|
||||||
|
|
||||||
Pick libraries consistent with this stack. If you introduce a significant dependency or a new service, note it in ARCHITECTURE.md in the same change.
|
Pick libraries consistent with this stack. If you introduce a significant dependency or a new service, note it in ARCHITECTURE.md in the same change.
|
||||||
|
|
||||||
@@ -38,13 +38,17 @@ Pick libraries consistent with this stack. If you introduce a significant depend
|
|||||||
```
|
```
|
||||||
/ # docs and project meta (this file, README, LICENSE, COC, CONTRIBUTING)
|
/ # docs and project meta (this file, README, LICENSE, COC, CONTRIBUTING)
|
||||||
/docs # PRD.md, ARCHITECTURE.md
|
/docs # PRD.md, ARCHITECTURE.md
|
||||||
|
/backend # FastAPI service (uv-managed). app/{api/v1, services (+ privacy engine), repositories, models, schemas, integrations (auth/mailer), core}; migrations/ = Alembic
|
||||||
|
/deploy # docker-compose.yml, Caddyfile, .env.example — the self-host stack
|
||||||
|
/.gitea/workflows # Gitea Actions CI (build images → Gitea registry)
|
||||||
|
/frontend # Next.js (App Router, TS, Tailwind, shadcn-style UI). app/ pages, lib/api generated OpenAPI client, components/ui
|
||||||
```
|
```
|
||||||
|
|
||||||
Code does not exist yet — Phase 0 has not landed. When you scaffold it, propose a layout (e.g. `/backend`, `/frontend`, `/deploy` for compose/Caddy) and record it here and in ARCHITECTURE.md. Keep this section current as the tree grows.
|
Phase 0 is landing **deploy-first**: the compose stack (Postgres + MinIO + Caddy + a minimal FastAPI backend exposing `/health` and `/health/ready`) and CI come before the real data model and the frontend. Backend dependencies are managed with **uv**; migrations use **Alembic**. The core data model (ARCHITECTURE §5), **local auth** (Argon2 passwords, backend-issued sessions, email verify/reset behind the `AuthProvider` interface; API auth via Bearer header or HttpOnly cookie), and the **Next.js frontend scaffold** (Tailwind + shadcn-style UI, generated OpenAPI client, auth + tree/person views) have all landed — **Phase 0 is complete and running on the live deployment.** Phase 1 (core tree features — media, soft-delete recovery, richer CRUD) is next; OIDC/social auth is Phase 5. Keep this section current as the tree grows.
|
||||||
|
|
||||||
## Where to start
|
## Where to start
|
||||||
|
|
||||||
The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation** is the current target:
|
The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Foundation is complete** and running on the live deployment; **Phase 1 (core tree features) is the current target.** For reference, Phase 0 covered:
|
||||||
|
|
||||||
1. Backend skeleton (FastAPI, async, layered) + Postgres + migrations
|
1. Backend skeleton (FastAPI, async, layered) + Postgres + migrations
|
||||||
2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support
|
2. Core data model from ARCHITECTURE §5 — start with User, Tree, TreeMembership, Person, Name, Relationship, Event, Place, Source, Citation, AuditEntry, soft-delete support
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# uv-managed Python image keeps the toolchain reproducible. Pinned to 3.13 for
|
||||||
|
# broad wheel availability (asyncpg etc.); bump when 3.14 wheels are ubiquitous.
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Dependencies first for layer caching. uv.lock is optional on first build;
|
||||||
|
# `uv sync` resolves and writes it if absent.
|
||||||
|
COPY pyproject.toml uv.lock* ./
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||||
|
uv sync --no-dev
|
||||||
|
|
||||||
|
# Application source + migrations (project is package=false, no install step).
|
||||||
|
COPY app ./app
|
||||||
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY migrations ./migrations
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uv", "run", "--no-dev", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# Alembic config. The database URL is injected from DATABASE_URL in
|
||||||
|
# migrations/env.py (twelve-factor) — intentionally not set here.
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Shared API dependencies: DB session, the authenticated user, and the mailer."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_session
|
||||||
|
from app.integrations.mailer.base import Mailer
|
||||||
|
from app.integrations.mailer.console import ConsoleMailer
|
||||||
|
from app.integrations.mailer.smtp import SMTPMailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_session_token(request: Request) -> str | None:
|
||||||
|
"""Bearer header (API clients) takes precedence over the session cookie
|
||||||
|
(browser)."""
|
||||||
|
authorization = request.headers.get("authorization")
|
||||||
|
if authorization and authorization.lower().startswith("bearer "):
|
||||||
|
return authorization[7:].strip()
|
||||||
|
return request.cookies.get(get_settings().cookie_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(request: Request, session: SessionDep) -> User:
|
||||||
|
raw_token = extract_session_token(request)
|
||||||
|
if raw_token is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "authentication required")
|
||||||
|
user = await auth_service.resolve_session_user(session, raw_token=raw_token)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or expired session")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_mailer() -> Mailer:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.mailer == "smtp" and settings.smtp_host:
|
||||||
|
return SMTPMailer(settings)
|
||||||
|
return ConsoleMailer()
|
||||||
|
|
||||||
|
|
||||||
|
MailerDep = Annotated[Mailer, Depends(get_mailer)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_objectstore() -> ObjectStore:
|
||||||
|
return S3ObjectStore(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
ObjectStoreDep = Annotated[ObjectStore, Depends(get_objectstore)]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Liveness and readiness endpoints.
|
||||||
|
|
||||||
|
- ``/health`` — liveness: the process is up. No dependencies touched.
|
||||||
|
- ``/health/ready`` — readiness: dependencies (Postgres) are reachable.
|
||||||
|
|
||||||
|
Orchestrators and Caddy probe these; they are intentionally outside the
|
||||||
|
versioned ``/api`` surface.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Response, status
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_engine
|
||||||
|
|
||||||
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
settings = get_settings()
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"service": settings.app_name,
|
||||||
|
"version": settings.version,
|
||||||
|
"env": settings.app_env,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health/ready")
|
||||||
|
async def ready(response: Response) -> dict:
|
||||||
|
checks: dict[str, str] = {}
|
||||||
|
try:
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
await conn.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = "ok"
|
||||||
|
return {"status": "ready", "checks": checks}
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface any failure as "not ready"
|
||||||
|
checks["database"] = "error"
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return {"status": "not ready", "checks": checks, "detail": str(exc)}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Versioned API surface. Mounts under /api/v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import (
|
||||||
|
auth,
|
||||||
|
citations,
|
||||||
|
events,
|
||||||
|
gedcom,
|
||||||
|
media,
|
||||||
|
persons,
|
||||||
|
relationships,
|
||||||
|
sources,
|
||||||
|
trees,
|
||||||
|
users,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_router.include_router(auth.router)
|
||||||
|
api_router.include_router(users.router)
|
||||||
|
api_router.include_router(trees.router)
|
||||||
|
api_router.include_router(persons.router)
|
||||||
|
api_router.include_router(events.router)
|
||||||
|
api_router.include_router(relationships.router)
|
||||||
|
api_router.include_router(sources.router)
|
||||||
|
api_router.include_router(citations.router)
|
||||||
|
api_router.include_router(media.router)
|
||||||
|
api_router.include_router(gedcom.router)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.api.deps import MailerDep, SessionDep, extract_session_token
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
PasswordResetConfirm,
|
||||||
|
PasswordResetRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
SessionRead,
|
||||||
|
TokenRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
from app.services import auth_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_session_cookie(response: Response, token: str) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
response.set_cookie(
|
||||||
|
settings.cookie_name,
|
||||||
|
token,
|
||||||
|
max_age=settings.session_ttl_days * 86400,
|
||||||
|
httponly=True,
|
||||||
|
secure=settings.cookie_secure,
|
||||||
|
samesite="lax",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=SessionRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register(
|
||||||
|
data: RegisterRequest, session: SessionDep, mailer: MailerDep, response: Response
|
||||||
|
) -> SessionRead:
|
||||||
|
user, token, expires_at = await auth_service.register(
|
||||||
|
session,
|
||||||
|
mailer,
|
||||||
|
email=data.email,
|
||||||
|
password=data.password,
|
||||||
|
display_name=data.display_name,
|
||||||
|
)
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=SessionRead)
|
||||||
|
async def login(data: LoginRequest, session: SessionDep, response: Response) -> SessionRead:
|
||||||
|
result = await auth_service.login(session, email=data.email, password=data.password)
|
||||||
|
if result is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid credentials")
|
||||||
|
user, token, expires_at = result
|
||||||
|
_set_session_cookie(response, token)
|
||||||
|
return SessionRead(user=UserRead.model_validate(user), token=token, expires_at=expires_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(request: Request, session: SessionDep, response: Response) -> None:
|
||||||
|
raw_token = extract_session_token(request)
|
||||||
|
if raw_token:
|
||||||
|
await auth_service.logout(session, raw_token=raw_token)
|
||||||
|
response.delete_cookie(get_settings().cookie_name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def verify_email(data: TokenRequest, session: SessionDep) -> None:
|
||||||
|
await auth_service.verify_email(session, raw_token=data.token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request-password-reset", status_code=status.HTTP_202_ACCEPTED)
|
||||||
|
async def request_password_reset(
|
||||||
|
data: PasswordResetRequest, session: SessionDep, mailer: MailerDep
|
||||||
|
) -> dict:
|
||||||
|
await auth_service.request_password_reset(session, mailer, email=data.email)
|
||||||
|
return {"status": "accepted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/reset-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def reset_password(data: PasswordResetConfirm, session: SessionDep) -> None:
|
||||||
|
await auth_service.reset_password(
|
||||||
|
session, raw_token=data.token, new_password=data.new_password
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.source import CitationCreate, CitationRead
|
||||||
|
from app.services import citation_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["citations"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/citations", response_model=CitationRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_citation(
|
||||||
|
tree_id: uuid.UUID, data: CitationCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CitationRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
citation = await citation_service.create_citation(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return CitationRead.model_validate(citation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/citations", response_model=list[CitationRead])
|
||||||
|
async def list_citations(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[CitationRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
citations = await citation_service.list_citations(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [CitationRead.model_validate(c) for c in citations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/citations/{citation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_citation(
|
||||||
|
tree_id: uuid.UUID, citation_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await citation_service.delete_citation(
|
||||||
|
session, actor=current, tree=tree, citation_id=citation_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.event import EventCreate, EventRead
|
||||||
|
from app.services import event_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["events"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/events", response_model=EventRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_event(
|
||||||
|
tree_id: uuid.UUID, data: EventCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> EventRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
event = await event_service.create_event(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_tree_events(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
events = await event_service.list_events(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
|
async def list_person_events(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
events = await event_service.list_events_for_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/events/{event_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_event(
|
||||||
|
tree_id: uuid.UUID, event_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await event_service.delete_event(session, actor=current, tree=tree, event_id=event_id)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Response, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.gedcom import ImportReport
|
||||||
|
from app.services import gedcom, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||||
|
async def import_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> ImportReport:
|
||||||
|
# NOTE: additive — records are created as new; existing people are not merged.
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
report = await gedcom.import_gedcom(session, actor=current, tree=tree, text=text)
|
||||||
|
return ImportReport(**report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/gedcom/export")
|
||||||
|
async def export_gedcom(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> Response:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = await gedcom.export_gedcom(session, viewer_id=current.id, tree=tree)
|
||||||
|
safe = "".join(c for c in tree.name if c.isalnum() or c in " -_").strip() or "tree"
|
||||||
|
return Response(
|
||||||
|
content=text,
|
||||||
|
media_type="text/plain",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe}.ged"'},
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Response, UploadFile, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
|
from app.schemas.media import MediaRead
|
||||||
|
from app.services import media_service, tree_service
|
||||||
|
|
||||||
|
|
||||||
|
def _content_url(media) -> str:
|
||||||
|
return f"/api/v1/trees/{media.tree_id}/media/{media.id}/content"
|
||||||
|
|
||||||
|
|
||||||
|
def _read(media) -> MediaRead:
|
||||||
|
out = MediaRead.model_validate(media)
|
||||||
|
# Stream through the backend (privacy-checked, browser-reachable) rather
|
||||||
|
# than expose the internal object store directly.
|
||||||
|
out.url = _content_url(media)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["media"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/media", response_model=MediaRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def upload_media(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
title: str | None = Form(None),
|
||||||
|
person_id: uuid.UUID | None = Form(None),
|
||||||
|
event_id: uuid.UUID | None = Form(None),
|
||||||
|
source_id: uuid.UUID | None = Form(None),
|
||||||
|
) -> MediaRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
data = await file.read()
|
||||||
|
media = await media_service.upload_media(
|
||||||
|
session,
|
||||||
|
store,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
data=data,
|
||||||
|
filename=file.filename or "upload",
|
||||||
|
content_type=file.content_type or "application/octet-stream",
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
return _read(media)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/media", response_model=list[MediaRead])
|
||||||
|
async def list_media(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[MediaRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
items = await media_service.list_media(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [_read(m) for m in items]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/media/{media_id}/content")
|
||||||
|
async def media_content(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
media_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> Response:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
media = await media_service.get_media(
|
||||||
|
session, viewer_id=current.id, tree=tree, media_id=media_id
|
||||||
|
)
|
||||||
|
data = await store.get_object(key=media.storage_key)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type=media.content_type,
|
||||||
|
headers={"Content-Disposition": f'inline; filename="{media.original_filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/media/{media_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_media(
|
||||||
|
tree_id: uuid.UUID, media_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await media_service.delete_media(session, actor=current, tree=tree, media_id=media_id)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.person import PersonCreate, PersonRead
|
||||||
|
from app.services import person_service, tree_service
|
||||||
|
|
||||||
|
# Persons are nested under their tree (the tenant boundary).
|
||||||
|
router = APIRouter(prefix="/trees", tags=["persons"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/persons",
|
||||||
|
response_model=PersonRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_person(
|
||||||
|
tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
# get_tree enforces existence + view access; create_person enforces edit rights.
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.create_person(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
given=data.given,
|
||||||
|
surname=data.surname,
|
||||||
|
gender=data.gender,
|
||||||
|
is_living=data.is_living,
|
||||||
|
privacy_setting=data.privacy,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
|
async def list_persons(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
deleted: bool = False,
|
||||||
|
q: str | None = None,
|
||||||
|
) -> list[PersonRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
if q:
|
||||||
|
persons = await person_service.search_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree, query=q
|
||||||
|
)
|
||||||
|
elif deleted:
|
||||||
|
persons = await person_service.list_deleted_persons(
|
||||||
|
session, viewer_id=current.id, tree=tree
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/persons/{person_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await person_service.delete_person(session, actor=current, tree=tree, person_id=person_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/persons/{person_id}/restore", response_model=PersonRead)
|
||||||
|
async def restore_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.restore_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def get_person(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.get_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.relationship import RelationshipCreate, RelationshipRead
|
||||||
|
from app.services import relationship_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["relationships"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/relationships",
|
||||||
|
response_model=RelationshipRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_relationship(
|
||||||
|
tree_id: uuid.UUID, data: RelationshipCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> RelationshipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
relationship = await relationship_service.create_relationship(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return RelationshipRead.model_validate(relationship)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def list_relationships(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rels = await relationship_service.list_relationships(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tree_id}/persons/{person_id}/relationships",
|
||||||
|
response_model=list[RelationshipRead],
|
||||||
|
)
|
||||||
|
async def list_person_relationships(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rels = await relationship_service.list_relationships_for_person(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tree_id}/relationships/{relationship_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
async def delete_relationship(
|
||||||
|
tree_id: uuid.UUID, relationship_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await relationship_service.delete_relationship(
|
||||||
|
session, actor=current, tree=tree, relationship_id=relationship_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.source import SourceCreate, SourceRead
|
||||||
|
from app.services import source_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/sources", response_model=SourceRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_source(
|
||||||
|
tree_id: uuid.UUID, data: SourceCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> SourceRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
source = await source_service.create_source(
|
||||||
|
session, actor=current, tree=tree, **data.model_dump()
|
||||||
|
)
|
||||||
|
return SourceRead.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/sources", response_model=list[SourceRead])
|
||||||
|
async def list_sources(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[SourceRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
sources = await source_service.list_sources(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [SourceRead.model_validate(s) for s in sources]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/sources/{source_id}", response_model=SourceRead)
|
||||||
|
async def get_source(
|
||||||
|
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> SourceRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
source = await source_service.get_source(
|
||||||
|
session, viewer_id=current.id, tree=tree, source_id=source_id
|
||||||
|
)
|
||||||
|
return SourceRead.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_source(
|
||||||
|
tree_id: uuid.UUID, source_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await source_service.delete_source(session, actor=current, tree=tree, source_id=source_id)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.tree import TreeCreate, TreeRead
|
||||||
|
from app.services import tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.create_tree(
|
||||||
|
session,
|
||||||
|
owner=current,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
visibility=data.visibility,
|
||||||
|
)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TreeRead])
|
||||||
|
async def list_my_trees(
|
||||||
|
session: SessionDep, current: CurrentUser, deleted: bool = False
|
||||||
|
) -> list[TreeRead]:
|
||||||
|
if deleted:
|
||||||
|
trees = await tree_service.list_deleted_trees_for_user(session, user=current)
|
||||||
|
else:
|
||||||
|
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||||
|
return [TreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}", response_model=TreeRead)
|
||||||
|
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> None:
|
||||||
|
await tree_service.delete_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/restore", response_model=TreeRead)
|
||||||
|
async def restore_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.restore_tree(session, actor=current, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def read_me(current: CurrentUser) -> UserRead:
|
||||||
|
return UserRead.model_validate(current)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""Application configuration.
|
||||||
|
|
||||||
|
Twelve-factor: everything is read from the environment. Defaults are
|
||||||
|
development-friendly; production supplies real values via the compose `.env`.
|
||||||
|
No secrets or endpoints are hard-coded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
app_name: str = "Provenance"
|
||||||
|
version: str = "0.0.0"
|
||||||
|
app_env: str = Field(default="development", description="development | production")
|
||||||
|
|
||||||
|
# SQLAlchemy async URL, e.g. postgresql+asyncpg://user:pass@host:5432/db
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+asyncpg://provenance:provenance@localhost:5432/provenance",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Auth / sessions ---
|
||||||
|
session_ttl_days: int = 30
|
||||||
|
token_ttl_hours: int = 24 # email-verify / password-reset token lifetime
|
||||||
|
cookie_name: str = "provenance_session"
|
||||||
|
cookie_secure: bool = True # set false for local http; true behind TLS
|
||||||
|
# Base URL used to build links in outbound email.
|
||||||
|
app_base_url: str = "http://localhost"
|
||||||
|
|
||||||
|
# --- Object storage (S3-compatible / MinIO) ---
|
||||||
|
s3_endpoint_url: str = "http://minio:9000"
|
||||||
|
s3_bucket: str = "provenance"
|
||||||
|
s3_access_key: str = "provenance"
|
||||||
|
s3_secret_key: str = "change-me-too"
|
||||||
|
s3_region: str = "us-east-1"
|
||||||
|
s3_presign_ttl: int = 3600 # seconds
|
||||||
|
|
||||||
|
# --- Worker ---
|
||||||
|
purge_interval_seconds: int = 3600 # how often to run the soft-delete purge
|
||||||
|
purge_after_days: int = 30 # soft-deleted rows older than this are purged
|
||||||
|
|
||||||
|
# --- Email (SMTP) ---
|
||||||
|
mailer: str = Field(default="console", description="console | smtp")
|
||||||
|
smtp_host: str | None = None
|
||||||
|
smtp_port: int = 587
|
||||||
|
smtp_username: str | None = None
|
||||||
|
smtp_password: str | None = None
|
||||||
|
smtp_from: str = "Provenance <no-reply@provenance.local>"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Async database engine, session factory, and the FastAPI session dependency.
|
||||||
|
|
||||||
|
The repository layer builds on ``get_session``; ``get_engine`` also backs the
|
||||||
|
readiness probe. Everything is lazy so importing the app never opens a
|
||||||
|
connection (important for tests and for ``--help``-style invocations).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
global _engine
|
||||||
|
if _engine is None:
|
||||||
|
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _sessionmaker
|
||||||
|
if _sessionmaker is None:
|
||||||
|
_sessionmaker = async_sessionmaker(
|
||||||
|
get_engine(), expire_on_commit=False, class_=AsyncSession
|
||||||
|
)
|
||||||
|
return _sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""FastAPI dependency. One session per request; commits are explicit in the
|
||||||
|
service layer."""
|
||||||
|
async with get_sessionmaker()() as session:
|
||||||
|
yield session
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Password hashing and token primitives.
|
||||||
|
|
||||||
|
Passwords use Argon2id (argon2-cffi). Session and email tokens are random
|
||||||
|
high-entropy strings; only their SHA-256 hash is stored, so a database leak
|
||||||
|
never exposes a usable credential.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import Argon2Error
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return _hasher.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password_hash: str, password: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, password)
|
||||||
|
except (Argon2Error, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""A URL-safe, high-entropy token (the raw secret handed to the client)."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""SHA-256 of a token — what we store and look up by."""
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""AuthProvider interface.
|
||||||
|
|
||||||
|
Operators enable any subset of providers (local, OIDC, social). A provider's
|
||||||
|
job is narrow: verify a credential and return the matching User (or None).
|
||||||
|
Session issuance, tokens, and registration live in the auth service and are
|
||||||
|
provider-agnostic, so adding OIDC/social later (Phase 5) is additive.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(ABC):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def authenticate(
|
||||||
|
self, session: AsyncSession, *, identifier: str, secret: str
|
||||||
|
) -> User | None:
|
||||||
|
"""Return the User if the credential is valid, else None."""
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Local (email + password) auth provider."""
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.integrations.auth.base import AuthProvider
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class LocalAuthProvider(AuthProvider):
|
||||||
|
name = "local"
|
||||||
|
|
||||||
|
async def authenticate(
|
||||||
|
self, session: AsyncSession, *, identifier: str, secret: str
|
||||||
|
) -> User | None:
|
||||||
|
email = identifier.strip().lower()
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.email == email, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None or user.hashed_password is None:
|
||||||
|
return None
|
||||||
|
if not verify_password(user.hashed_password, secret):
|
||||||
|
return None
|
||||||
|
return user
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Mailer interface for transactional email.
|
||||||
|
|
||||||
|
Implementations: ConsoleMailer (dev default — logs the link) and SMTPMailer
|
||||||
|
(operator-configured). Selected by config; resolved via app.api.deps.get_mailer.
|
||||||
|
Real deployments will move sending to the worker; for now it is inline.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class Mailer(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def send_email_verification(self, *, to: str, link: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def send_password_reset(self, *, to: str, link: str) -> None: ...
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""Development mailer: logs the would-be email (including the action link) to
|
||||||
|
stdout instead of sending. Never use in production."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.integrations.mailer.base import Mailer
|
||||||
|
|
||||||
|
logger = logging.getLogger("provenance.mailer")
|
||||||
|
|
||||||
|
|
||||||
|
class ConsoleMailer(Mailer):
|
||||||
|
async def send_email_verification(self, *, to: str, link: str) -> None:
|
||||||
|
logger.info("[email] verify address for %s -> %s", to, link)
|
||||||
|
|
||||||
|
async def send_password_reset(self, *, to: str, link: str) -> None:
|
||||||
|
logger.info("[email] password reset for %s -> %s", to, link)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""SMTP mailer using the standard library, run off the event loop. Configured
|
||||||
|
entirely from settings (host/port/credentials/from)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.integrations.mailer.base import Mailer
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPMailer(Mailer):
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
|
||||||
|
def _send(self, *, to: str, subject: str, body: str) -> None:
|
||||||
|
msg = EmailMessage()
|
||||||
|
msg["From"] = self.settings.smtp_from
|
||||||
|
msg["To"] = to
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg.set_content(body)
|
||||||
|
with smtplib.SMTP(self.settings.smtp_host, self.settings.smtp_port) as smtp:
|
||||||
|
smtp.starttls()
|
||||||
|
if self.settings.smtp_username and self.settings.smtp_password:
|
||||||
|
smtp.login(self.settings.smtp_username, self.settings.smtp_password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
async def _send_async(self, *, to: str, subject: str, body: str) -> None:
|
||||||
|
await asyncio.to_thread(self._send, to=to, subject=subject, body=body)
|
||||||
|
|
||||||
|
async def send_email_verification(self, *, to: str, link: str) -> None:
|
||||||
|
await self._send_async(
|
||||||
|
to=to,
|
||||||
|
subject="Verify your Provenance email",
|
||||||
|
body=f"Confirm your email address:\n\n{link}\n",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_password_reset(self, *, to: str, link: str) -> None:
|
||||||
|
await self._send_async(
|
||||||
|
to=to,
|
||||||
|
subject="Reset your Provenance password",
|
||||||
|
body=f"Reset your password:\n\n{link}\n",
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""ObjectStore interface — pluggable binary storage behind the service layer.
|
||||||
|
|
||||||
|
Implementations are S3-compatible (MinIO for self-host, any S3 otherwise).
|
||||||
|
Methods are async wrappers so the service layer stays non-blocking even though
|
||||||
|
the underlying SDK (boto3) is synchronous.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class ObjectStore(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def ensure_bucket(self) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_object(self, *, key: str) -> bytes: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def delete_object(self, *, key: str) -> None: ...
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""S3-compatible ObjectStore (boto3), suitable for MinIO or any S3 provider.
|
||||||
|
|
||||||
|
boto3 is synchronous; each call is dispatched to a thread so request handlers
|
||||||
|
and the worker stay async."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
|
||||||
|
|
||||||
|
class S3ObjectStore(ObjectStore):
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self.bucket = settings.s3_bucket
|
||||||
|
self.presign_ttl = settings.s3_presign_ttl
|
||||||
|
self._client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.s3_endpoint_url,
|
||||||
|
aws_access_key_id=settings.s3_access_key,
|
||||||
|
aws_secret_access_key=settings.s3_secret_key,
|
||||||
|
region_name=settings.s3_region,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_bucket_sync(self) -> None:
|
||||||
|
try:
|
||||||
|
self._client.head_bucket(Bucket=self.bucket)
|
||||||
|
except ClientError:
|
||||||
|
self._client.create_bucket(Bucket=self.bucket)
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
await asyncio.to_thread(self._ensure_bucket_sync)
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
await asyncio.to_thread(
|
||||||
|
self._client.put_object,
|
||||||
|
Bucket=self.bucket,
|
||||||
|
Key=key,
|
||||||
|
Body=data,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_object(self, *, key: str) -> bytes:
|
||||||
|
def _get() -> bytes:
|
||||||
|
obj = self._client.get_object(Bucket=self.bucket, Key=key)
|
||||||
|
return obj["Body"].read()
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_get)
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return await asyncio.to_thread(
|
||||||
|
self._client.generate_presigned_url,
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": self.bucket, "Key": key},
|
||||||
|
ExpiresIn=self.presign_ttl,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
await asyncio.to_thread(self._client.delete_object, Bucket=self.bucket, Key=key)
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""FastAPI application entrypoint.
|
||||||
|
|
||||||
|
Thin by design: wire settings, routers, and error handling, and expose the
|
||||||
|
OpenAPI contract. All domain logic lives in the service layer; the privacy
|
||||||
|
engine is the single enforcement point for reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.api.health import router as health_router
|
||||||
|
from app.api.v1 import api_router
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_logging() -> None:
|
||||||
|
"""Emit the app's own ``provenance.*`` logs at INFO to stdout (uvicorn only
|
||||||
|
configures its own loggers). The ConsoleMailer relies on this so self-hosters
|
||||||
|
can read verification/reset links from the logs."""
|
||||||
|
app_logger = logging.getLogger("provenance")
|
||||||
|
app_logger.setLevel(logging.INFO)
|
||||||
|
if not app_logger.handlers:
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(logging.Formatter("%(levelname)s [%(name)s] %(message)s"))
|
||||||
|
app_logger.addHandler(handler)
|
||||||
|
app_logger.propagate = False
|
||||||
|
|
||||||
|
|
||||||
|
def _register_error_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(NotFound)
|
||||||
|
async def _not_found(request: Request, exc: NotFound) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"})
|
||||||
|
|
||||||
|
@app.exception_handler(Forbidden)
|
||||||
|
async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"})
|
||||||
|
|
||||||
|
@app.exception_handler(Conflict)
|
||||||
|
async def _conflict(request: Request, exc: Conflict) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"})
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
_configure_logging()
|
||||||
|
settings = get_settings()
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version=settings.version,
|
||||||
|
description="Provenance API — family and land provenance.",
|
||||||
|
)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
_register_error_handlers(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Import every model so ``Base.metadata`` is complete for Alembic autogenerate
|
||||||
|
and for ``create_all`` in tests."""
|
||||||
|
|
||||||
|
from app.models.audit import AuditEntry
|
||||||
|
from app.models.auth import Session, UserToken
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place, PlaceName
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Base",
|
||||||
|
"User",
|
||||||
|
"Tree",
|
||||||
|
"TreeMembership",
|
||||||
|
"Person",
|
||||||
|
"Name",
|
||||||
|
"Place",
|
||||||
|
"PlaceName",
|
||||||
|
"Relationship",
|
||||||
|
"Event",
|
||||||
|
"Source",
|
||||||
|
"Citation",
|
||||||
|
"AuditEntry",
|
||||||
|
"Session",
|
||||||
|
"UserToken",
|
||||||
|
"Media",
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""AuditEntry — append-only, immutable record of every mutation.
|
||||||
|
|
||||||
|
The actor is a User, or the assistant principal acting *on behalf of* a User
|
||||||
|
(actor_type = assistant, actor_user_id = the user it serves). No timestamps
|
||||||
|
mixin and no soft delete: this table is never updated or deleted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import AuditActorType
|
||||||
|
from app.models.mixins import UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEntry(Base, UUIDPrimaryKey):
|
||||||
|
__tablename__ = "audit_entries"
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||||
|
)
|
||||||
|
tree_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("trees.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
actor_type: Mapped[AuditActorType] = mapped_column(
|
||||||
|
SAEnum(AuditActorType, name="audit_actor_type"),
|
||||||
|
default=AuditActorType.user,
|
||||||
|
server_default=AuditActorType.user.value,
|
||||||
|
)
|
||||||
|
actor_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(String(64)) # create | update | delete | restore | ...
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64))
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column()
|
||||||
|
before: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
after: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Authentication state: opaque backend-issued sessions and single-use email
|
||||||
|
tokens. Only token *hashes* are stored (see app.core.security).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import TokenPurpose
|
||||||
|
from app.models.mixins import UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base, UUIDPrimaryKey):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
|
||||||
|
class UserToken(Base, UUIDPrimaryKey):
|
||||||
|
__tablename__ = "user_tokens"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
purpose: Mapped[TokenPurpose] = mapped_column(SAEnum(TokenPurpose, name="token_purpose"))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""Declarative base with a stable constraint-naming convention.
|
||||||
|
|
||||||
|
A fixed naming convention is important so Alembic generates deterministic,
|
||||||
|
human-readable names for indexes/constraints across migrations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import MetaData
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
NAMING_CONVENTION = {
|
||||||
|
"ix": "ix_%(column_0_label)s",
|
||||||
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
|
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||||
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
|
"pk": "pk_%(table_name)s",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Closed-set enumerations that drive logic (authorization, privacy, traversal).
|
||||||
|
|
||||||
|
Open-ended, GEDCOM-extensible vocabularies (event type, name type, source type)
|
||||||
|
are stored as strings instead, so importing real-world files never fails on an
|
||||||
|
unknown tag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class TreeVisibility(enum.StrEnum):
|
||||||
|
public = "public"
|
||||||
|
unlisted = "unlisted"
|
||||||
|
private = "private"
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRole(enum.StrEnum):
|
||||||
|
owner = "owner"
|
||||||
|
editor = "editor"
|
||||||
|
viewer = "viewer"
|
||||||
|
|
||||||
|
|
||||||
|
class PersonPrivacy(enum.StrEnum):
|
||||||
|
"""Per-person override of the tree's visibility (PRD US-041)."""
|
||||||
|
|
||||||
|
inherit = "inherit"
|
||||||
|
private = "private"
|
||||||
|
public = "public"
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipType(enum.StrEnum):
|
||||||
|
parent_child = "parent_child"
|
||||||
|
partnership = "partnership"
|
||||||
|
sibling = "sibling"
|
||||||
|
|
||||||
|
|
||||||
|
class ParentChildQualifier(enum.StrEnum):
|
||||||
|
"""Qualifies a parent_child edge so adoption/donor/blended families are
|
||||||
|
first-class rather than edge cases (ARCHITECTURE §5)."""
|
||||||
|
|
||||||
|
biological = "biological"
|
||||||
|
adoptive = "adoptive"
|
||||||
|
step = "step"
|
||||||
|
foster = "foster"
|
||||||
|
donor = "donor"
|
||||||
|
guardian = "guardian"
|
||||||
|
|
||||||
|
|
||||||
|
class CitationConfidence(enum.StrEnum):
|
||||||
|
high = "high"
|
||||||
|
medium = "medium"
|
||||||
|
low = "low"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditActorType(enum.StrEnum):
|
||||||
|
user = "user"
|
||||||
|
assistant = "assistant"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenPurpose(enum.StrEnum):
|
||||||
|
email_verify = "email_verify"
|
||||||
|
password_reset = "password_reset"
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Event — a typed, dated, placed fact attached to a Person or a partnership.
|
||||||
|
|
||||||
|
Genealogical dates are messy, so we keep both:
|
||||||
|
- ``date_value`` — the original string, verbatim (e.g. "ABT 1850", "BET 1850 AND
|
||||||
|
1855"), for fidelity and GEDCOM round-trip.
|
||||||
|
- ``date_start`` / ``date_end`` — a normalized range for sorting and filtering
|
||||||
|
(an exact date sets start == end).
|
||||||
|
A CHECK enforces that exactly one subject (person XOR relationship) is set.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, Date, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Event(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "events"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)",
|
||||||
|
name="subject_person_xor_relationship",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Open vocabulary (birth, death, marriage, residence, immigration, ...).
|
||||||
|
event_type: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
|
||||||
|
person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
relationship_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("relationships.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
place_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("places.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
date_value: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
date_start: Mapped[date | None] = mapped_column(Date)
|
||||||
|
date_end: Mapped[date | None] = mapped_column(Date)
|
||||||
|
date_precision: Mapped[str | None] = mapped_column(String(32)) # exact|about|before|after|range
|
||||||
|
calendar: Mapped[str] = mapped_column(
|
||||||
|
String(32), default="gregorian", server_default="gregorian"
|
||||||
|
)
|
||||||
|
detail: Mapped[str | None] = mapped_column(String(512)) # e.g. occupation, address
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Media — a binary asset (image, scan, PDF, audio) in object storage. The row
|
||||||
|
holds metadata + checksum + the storage key; the bytes live in the ObjectStore.
|
||||||
|
Optionally attached to a single fact (person, event, or source) for now."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Media(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "media"
|
||||||
|
|
||||||
|
uploader_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
storage_key: Mapped[str] = mapped_column(String(512), unique=True)
|
||||||
|
original_filename: Mapped[str] = mapped_column(String(512))
|
||||||
|
content_type: Mapped[str] = mapped_column(String(128))
|
||||||
|
byte_size: Mapped[int] = mapped_column(BigInteger)
|
||||||
|
checksum_sha256: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
|
||||||
|
# Optional single attachment target.
|
||||||
|
person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("events.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
source_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Reusable column mixins.
|
||||||
|
|
||||||
|
- ``UUIDPrimaryKey`` — UUID surrogate key (no PII in URLs; safe for multi-tenant).
|
||||||
|
- ``Timestamps`` — created/updated audit timestamps (DB-managed).
|
||||||
|
- ``SoftDelete`` — ``deleted_at``; a row is "deleted" when set. A scheduled
|
||||||
|
worker purges rows past the 30-day window (PRD US-080/081).
|
||||||
|
- ``TenantScoped`` — ``tree_id`` FK; every tree-owned row carries it so the
|
||||||
|
privacy engine can enforce isolation uniformly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, func
|
||||||
|
from sqlalchemy.orm import Mapped, declared_attr, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKey:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||||
|
|
||||||
|
|
||||||
|
class Timestamps:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SoftDelete:
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True, default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantScoped:
|
||||||
|
@declared_attr
|
||||||
|
def tree_id(cls) -> Mapped[uuid.UUID]: # noqa: N805
|
||||||
|
return mapped_column(
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Person and Name.
|
||||||
|
|
||||||
|
A Person carries living/deceased status and a per-person privacy override; the
|
||||||
|
display identity lives in one or more Name rows (variants, married names,
|
||||||
|
aliases) so name changes over time are first-class.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Index, Integer, String, Text, text
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import PersonPrivacy
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Person(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "persons"
|
||||||
|
|
||||||
|
# Free-form to stay inclusive; not a closed enum.
|
||||||
|
gender: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
# NULL = unknown (let the living-person rule derive it); True/False = asserted.
|
||||||
|
is_living: Mapped[bool | None] = mapped_column(Boolean)
|
||||||
|
privacy: Mapped[PersonPrivacy] = mapped_column(
|
||||||
|
SAEnum(PersonPrivacy, name="person_privacy"),
|
||||||
|
default=PersonPrivacy.inherit,
|
||||||
|
server_default=PersonPrivacy.inherit.value,
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class Name(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "names"
|
||||||
|
# Trigram indexes for fuzzy name search (Mueller/Müller/Muller). Requires the
|
||||||
|
# pg_trgm extension (enabled in the accompanying migration).
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_names_given_trgm",
|
||||||
|
"given",
|
||||||
|
postgresql_using="gin",
|
||||||
|
postgresql_ops={"given": "gin_trgm_ops"},
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"ix_names_surname_trgm",
|
||||||
|
"surname",
|
||||||
|
postgresql_using="gin",
|
||||||
|
postgresql_ops={"surname": "gin_trgm_ops"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
person_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# Open vocabulary (birth, married, alias, religious, ...) for GEDCOM fidelity.
|
||||||
|
name_type: Mapped[str] = mapped_column(String(32), default="birth", server_default="birth")
|
||||||
|
given: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
surname: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
prefix: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
suffix: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
nickname: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
# Original full form preserved verbatim (round-trip fidelity).
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
is_primary: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Place — a gazetteer entity — and PlaceName, its historical name variants.
|
||||||
|
|
||||||
|
PlaceName carries date ranges so a record entered as "Königsberg, 1900" sorts
|
||||||
|
and displays correctly against "Kaliningrad" (ARCHITECTURE §5, §10).
|
||||||
|
|
||||||
|
Phase 0 scopes Place to a Tree (``tree_id``) to keep tenant isolation absolute.
|
||||||
|
ARCHITECTURE calls the gazetteer "tenant-shared"; a deployment-wide shared
|
||||||
|
gazetteer is a deliberate later refinement (see ARCHITECTURE §5 note).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Date, Float, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Place(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "places"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(String(512))
|
||||||
|
# Self-referential hierarchy: place within place.
|
||||||
|
parent_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("places.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
place_type: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
latitude: Mapped[float | None] = mapped_column(Float)
|
||||||
|
longitude: Mapped[float | None] = mapped_column(Float)
|
||||||
|
|
||||||
|
|
||||||
|
class PlaceName(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "place_names"
|
||||||
|
|
||||||
|
place_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("places.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(512))
|
||||||
|
valid_from: Mapped[date | None] = mapped_column(Date)
|
||||||
|
valid_to: Mapped[date | None] = mapped_column(Date)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Relationship — a typed, qualified edge between two Persons.
|
||||||
|
|
||||||
|
Modeling parentage as qualified edges (rather than assuming two biological
|
||||||
|
parents) is what makes adoption, donor conception, and blended families
|
||||||
|
first-class. ``qualifier`` applies to parent_child edges; partnership events
|
||||||
|
(marriage, divorce) attach to the Relationship via Event.relationship_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, Text
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Relationship(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "relationships"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint("person_from_id <> person_to_id", name="different_persons"),
|
||||||
|
)
|
||||||
|
|
||||||
|
type: Mapped[RelationshipType] = mapped_column(
|
||||||
|
SAEnum(RelationshipType, name="relationship_type")
|
||||||
|
)
|
||||||
|
# For parent_child: from = parent, to = child. For partnership/sibling: symmetric.
|
||||||
|
person_from_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
person_to_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
# Only meaningful for parent_child edges.
|
||||||
|
qualifier: Mapped[ParentChildQualifier | None] = mapped_column(
|
||||||
|
SAEnum(ParentChildQualifier, name="parent_child_qualifier")
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Source and Citation — the first-class provenance spine.
|
||||||
|
|
||||||
|
A Source is a reusable record of an origin; a Citation links one Source to one
|
||||||
|
specific fact (a Person, Name, Event, or Relationship — and OwnershipEvent once
|
||||||
|
property lands). A CHECK enforces exactly one target so a citation always points
|
||||||
|
at a single fact.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import CheckConstraint, ForeignKey, String, Text
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import CitationConfidence
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Source(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "sources"
|
||||||
|
|
||||||
|
title: Mapped[str] = mapped_column(String(512))
|
||||||
|
author: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
source_type: Mapped[str | None] = mapped_column(String(64)) # book, census, deed, ...
|
||||||
|
repository: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
url: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
citation_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
publication_info: Mapped[str | None] = mapped_column(Text)
|
||||||
|
quality_note: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
|
||||||
|
|
||||||
|
class Citation(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "citations"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int "
|
||||||
|
"+ (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1",
|
||||||
|
name="exactly_one_target",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Exactly one of these is set (see CHECK above).
|
||||||
|
person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("persons.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
event_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("events.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
name_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("names.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
relationship_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("relationships.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Locality within the source.
|
||||||
|
page: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text) # entry, line, free notes
|
||||||
|
confidence: Mapped[CitationConfidence | None] = mapped_column(
|
||||||
|
SAEnum(CitationConfidence, name="citation_confidence")
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Tree — the top-level tenant boundary for genealogical data — and
|
||||||
|
TreeMembership, the basis for authorization (ARCHITECTURE §5).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy import ForeignKey, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.enums import MembershipRole, TreeVisibility
|
||||||
|
from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class Tree(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "trees"
|
||||||
|
|
||||||
|
owner_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="RESTRICT"), index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
visibility: Mapped[TreeVisibility] = mapped_column(
|
||||||
|
SAEnum(TreeVisibility, name="tree_visibility"),
|
||||||
|
default=TreeVisibility.private,
|
||||||
|
server_default=TreeVisibility.private.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TreeMembership(Base, UUIDPrimaryKey, Timestamps):
|
||||||
|
__tablename__ = "tree_memberships"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tree_id", "user_id", name="uq_tree_memberships_tree_user"),
|
||||||
|
)
|
||||||
|
|
||||||
|
tree_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("trees.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
role: Mapped[MembershipRole] = mapped_column(SAEnum(MembershipRole, name="membership_role"))
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""User — a person with login. Identity is internal so one user can link
|
||||||
|
multiple auth providers later (the provider-link table arrives with the auth
|
||||||
|
slice). ``hashed_password`` is nullable: external/OIDC users have none.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base
|
||||||
|
from app.models.mixins import SoftDelete, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, UUIDPrimaryKey, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
|
email_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
hashed_password: Mapped[str | None] = mapped_column(String(255))
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Thin data-access layer over SQLAlchemy. No business rules live here — the
|
||||||
|
service layer owns those (and the privacy engine). The repository only knows how
|
||||||
|
to fetch and stage rows, transparently excluding soft-deleted ones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository:
|
||||||
|
def __init__(self, session: AsyncSession, model: type) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def _exclude_deleted(self, stmt):
|
||||||
|
if hasattr(self.model, "deleted_at"):
|
||||||
|
stmt = stmt.where(self.model.deleted_at.is_(None))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
async def get(self, id_: Any, *, include_deleted: bool = False):
|
||||||
|
stmt = select(self.model).where(self.model.id == id_)
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = self._exclude_deleted(stmt)
|
||||||
|
return (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list(self, *conditions, include_deleted: bool = False, order_by=None):
|
||||||
|
stmt = select(self.model)
|
||||||
|
for condition in conditions:
|
||||||
|
stmt = stmt.where(condition)
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = self._exclude_deleted(stmt)
|
||||||
|
if order_by is not None:
|
||||||
|
stmt = stmt.order_by(order_by)
|
||||||
|
return list((await self.session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.session.add(obj)
|
||||||
|
return obj
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.schemas.user import UserRead
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str = Field(min_length=8)
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenRequest(BaseModel):
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirm(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRead(BaseModel):
|
||||||
|
user: UserRead
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class EventCreate(BaseModel):
|
||||||
|
event_type: str
|
||||||
|
# Exactly one subject: a person or a partnership (relationship).
|
||||||
|
person_id: uuid.UUID | None = None
|
||||||
|
relationship_id: uuid.UUID | None = None
|
||||||
|
place_id: uuid.UUID | None = None
|
||||||
|
# Verbatim date string (e.g. "ABT 1850") and/or a normalized range.
|
||||||
|
date_value: str | None = None
|
||||||
|
date_start: date | None = None
|
||||||
|
date_end: date | None = None
|
||||||
|
date_precision: str | None = None
|
||||||
|
calendar: str = "gregorian"
|
||||||
|
detail: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EventRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
event_type: str
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
relationship_id: uuid.UUID | None
|
||||||
|
place_id: uuid.UUID | None
|
||||||
|
date_value: str | None
|
||||||
|
date_start: date | None
|
||||||
|
date_end: date | None
|
||||||
|
date_precision: str | None
|
||||||
|
calendar: str
|
||||||
|
detail: str | None
|
||||||
|
notes: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ImportReport(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
unmapped_tags: list[str]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MediaRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
original_filename: str
|
||||||
|
content_type: str
|
||||||
|
byte_size: int
|
||||||
|
checksum_sha256: str
|
||||||
|
title: str | None
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
event_id: uuid.UUID | None
|
||||||
|
source_id: uuid.UUID | None
|
||||||
|
created_at: datetime
|
||||||
|
# Presigned download URL, filled in by the router from the ObjectStore.
|
||||||
|
url: str | None = None
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import PersonPrivacy
|
||||||
|
|
||||||
|
|
||||||
|
class PersonCreate(BaseModel):
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
is_living: bool | None = None
|
||||||
|
privacy: PersonPrivacy = PersonPrivacy.inherit
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
primary_name: str | None = None
|
||||||
|
gender: str | None
|
||||||
|
is_living: bool | None
|
||||||
|
privacy: PersonPrivacy
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipCreate(BaseModel):
|
||||||
|
type: RelationshipType
|
||||||
|
person_from_id: uuid.UUID
|
||||||
|
person_to_id: uuid.UUID
|
||||||
|
# Only meaningful for parent_child edges (from = parent, to = child).
|
||||||
|
qualifier: ParentChildQualifier | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RelationshipRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
type: RelationshipType
|
||||||
|
person_from_id: uuid.UUID
|
||||||
|
person_to_id: uuid.UUID
|
||||||
|
qualifier: ParentChildQualifier | None
|
||||||
|
notes: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import CitationConfidence
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCreate(BaseModel):
|
||||||
|
title: str
|
||||||
|
author: str | None = None
|
||||||
|
source_type: str | None = None
|
||||||
|
repository: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
citation_text: str | None = None
|
||||||
|
publication_info: str | None = None
|
||||||
|
quality_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SourceRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
title: str
|
||||||
|
author: str | None
|
||||||
|
source_type: str | None
|
||||||
|
repository: str | None
|
||||||
|
url: str | None
|
||||||
|
citation_text: str | None
|
||||||
|
publication_info: str | None
|
||||||
|
quality_note: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CitationCreate(BaseModel):
|
||||||
|
source_id: uuid.UUID
|
||||||
|
# Exactly one target fact.
|
||||||
|
person_id: uuid.UUID | None = None
|
||||||
|
event_id: uuid.UUID | None = None
|
||||||
|
name_id: uuid.UUID | None = None
|
||||||
|
relationship_id: uuid.UUID | None = None
|
||||||
|
page: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
confidence: CitationConfidence | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CitationRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
source_id: uuid.UUID
|
||||||
|
person_id: uuid.UUID | None
|
||||||
|
event_id: uuid.UUID | None
|
||||||
|
name_id: uuid.UUID | None
|
||||||
|
relationship_id: uuid.UUID | None
|
||||||
|
page: str | None
|
||||||
|
detail: str | None
|
||||||
|
confidence: CitationConfidence | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import TreeVisibility
|
||||||
|
|
||||||
|
|
||||||
|
class TreeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
visibility: TreeVisibility = TreeVisibility.private
|
||||||
|
|
||||||
|
|
||||||
|
class TreeRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
visibility: TreeVisibility
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
# Note: email is a plain str for now (no email-validator dependency yet); the
|
||||||
|
# auth slice can tighten this to EmailStr.
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
email_verified_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Audit logging. Every mutation records an append-only AuditEntry attributing
|
||||||
|
the change to a User (or the assistant principal acting for a User). Staged on
|
||||||
|
the session; the caller commits as part of its unit of work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit import AuditEntry
|
||||||
|
from app.models.enums import AuditActorType
|
||||||
|
|
||||||
|
|
||||||
|
def record_audit(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID | None = None,
|
||||||
|
tree_id: uuid.UUID | None = None,
|
||||||
|
actor_user_id: uuid.UUID | None = None,
|
||||||
|
actor_type: AuditActorType = AuditActorType.user,
|
||||||
|
before: dict | None = None,
|
||||||
|
after: dict | None = None,
|
||||||
|
) -> AuditEntry:
|
||||||
|
entry = AuditEntry(
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
tree_id=tree_id,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Authentication service: registration, login, sessions, email verification,
|
||||||
|
and password reset. Provider-agnostic — credential checking is delegated to an
|
||||||
|
AuthProvider; this module owns session/token issuance and the audit trail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import generate_token, hash_password, hash_token
|
||||||
|
from app.integrations.auth.local import LocalAuthProvider
|
||||||
|
from app.integrations.mailer.base import Mailer
|
||||||
|
from app.models.auth import Session as SessionModel
|
||||||
|
from app.models.auth import UserToken
|
||||||
|
from app.models.enums import TokenPurpose
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict, NotFound
|
||||||
|
|
||||||
|
_local_provider = LocalAuthProvider()
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _link(path: str, raw: str) -> str:
|
||||||
|
return f"{get_settings().app_base_url}{path}?token={raw}"
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_session(session: AsyncSession, user: User) -> tuple[str, SessionModel]:
|
||||||
|
raw = generate_token()
|
||||||
|
record = SessionModel(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
expires_at=_now() + timedelta(days=get_settings().session_ttl_days),
|
||||||
|
)
|
||||||
|
session.add(record)
|
||||||
|
return raw, record
|
||||||
|
|
||||||
|
|
||||||
|
def _create_email_token(session: AsyncSession, user: User, purpose: TokenPurpose) -> str:
|
||||||
|
raw = generate_token()
|
||||||
|
session.add(
|
||||||
|
UserToken(
|
||||||
|
user_id=user.id,
|
||||||
|
purpose=purpose,
|
||||||
|
token_hash=hash_token(raw),
|
||||||
|
expires_at=_now() + timedelta(hours=get_settings().token_ttl_hours),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
async def _consume_token(
|
||||||
|
session: AsyncSession, raw_token: str, purpose: TokenPurpose
|
||||||
|
) -> UserToken:
|
||||||
|
token = (
|
||||||
|
await session.execute(
|
||||||
|
select(UserToken).where(
|
||||||
|
UserToken.token_hash == hash_token(raw_token),
|
||||||
|
UserToken.purpose == purpose,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if token is None or token.used_at is not None or token.expires_at <= _now():
|
||||||
|
raise NotFound("invalid or expired token")
|
||||||
|
token.used_at = _now()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
async def register(
|
||||||
|
session: AsyncSession,
|
||||||
|
mailer: Mailer,
|
||||||
|
*,
|
||||||
|
email: str,
|
||||||
|
password: str,
|
||||||
|
display_name: str | None = None,
|
||||||
|
) -> tuple[User, str, datetime]:
|
||||||
|
email = email.strip().lower()
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise Conflict("email already registered")
|
||||||
|
|
||||||
|
user = User(email=email, display_name=display_name, hashed_password=hash_password(password))
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
verify_raw = _create_email_token(session, user, TokenPurpose.email_verify)
|
||||||
|
raw_token, record = _issue_session(session, user)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="register",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after={"email": email},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
|
||||||
|
await mailer.send_email_verification(to=email, link=_link("/auth/verify-email", verify_raw))
|
||||||
|
return user, raw_token, record.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
session: AsyncSession, *, email: str, password: str
|
||||||
|
) -> tuple[User, str, datetime] | None:
|
||||||
|
user = await _local_provider.authenticate(session, identifier=email, secret=password)
|
||||||
|
if user is None:
|
||||||
|
return None
|
||||||
|
raw_token, record = _issue_session(session, user)
|
||||||
|
record_audit(
|
||||||
|
session, action="login", entity_type="User", entity_id=user.id, actor_user_id=user.id
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return user, raw_token, record.expires_at
|
||||||
|
|
||||||
|
|
||||||
|
async def logout(session: AsyncSession, *, raw_token: str) -> None:
|
||||||
|
await session.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(
|
||||||
|
SessionModel.token_hash == hash_token(raw_token),
|
||||||
|
SessionModel.revoked_at.is_(None),
|
||||||
|
)
|
||||||
|
.values(revoked_at=_now())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_session_user(session: AsyncSession, *, raw_token: str) -> User | None:
|
||||||
|
record = (
|
||||||
|
await session.execute(
|
||||||
|
select(SessionModel).where(SessionModel.token_hash == hash_token(raw_token))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if record is None or record.revoked_at is not None or record.expires_at <= _now():
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_email(session: AsyncSession, *, raw_token: str) -> None:
|
||||||
|
token = await _consume_token(session, raw_token, TokenPurpose.email_verify)
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.id == token.user_id).values(email_verified_at=_now())
|
||||||
|
)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="verify_email",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=token.user_id,
|
||||||
|
actor_user_id=token.user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def request_password_reset(session: AsyncSession, mailer: Mailer, *, email: str) -> None:
|
||||||
|
email = email.strip().lower()
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.email == email, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
# Always succeed to avoid leaking which emails are registered.
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
raw = _create_email_token(session, user, TokenPurpose.password_reset)
|
||||||
|
await session.commit()
|
||||||
|
await mailer.send_password_reset(to=email, link=_link("/auth/reset-password", raw))
|
||||||
|
|
||||||
|
|
||||||
|
async def reset_password(session: AsyncSession, *, raw_token: str, new_password: str) -> None:
|
||||||
|
token = await _consume_token(session, raw_token, TokenPurpose.password_reset)
|
||||||
|
await session.execute(
|
||||||
|
update(User)
|
||||||
|
.where(User.id == token.user_id)
|
||||||
|
.values(hashed_password=hash_password(new_password))
|
||||||
|
)
|
||||||
|
# Revoke all existing sessions — a reset invalidates prior logins.
|
||||||
|
await session.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(SessionModel.user_id == token.user_id, SessionModel.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=_now())
|
||||||
|
)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="reset_password",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=token.user_id,
|
||||||
|
actor_user_id=token.user_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
"""Citation service. A citation links one Source to exactly one fact (person,
|
||||||
|
event, name, or relationship) within a tree — the provenance spine."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import CitationConfidence
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
# Citation target column -> model, for tenant/existence validation.
|
||||||
|
_TARGET_MODELS = {
|
||||||
|
"person_id": Person,
|
||||||
|
"event_id": Event,
|
||||||
|
"name_id": Name,
|
||||||
|
"relationship_id": Relationship,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _in_tree(session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID) -> bool:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(model.id).where(
|
||||||
|
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_citation(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
event_id: uuid.UUID | None = None,
|
||||||
|
name_id: uuid.UUID | None = None,
|
||||||
|
relationship_id: uuid.UUID | None = None,
|
||||||
|
page: str | None = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
confidence: CitationConfidence | None = None,
|
||||||
|
) -> Citation:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
targets = {
|
||||||
|
"person_id": person_id,
|
||||||
|
"event_id": event_id,
|
||||||
|
"name_id": name_id,
|
||||||
|
"relationship_id": relationship_id,
|
||||||
|
}
|
||||||
|
set_targets = {k: v for k, v in targets.items() if v is not None}
|
||||||
|
if len(set_targets) != 1:
|
||||||
|
raise Conflict("a citation must reference exactly one fact")
|
||||||
|
|
||||||
|
if not await _in_tree(session, Source, source_id, tree.id):
|
||||||
|
raise NotFound("source not found in this tree")
|
||||||
|
(target_col, target_id), = set_targets.items()
|
||||||
|
if not await _in_tree(session, _TARGET_MODELS[target_col], target_id, tree.id):
|
||||||
|
raise NotFound("cited fact not found in this tree")
|
||||||
|
|
||||||
|
citation = Citation(
|
||||||
|
tree_id=tree.id,
|
||||||
|
source_id=source_id,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
name_id=name_id,
|
||||||
|
relationship_id=relationship_id,
|
||||||
|
page=page,
|
||||||
|
detail=detail,
|
||||||
|
confidence=confidence,
|
||||||
|
)
|
||||||
|
session.add(citation)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Citation",
|
||||||
|
entity_id=citation.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"source_id": str(source_id), target_col: str(target_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(citation)
|
||||||
|
return citation
|
||||||
|
|
||||||
|
|
||||||
|
async def list_citations(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Citation]:
|
||||||
|
"""All citations in the tree — the UI maps them to facts to show 'sourced'
|
||||||
|
indicators in a single round-trip."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Citation)
|
||||||
|
.where(Citation.tree_id == tree.id, Citation.deleted_at.is_(None))
|
||||||
|
.order_by(Citation.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_citation(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
citation = (
|
||||||
|
await session.execute(
|
||||||
|
select(Citation).where(
|
||||||
|
Citation.id == citation_id,
|
||||||
|
Citation.tree_id == tree.id,
|
||||||
|
Citation.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if citation is None:
|
||||||
|
raise NotFound("citation not found")
|
||||||
|
citation.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Citation",
|
||||||
|
entity_id=citation.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Event service. Writes require editor rights; reads go through the privacy
|
||||||
|
engine. Every event has exactly one subject — a Person or a partnership."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def _belongs_to_tree(
|
||||||
|
session: AsyncSession, model: type, id_: uuid.UUID, tree_id: uuid.UUID
|
||||||
|
) -> bool:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(model.id).where(
|
||||||
|
model.id == id_, model.tree_id == tree_id, model.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
event_type: str,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
relationship_id: uuid.UUID | None = None,
|
||||||
|
place_id: uuid.UUID | None = None,
|
||||||
|
date_value: str | None = None,
|
||||||
|
date_start: date | None = None,
|
||||||
|
date_end: date | None = None,
|
||||||
|
date_precision: str | None = None,
|
||||||
|
calendar: str = "gregorian",
|
||||||
|
detail: str | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Event:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
if bool(person_id) == bool(relationship_id):
|
||||||
|
raise Conflict("an event needs exactly one subject: person_id or relationship_id")
|
||||||
|
if person_id and not await _belongs_to_tree(session, Person, person_id, tree.id):
|
||||||
|
raise NotFound("person not found in this tree")
|
||||||
|
if relationship_id and not await _belongs_to_tree(
|
||||||
|
session, Relationship, relationship_id, tree.id
|
||||||
|
):
|
||||||
|
raise NotFound("relationship not found in this tree")
|
||||||
|
if place_id and not await _belongs_to_tree(session, Place, place_id, tree.id):
|
||||||
|
raise NotFound("place not found in this tree")
|
||||||
|
|
||||||
|
event = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
event_type=event_type,
|
||||||
|
person_id=person_id,
|
||||||
|
relationship_id=relationship_id,
|
||||||
|
place_id=place_id,
|
||||||
|
date_value=date_value,
|
||||||
|
date_start=date_start,
|
||||||
|
date_end=date_end,
|
||||||
|
date_precision=date_precision,
|
||||||
|
calendar=calendar,
|
||||||
|
detail=detail,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(event)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"event_type": event_type, "person_id": str(person_id) if person_id else None},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Event]:
|
||||||
|
"""All events in the tree — lets the family view compute birth/death years."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_events_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Event]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.person_id == person_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_event(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, event_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
event = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.id == event_id, Event.tree_id == tree.id, Event.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if event is None:
|
||||||
|
raise NotFound("event not found")
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
event.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Domain errors. The API layer maps these to HTTP status codes so services
|
||||||
|
stay transport-agnostic."""
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Exception):
|
||||||
|
"""Base for domain-level errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(DomainError):
|
||||||
|
"""Requested entity does not exist (or is soft-deleted / not visible)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Forbidden(DomainError):
|
||||||
|
"""Caller lacks the required role for this action."""
|
||||||
|
|
||||||
|
|
||||||
|
class Conflict(DomainError):
|
||||||
|
"""Operation conflicts with current state (e.g. duplicate email)."""
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
"""GEDCOM import/export.
|
||||||
|
|
||||||
|
A pragmatic parser + mapper for the common subset of GEDCOM (5.5.1 / 7 share
|
||||||
|
the line grammar): INDI, FAM, SOUR. Import maps records into a tree and returns
|
||||||
|
a mapping report (counts + unmapped tags); export serializes the tree back to
|
||||||
|
GEDCOM. Runs inline for now — large files should move to the worker later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.place import Place
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.source import Citation, Source
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden
|
||||||
|
|
||||||
|
# GEDCOM event tag -> our event_type (INDI-level).
|
||||||
|
INDI_EVENTS = {
|
||||||
|
"BIRT": "birth", "DEAT": "death", "BAPM": "baptism", "CHR": "christening",
|
||||||
|
"BURI": "burial", "CREM": "cremation", "RESI": "residence", "CENS": "census",
|
||||||
|
"IMMI": "immigration", "EMIG": "emigration", "OCCU": "occupation",
|
||||||
|
"EDUC": "education", "GRAD": "graduation", "RETI": "retirement",
|
||||||
|
"NATU": "naturalization", "BAPL": "baptism",
|
||||||
|
}
|
||||||
|
# FAM-level events.
|
||||||
|
FAM_EVENTS = {"MARR": "marriage", "DIV": "divorce", "ENGA": "engagement"}
|
||||||
|
EVENT_TO_GED = {v: k for k, v in {**INDI_EVENTS, **FAM_EVENTS}.items()}
|
||||||
|
|
||||||
|
|
||||||
|
class GedcomNode:
|
||||||
|
__slots__ = ("level", "tag", "value", "xref", "children")
|
||||||
|
|
||||||
|
def __init__(self, level: int, tag: str, value: str = "", xref: str | None = None):
|
||||||
|
self.level = level
|
||||||
|
self.tag = tag
|
||||||
|
self.value = value
|
||||||
|
self.xref = xref
|
||||||
|
self.children: list[GedcomNode] = []
|
||||||
|
|
||||||
|
def first(self, tag: str) -> "GedcomNode | None":
|
||||||
|
return next((c for c in self.children if c.tag == tag), None)
|
||||||
|
|
||||||
|
def all(self, tag: str) -> list["GedcomNode"]:
|
||||||
|
return [c for c in self.children if c.tag == tag]
|
||||||
|
|
||||||
|
def text(self, tag: str, default: str | None = None) -> str | None:
|
||||||
|
n = self.first(tag)
|
||||||
|
return n.value if n is not None else default
|
||||||
|
|
||||||
|
|
||||||
|
def parse_records(text: str) -> list[GedcomNode]:
|
||||||
|
roots: list[GedcomNode] = []
|
||||||
|
stack: list[GedcomNode] = []
|
||||||
|
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = raw.lstrip("").rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(" ", 1)
|
||||||
|
try:
|
||||||
|
level = int(parts[0])
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
rest = parts[1] if len(parts) > 1 else ""
|
||||||
|
xref: str | None = None
|
||||||
|
if rest.startswith("@"):
|
||||||
|
end = rest.find("@", 1)
|
||||||
|
if end != -1:
|
||||||
|
xref = rest[: end + 1]
|
||||||
|
rest = rest[end + 1:].strip()
|
||||||
|
tparts = rest.split(" ", 1)
|
||||||
|
tag = tparts[0]
|
||||||
|
value = tparts[1] if len(tparts) > 1 else ""
|
||||||
|
|
||||||
|
while stack and stack[-1].level >= level:
|
||||||
|
stack.pop()
|
||||||
|
parent = stack[-1] if stack else None
|
||||||
|
|
||||||
|
if tag in ("CONC", "CONT") and parent is not None:
|
||||||
|
parent.value += ("" if tag == "CONC" else "\n") + value
|
||||||
|
continue
|
||||||
|
|
||||||
|
node = GedcomNode(level, tag, value, xref)
|
||||||
|
if parent is None:
|
||||||
|
roots.append(node)
|
||||||
|
else:
|
||||||
|
parent.children.append(node)
|
||||||
|
stack.append(node)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_name(value: str) -> tuple[str | None, str | None]:
|
||||||
|
if "/" in value:
|
||||||
|
given, _, rest = value.partition("/")
|
||||||
|
surname = rest.split("/", 1)[0]
|
||||||
|
return given.strip() or None, surname.strip() or None
|
||||||
|
return value.strip() or None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _year(date_value: str | None) -> str | None:
|
||||||
|
if not date_value:
|
||||||
|
return None
|
||||||
|
m = re.search(r"\b(\d{3,4})\b", date_value)
|
||||||
|
return m.group(1) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
def _date_start(date_value: str | None) -> date | None:
|
||||||
|
y = _year(date_value)
|
||||||
|
if not y:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date(int(y), 1, 1)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sex(value: str | None) -> str | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
v = value.strip().upper()
|
||||||
|
return {"M": "male", "F": "female"}.get(v, value.strip().lower() or None)
|
||||||
|
|
||||||
|
|
||||||
|
async def import_gedcom(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, text: str
|
||||||
|
) -> dict:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
roots = parse_records(text)
|
||||||
|
counts = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
place_cache: dict[str, uuid.UUID] = {}
|
||||||
|
source_map: dict[str, uuid.UUID] = {}
|
||||||
|
person_map: dict[str, uuid.UUID] = {}
|
||||||
|
|
||||||
|
async def place_id(name: str | None) -> uuid.UUID | None:
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
if name in place_cache:
|
||||||
|
return place_cache[name]
|
||||||
|
p = Place(tree_id=tree.id, name=name)
|
||||||
|
session.add(p)
|
||||||
|
await session.flush()
|
||||||
|
place_cache[name] = p.id
|
||||||
|
counts["places"] += 1
|
||||||
|
return p.id
|
||||||
|
|
||||||
|
# Sources first (so citations can reference them).
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "SOUR" and rec.xref:
|
||||||
|
src = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=rec.text("TITL") or rec.text("ABBR") or "Untitled source",
|
||||||
|
author=rec.text("AUTH"),
|
||||||
|
publication_info=rec.text("PUBL"),
|
||||||
|
citation_text=rec.text("TEXT"),
|
||||||
|
)
|
||||||
|
session.add(src)
|
||||||
|
await session.flush()
|
||||||
|
source_map[rec.xref] = src.id
|
||||||
|
counts["sources"] += 1
|
||||||
|
|
||||||
|
async def add_citations(holder: GedcomNode, **target) -> None:
|
||||||
|
for s in holder.all("SOUR"):
|
||||||
|
sid = source_map.get(s.value.strip())
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Citation(tree_id=tree.id, source_id=sid, page=s.text("PAGE"), **target)
|
||||||
|
)
|
||||||
|
counts["citations"] += 1
|
||||||
|
|
||||||
|
# Individuals.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")))
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
person_map[rec.xref] = person.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
|
||||||
|
for i, nm in enumerate(rec.all("NAME")):
|
||||||
|
given, surname = _parse_name(nm.value)
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
name_type="birth",
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
display_name=nm.value or None,
|
||||||
|
is_primary=(i == 0),
|
||||||
|
sort_order=i,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["names"] += 1
|
||||||
|
|
||||||
|
await add_citations(rec, person_id=person.id)
|
||||||
|
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
dv = child.text("DATE")
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
event_type=INDI_EVENTS[child.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(child.text("PLAC")),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
await add_citations(child, event_id=ev.id)
|
||||||
|
elif child.tag in ("NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID"):
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
|
||||||
|
# Families -> partnerships, parent-child edges, marriage events.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "FAM":
|
||||||
|
continue
|
||||||
|
counts["families"] += 1
|
||||||
|
husb = person_map.get((rec.text("HUSB") or "").strip())
|
||||||
|
wife = person_map.get((rec.text("WIFE") or "").strip())
|
||||||
|
partnership_id: uuid.UUID | None = None
|
||||||
|
if husb and wife:
|
||||||
|
rel = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=RelationshipType.partnership,
|
||||||
|
person_from_id=husb,
|
||||||
|
person_to_id=wife,
|
||||||
|
)
|
||||||
|
session.add(rel)
|
||||||
|
await session.flush()
|
||||||
|
partnership_id = rel.id
|
||||||
|
counts["relationships"] += 1
|
||||||
|
|
||||||
|
for fe in rec.children:
|
||||||
|
if fe.tag in FAM_EVENTS and partnership_id is not None:
|
||||||
|
dv = fe.text("DATE")
|
||||||
|
ev = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
relationship_id=partnership_id,
|
||||||
|
event_type=FAM_EVENTS[fe.tag],
|
||||||
|
date_value=dv,
|
||||||
|
date_start=_date_start(dv),
|
||||||
|
place_id=await place_id(fe.text("PLAC")),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for chil in rec.all("CHIL"):
|
||||||
|
cp = person_map.get(chil.value.strip())
|
||||||
|
if cp is None:
|
||||||
|
continue
|
||||||
|
for parent in (husb, wife):
|
||||||
|
if parent and parent != cp:
|
||||||
|
session.add(
|
||||||
|
Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=RelationshipType.parent_child,
|
||||||
|
person_from_id=parent,
|
||||||
|
person_to_id=cp,
|
||||||
|
qualifier=ParentChildQualifier.biological,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["relationships"] += 1
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Gedcom",
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=dict(counts),
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"counts": dict(counts), "unmapped_tags": sorted(unmapped)}
|
||||||
|
|
||||||
|
|
||||||
|
def _ged_date(value: str | None) -> str | None:
|
||||||
|
return value.strip() if value else None
|
||||||
|
|
||||||
|
|
||||||
|
async def export_gedcom(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> str:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
|
||||||
|
persons = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
names = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
events = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(Event.tree_id == tree.id, Event.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
sources = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
places = {
|
||||||
|
p.id: p
|
||||||
|
for p in (
|
||||||
|
await session.execute(select(Place).where(Place.tree_id == tree.id))
|
||||||
|
).scalars().all()
|
||||||
|
}
|
||||||
|
|
||||||
|
pxref = {p.id: f"@I{i + 1}@" for i, p in enumerate(persons)}
|
||||||
|
gender_by_id = {p.id: p.gender for p in persons}
|
||||||
|
sxref = {s.id: f"@S{i + 1}@" for i, s in enumerate(sources)}
|
||||||
|
names_by_person: dict[uuid.UUID, list[Name]] = defaultdict(list)
|
||||||
|
for n in sorted(names, key=lambda n: (n.sort_order, not n.is_primary)):
|
||||||
|
names_by_person[n.person_id].append(n)
|
||||||
|
events_by_person: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
events_by_rel: dict[uuid.UUID, list[Event]] = defaultdict(list)
|
||||||
|
for e in events:
|
||||||
|
if e.person_id:
|
||||||
|
events_by_person[e.person_id].append(e)
|
||||||
|
elif e.relationship_id:
|
||||||
|
events_by_rel[e.relationship_id].append(e)
|
||||||
|
|
||||||
|
# Build families from parent-child + partnership edges (group by parent set).
|
||||||
|
parents_of: dict[uuid.UUID, set[uuid.UUID]] = defaultdict(set)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.parent_child:
|
||||||
|
parents_of[r.person_to_id].add(r.person_from_id)
|
||||||
|
fams: dict[frozenset, dict] = {}
|
||||||
|
for child, ps in parents_of.items():
|
||||||
|
key = frozenset(ps)
|
||||||
|
fams.setdefault(key, {"parents": set(ps), "children": [], "rel_id": None})
|
||||||
|
fams[key]["children"].append(child)
|
||||||
|
for r in rels:
|
||||||
|
if r.type == RelationshipType.partnership:
|
||||||
|
key = frozenset({r.person_from_id, r.person_to_id})
|
||||||
|
fam = fams.setdefault(
|
||||||
|
key,
|
||||||
|
{"parents": {r.person_from_id, r.person_to_id}, "children": [], "rel_id": None},
|
||||||
|
)
|
||||||
|
fam["rel_id"] = r.id
|
||||||
|
fam_list = list(fams.values())
|
||||||
|
fxref = {id(f): f"@F{i + 1}@" for i, f in enumerate(fam_list)}
|
||||||
|
# person -> the families they are a spouse in / a child in
|
||||||
|
spouse_fams: dict[uuid.UUID, list[str]] = defaultdict(list)
|
||||||
|
child_fams: dict[uuid.UUID, str] = {}
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
for pid in f["parents"]:
|
||||||
|
spouse_fams[pid].append(x)
|
||||||
|
for cid in f["children"]:
|
||||||
|
child_fams[cid] = x
|
||||||
|
|
||||||
|
out: list[str] = ["0 HEAD", "1 SOUR Provenance", "1 GEDC", "2 VERS 5.5.1", "1 CHAR UTF-8"]
|
||||||
|
|
||||||
|
for p in persons:
|
||||||
|
out.append(f"0 {pxref[p.id]} INDI")
|
||||||
|
for n in names_by_person.get(p.id, []):
|
||||||
|
display = n.display_name or f"{n.given or ''} /{n.surname or ''}/".strip()
|
||||||
|
out.append(f"1 NAME {display}")
|
||||||
|
sex = {"male": "M", "female": "F"}.get(p.gender or "")
|
||||||
|
if sex:
|
||||||
|
out.append(f"1 SEX {sex}")
|
||||||
|
for e in events_by_person.get(p.id, []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
if e.place_id and e.place_id in places:
|
||||||
|
out.append(f"2 PLAC {places[e.place_id].name}")
|
||||||
|
if p.id in child_fams:
|
||||||
|
out.append(f"1 FAMC {child_fams[p.id]}")
|
||||||
|
for x in spouse_fams.get(p.id, []):
|
||||||
|
out.append(f"1 FAMS {x}")
|
||||||
|
|
||||||
|
for f in fam_list:
|
||||||
|
x = fxref[id(f)]
|
||||||
|
out.append(f"0 {x} FAM")
|
||||||
|
ps = list(f["parents"])
|
||||||
|
# HUSB/WIFE by recorded gender where possible.
|
||||||
|
males = [pid for pid in ps if gender_by_id.get(pid) == "male"]
|
||||||
|
females = [pid for pid in ps if gender_by_id.get(pid) == "female"]
|
||||||
|
husb = males[0] if males else (ps[0] if ps else None)
|
||||||
|
wife = females[0] if females else next((pid for pid in ps if pid != husb), None)
|
||||||
|
if husb:
|
||||||
|
out.append(f"1 HUSB {pxref[husb]}")
|
||||||
|
if wife:
|
||||||
|
out.append(f"1 WIFE {pxref[wife]}")
|
||||||
|
for cid in f["children"]:
|
||||||
|
out.append(f"1 CHIL {pxref[cid]}")
|
||||||
|
if f["rel_id"]:
|
||||||
|
for e in events_by_rel.get(f["rel_id"], []):
|
||||||
|
tag = EVENT_TO_GED.get(e.event_type)
|
||||||
|
if not tag:
|
||||||
|
continue
|
||||||
|
out.append(f"1 {tag}")
|
||||||
|
if _ged_date(e.date_value):
|
||||||
|
out.append(f"2 DATE {e.date_value}")
|
||||||
|
|
||||||
|
for s in sources:
|
||||||
|
out.append(f"0 {sxref[s.id]} SOUR")
|
||||||
|
if s.title:
|
||||||
|
out.append(f"1 TITL {s.title}")
|
||||||
|
if s.author:
|
||||||
|
out.append(f"1 AUTH {s.author}")
|
||||||
|
if s.publication_info:
|
||||||
|
out.append(f"1 PUBL {s.publication_info}")
|
||||||
|
|
||||||
|
out.append("0 TRLR")
|
||||||
|
return "\n".join(out) + "\n"
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Media service. Bytes go to the ObjectStore; a metadata row goes to the DB.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.media import Media
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
session: AsyncSession,
|
||||||
|
store: ObjectStore,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
data: bytes,
|
||||||
|
filename: str,
|
||||||
|
content_type: str,
|
||||||
|
title: str | None = None,
|
||||||
|
person_id: uuid.UUID | None = None,
|
||||||
|
event_id: uuid.UUID | None = None,
|
||||||
|
source_id: uuid.UUID | None = None,
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(key=key, data=data, content_type=content_type)
|
||||||
|
|
||||||
|
media = Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=actor.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=content_type,
|
||||||
|
byte_size=len(data),
|
||||||
|
checksum_sha256=hashlib.sha256(data).hexdigest(),
|
||||||
|
title=title,
|
||||||
|
person_id=person_id,
|
||||||
|
event_id=event_id,
|
||||||
|
source_id=source_id,
|
||||||
|
)
|
||||||
|
session.add(media)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"filename": filename, "bytes": len(data)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(media)
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def list_media(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Media]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Media)
|
||||||
|
.where(Media.tree_id == tree.id, Media.deleted_at.is_(None))
|
||||||
|
.order_by(Media.created_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_media(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, media_id: uuid.UUID
|
||||||
|
) -> Media:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_media(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
media = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(
|
||||||
|
Media.id == media_id, Media.tree_id == tree.id, Media.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if media is None:
|
||||||
|
raise NotFound("media not found")
|
||||||
|
# Soft delete the row; the object is removed by the worker's purge job.
|
||||||
|
media.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
"""Person service. Writes require editor rights on the tree; reads run every
|
||||||
|
person through the privacy engine. Each returned Person gets a transient
|
||||||
|
``primary_name`` for display (not persisted).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import PersonPrivacy
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
from app.services.privacy import Visibility
|
||||||
|
|
||||||
|
|
||||||
|
def _format_name(name: Name) -> str | None:
|
||||||
|
parts = [name.given, name.surname]
|
||||||
|
joined = " ".join(p for p in parts if p)
|
||||||
|
return joined or name.display_name
|
||||||
|
|
||||||
|
|
||||||
|
def _redact(person: Person) -> None:
|
||||||
|
"""Minimise a possibly-living person for a non-member view (transient only —
|
||||||
|
never committed)."""
|
||||||
|
person.primary_name = "Living person"
|
||||||
|
person.gender = None
|
||||||
|
person.is_living = True
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
|
||||||
|
stmt = (
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||||
|
)
|
||||||
|
name = (await session.execute(stmt)).scalars().first()
|
||||||
|
# Transient display attribute consumed by the PersonRead schema.
|
||||||
|
person.primary_name = _format_name(name) if name is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_person(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
given: str | None = None,
|
||||||
|
surname: str | None = None,
|
||||||
|
gender: str | None = None,
|
||||||
|
is_living: bool | None = None,
|
||||||
|
privacy_setting: PersonPrivacy = PersonPrivacy.inherit,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
person = Person(
|
||||||
|
tree_id=tree.id,
|
||||||
|
gender=gender,
|
||||||
|
is_living=is_living,
|
||||||
|
privacy=privacy_setting,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(person)
|
||||||
|
await session.flush() # assign person.id
|
||||||
|
|
||||||
|
if given or surname:
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
name_type="birth",
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"given": given, "surname": surname},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def get_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id,
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
# Run the single person through the privacy engine (redaction lands Phase 2).
|
||||||
|
vis = await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("person not found")
|
||||||
|
person.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_person(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
person = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree.id, Person.deleted_at.is_not(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if person is None:
|
||||||
|
raise NotFound("deleted person not found")
|
||||||
|
person.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_not(None))
|
||||||
|
.order_by(Person.deleted_at.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
for person in persons:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return persons
|
||||||
|
|
||||||
|
|
||||||
|
async def list_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
.order_by(Person.created_at)
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
visible: list[Person] = []
|
||||||
|
for person in persons:
|
||||||
|
vis = await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
continue
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
visible.append(person)
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
async def search_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, query: str, limit: int = 50
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
q = query.strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
like = f"%{q}%"
|
||||||
|
score = func.greatest(
|
||||||
|
func.similarity(func.coalesce(Name.given, ""), q),
|
||||||
|
func.similarity(func.coalesce(Name.surname, ""), q),
|
||||||
|
)
|
||||||
|
sub = (
|
||||||
|
select(Name.person_id.label("pid"), func.max(score).label("score"))
|
||||||
|
.where(
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Name.given.op("%")(q),
|
||||||
|
Name.surname.op("%")(q),
|
||||||
|
Name.given.ilike(like),
|
||||||
|
Name.surname.ilike(like),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.group_by(Name.person_id)
|
||||||
|
.order_by(func.max(score).desc())
|
||||||
|
.limit(limit)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.join(sub, sub.c.pid == Person.id)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
.order_by(sub.c.score.desc())
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
out: list[Person] = []
|
||||||
|
for person in persons:
|
||||||
|
vis = await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
if vis == Visibility.hidden:
|
||||||
|
continue
|
||||||
|
if vis == Visibility.redacted:
|
||||||
|
_redact(person)
|
||||||
|
else:
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
out.append(person)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""The privacy engine — the single enforcement point for visibility.
|
||||||
|
|
||||||
|
INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a
|
||||||
|
query path that returns rows to a caller without first passing through this
|
||||||
|
module. Effective visibility is a function of the viewer's role on the tree, the
|
||||||
|
tree's visibility, the per-person override, and (Phase 2) living-person status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
|
||||||
|
# A person with no death fact whose birth is within this window (or unknown) is
|
||||||
|
# treated as possibly living and redacted from non-members (ARCHITECTURE §6).
|
||||||
|
LIVING_RECENCY_YEARS = 100
|
||||||
|
|
||||||
|
|
||||||
|
class Visibility(enum.StrEnum):
|
||||||
|
full = "full"
|
||||||
|
redacted = "redacted"
|
||||||
|
hidden = "hidden"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_membership_role(
|
||||||
|
session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID
|
||||||
|
) -> MembershipRole | None:
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
stmt = select(TreeMembership.role).where(
|
||||||
|
TreeMembership.tree_id == tree_id,
|
||||||
|
TreeMembership.user_id == user_id,
|
||||||
|
)
|
||||||
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
return False
|
||||||
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return True
|
||||||
|
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
|
||||||
|
|
||||||
|
|
||||||
|
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
role = await get_membership_role(session, user_id, tree.id)
|
||||||
|
return role in (MembershipRole.owner, MembershipRole.editor)
|
||||||
|
|
||||||
|
|
||||||
|
async def is_possibly_living(session: AsyncSession, person: Person) -> bool:
|
||||||
|
"""True if the person should be treated as living: explicit flag, or (absent
|
||||||
|
a death fact) a birth within the recency window or an unknown birth."""
|
||||||
|
if person.is_living is True:
|
||||||
|
return True
|
||||||
|
if person.is_living is False:
|
||||||
|
return False
|
||||||
|
death = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event.id)
|
||||||
|
.where(
|
||||||
|
Event.person_id == person.id,
|
||||||
|
Event.event_type == "death",
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if death is not None:
|
||||||
|
return False
|
||||||
|
birth = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event.date_start)
|
||||||
|
.where(
|
||||||
|
Event.person_id == person.id,
|
||||||
|
Event.event_type == "birth",
|
||||||
|
Event.date_start.is_not(None),
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if birth is None:
|
||||||
|
return True # unknown birth → treat as possibly living
|
||||||
|
return (datetime.now(UTC).year - birth.year) < LIVING_RECENCY_YEARS
|
||||||
|
|
||||||
|
|
||||||
|
async def person_visibility(
|
||||||
|
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
|
||||||
|
) -> Visibility:
|
||||||
|
if not await can_view_tree(session, user_id=user_id, tree=tree):
|
||||||
|
return Visibility.hidden
|
||||||
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return Visibility.full # members see everyone in their tree
|
||||||
|
# Non-member viewing a public/unlisted tree:
|
||||||
|
if person.privacy == PersonPrivacy.private:
|
||||||
|
return Visibility.hidden
|
||||||
|
if person.privacy == PersonPrivacy.public:
|
||||||
|
return Visibility.full # explicit per-person opt-in
|
||||||
|
if await is_possibly_living(session, person):
|
||||||
|
return Visibility.redacted # living people are protected by default
|
||||||
|
return Visibility.full
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Relationship service. Typed, qualified edges between two Persons in a tree.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import ParentChildQualifier, RelationshipType
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def _person_in_tree(session: AsyncSession, person_id: uuid.UUID, tree_id: uuid.UUID) -> bool:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person.id).where(
|
||||||
|
Person.id == person_id, Person.tree_id == tree_id, Person.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_relationship(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
type: RelationshipType,
|
||||||
|
person_from_id: uuid.UUID,
|
||||||
|
person_to_id: uuid.UUID,
|
||||||
|
qualifier: ParentChildQualifier | None = None,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Relationship:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
if person_from_id == person_to_id:
|
||||||
|
raise Conflict("a relationship needs two different people")
|
||||||
|
if qualifier is not None and type is not RelationshipType.parent_child:
|
||||||
|
raise Conflict("qualifier only applies to parent_child relationships")
|
||||||
|
for pid in (person_from_id, person_to_id):
|
||||||
|
if not await _person_in_tree(session, pid, tree.id):
|
||||||
|
raise NotFound("person not found in this tree")
|
||||||
|
|
||||||
|
relationship = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=type,
|
||||||
|
person_from_id=person_from_id,
|
||||||
|
person_to_id=person_to_id,
|
||||||
|
qualifier=qualifier,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(relationship)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Relationship",
|
||||||
|
entity_id=relationship.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"type": type.value, "from": str(person_from_id), "to": str(person_to_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(relationship)
|
||||||
|
return relationship
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Relationship]:
|
||||||
|
"""All relationships in the tree — powers the family/pedigree view in one call."""
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Relationship)
|
||||||
|
.where(Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None))
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_relationships_for_person(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Relationship]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Relationship)
|
||||||
|
.where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Relationship.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_relationship(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, relationship_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
relationship = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.id == relationship_id,
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if relationship is None:
|
||||||
|
raise NotFound("relationship not found")
|
||||||
|
relationship.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Relationship",
|
||||||
|
entity_id=relationship.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Source service. Sources are reusable, tree-scoped records of an origin.
|
||||||
|
Writes require editor rights; reads go through the privacy engine."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.source import Source
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def create_source(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
title: str,
|
||||||
|
author: str | None = None,
|
||||||
|
source_type: str | None = None,
|
||||||
|
repository: str | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
citation_text: str | None = None,
|
||||||
|
publication_info: str | None = None,
|
||||||
|
quality_note: str | None = None,
|
||||||
|
) -> Source:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
source = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=title,
|
||||||
|
author=author,
|
||||||
|
source_type=source_type,
|
||||||
|
repository=repository,
|
||||||
|
url=url,
|
||||||
|
citation_text=citation_text,
|
||||||
|
publication_info=publication_info,
|
||||||
|
quality_note=quality_note,
|
||||||
|
)
|
||||||
|
session.add(source)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Source",
|
||||||
|
entity_id=source.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"title": title},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(source)
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
async def list_sources(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[Source]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
stmt = (
|
||||||
|
select(Source)
|
||||||
|
.where(Source.tree_id == tree.id, Source.deleted_at.is_(None))
|
||||||
|
.order_by(Source.title)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_source(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, source_id: uuid.UUID
|
||||||
|
) -> Source:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
source = (
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise NotFound("source not found")
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_source(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, source_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
source = (
|
||||||
|
await session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.id == source_id, Source.tree_id == tree.id, Source.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if source is None:
|
||||||
|
raise NotFound("source not found")
|
||||||
|
source.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Source",
|
||||||
|
entity_id=source.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Tree service. Creating a tree also creates the owner's TreeMembership (the
|
||||||
|
authorization basis) and an audit entry. Reads go through the privacy engine.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole, TreeVisibility
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
async def create_tree(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
owner: User,
|
||||||
|
name: str,
|
||||||
|
description: str | None = None,
|
||||||
|
visibility: TreeVisibility = TreeVisibility.private,
|
||||||
|
) -> Tree:
|
||||||
|
tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility)
|
||||||
|
session.add(tree)
|
||||||
|
await session.flush() # assign tree.id
|
||||||
|
session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner))
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=owner.id,
|
||||||
|
after={"name": name, "visibility": visibility.value},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tree)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
|
stmt = (
|
||||||
|
select(Tree)
|
||||||
|
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||||
|
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
.order_by(Tree.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree:
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def _owned_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
"""Load a tree (including soft-deleted) and require the actor be its owner."""
|
||||||
|
tree = await BaseRepository(session, Tree).get(tree_id, include_deleted=True)
|
||||||
|
if tree is None:
|
||||||
|
raise NotFound("tree not found")
|
||||||
|
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||||
|
if role is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can delete or restore a tree")
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> None:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is None:
|
||||||
|
tree.deleted_at = datetime.now(UTC)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def restore_tree(session: AsyncSession, *, actor: User, tree_id: uuid.UUID) -> Tree:
|
||||||
|
tree = await _owned_tree(session, actor=actor, tree_id=tree_id)
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
tree.deleted_at = None
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="restore",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
|
async def list_deleted_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||||
|
stmt = (
|
||||||
|
select(Tree)
|
||||||
|
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||||
|
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_not(None))
|
||||||
|
.order_by(Tree.deleted_at.desc())
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""User service. Account creation here is a temporary, open dev bootstrap so we
|
||||||
|
can create tree owners before the auth slice exists; the auth slice replaces it
|
||||||
|
with the AuthProvider (password/OIDC/social) and proper verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
session: AsyncSession, *, email: str, display_name: str | None = None
|
||||||
|
) -> User:
|
||||||
|
email = email.strip().lower()
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise Conflict("email already registered")
|
||||||
|
|
||||||
|
user = User(email=email, display_name=display_name)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush() # assign user.id
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after={"email": email},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||||
|
return await BaseRepository(session, User).get(user_id)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Background worker. Same image as the backend, run in worker mode
|
||||||
|
(`python -m app.worker`). First job: the scheduled soft-delete purge — hard-
|
||||||
|
delete rows whose ``deleted_at`` is older than the recovery window, and remove
|
||||||
|
their objects from storage. More jobs (media processing, scraping, hints) and a
|
||||||
|
proper queue arrive in later phases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.db import get_sessionmaker
|
||||||
|
from app.integrations.objectstore.s3 import S3ObjectStore
|
||||||
|
from app.models import (
|
||||||
|
Citation,
|
||||||
|
Event,
|
||||||
|
Media,
|
||||||
|
Name,
|
||||||
|
Person,
|
||||||
|
Place,
|
||||||
|
PlaceName,
|
||||||
|
Relationship,
|
||||||
|
Source,
|
||||||
|
Tree,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("provenance.worker")
|
||||||
|
|
||||||
|
# Child -> parent so foreign keys are satisfied as rows are removed.
|
||||||
|
_PURGE_ORDER = [Citation, Name, Event, Relationship, PlaceName, Place, Source, Person, Tree, User]
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_media(sessionmaker, store, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Media).where(Media.deleted_at.is_not(None), Media.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for media in rows:
|
||||||
|
try:
|
||||||
|
await store.delete_object(key=media.storage_key)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("object delete failed for %s: %s", media.storage_key, exc)
|
||||||
|
await session.delete(media)
|
||||||
|
await session.commit()
|
||||||
|
if rows:
|
||||||
|
logger.info("purged %d media", len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
async def _purge_table(sessionmaker, model, cutoff: datetime) -> None:
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
try:
|
||||||
|
res = await session.execute(
|
||||||
|
delete(model).where(model.deleted_at.is_not(None), model.deleted_at < cutoff)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if res.rowcount:
|
||||||
|
logger.info("purged %d %s", res.rowcount, model.__tablename__)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
await session.rollback()
|
||||||
|
logger.warning("purge %s failed: %s", model.__tablename__, exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_once(sessionmaker, store) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=settings.purge_after_days)
|
||||||
|
await _purge_media(sessionmaker, store, cutoff)
|
||||||
|
for model in _PURGE_ORDER:
|
||||||
|
await _purge_table(sessionmaker, model, cutoff)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s", stream=sys.stdout
|
||||||
|
)
|
||||||
|
settings = get_settings()
|
||||||
|
store = S3ObjectStore(settings)
|
||||||
|
try:
|
||||||
|
await store.ensure_bucket()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("ensure_bucket failed: %s", exc)
|
||||||
|
sessionmaker = get_sessionmaker()
|
||||||
|
logger.info(
|
||||||
|
"worker started; purge every %ds (recovery window %dd)",
|
||||||
|
settings.purge_interval_seconds,
|
||||||
|
settings.purge_after_days,
|
||||||
|
)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await purge_once(sessionmaker, store)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("purge cycle error: %s", exc)
|
||||||
|
await asyncio.sleep(settings.purge_interval_seconds)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Alembic environment — async, URL sourced from settings (DATABASE_URL)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models import Base # noqa: F401 — importing registers all models
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# Inject the runtime database URL (asyncpg driver) from the environment.
|
||||||
|
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=config.get_main_option("sqlalchemy.url"),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
compare_type=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_run_migrations(connection) -> None:
|
||||||
|
context.configure(
|
||||||
|
connection=connection,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
compare_type=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_migrations_online() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(_do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
asyncio.run(run_migrations_online())
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""auth sessions and tokens
|
||||||
|
|
||||||
|
Revision ID: 1f6e54f6406a
|
||||||
|
Revises: ec43c338e155
|
||||||
|
Create Date: 2026-06-06 10:47:06.454748
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '1f6e54f6406a'
|
||||||
|
down_revision: str | None = 'ec43c338e155'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('sessions',
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_sessions_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_sessions'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sessions_token_hash'), 'sessions', ['token_hash'], unique=True)
|
||||||
|
op.create_index(op.f('ix_sessions_user_id'), 'sessions', ['user_id'], unique=False)
|
||||||
|
op.create_table('user_tokens',
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('purpose', sa.Enum('email_verify', 'password_reset', name='token_purpose'), nullable=False),
|
||||||
|
sa.Column('token_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_user_tokens_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_user_tokens'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_user_tokens_token_hash'), 'user_tokens', ['token_hash'], unique=True)
|
||||||
|
op.create_index(op.f('ix_user_tokens_user_id'), 'user_tokens', ['user_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_user_tokens_user_id'), table_name='user_tokens')
|
||||||
|
op.drop_index(op.f('ix_user_tokens_token_hash'), table_name='user_tokens')
|
||||||
|
op.drop_table('user_tokens')
|
||||||
|
op.drop_index(op.f('ix_sessions_user_id'), table_name='sessions')
|
||||||
|
op.drop_index(op.f('ix_sessions_token_hash'), table_name='sessions')
|
||||||
|
op.drop_table('sessions')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# Enum type created implicitly by create_table(); drop it for reversibility.
|
||||||
|
op.execute("DROP TYPE IF EXISTS token_purpose")
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""media
|
||||||
|
|
||||||
|
Revision ID: 7fc7024ef432
|
||||||
|
Revises: 1f6e54f6406a
|
||||||
|
Create Date: 2026-06-06 21:44:03.204170
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '7fc7024ef432'
|
||||||
|
down_revision: str | None = '1f6e54f6406a'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('media',
|
||||||
|
sa.Column('uploader_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('storage_key', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('original_filename', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('content_type', sa.String(length=128), nullable=False),
|
||||||
|
sa.Column('byte_size', sa.BigInteger(), nullable=False),
|
||||||
|
sa.Column('checksum_sha256', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('title', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('event_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('source_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_media_event_id_events'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_media_person_id_persons'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_media_source_id_sources'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_media_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['uploader_id'], ['users.id'], name=op.f('fk_media_uploader_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_media')),
|
||||||
|
sa.UniqueConstraint('storage_key', name=op.f('uq_media_storage_key'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_media_checksum_sha256'), 'media', ['checksum_sha256'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_event_id'), 'media', ['event_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_person_id'), 'media', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_source_id'), 'media', ['source_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_tree_id'), 'media', ['tree_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_media_uploader_id'), 'media', ['uploader_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_media_uploader_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_tree_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_source_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_person_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_event_id'), table_name='media')
|
||||||
|
op.drop_index(op.f('ix_media_checksum_sha256'), table_name='media')
|
||||||
|
op.drop_table('media')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""pg_trgm extension + trigram name indexes for fuzzy search
|
||||||
|
|
||||||
|
Revision ID: 9a2b1c7d4e10
|
||||||
|
Revises: 7fc7024ef432
|
||||||
|
Create Date: 2026-06-07
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "9a2b1c7d4e10"
|
||||||
|
down_revision: str | None = "7fc7024ef432"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_names_given_trgm "
|
||||||
|
"ON names USING gin (given gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_names_surname_trgm "
|
||||||
|
"ON names USING gin (surname gin_trgm_ops)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_names_surname_trgm")
|
||||||
|
op.execute("DROP INDEX IF EXISTS ix_names_given_trgm")
|
||||||
|
# Leave the pg_trgm extension in place; other features may rely on it.
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"""core data model
|
||||||
|
|
||||||
|
Revision ID: ec43c338e155
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-06-06 10:27:41.671787
|
||||||
|
|
||||||
|
"""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'ec43c338e155'
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('users',
|
||||||
|
sa.Column('email', sa.String(length=320), nullable=False),
|
||||||
|
sa.Column('email_verified_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('display_name', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('hashed_password', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_users'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||||
|
op.create_table('trees',
|
||||||
|
sa.Column('owner_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('visibility', sa.Enum('public', 'unlisted', 'private', name='tree_visibility'), server_default='private', nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], name=op.f('fk_trees_owner_id_users'), ondelete='RESTRICT'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_trees'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_trees_owner_id'), 'trees', ['owner_id'], unique=False)
|
||||||
|
op.create_table('audit_entries',
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('actor_type', sa.Enum('user', 'assistant', name='audit_actor_type'), server_default='user', nullable=False),
|
||||||
|
sa.Column('actor_user_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('action', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('entity_type', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('entity_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('before', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('after', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['actor_user_id'], ['users.id'], name=op.f('fk_audit_entries_actor_user_id_users'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_audit_entries_tree_id_trees'), ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_entries'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_audit_entries_actor_user_id'), 'audit_entries', ['actor_user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_entries_created_at'), 'audit_entries', ['created_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_audit_entries_tree_id'), 'audit_entries', ['tree_id'], unique=False)
|
||||||
|
op.create_table('persons',
|
||||||
|
sa.Column('gender', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('is_living', sa.Boolean(), nullable=True),
|
||||||
|
sa.Column('privacy', sa.Enum('inherit', 'private', 'public', name='person_privacy'), server_default='inherit', nullable=False),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_persons_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_persons'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_persons_tree_id'), 'persons', ['tree_id'], unique=False)
|
||||||
|
op.create_table('places',
|
||||||
|
sa.Column('name', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('parent_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('place_type', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('latitude', sa.Float(), nullable=True),
|
||||||
|
sa.Column('longitude', sa.Float(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['parent_id'], ['places.id'], name=op.f('fk_places_parent_id_places'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_places_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_places'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_places_parent_id'), 'places', ['parent_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_places_tree_id'), 'places', ['tree_id'], unique=False)
|
||||||
|
op.create_table('sources',
|
||||||
|
sa.Column('title', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('author', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('source_type', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('repository', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('url', sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column('citation_text', sa.Text(), nullable=True),
|
||||||
|
sa.Column('publication_info', sa.Text(), nullable=True),
|
||||||
|
sa.Column('quality_note', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_sources_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_sources'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_sources_tree_id'), 'sources', ['tree_id'], unique=False)
|
||||||
|
op.create_table('tree_memberships',
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('role', sa.Enum('owner', 'editor', 'viewer', name='membership_role'), nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_tree_memberships_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_tree_memberships_user_id_users'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_tree_memberships')),
|
||||||
|
sa.UniqueConstraint('tree_id', 'user_id', name='uq_tree_memberships_tree_user')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_tree_memberships_tree_id'), 'tree_memberships', ['tree_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_tree_memberships_user_id'), 'tree_memberships', ['user_id'], unique=False)
|
||||||
|
op.create_table('names',
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name_type', sa.String(length=32), server_default='birth', nullable=False),
|
||||||
|
sa.Column('given', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('surname', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('prefix', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('suffix', sa.String(length=64), nullable=True),
|
||||||
|
sa.Column('nickname', sa.String(length=128), nullable=True),
|
||||||
|
sa.Column('display_name', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('is_primary', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_names_person_id_persons'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_names_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_names'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_names_person_id'), 'names', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_names_tree_id'), 'names', ['tree_id'], unique=False)
|
||||||
|
op.create_table('place_names',
|
||||||
|
sa.Column('place_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('valid_from', sa.Date(), nullable=True),
|
||||||
|
sa.Column('valid_to', sa.Date(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_place_names_place_id_places'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_place_names_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_place_names'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_place_names_place_id'), 'place_names', ['place_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_place_names_tree_id'), 'place_names', ['tree_id'], unique=False)
|
||||||
|
op.create_table('relationships',
|
||||||
|
sa.Column('type', sa.Enum('parent_child', 'partnership', 'sibling', name='relationship_type'), nullable=False),
|
||||||
|
sa.Column('person_from_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('person_to_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('qualifier', sa.Enum('biological', 'adoptive', 'step', 'foster', 'donor', 'guardian', name='parent_child_qualifier'), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint('person_from_id <> person_to_id', name=op.f('ck_relationships_different_persons')),
|
||||||
|
sa.ForeignKeyConstraint(['person_from_id'], ['persons.id'], name=op.f('fk_relationships_person_from_id_persons'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['person_to_id'], ['persons.id'], name=op.f('fk_relationships_person_to_id_persons'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_relationships_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_relationships'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_relationships_person_from_id'), 'relationships', ['person_from_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_relationships_person_to_id'), 'relationships', ['person_to_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_relationships_tree_id'), 'relationships', ['tree_id'], unique=False)
|
||||||
|
op.create_table('events',
|
||||||
|
sa.Column('event_type', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('relationship_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('place_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('date_value', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('date_start', sa.Date(), nullable=True),
|
||||||
|
sa.Column('date_end', sa.Date(), nullable=True),
|
||||||
|
sa.Column('date_precision', sa.String(length=32), nullable=True),
|
||||||
|
sa.Column('calendar', sa.String(length=32), server_default='gregorian', nullable=False),
|
||||||
|
sa.Column('detail', sa.String(length=512), nullable=True),
|
||||||
|
sa.Column('notes', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint('(person_id IS NOT NULL) <> (relationship_id IS NOT NULL)', name=op.f('ck_events_subject_person_xor_relationship')),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_events_person_id_persons'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['place_id'], ['places.id'], name=op.f('fk_events_place_id_places'), ondelete='SET NULL'),
|
||||||
|
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_events_relationship_id_relationships'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_events_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_events'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_events_event_type'), 'events', ['event_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_events_person_id'), 'events', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_events_place_id'), 'events', ['place_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_events_relationship_id'), 'events', ['relationship_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_events_tree_id'), 'events', ['tree_id'], unique=False)
|
||||||
|
op.create_table('citations',
|
||||||
|
sa.Column('source_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('person_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('event_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('name_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('relationship_id', sa.Uuid(), nullable=True),
|
||||||
|
sa.Column('page', sa.String(length=255), nullable=True),
|
||||||
|
sa.Column('detail', sa.Text(), nullable=True),
|
||||||
|
sa.Column('confidence', sa.Enum('high', 'medium', 'low', name='citation_confidence'), nullable=True),
|
||||||
|
sa.Column('id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('tree_id', sa.Uuid(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.CheckConstraint('(person_id IS NOT NULL)::int + (event_id IS NOT NULL)::int + (name_id IS NOT NULL)::int + (relationship_id IS NOT NULL)::int = 1', name=op.f('ck_citations_exactly_one_target')),
|
||||||
|
sa.ForeignKeyConstraint(['event_id'], ['events.id'], name=op.f('fk_citations_event_id_events'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['name_id'], ['names.id'], name=op.f('fk_citations_name_id_names'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['person_id'], ['persons.id'], name=op.f('fk_citations_person_id_persons'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['relationship_id'], ['relationships.id'], name=op.f('fk_citations_relationship_id_relationships'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['source_id'], ['sources.id'], name=op.f('fk_citations_source_id_sources'), ondelete='CASCADE'),
|
||||||
|
sa.ForeignKeyConstraint(['tree_id'], ['trees.id'], name=op.f('fk_citations_tree_id_trees'), ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_citations'))
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_citations_event_id'), 'citations', ['event_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_citations_name_id'), 'citations', ['name_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_citations_person_id'), 'citations', ['person_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_citations_relationship_id'), 'citations', ['relationship_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_citations_source_id'), 'citations', ['source_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_citations_tree_id'), 'citations', ['tree_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_citations_tree_id'), table_name='citations')
|
||||||
|
op.drop_index(op.f('ix_citations_source_id'), table_name='citations')
|
||||||
|
op.drop_index(op.f('ix_citations_relationship_id'), table_name='citations')
|
||||||
|
op.drop_index(op.f('ix_citations_person_id'), table_name='citations')
|
||||||
|
op.drop_index(op.f('ix_citations_name_id'), table_name='citations')
|
||||||
|
op.drop_index(op.f('ix_citations_event_id'), table_name='citations')
|
||||||
|
op.drop_table('citations')
|
||||||
|
op.drop_index(op.f('ix_events_tree_id'), table_name='events')
|
||||||
|
op.drop_index(op.f('ix_events_relationship_id'), table_name='events')
|
||||||
|
op.drop_index(op.f('ix_events_place_id'), table_name='events')
|
||||||
|
op.drop_index(op.f('ix_events_person_id'), table_name='events')
|
||||||
|
op.drop_index(op.f('ix_events_event_type'), table_name='events')
|
||||||
|
op.drop_table('events')
|
||||||
|
op.drop_index(op.f('ix_relationships_tree_id'), table_name='relationships')
|
||||||
|
op.drop_index(op.f('ix_relationships_person_to_id'), table_name='relationships')
|
||||||
|
op.drop_index(op.f('ix_relationships_person_from_id'), table_name='relationships')
|
||||||
|
op.drop_table('relationships')
|
||||||
|
op.drop_index(op.f('ix_place_names_tree_id'), table_name='place_names')
|
||||||
|
op.drop_index(op.f('ix_place_names_place_id'), table_name='place_names')
|
||||||
|
op.drop_table('place_names')
|
||||||
|
op.drop_index(op.f('ix_names_tree_id'), table_name='names')
|
||||||
|
op.drop_index(op.f('ix_names_person_id'), table_name='names')
|
||||||
|
op.drop_table('names')
|
||||||
|
op.drop_index(op.f('ix_tree_memberships_user_id'), table_name='tree_memberships')
|
||||||
|
op.drop_index(op.f('ix_tree_memberships_tree_id'), table_name='tree_memberships')
|
||||||
|
op.drop_table('tree_memberships')
|
||||||
|
op.drop_index(op.f('ix_sources_tree_id'), table_name='sources')
|
||||||
|
op.drop_table('sources')
|
||||||
|
op.drop_index(op.f('ix_places_tree_id'), table_name='places')
|
||||||
|
op.drop_index(op.f('ix_places_parent_id'), table_name='places')
|
||||||
|
op.drop_table('places')
|
||||||
|
op.drop_index(op.f('ix_persons_tree_id'), table_name='persons')
|
||||||
|
op.drop_table('persons')
|
||||||
|
op.drop_index(op.f('ix_audit_entries_tree_id'), table_name='audit_entries')
|
||||||
|
op.drop_index(op.f('ix_audit_entries_created_at'), table_name='audit_entries')
|
||||||
|
op.drop_index(op.f('ix_audit_entries_actor_user_id'), table_name='audit_entries')
|
||||||
|
op.drop_table('audit_entries')
|
||||||
|
op.drop_index(op.f('ix_trees_owner_id'), table_name='trees')
|
||||||
|
op.drop_table('trees')
|
||||||
|
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||||
|
op.drop_table('users')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
# Enum types are created implicitly by create_table() but not dropped by
|
||||||
|
# drop_table(); drop them explicitly so downgrade is fully reversible.
|
||||||
|
for enum_name in (
|
||||||
|
"tree_visibility",
|
||||||
|
"membership_role",
|
||||||
|
"person_privacy",
|
||||||
|
"relationship_type",
|
||||||
|
"parent_child_qualifier",
|
||||||
|
"citation_confidence",
|
||||||
|
"audit_actor_type",
|
||||||
|
):
|
||||||
|
op.execute(f"DROP TYPE IF EXISTS {enum_name}")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
[project]
|
||||||
|
name = "provenance-backend"
|
||||||
|
version = "0.0.0"
|
||||||
|
description = "Provenance backend — FastAPI service for family + land provenance."
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115",
|
||||||
|
"uvicorn[standard]>=0.34",
|
||||||
|
"pydantic>=2.9",
|
||||||
|
"pydantic-settings>=2.5",
|
||||||
|
"sqlalchemy[asyncio]>=2.0",
|
||||||
|
"asyncpg>=0.30",
|
||||||
|
"alembic>=1.14",
|
||||||
|
"argon2-cffi>=23.1",
|
||||||
|
"boto3>=1.35",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"ruff>=0.8",
|
||||||
|
"pytest>=8.3",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"httpx>=0.27",
|
||||||
|
]
|
||||||
|
|
||||||
|
# This is an application, not a library: install dependencies but do not build/
|
||||||
|
# install the project itself. Code runs from source via `uv run`.
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py313"
|
||||||
|
# Alembic writes the migration files; don't hold generated code to our style.
|
||||||
|
extend-exclude = ["migrations/versions"]
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "I", "UP", "B"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.flake8-bugbear]
|
||||||
|
# FastAPI uses these as call-expressions in argument defaults by design.
|
||||||
|
extend-immutable-calls = ["fastapi.File", "fastapi.Form", "fastapi.Depends", "fastapi.Query", "fastapi.Header"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
pythonpath = ["."]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Test fixtures.
|
||||||
|
|
||||||
|
DB-backed tests require ``TEST_DATABASE_URL`` (an async URL to a *disposable*
|
||||||
|
Postgres); without it they skip, so the no-DB unit suite still runs anywhere.
|
||||||
|
The schema is built from the models via ``create_all`` and dropped per test for
|
||||||
|
isolation. A capturing mailer replaces the real one so email flows are testable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
import app.models # noqa: F401 — register all models on Base.metadata
|
||||||
|
from app.api.deps import get_mailer, get_objectstore
|
||||||
|
from app.core.db import get_session
|
||||||
|
from app.integrations.mailer.base import Mailer
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.main import app
|
||||||
|
from app.models import Base
|
||||||
|
|
||||||
|
TEST_DATABASE_URL = os.getenv("TEST_DATABASE_URL")
|
||||||
|
|
||||||
|
|
||||||
|
class CapturingMailer(Mailer):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.verifications: list[tuple[str, str]] = []
|
||||||
|
self.resets: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def send_email_verification(self, *, to: str, link: str) -> None:
|
||||||
|
self.verifications.append((to, link))
|
||||||
|
|
||||||
|
async def send_password_reset(self, *, to: str, link: str) -> None:
|
||||||
|
self.resets.append((to, link))
|
||||||
|
|
||||||
|
|
||||||
|
class FakeObjectStore(ObjectStore):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.objects: dict[str, tuple[bytes, str]] = {}
|
||||||
|
|
||||||
|
async def ensure_bucket(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def put_object(self, *, key: str, data: bytes, content_type: str) -> None:
|
||||||
|
self.objects[key] = (data, content_type)
|
||||||
|
|
||||||
|
async def get_object(self, *, key: str) -> bytes:
|
||||||
|
return self.objects[key][0]
|
||||||
|
|
||||||
|
async def presigned_get_url(self, *, key: str) -> str:
|
||||||
|
return f"https://objects.test/{key}"
|
||||||
|
|
||||||
|
async def delete_object(self, *, key: str) -> None:
|
||||||
|
self.objects.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
_mailer = CapturingMailer()
|
||||||
|
_store = FakeObjectStore()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mailer() -> CapturingMailer:
|
||||||
|
return _mailer
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client():
|
||||||
|
if not TEST_DATABASE_URL:
|
||||||
|
pytest.skip("TEST_DATABASE_URL not set")
|
||||||
|
|
||||||
|
engine = create_async_engine(TEST_DATABASE_URL)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
sessionmaker = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||||
|
|
||||||
|
async def _override_session():
|
||||||
|
async with sessionmaker() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
_mailer.verifications.clear()
|
||||||
|
_mailer.resets.clear()
|
||||||
|
_store.objects.clear()
|
||||||
|
app.dependency_overrides[get_session] = _override_session
|
||||||
|
app.dependency_overrides[get_mailer] = lambda: _mailer
|
||||||
|
app.dependency_overrides[get_objectstore] = lambda: _store
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as http_client:
|
||||||
|
yield http_client
|
||||||
|
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def token_from_link(link: str) -> str:
|
||||||
|
return link.split("token=", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
async def register(client, email: str, password: str = "password123") -> str:
|
||||||
|
"""Register a user and return their bearer session token."""
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/register", json={"email": email, "password": password}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["token"]
|
||||||
|
|
||||||
|
|
||||||
|
def auth(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Auth flows: registration, login, email verification, password reset, logout."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register, token_from_link
|
||||||
|
|
||||||
|
|
||||||
|
async def test_register_issues_session_and_verification_email(client, mailer):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": "new@example.com", "password": "password123", "display_name": "New"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["token"]
|
||||||
|
assert body["user"]["email"] == "new@example.com"
|
||||||
|
assert body["user"]["email_verified_at"] is None
|
||||||
|
# A verification email was "sent".
|
||||||
|
assert len(mailer.verifications) == 1
|
||||||
|
assert mailer.verifications[0][0] == "new@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_email_conflicts(client):
|
||||||
|
await register(client, "dupe@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/register", json={"email": "dupe@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_wrong_password_rejected(client):
|
||||||
|
await register(client, "user@example.com", password="password123")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "user@example.com", "password": "wrong-password"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_login_succeeds_and_me_returns_user(client):
|
||||||
|
await register(client, "user2@example.com", password="password123")
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "user2@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
token = resp.json()["token"]
|
||||||
|
resp = await client.get("/api/v1/users/me", headers=auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["email"] == "user2@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_email_verification(client, mailer):
|
||||||
|
await register(client, "verify@example.com")
|
||||||
|
token = token_from_link(mailer.verifications[0][1])
|
||||||
|
resp = await client.post("/api/v1/auth/verify-email", json={"token": token})
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Logging in and checking /me shows the address is now verified.
|
||||||
|
login = await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "verify@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
me = await client.get("/api/v1/users/me", headers=auth(login.json()["token"]))
|
||||||
|
assert me.json()["email_verified_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_password_reset_flow_revokes_old_sessions(client, mailer):
|
||||||
|
old_token = await register(client, "reset@example.com", password="password123")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/request-password-reset", json={"email": "reset@example.com"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 202
|
||||||
|
reset_token = token_from_link(mailer.resets[0][1])
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/reset-password",
|
||||||
|
json={"token": reset_token, "new_password": "new-password456"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
|
||||||
|
# Old session is revoked.
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=auth(old_token))).status_code == 401
|
||||||
|
# Old password no longer works; new one does.
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login", json={"email": "reset@example.com", "password": "password123"}
|
||||||
|
)
|
||||||
|
).status_code == 401
|
||||||
|
assert (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": "reset@example.com", "password": "new-password456"},
|
||||||
|
)
|
||||||
|
).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_request_password_reset_unknown_email_still_accepted(client, mailer):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/request-password-reset", json={"email": "nobody@example.com"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 202
|
||||||
|
assert len(mailer.resets) == 0 # no email sent, but no enumeration either
|
||||||
|
|
||||||
|
|
||||||
|
async def test_logout_revokes_session(client):
|
||||||
|
token = await register(client, "logout@example.com")
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 200
|
||||||
|
assert (await client.post("/api/v1/auth/logout", headers=auth(token))).status_code == 204
|
||||||
|
assert (await client.get("/api/v1/users/me", headers=auth(token))).status_code == 401
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""End-to-end coverage of the core data model through the API: tenancy, the
|
||||||
|
privacy seam, and real session auth."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_and_person_flow(client):
|
||||||
|
token = await register(client, "keeper@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/trees",
|
||||||
|
json={"name": "Smith Family", "visibility": "private"},
|
||||||
|
headers=auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
tree = resp.json()
|
||||||
|
assert tree["visibility"] == "private"
|
||||||
|
tree_id = tree["id"]
|
||||||
|
|
||||||
|
resp = await client.get("/api/v1/trees", headers=auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons",
|
||||||
|
json={"given": "John", "surname": "Smith"},
|
||||||
|
headers=auth(token),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
person = resp.json()
|
||||||
|
assert person["primary_name"] == "John Smith"
|
||||||
|
assert person["tree_id"] == tree_id
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_private_tree_isolated_from_other_users(client):
|
||||||
|
owner = await register(client, "owner@example.com")
|
||||||
|
other = await register(client, "stranger@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Private", "visibility": "private"}, headers=auth(owner)
|
||||||
|
)
|
||||||
|
tree_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(other))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
resp = await client.get(f"/api/v1/trees/{tree_id}/persons", headers=auth(other))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_tree_viewable_but_not_editable_by_non_member(client):
|
||||||
|
owner = await register(client, "owner2@example.com")
|
||||||
|
viewer = await register(client, "viewer2@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=auth(owner)
|
||||||
|
)
|
||||||
|
tree_id = resp.json()["id"]
|
||||||
|
|
||||||
|
resp = await client.get(f"/api/v1/trees/{tree_id}", headers=auth(viewer))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Nope"}, headers=auth(viewer)
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
async def test_auth_required_without_token(client):
|
||||||
|
resp = await client.get("/api/v1/trees")
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""GEDCOM import + export round-trip."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
SAMPLE = b"""0 HEAD
|
||||||
|
1 CHAR UTF-8
|
||||||
|
0 @I1@ INDI
|
||||||
|
1 NAME John /Smith/
|
||||||
|
1 SEX M
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1850
|
||||||
|
2 PLAC Boston, Massachusetts
|
||||||
|
0 @I2@ INDI
|
||||||
|
1 NAME Mary /Jones/
|
||||||
|
1 SEX F
|
||||||
|
0 @I3@ INDI
|
||||||
|
1 NAME Junior /Smith/
|
||||||
|
1 BIRT
|
||||||
|
2 DATE 1872
|
||||||
|
0 @F1@ FAM
|
||||||
|
1 HUSB @I1@
|
||||||
|
1 WIFE @I2@
|
||||||
|
1 CHIL @I3@
|
||||||
|
1 MARR
|
||||||
|
2 DATE 1870
|
||||||
|
0 TRLR
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "Imported"}, headers=h)).json()["id"]
|
||||||
|
return h, tid
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_import(client):
|
||||||
|
h, tid = await _tree(client, "ged1@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
counts = resp.json()["counts"]
|
||||||
|
assert counts["persons"] == 3
|
||||||
|
assert counts["families"] == 1
|
||||||
|
# partnership (1) + parent_child from both parents to the child (2)
|
||||||
|
assert counts["relationships"] == 3
|
||||||
|
assert counts["events"] == 3 # 2 births + 1 marriage
|
||||||
|
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=h)).json()
|
||||||
|
assert len(people) == 3
|
||||||
|
rels = (await client.get(f"/api/v1/trees/{tid}/relationships", headers=h)).json()
|
||||||
|
assert len(rels) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gedcom_export_and_reimport(client):
|
||||||
|
h, tid = await _tree(client, "ged2@example.com")
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/gedcom/import",
|
||||||
|
files={"file": ("sample.ged", SAMPLE, "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
exported = await client.get(f"/api/v1/trees/{tid}/gedcom/export", headers=h)
|
||||||
|
assert exported.status_code == 200
|
||||||
|
text = exported.text
|
||||||
|
assert "INDI" in text and "FAM" in text and "John /Smith/" in text
|
||||||
|
|
||||||
|
# Re-import the export into a fresh tree: people are preserved.
|
||||||
|
tid2 = (await client.post("/api/v1/trees", json={"name": "Round"}, headers=h)).json()["id"]
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tid2}/gedcom/import",
|
||||||
|
files={"file": ("rt.ged", text.encode(), "text/plain")},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.json()["counts"]["persons"] == 3
|
||||||
|
assert resp.json()["counts"]["relationships"] == 3
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""Events and relationships through the API."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup_tree_with_two_people(client, email: str):
|
||||||
|
token = await register(client, email)
|
||||||
|
h = auth(token)
|
||||||
|
tree_id = (
|
||||||
|
await client.post("/api/v1/trees", json={"name": "Graph"}, headers=h)
|
||||||
|
).json()["id"]
|
||||||
|
parent = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons",
|
||||||
|
json={"given": "Anna", "surname": "Vogel"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
child = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons",
|
||||||
|
json={"given": "Beth", "surname": "Vogel"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return h, tree_id, parent, child
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_create_list_delete(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "ev1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": parent, "date_value": "ABT 1850"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
event_id = resp.json()["id"]
|
||||||
|
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
assert listed.json()[0]["event_type"] == "birth"
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/events/{event_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/persons/{parent}/events", headers=h)
|
||||||
|
assert len(listed.json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_event_requires_exactly_one_subject(client):
|
||||||
|
h, tree_id, _, _ = await _setup_tree_with_two_people(client, "ev2@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events", json={"event_type": "birth"}, headers=h
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relationship_create_and_list(client):
|
||||||
|
h, tree_id, parent, child = await _setup_tree_with_two_people(client, "rel1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/relationships",
|
||||||
|
json={
|
||||||
|
"type": "parent_child",
|
||||||
|
"person_from_id": parent,
|
||||||
|
"person_to_id": child,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
|
||||||
|
for pid in (parent, child):
|
||||||
|
listed = await client.get(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons/{pid}/relationships", headers=h
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
assert listed.json()[0]["qualifier"] == "biological"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_relationship_validation(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel2@example.com")
|
||||||
|
# Same person on both ends.
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/relationships",
|
||||||
|
json={"type": "sibling", "person_from_id": parent, "person_to_id": parent},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
# Qualifier on a non-parent_child edge.
|
||||||
|
h2, t2, p_a, p_b = await _setup_tree_with_two_people(client, "rel3@example.com")
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{t2}/relationships",
|
||||||
|
json={
|
||||||
|
"type": "partnership",
|
||||||
|
"person_from_id": p_a,
|
||||||
|
"person_to_id": p_b,
|
||||||
|
"qualifier": "biological",
|
||||||
|
},
|
||||||
|
headers=h2,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_write_graph(client):
|
||||||
|
h, tree_id, parent, _ = await _setup_tree_with_two_people(client, "rel4@example.com")
|
||||||
|
other = auth(await register(client, "intruder@example.com"))
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": parent},
|
||||||
|
headers=other,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Liveness probe test. Readiness is covered by an integration test once a
|
||||||
|
test database fixture exists (it requires a live Postgres)."""
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def test_health_liveness():
|
||||||
|
resp = client.get("/health")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.json()
|
||||||
|
assert body["status"] == "ok"
|
||||||
|
assert body["service"] == "Provenance"
|
||||||
|
assert "version" in body
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Media upload/list/delete through the API (object store faked in conftest)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _tree(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "M"}, headers=h)).json()["id"]
|
||||||
|
return h, tree_id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_media_upload_list_delete(client):
|
||||||
|
h, tree_id = await _tree(client, "media1@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("scan.txt", b"hello world", "text/plain")},
|
||||||
|
data={"title": "A scan"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
body = resp.json()
|
||||||
|
assert body["original_filename"] == "scan.txt"
|
||||||
|
assert body["byte_size"] == 11
|
||||||
|
assert body["url"] == f"/api/v1/trees/{tree_id}/media/{body['id']}/content"
|
||||||
|
media_id = body["id"]
|
||||||
|
|
||||||
|
listed = await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert len(listed.json()) == 1
|
||||||
|
|
||||||
|
# The content endpoint streams the bytes back.
|
||||||
|
content = await client.get(f"/api/v1/trees/{tree_id}/media/{media_id}/content", headers=h)
|
||||||
|
assert content.status_code == 200
|
||||||
|
assert content.content == b"hello world"
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/media/{media_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/media", headers=h)).json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_upload(client):
|
||||||
|
h, tree_id = await _tree(client, "media2@example.com")
|
||||||
|
other = auth(await register(client, "media-intruder@example.com"))
|
||||||
|
resp = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/media",
|
||||||
|
files={"file": ("x.txt", b"x", "text/plain")},
|
||||||
|
headers=other,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Living-person protection: living people are redacted from non-members."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_living_person_redacted_for_non_members(client):
|
||||||
|
owner = auth(await register(client, "pub-owner@example.com"))
|
||||||
|
tid = (
|
||||||
|
await client.post(
|
||||||
|
"/api/v1/trees", json={"name": "Public", "visibility": "public"}, headers=owner
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Old", "surname": "Ancestor", "is_living": False},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": "Young", "surname": "Living", "is_living": True},
|
||||||
|
headers=owner,
|
||||||
|
)
|
||||||
|
|
||||||
|
other = auth(await register(client, "pub-viewer@example.com"))
|
||||||
|
people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=other)).json()
|
||||||
|
names = {p["primary_name"] for p in people}
|
||||||
|
assert "Old Ancestor" in names # deceased is visible
|
||||||
|
assert "Living person" in names # living is redacted
|
||||||
|
assert "Young Living" not in names # the real living name is hidden
|
||||||
|
# The redacted person leaks no gender.
|
||||||
|
living = next(p for p in people if p["primary_name"] == "Living person")
|
||||||
|
assert living["gender"] is None
|
||||||
|
|
||||||
|
# The owner (a member) sees real names.
|
||||||
|
owner_people = (await client.get(f"/api/v1/trees/{tid}/persons", headers=owner)).json()
|
||||||
|
assert "Young Living" in {p["primary_name"] for p in owner_people}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Soft-delete + recovery for trees and people."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_tree_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec1@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
|
||||||
|
# Delete -> gone from active lists, present in the recovery list.
|
||||||
|
assert (await client.delete(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 204
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 0
|
||||||
|
# A soft-deleted tree is no longer visible (404 to the would-be viewer).
|
||||||
|
gone = await client.get(f"/api/v1/trees/{tree_id}", headers=h)
|
||||||
|
assert gone.status_code == 404
|
||||||
|
deleted = (await client.get("/api/v1/trees?deleted=true", headers=h)).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["id"] == tree_id
|
||||||
|
|
||||||
|
# Restore -> back in active lists.
|
||||||
|
assert (await client.post(f"/api/v1/trees/{tree_id}/restore", headers=h)).status_code == 200
|
||||||
|
assert len((await client.get("/api/v1/trees", headers=h)).json()) == 1
|
||||||
|
assert (await client.get(f"/api/v1/trees/{tree_id}", headers=h)).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_owner_can_delete_tree(client):
|
||||||
|
owner = auth(await register(client, "rec-owner@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=owner)).json()["id"]
|
||||||
|
other = auth(await register(client, "rec-other@example.com"))
|
||||||
|
blocked = await client.delete(f"/api/v1/trees/{tree_id}", headers=other)
|
||||||
|
assert blocked.status_code in (403, 404)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_person_delete_and_restore(client):
|
||||||
|
h = auth(await register(client, "rec2@example.com"))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "T"}, headers=h)).json()["id"]
|
||||||
|
person_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Ada"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.delete(f"/api/v1/trees/{tree_id}/persons/{person_id}", headers=h)
|
||||||
|
).status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 0
|
||||||
|
deleted = (
|
||||||
|
await client.get(f"/api/v1/trees/{tree_id}/persons?deleted=true", headers=h)
|
||||||
|
).json()
|
||||||
|
assert len(deleted) == 1 and deleted[0]["primary_name"] == "Ada"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await client.post(f"/api/v1/trees/{tree_id}/persons/{person_id}/restore", headers=h)
|
||||||
|
).status_code == 200
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/persons", headers=h)).json()) == 1
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Fuzzy name search (pg_trgm)."""
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fuzzy_name_search(client):
|
||||||
|
h = auth(await register(client, "search@example.com"))
|
||||||
|
tid = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
|
||||||
|
for given, surname in [("Hans", "Mueller"), ("John", "Smith"), ("Anna", "Vogel")]:
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tid}/persons",
|
||||||
|
json={"given": given, "surname": surname},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Trigram fuzziness: "muller" should find "Mueller" (not a substring match).
|
||||||
|
r = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "muller"}, headers=h)
|
||||||
|
assert r.status_code == 200
|
||||||
|
names = [p["primary_name"] or "" for p in r.json()]
|
||||||
|
assert any("Mueller" in n for n in names)
|
||||||
|
|
||||||
|
# Substring search still works.
|
||||||
|
r2 = await client.get(f"/api/v1/trees/{tid}/persons", params={"q": "smi"}, headers=h)
|
||||||
|
assert any("Smith" in (p["primary_name"] or "") for p in r2.json())
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Sources and citations (the provenance spine)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from tests.conftest import auth, register
|
||||||
|
|
||||||
|
|
||||||
|
async def _setup(client, email):
|
||||||
|
h = auth(await register(client, email))
|
||||||
|
tree_id = (await client.post("/api/v1/trees", json={"name": "S"}, headers=h)).json()["id"]
|
||||||
|
person = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/persons", json={"given": "Cy"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
event = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/events",
|
||||||
|
json={"event_type": "birth", "person_id": person},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
return h, tree_id, person, event
|
||||||
|
|
||||||
|
|
||||||
|
async def test_source_and_citation_flow(client):
|
||||||
|
h, tree_id, person, event = await _setup(client, "src1@example.com")
|
||||||
|
|
||||||
|
src = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/sources",
|
||||||
|
json={"title": "1880 Census", "repository": "NARA"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert src.status_code == 201, src.text
|
||||||
|
source_id = src.json()["id"]
|
||||||
|
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/sources", headers=h)).json()) == 1
|
||||||
|
|
||||||
|
# Cite the source on the birth event.
|
||||||
|
cite = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/citations",
|
||||||
|
json={"source_id": source_id, "event_id": event, "page": "p. 4"},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert cite.status_code == 201, cite.text
|
||||||
|
citation_id = cite.json()["id"]
|
||||||
|
|
||||||
|
citations = (await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()
|
||||||
|
assert len(citations) == 1
|
||||||
|
assert citations[0]["event_id"] == event
|
||||||
|
assert citations[0]["source_id"] == source_id
|
||||||
|
|
||||||
|
resp = await client.delete(f"/api/v1/trees/{tree_id}/citations/{citation_id}", headers=h)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert len((await client.get(f"/api/v1/trees/{tree_id}/citations", headers=h)).json()) == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_citation_needs_exactly_one_target(client):
|
||||||
|
h, tree_id, person, event = await _setup(client, "src2@example.com")
|
||||||
|
source_id = (
|
||||||
|
await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/sources", json={"title": "X"}, headers=h
|
||||||
|
)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
# No target.
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/citations", json={"source_id": source_id}, headers=h
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
# Two targets.
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/citations",
|
||||||
|
json={"source_id": source_id, "person_id": person, "event_id": event},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
async def test_citation_unknown_source_404(client):
|
||||||
|
h, tree_id, person, _ = await _setup(client, "src3@example.com")
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/citations",
|
||||||
|
json={"source_id": str(uuid.uuid4()), "person_id": person},
|
||||||
|
headers=h,
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_non_member_cannot_create_source(client):
|
||||||
|
h, tree_id, _, _ = await _setup(client, "src4@example.com")
|
||||||
|
other = auth(await register(client, "src-intruder@example.com"))
|
||||||
|
r = await client.post(
|
||||||
|
f"/api/v1/trees/{tree_id}/sources", json={"title": "nope"}, headers=other
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
Generated
+1006
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
|||||||
|
# Provenance configuration — copy to `.env` and fill in. Never commit `.env`.
|
||||||
|
# Everything is twelve-factor; no endpoints or secrets live in code.
|
||||||
|
|
||||||
|
# --- Core ---
|
||||||
|
APP_ENV=development
|
||||||
|
|
||||||
|
# --- Images (pulled from git.jpaul.io; CI pushes to the LAN registry) ---
|
||||||
|
# test-main = current main build; or pin a semver / test-sha-<sha> for rollback.
|
||||||
|
IMAGE_TAG=test-main
|
||||||
|
|
||||||
|
# --- Database (Postgres) ---
|
||||||
|
POSTGRES_USER=provenance
|
||||||
|
POSTGRES_PASSWORD=change-me
|
||||||
|
POSTGRES_DB=provenance
|
||||||
|
# Backend connection string (async driver). Host 'postgres' = compose service.
|
||||||
|
DATABASE_URL=postgresql+asyncpg://provenance:change-me@postgres:5432/provenance
|
||||||
|
|
||||||
|
# --- Object storage (S3-compatible / MinIO) ---
|
||||||
|
MINIO_ROOT_USER=provenance
|
||||||
|
MINIO_ROOT_PASSWORD=change-me-too
|
||||||
|
S3_ENDPOINT_URL=http://minio:9000
|
||||||
|
S3_BUCKET=provenance
|
||||||
|
S3_ACCESS_KEY=provenance
|
||||||
|
S3_SECRET_KEY=change-me-too
|
||||||
|
S3_REGION=us-east-1
|
||||||
|
|
||||||
|
# --- Edge (Caddy) ---
|
||||||
|
# Local: ':80' (http://localhost). Production: 'provenance.example.com' for auto-HTTPS.
|
||||||
|
# Behind a Cloudflare Tunnel, keep ':80' — Cloudflare terminates TLS and the
|
||||||
|
# tunnel forwards plain HTTP to caddy:80.
|
||||||
|
PROVENANCE_SITE_ADDRESS=:80
|
||||||
|
|
||||||
|
# --- Deploy-host services (optional, selected via COMPOSE_PROFILES) ---
|
||||||
|
# 'tunnel' -> cloudflared connector (needs CLOUDFLARE_TUNNEL_TOKEN; public hostname -> http://caddy:80)
|
||||||
|
# Auto-deploy is handled by the host's global Watchtower (watches the
|
||||||
|
# watchtower-enabled backend/frontend labels) — no profile needed here.
|
||||||
|
CLOUDFLARE_TUNNEL_TOKEN=
|
||||||
|
COMPOSE_PROFILES=
|
||||||
|
|
||||||
|
# --- Auth / sessions ---
|
||||||
|
SESSION_TTL_DAYS=30
|
||||||
|
TOKEN_TTL_HOURS=24
|
||||||
|
# Set false for local http; true (default) behind TLS.
|
||||||
|
COOKIE_SECURE=false
|
||||||
|
# Base URL used to build links in outbound email.
|
||||||
|
APP_BASE_URL=http://localhost
|
||||||
|
# Mailer: 'console' logs links to stdout (dev); 'smtp' uses the SMTP settings below.
|
||||||
|
MAILER=console
|
||||||
|
|
||||||
|
# --- Email (SMTP) — wired in a later phase ---
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
|
||||||
|
# --- Model providers — wired in Phase 4 (AI assistant). BYO key. ---
|
||||||
|
# ANTHROPIC_API_KEY=
|
||||||
|
# OPENAI_API_KEY=
|
||||||
|
# XAI_API_KEY=
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Provenance edge. Site address is env-driven: ':80' for local http://localhost,
|
||||||
|
# a domain in production for automatic HTTPS. Behind a Cloudflare Tunnel you can
|
||||||
|
# keep this on plain HTTP and let the tunnel terminate TLS.
|
||||||
|
|
||||||
|
{$PROVENANCE_SITE_ADDRESS::80} {
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
# Versioned API surface (FastAPI). The assistant mounts under /assistant later.
|
||||||
|
handle /api/* {
|
||||||
|
reverse_proxy backend:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Liveness/readiness probes, proxied for external monitoring.
|
||||||
|
handle /health* {
|
||||||
|
reverse_proxy backend:8000
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else -> the Next.js frontend.
|
||||||
|
handle {
|
||||||
|
reverse_proxy frontend:3000
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user