Compare commits
174 Commits
4e115086e6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7043532c3b | |||
| 1340d1957f | |||
| e24a7cfcc9 | |||
| 07944e329e | |||
| a33a88e558 | |||
| fe8349819f | |||
| e745fb5d4d | |||
| e0573e6be2 | |||
| 3731d77d4b | |||
| bf1576252b | |||
| 0ed6ba4505 | |||
| ed263cf9a7 | |||
| f7666ad30b | |||
| 690a6da659 | |||
| e7115023e1 | |||
| 58400ffdf7 | |||
| 629bfa1367 | |||
| 1562febdcf | |||
| 265f5f4e7a | |||
| a6179037c2 | |||
| 7ed3ddd448 | |||
| 447daf7fa8 | |||
| 0388b9b99f | |||
| 00f403defa | |||
| 519f1c31b5 | |||
| 3a1395b6af | |||
| 2712ae469b | |||
| 88beb9650f | |||
| 15504ba6e1 | |||
| c5631d3eab | |||
| 6fbad3106d | |||
| 94b5caa7e5 | |||
| f8fa23c1f6 | |||
| c6b1e72130 | |||
| ceafb299d6 | |||
| de50f2c803 | |||
| 9187c0a791 | |||
| abaa8efdd5 | |||
| 251a10a087 | |||
| 330543f9ce | |||
| d540dc3f32 | |||
| 8652425413 | |||
| 3a7728f1dc | |||
| eb0350733b | |||
| 6d3147e86d | |||
| b4434cb5dd | |||
| 39e3eac3df | |||
| 660fe7b37f | |||
| 5485dd2077 | |||
| 05d2773e25 | |||
| 768c68cbe0 | |||
| 7d6fbce87e | |||
| 12ba0a0fb6 | |||
| 150d69e5ac | |||
| 053ce357ac | |||
| 269cae556f | |||
| 0df44e7e59 | |||
| 7a5c5f2882 | |||
| 20c7fbd8d6 | |||
| b8405ced07 | |||
| 91a7ce1dc2 | |||
| 8b91326481 | |||
| 671b560768 | |||
| 6a5ef4d392 | |||
| 3810b65de0 | |||
| 9820a77d25 | |||
| 3ff03b037b | |||
| 84a743f5b9 | |||
| e6dfe39e84 | |||
| 4a3fe983fa | |||
| 251652a935 | |||
| dc1b6aac01 | |||
| f93327f5d3 | |||
| c86771034c | |||
| b51b65de80 | |||
| 93c22b4bcf | |||
| 7255920135 | |||
| 62513ee22e | |||
| ac0b9818dd | |||
| 182a5dab16 | |||
| 77b78410ff | |||
| fe1e0171ff | |||
| 9dbdae975a | |||
| c5a2a7f0d4 | |||
| 8c36785197 | |||
| fae1162ff8 | |||
| 1025f86657 | |||
| a53858f920 | |||
| 941f9827c1 | |||
| 6ec852a23a | |||
| 7405ec762f | |||
| aa62ca490e | |||
| 97f7a9e0ff | |||
| cd4ccb4ac8 | |||
| 6696015970 | |||
| e8839b15a0 | |||
| 548e883d82 | |||
| 37ac49767e | |||
| 9b04bcefba | |||
| e9b2436ce0 | |||
| 8903e480cf | |||
| d27cc5dddc | |||
| 943f459b91 | |||
| 5106538934 | |||
| 2669543e56 | |||
| 0262ed3d97 | |||
| 9ee960c4ef | |||
| 7f640649b9 | |||
| a8929c2862 | |||
| b90ba53a3f | |||
| c4e9d69e00 | |||
| 0673896133 | |||
| 1164841950 | |||
| 5824e70895 | |||
| 04ccdbf96a | |||
| f165ccb941 | |||
| e0fb924a1d | |||
| cf5518c7ec | |||
| 26df03cfd7 | |||
| ab064bce6e | |||
| 76b7f453c1 | |||
| 438d2db2e7 | |||
| 99913ada94 | |||
| 584b323121 | |||
| 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*
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ These are product invariants, not preferences. Do not violate them, and flag any
|
|||||||
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
|
5. **Sources are first-class.** Don't model citations as free-text afterthoughts. A `Source` is a reusable entity; a `Citation` links it to a specific fact.
|
||||||
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
|
6. **Only legal data sources.** Ship scrapers/connectors only for permissible sources (FamilySearch API, Find A Grave, WikiTree, BLM/GLO, USGS, public-domain newspapers, public county records). Never add connectors for paywalled/terms-prohibited sites (Ancestry, MyHeritage, 23andMe).
|
||||||
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
|
7. **Everything is configurable via environment.** Auth, mail, object storage, database, model providers, scrapers — all twelve-factor. No hard-coded endpoints or keys.
|
||||||
|
8. **Full CRUD on every object.** Every stored entity (person, name, event, relationship, source, citation, media, tree, …) must support create, read, **update**, and delete — in the API *and* the UI. Historical research is constant correction and new information, so nothing is write-once. Any new feature or data type ships with all four operations; an entity you can create but not edit is a bug.
|
||||||
|
|
||||||
## Tech stack
|
## Tech stack
|
||||||
|
|
||||||
@@ -29,7 +30,8 @@ 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.
|
- **Model providers:** pluggable `LLMProvider` + `EmbeddingProvider` abstraction (ABCs) with Null / Anthropic / OpenAI-compatible (OpenAI, xAI, Ollama) implementations; an operator configures one or more via env and they're selectable by name through a registry (per-tree AI policy + `default_llm_provider`/`default_embedding_provider`).
|
||||||
|
- **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 +40,24 @@ 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, objectstore, models = pluggable LLM/embedding providers), core}; migrations/ = Alembic
|
||||||
|
/deploy # docker-compose.yml (+ docker-compose.dev.yml), Caddyfile, .env.example, backup.sh + BACKUP.md (one-command pg_dump + MinIO backup) — 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 landed **deploy-first**: the compose stack (Postgres + MinIO + Caddy + FastAPI backend) and CI before the data model and frontend. Backend deps use **uv**; migrations use **Alembic**. Status (keep current as the tree grows):
|
||||||
|
|
||||||
|
- **Phase 0 — Foundation: complete** and running live (core data model, local auth behind `AuthProvider`, Next.js frontend).
|
||||||
|
- **Phase 1 — Core tree: complete.** Media (upload/serve), soft-delete + recovery UI, full CRUD across entities, and the 4-level tree visibility/privacy model (#41–#51).
|
||||||
|
- **Phase 2 — substantially landed.** GEDCOM import (preview→apply, duplicate-aware) and export (citation-preserving, #232); fuzzy name search (pg_trgm) + the public `/explore` directory. Living-person protection is still hardening.
|
||||||
|
- **Phase 4 — AI assistant foundations landed.** Pluggable `LLMProvider`/`EmbeddingProvider` abstraction + multi-provider registry (Anthropic/OpenAI/xAI/Ollama, #235/#237), the **ChangeProposal** propose-then-confirm flow (#236), and per-tree AI model policy (#238). The assistant's *tool surface that emits proposals* is the remaining piece.
|
||||||
|
- Also shipped: tree membership management (#233), an **instance owner/operator** role (`OWNER_EMAIL`, #240), a schema-drift readiness guard (#239), and a one-command operator backup (#234).
|
||||||
|
- **Not built yet:** Phase 3 (Property — parcels/deeds/chain-of-title; no property models exist), Phase 5 (OIDC/social auth — only the `AuthProvider` ABC exists), and cross-tree hints (last; needs multiple populated trees + the embedding provider).
|
||||||
|
|
||||||
## 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. **Phases 0 and 1 are complete**, Phase 2 is substantially done, and Phase 4's AI foundations have shipped (see the status list above). The biggest unbuilt areas are **Phase 3 (Property)** and **Phase 5 (OIDC/social auth)** — likely current targets. 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
|
||||||
@@ -53,7 +66,7 @@ The roadmap is phased in PRD §8. Build in dependency order. **Phase 0 — Found
|
|||||||
5. The deploy stack: `compose` for app + postgres + objectstore, Caddy config, env-driven settings
|
5. The deploy stack: `compose` for app + postgres + objectstore, Caddy config, env-driven settings
|
||||||
6. CI/CD: Gitea Actions building images to the registry
|
6. CI/CD: Gitea Actions building images to the registry
|
||||||
|
|
||||||
Don't get ahead of the phases. GEDCOM lands before the assistant (so AI writes target a stable model); property follows a tested people graph; hints come last because they need multiple populated trees. If you think the order is wrong, raise it rather than reordering silently.
|
Don't get ahead of the phases. GEDCOM and the assistant's propose-diff foundation (provider abstraction + ChangeProposal approval flow) have shipped; the remaining dependency-ordered work is **Property** (Phase 3, on top of the tested people graph), then richer collaboration/audit UI, with **cross-tree hints last** (they need multiple populated trees and the embedding provider). If you think the order is wrong, raise it rather than reordering silently.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
@@ -64,6 +77,23 @@ Don't get ahead of the phases. GEDCOM lands before the assistant (so AI writes t
|
|||||||
- **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change.
|
- **Privacy/assistant/hint code gets extra care** — these are the areas where bugs do real harm. Prefer a design note before a large change.
|
||||||
- **No secrets in the repo.** Config via env; provide `.env.example` with placeholders.
|
- **No secrets in the repo.** Config via env; provide `.env.example` with placeholders.
|
||||||
|
|
||||||
|
## Patched dependencies (family-chart)
|
||||||
|
|
||||||
|
The tree view uses **family-chart** (d3-based). Two adjustments live in the repo:
|
||||||
|
|
||||||
|
- **CSS is vendored** at `frontend/app/trees/[id]/tree/chart.css` — the package blocks its CSS subpath export, so we copy it in.
|
||||||
|
- **The library is patched** via `patch-package` (`frontend/patches/family-chart+0.9.0.patch`, applied by the `postinstall` hook; the backend/frontend Dockerfiles `COPY patches` before install). Both hunks touch `dist/family-chart.js` **and** `dist/family-chart.esm.js` (the app loads the `esm` build). Current fixes:
|
||||||
|
1. **Spouse-centering layout** (`setupSpouses` / `sortChildrenWithSpouses`) — center a person between two spouses with children under the correct pair.
|
||||||
|
2. **`cardToMiddle` vertical centering** — the lib scaled `datum.x` by the zoom factor `k` but not `datum.y`, so "fly to a node" drifted vertically at any zoom ≠ 1; we add the missing `* k`.
|
||||||
|
|
||||||
|
To change a patch: edit the file(s) under `node_modules/family-chart/dist/`, then `cd frontend && npx patch-package family-chart` to regenerate, and verify with `npx patch-package --error-on-fail`.
|
||||||
|
|
||||||
|
**Upstreamed.** Both are general library bugfixes, not app-specific, and are submitted upstream:
|
||||||
|
- `cardToMiddle` vertical centering — **donatso/family-chart#103** (issue **#102**).
|
||||||
|
- Multi-spouse centered layout — **donatso/family-chart#105** (issue **#104**).
|
||||||
|
|
||||||
|
If either is merged + released, bump `family-chart`, drop the corresponding patch hunk, **and** remove any in-app compensation (e.g. the `cardToMiddle` caller in `tree/page.tsx` passes raw `y` precisely because the patch fixes it — pre-scaling there too would double-correct). Until then, keep the patch.
|
||||||
|
|
||||||
## License & contribution terms
|
## License & contribution terms
|
||||||
|
|
||||||
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
|
Provenance is **source-available** under **BUSL-1.1** (see [LICENSE](LICENSE)): free for personal/family/non-commercial use, no third-party commercial hosting, and each release converts to **AGPL-3.0** four years after it ships. The DCO sign-off keeps the licensing chain clean so the maintainer can manage that conversion and a possible future hosted offering. Don't add code under an incompatible license, and don't vendor dependencies whose licenses conflict with eventual AGPL distribution.
|
||||||
|
|||||||
@@ -19,13 +19,14 @@ Every fact links to its source. Every claim can be traced. Nothing is just asser
|
|||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
- **Build a tree that holds up.** People, relationships, events, and places — with every fact linked to the document, photo, or record it came from.
|
- **Build a tree that holds up.** People, relationships, events, and places — with every fact linked to the document, photo, or record it came from.
|
||||||
- **Trace the land, not just the family.** Properties are first-class. Record ownership events (grants, deeds, inheritances, sales), reconstruct chain-of-title, and tie parcels to the people who held them.
|
|
||||||
- **Bring your own archive.** Scans, PDFs, photos, audio recordings — first-class citizens, not afterthoughts.
|
- **Bring your own archive.** Scans, PDFs, photos, audio recordings — first-class citizens, not afterthoughts.
|
||||||
- **A research assistant that proposes, never overwrites.** The built-in AI assistant searches legal sources, lays out what it found, and waits for your approval before anything touches your data. You can point it at the major model providers or a self-hosted model — your keys, your choice.
|
- **A research assistant that proposes, never overwrites.** The built-in AI assistant searches legal sources, lays out what it found, and waits for your approval before anything touches your data. You can point it at the major model providers or a self-hosted model — your keys, your choice.
|
||||||
- **Standards over silos.** Full GEDCOM 7 import and export. Migrate in, migrate out.
|
- **Standards over silos.** GEDCOM import and export (5.5.1 / 7 common subset) — duplicate-aware import, citation-preserving export. Migrate in, migrate out.
|
||||||
- **Privacy you control.** Public, unlisted, or private per tree; any individual can be hidden; living people are protected by default.
|
- **Privacy you control.** Public, members-only (any signed-in user on your instance), unlisted, or private per tree; any individual can be hidden; living people are protected by default.
|
||||||
- **Find your people.** When another user's tree overlaps with yours, Provenance can surface an anonymous "possible match" — and only connects you if you both say yes.
|
- **Find your people.** When another user's tree overlaps with yours, Provenance can surface an anonymous "possible match" — and only connects you if you both say yes.
|
||||||
- **Run it your way.** Container-native. Self-host behind Caddy and, if you like, a Cloudflare Tunnel. Multi-tenant, so your whole extended family — or a whole community of strangers — can coexist on one deployment.
|
- **Run it your way.** Container-native. Self-host behind Caddy and, if you like, a Cloudflare Tunnel. Multi-tenant, so your whole extended family — or a whole community of strangers — can coexist on one deployment. One-command backups (Postgres + object storage) and an instance-owner admin role keep operations in your hands.
|
||||||
|
|
||||||
|
**Where it's headed — trace the land, not just the family.** The same source-backed treatment for *property*: parcels, deeds, and ownership events, reconstructing chain-of-title and tying land to the people who held it. The people side ships today; the land half is on the roadmap, not yet built — but it's why Provenance exists, not an afterthought.
|
||||||
|
|
||||||
## Who it's for
|
## Who it's for
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.md
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# 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
|
||||||
|
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||||
|
RUN chmod +x ./docker-entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# The entrypoint runs migrations first when RUN_MIGRATIONS=1, then the command.
|
||||||
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
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,182 @@
|
|||||||
|
"""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.models.base import EmbeddingProvider, LLMProvider
|
||||||
|
from app.integrations.models.null import NullEmbeddingProvider, NullLLMProvider
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user_or_none(request: Request, session: SessionDep) -> User | None:
|
||||||
|
"""Optional auth for public read endpoints — never raises. Returns the user
|
||||||
|
when a valid session is present, else None (anonymous viewer)."""
|
||||||
|
raw_token = extract_session_token(request)
|
||||||
|
if raw_token is None:
|
||||||
|
return None
|
||||||
|
return await auth_service.resolve_session_user(session, raw_token=raw_token)
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUserOrNone = Annotated[User | None, Depends(get_current_user_or_none)]
|
||||||
|
|
||||||
|
|
||||||
|
def is_instance_owner(user: User) -> bool:
|
||||||
|
"""Whether this account is an instance owner/operator — i.e. its email is
|
||||||
|
named in OWNER_EMAIL *and* that email has been verified. Instance ownership
|
||||||
|
is an operational/config role; it does NOT bypass the privacy engine or grant
|
||||||
|
access to others' tree data.
|
||||||
|
|
||||||
|
The verified-email requirement is load-bearing: registration is open and (by
|
||||||
|
default) doesn't require verification, so without it an attacker could claim
|
||||||
|
the owner email by registering it before the operator does — a land-grab to
|
||||||
|
the highest role with no proof of inbox control. Requiring verification ties
|
||||||
|
ownership to actual control of the named inbox regardless of the global
|
||||||
|
REQUIRE_EMAIL_VERIFICATION setting. (Self-hosts without SMTP can verify via
|
||||||
|
the link the console mailer prints to the operator-controlled logs.)"""
|
||||||
|
owners = get_settings().owner_emails()
|
||||||
|
return (
|
||||||
|
bool(owners)
|
||||||
|
and user.email_verified_at is not None
|
||||||
|
and user.email.strip().lower() in owners
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_instance_owner(current: CurrentUser) -> User:
|
||||||
|
if not is_instance_owner(current):
|
||||||
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "instance owner only")
|
||||||
|
return current
|
||||||
|
|
||||||
|
|
||||||
|
InstanceOwner = Annotated[User, Depends(require_instance_owner)]
|
||||||
|
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_llm_providers() -> dict[str, LLMProvider]:
|
||||||
|
"""Every LLM provider whose credentials are configured, keyed by name. Run
|
||||||
|
several at once; pick one with get_llm_provider(name)."""
|
||||||
|
from app.integrations.models.anthropic_provider import AnthropicLLMProvider
|
||||||
|
from app.integrations.models.openai_compat import OpenAICompatibleLLMProvider
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
providers: dict[str, LLMProvider] = {}
|
||||||
|
if s.anthropic_api_key:
|
||||||
|
providers["anthropic"] = AnthropicLLMProvider(
|
||||||
|
api_key=s.anthropic_api_key, model=s.anthropic_model, max_tokens=s.llm_max_tokens
|
||||||
|
)
|
||||||
|
if s.openai_api_key:
|
||||||
|
providers["openai"] = OpenAICompatibleLLMProvider(
|
||||||
|
api_key=s.openai_api_key, base_url=s.openai_base_url, model=s.openai_model,
|
||||||
|
max_tokens=s.llm_max_tokens,
|
||||||
|
)
|
||||||
|
if s.xai_api_key:
|
||||||
|
providers["xai"] = OpenAICompatibleLLMProvider(
|
||||||
|
api_key=s.xai_api_key, base_url=s.xai_base_url, model=s.xai_model,
|
||||||
|
max_tokens=s.llm_max_tokens,
|
||||||
|
)
|
||||||
|
if s.ollama_enabled:
|
||||||
|
providers["ollama"] = OpenAICompatibleLLMProvider(
|
||||||
|
api_key=None, base_url=s.ollama_base_url, model=s.ollama_model,
|
||||||
|
max_tokens=s.llm_max_tokens,
|
||||||
|
)
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def configured_llm_providers() -> list[dict]:
|
||||||
|
"""Configured LLM providers as {name, model} — for the AI admin view (no
|
||||||
|
secrets). Mirrors build_llm_providers() without constructing clients."""
|
||||||
|
s = get_settings()
|
||||||
|
out: list[dict] = []
|
||||||
|
if s.anthropic_api_key:
|
||||||
|
out.append({"name": "anthropic", "model": s.anthropic_model})
|
||||||
|
if s.openai_api_key:
|
||||||
|
out.append({"name": "openai", "model": s.openai_model})
|
||||||
|
if s.xai_api_key:
|
||||||
|
out.append({"name": "xai", "model": s.xai_model})
|
||||||
|
if s.ollama_enabled:
|
||||||
|
out.append({"name": "ollama", "model": s.ollama_model})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_provider(name: str | None = None) -> LLMProvider:
|
||||||
|
"""The named LLM provider, or the configured default, or Null if unconfigured."""
|
||||||
|
providers = build_llm_providers()
|
||||||
|
return providers.get(name or get_settings().default_llm_provider) or NullLLMProvider()
|
||||||
|
|
||||||
|
|
||||||
|
LLMProviderDep = Annotated[LLMProvider, Depends(get_llm_provider)]
|
||||||
|
|
||||||
|
|
||||||
|
def build_embedding_providers() -> dict[str, EmbeddingProvider]:
|
||||||
|
from app.integrations.models.openai_compat import OpenAICompatibleEmbeddingProvider
|
||||||
|
|
||||||
|
s = get_settings()
|
||||||
|
providers: dict[str, EmbeddingProvider] = {}
|
||||||
|
if s.openai_api_key:
|
||||||
|
providers["openai"] = OpenAICompatibleEmbeddingProvider(
|
||||||
|
api_key=s.openai_api_key, base_url=s.openai_base_url,
|
||||||
|
model=s.openai_embedding_model, dimensions=s.embedding_dimensions,
|
||||||
|
)
|
||||||
|
if s.ollama_enabled:
|
||||||
|
providers["ollama"] = OpenAICompatibleEmbeddingProvider(
|
||||||
|
api_key=None, base_url=s.ollama_base_url,
|
||||||
|
model=s.ollama_embedding_model, dimensions=s.embedding_dimensions,
|
||||||
|
)
|
||||||
|
return providers
|
||||||
|
|
||||||
|
|
||||||
|
def get_embedding_provider(name: str | None = None) -> EmbeddingProvider:
|
||||||
|
providers = build_embedding_providers()
|
||||||
|
return providers.get(name or get_settings().default_embedding_provider) or NullEmbeddingProvider()
|
||||||
|
|
||||||
|
|
||||||
|
EmbeddingProviderDep = Annotated[EmbeddingProvider, Depends(get_embedding_provider)]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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
|
||||||
|
from app.core.schema_version import schema_is_current
|
||||||
|
|
||||||
|
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"
|
||||||
|
# Schema drift = code ahead of the DB; queries would 500. Fail
|
||||||
|
# readiness loudly rather than serve a broken surface.
|
||||||
|
ok, db, expected = await schema_is_current(conn)
|
||||||
|
if not ok:
|
||||||
|
checks["schema"] = (
|
||||||
|
f"drift: db={sorted(db) or ['none']} expected={sorted(expected)} "
|
||||||
|
"— run 'alembic upgrade head'"
|
||||||
|
)
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return {"status": "not ready", "checks": checks}
|
||||||
|
checks["schema"] = "ok"
|
||||||
|
return {"status": "ready", "checks": checks}
|
||||||
|
except Exception as exc: # noqa: BLE001 — surface any failure as "not ready"
|
||||||
|
checks.setdefault("database", "error")
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return {"status": "not ready", "checks": checks, "detail": str(exc)}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Versioned API surface. Mounts under /api/v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import (
|
||||||
|
admin,
|
||||||
|
ai,
|
||||||
|
auth,
|
||||||
|
citations,
|
||||||
|
cleanup,
|
||||||
|
events,
|
||||||
|
gedcom,
|
||||||
|
media,
|
||||||
|
members,
|
||||||
|
names,
|
||||||
|
persons,
|
||||||
|
proposals,
|
||||||
|
public,
|
||||||
|
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(names.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)
|
||||||
|
api_router.include_router(cleanup.router)
|
||||||
|
api_router.include_router(public.router)
|
||||||
|
api_router.include_router(members.router)
|
||||||
|
api_router.include_router(proposals.router)
|
||||||
|
api_router.include_router(ai.router)
|
||||||
|
api_router.include_router(admin.router)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Instance-admin surface — owner-only (OWNER_EMAIL). Operational status and
|
||||||
|
instance-wide configuration. Deliberately exposes no tree contents or PII:
|
||||||
|
instance ownership is an operator role, not a privacy bypass."""
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import InstanceOwner, SessionDep, configured_llm_providers
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.admin import InstanceStatus
|
||||||
|
from app.schemas.ai_policy import ConfiguredProvider
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instance", response_model=InstanceStatus)
|
||||||
|
async def instance_status(owner: InstanceOwner, session: SessionDep) -> InstanceStatus:
|
||||||
|
"""Operator dashboard data. Requires the caller to be an instance owner."""
|
||||||
|
s = get_settings()
|
||||||
|
user_count = await session.scalar(
|
||||||
|
select(func.count()).select_from(User).where(User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
tree_count = await session.scalar(
|
||||||
|
select(func.count()).select_from(Tree).where(Tree.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
return InstanceStatus(
|
||||||
|
version=s.version,
|
||||||
|
env=s.app_env,
|
||||||
|
owner_emails=sorted(s.owner_emails()),
|
||||||
|
require_email_verification=s.require_email_verification,
|
||||||
|
user_count=user_count or 0,
|
||||||
|
tree_count=tree_count or 0,
|
||||||
|
default_llm_provider=s.default_llm_provider,
|
||||||
|
ai_providers=[ConfiguredProvider(**p) for p in configured_llm_providers()],
|
||||||
|
)
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""Per-tree AI model policy — owner-only admin view."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.ai_policy import TreeAiPolicyRead, TreeAiPolicyUpdate
|
||||||
|
from app.services import ai_policy_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["ai"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/ai", response_model=TreeAiPolicyRead)
|
||||||
|
async def get_ai_policy(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> TreeAiPolicyRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
return TreeAiPolicyRead(**await ai_policy_service.get_policy(session, actor=current, tree=tree))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/ai", response_model=TreeAiPolicyRead)
|
||||||
|
async def update_ai_policy(
|
||||||
|
tree_id: uuid.UUID, data: TreeAiPolicyUpdate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> TreeAiPolicyRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
policy = await ai_policy_service.update_policy(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
member_provider=data.member_provider,
|
||||||
|
recommender_provider=data.recommender_provider,
|
||||||
|
)
|
||||||
|
return TreeAiPolicyRead(**policy)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, Request, Response, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, MailerDep, SessionDep, extract_session_token
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
PasswordChange,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def change_password(
|
||||||
|
data: PasswordChange, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
await auth_service.change_password(
|
||||||
|
session,
|
||||||
|
user=current,
|
||||||
|
current_password=data.current_password,
|
||||||
|
new_password=data.new_password,
|
||||||
|
)
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.source import CitationCreate, CitationRead, CitationUpdate
|
||||||
|
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.patch("/{tree_id}/citations/{citation_id}", response_model=CitationRead)
|
||||||
|
async def update_citation(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
citation_id: uuid.UUID,
|
||||||
|
data: CitationUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> CitationRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
citation = await citation_service.update_citation(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
citation_id=citation_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return CitationRead.model_validate(citation)
|
||||||
|
|
||||||
|
|
||||||
|
@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,130 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.cleanup import (
|
||||||
|
CleanupResult,
|
||||||
|
DeceasedApply,
|
||||||
|
DeceasedByChildCandidate,
|
||||||
|
DeceasedCandidate,
|
||||||
|
GenderApply,
|
||||||
|
GenderProposal,
|
||||||
|
NameApply,
|
||||||
|
NameIssue,
|
||||||
|
)
|
||||||
|
from app.services import cleanup_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["cleanup"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/deceased", response_model=list[DeceasedCandidate])
|
||||||
|
async def preview_deceased(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
born_on_or_before: int = 1930,
|
||||||
|
) -> list[DeceasedCandidate]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.preview_deceased(
|
||||||
|
session, actor=current, tree=tree, year=born_on_or_before
|
||||||
|
)
|
||||||
|
return [DeceasedCandidate(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{tree_id}/cleanup/deceased-by-child", response_model=list[DeceasedByChildCandidate]
|
||||||
|
)
|
||||||
|
async def preview_deceased_by_child(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
born_on_or_before: int = 1900,
|
||||||
|
) -> list[DeceasedByChildCandidate]:
|
||||||
|
"""People with a child born on/before the cutoff — necessarily deceased even
|
||||||
|
when their own birth date is missing. Apply via POST .../cleanup/deceased."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.preview_deceased_by_child(
|
||||||
|
session, actor=current, tree=tree, year=born_on_or_before
|
||||||
|
)
|
||||||
|
return [DeceasedByChildCandidate(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/deceased", response_model=CleanupResult)
|
||||||
|
async def apply_deceased(
|
||||||
|
tree_id: uuid.UUID, data: DeceasedApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_deceased(
|
||||||
|
session, actor=current, tree=tree, person_ids=data.person_ids
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/gender/preview", response_model=list[GenderProposal])
|
||||||
|
async def preview_gender(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
rows = await cleanup_service.preview_gender(
|
||||||
|
session, actor=current, tree=tree, gedcom_text=text
|
||||||
|
)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/gender/guess", response_model=list[GenderProposal])
|
||||||
|
async def guess_gender(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
"""Best-guess sex from first names (bundled dictionary) for people missing it."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.guess_gender_by_name(session, actor=current, tree=tree)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/gender/from-spouse", response_model=list[GenderProposal])
|
||||||
|
async def guess_gender_from_spouse(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[GenderProposal]:
|
||||||
|
"""Infer a missing sex from a partner whose sex is set (opposite-sex couple)."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.guess_gender_by_spouse(session, actor=current, tree=tree)
|
||||||
|
return [GenderProposal(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/gender", response_model=CleanupResult)
|
||||||
|
async def apply_gender(
|
||||||
|
tree_id: uuid.UUID, data: GenderApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_gender(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
updates=[u.model_dump() for u in data.updates],
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/cleanup/names", response_model=list[NameIssue])
|
||||||
|
async def preview_names(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[NameIssue]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await cleanup_service.preview_names(session, actor=current, tree=tree)
|
||||||
|
return [NameIssue(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/cleanup/names", response_model=CleanupResult)
|
||||||
|
async def apply_names(
|
||||||
|
tree_id: uuid.UUID, data: NameApply, session: SessionDep, current: CurrentUser
|
||||||
|
) -> CleanupResult:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
n = await cleanup_service.apply_names(
|
||||||
|
session, actor=current, tree=tree, edits=[e.model_dump() for e in data.edits]
|
||||||
|
)
|
||||||
|
return CleanupResult(updated=n)
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.event import EventCreate, EventRead, EventUpdate
|
||||||
|
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.patch("/{tree_id}/events/{event_id}", response_model=EventRead)
|
||||||
|
async def update_event(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
event_id: uuid.UUID,
|
||||||
|
data: EventUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> EventRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
event = await event_service.update_event(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
event_id=event_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return EventRead.model_validate(event)
|
||||||
|
|
||||||
|
|
||||||
|
@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,68 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.gedcom import ImportPreview, ImportReport
|
||||||
|
from app.services import gedcom, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["gedcom"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/preview", response_model=ImportPreview)
|
||||||
|
async def preview_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> ImportPreview:
|
||||||
|
"""Dry run: report counts and incoming people that look like duplicates of
|
||||||
|
existing ones, so the user can choose how to resolve each before importing."""
|
||||||
|
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.preview_gedcom(session, actor=current, tree=tree, text=text)
|
||||||
|
return ImportPreview(**report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/gedcom/import", response_model=ImportReport)
|
||||||
|
async def import_gedcom(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
default_action: str = Form("new"),
|
||||||
|
resolutions: str = Form("{}"),
|
||||||
|
) -> ImportReport:
|
||||||
|
"""Import a GEDCOM. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||||
|
incoming people that match an existing one; ``resolutions`` is a JSON object
|
||||||
|
{xref: {action, target_id}} overriding it per record."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
text = (await file.read()).decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
parsed = json.loads(resolutions or "{}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = {}
|
||||||
|
report = await gedcom.import_gedcom(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
text=text,
|
||||||
|
default_action=default_action,
|
||||||
|
resolutions=parsed,
|
||||||
|
)
|
||||||
|
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,109 @@
|
|||||||
|
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, MediaUpdate
|
||||||
|
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.patch("/{tree_id}/media/{media_id}", response_model=MediaRead)
|
||||||
|
async def update_media(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
media_id: uuid.UUID,
|
||||||
|
data: MediaUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> MediaRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
media = await media_service.update_media(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
media_id=media_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return _read(media)
|
||||||
|
|
||||||
|
|
||||||
|
@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,61 @@
|
|||||||
|
"""Tree membership management endpoints (owner-managed; members can list)."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.membership import MemberAdd, MemberRoleUpdate, MembershipRead
|
||||||
|
from app.services import membership_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["members"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/members", response_model=list[MembershipRead])
|
||||||
|
async def list_members(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[MembershipRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await membership_service.list_members(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [MembershipRead(**r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/members", response_model=MembershipRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def add_member(
|
||||||
|
tree_id: uuid.UUID, data: MemberAdd, session: SessionDep, current: CurrentUser
|
||||||
|
) -> MembershipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
row = await membership_service.add_member(
|
||||||
|
session, actor=current, tree=tree, email=data.email, role=data.role
|
||||||
|
)
|
||||||
|
return MembershipRead(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{tree_id}/members/{membership_id}", response_model=MembershipRead)
|
||||||
|
async def update_member(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
data: MemberRoleUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> MembershipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
row = await membership_service.update_member_role(
|
||||||
|
session, actor=current, tree=tree, membership_id=membership_id, role=data.role
|
||||||
|
)
|
||||||
|
return MembershipRead(**row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/members/{membership_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_member(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await membership_service.remove_member(
|
||||||
|
session, actor=current, tree=tree, membership_id=membership_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.name import NameCreate, NameRead, NameUpdate
|
||||||
|
from app.services import name_service, tree_service
|
||||||
|
|
||||||
|
# Names are nested under their person (which is nested under the tree tenant).
|
||||||
|
router = APIRouter(prefix="/trees", tags=["names"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
|
||||||
|
async def list_names(
|
||||||
|
tree_id: uuid.UUID, person_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[NameRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
names = await name_service.list_names(
|
||||||
|
session, viewer_id=current.id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [NameRead.model_validate(n) for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/persons/{person_id}/names",
|
||||||
|
response_model=NameRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_name(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
data: NameCreate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> NameRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
name = await name_service.create_name(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
person_id=person_id,
|
||||||
|
name_type=data.name_type,
|
||||||
|
given=data.given,
|
||||||
|
surname=data.surname,
|
||||||
|
prefix=data.prefix,
|
||||||
|
suffix=data.suffix,
|
||||||
|
nickname=data.nickname,
|
||||||
|
is_primary=data.is_primary,
|
||||||
|
)
|
||||||
|
return NameRead.model_validate(name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{tree_id}/persons/{person_id}/names/{name_id}", response_model=NameRead
|
||||||
|
)
|
||||||
|
async def update_name(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_id: uuid.UUID,
|
||||||
|
data: NameUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> NameRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
name = await name_service.update_name(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
person_id=person_id,
|
||||||
|
name_id=name_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return NameRead.model_validate(name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tree_id}/persons/{person_id}/names/{name_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
)
|
||||||
|
async def delete_name(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await name_service.delete_name(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id, name_id=name_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.person import PersonCreate, PersonRead, PersonUpdate
|
||||||
|
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,
|
||||||
|
ids: str | None = None,
|
||||||
|
) -> list[PersonRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
if ids is not None:
|
||||||
|
try:
|
||||||
|
id_list = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "invalid ids") from exc
|
||||||
|
persons = await person_service.list_persons_by_ids(
|
||||||
|
session, viewer_id=current.id, tree=tree, ids=id_list
|
||||||
|
)
|
||||||
|
elif 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.patch("/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def update_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
data: PersonUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.update_person(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
person_id=person_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{tree_id}/persons/{person_id}")
|
||||||
|
async def delete_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
cascade: bool = False,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Delete a person. ``cascade=true`` also deletes all descendants. Returns
|
||||||
|
the number of persons deleted (1 unless cascading)."""
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
deleted = await person_service.delete_person(
|
||||||
|
session, actor=current, tree=tree, person_id=person_id, cascade=cascade
|
||||||
|
)
|
||||||
|
return {"deleted": deleted}
|
||||||
|
|
||||||
|
|
||||||
|
@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,116 @@
|
|||||||
|
"""Change-proposal endpoints: list / create / get / apply / reject / delete.
|
||||||
|
|
||||||
|
Applying a proposal is the only way its operations reach the database, and only
|
||||||
|
an editor can do it (enforced in the service). See docs/design/change-proposal.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.models.enums import ChangeProposalStatus
|
||||||
|
from app.schemas.change_proposal import (
|
||||||
|
ChangeProposalCreate,
|
||||||
|
ChangeProposalRead,
|
||||||
|
ProposalReview,
|
||||||
|
)
|
||||||
|
from app.services import change_proposal_service, tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["proposals"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/proposals", response_model=list[ChangeProposalRead])
|
||||||
|
async def list_proposals(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
status: ChangeProposalStatus | None = None,
|
||||||
|
) -> list[ChangeProposalRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rows = await change_proposal_service.list_proposals(
|
||||||
|
session, viewer_id=current.id, tree=tree, status=status
|
||||||
|
)
|
||||||
|
return [ChangeProposalRead.model_validate(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/proposals", response_model=ChangeProposalRead, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
async def create_proposal(
|
||||||
|
tree_id: uuid.UUID, data: ChangeProposalCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
operations = [op.model_dump(mode="json") for op in data.operations]
|
||||||
|
cp = await change_proposal_service.propose(
|
||||||
|
session,
|
||||||
|
tree=tree,
|
||||||
|
origin=data.origin,
|
||||||
|
created_by=current.id,
|
||||||
|
summary=data.summary,
|
||||||
|
rationale=data.rationale,
|
||||||
|
operations=operations,
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/proposals/{proposal_id}", response_model=ChangeProposalRead)
|
||||||
|
async def get_proposal(
|
||||||
|
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
cp = await change_proposal_service.get_proposal(
|
||||||
|
session, viewer_id=current.id, tree=tree, proposal_id=proposal_id
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/proposals/{proposal_id}/apply", response_model=ChangeProposalRead)
|
||||||
|
async def apply_proposal(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
data: ProposalReview | None = None,
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
edited = (
|
||||||
|
[op.model_dump(mode="json") for op in data.operations]
|
||||||
|
if data and data.operations is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
cp = await change_proposal_service.apply(
|
||||||
|
session, actor=current, tree=tree, proposal_id=proposal_id, edited_operations=edited
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/proposals/{proposal_id}/reject", response_model=ChangeProposalRead)
|
||||||
|
async def reject_proposal(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
data: ProposalReview | None = None,
|
||||||
|
) -> ChangeProposalRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
cp = await change_proposal_service.reject(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
proposal_id=proposal_id,
|
||||||
|
note=data.note if data else None,
|
||||||
|
)
|
||||||
|
return ChangeProposalRead.model_validate(cp)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{tree_id}/proposals/{proposal_id}", status_code=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
async def delete_proposal(
|
||||||
|
tree_id: uuid.UUID, proposal_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> None:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
await change_proposal_service.delete_proposal(
|
||||||
|
session, actor=current, tree=tree, proposal_id=proposal_id
|
||||||
|
)
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""Public, read-only viewing surface.
|
||||||
|
|
||||||
|
Optional auth (anonymous allowed). Every response is built by
|
||||||
|
``public_view_service``, which routes through the privacy engine and redacts
|
||||||
|
possibly-living people. No create/update/delete here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUserOrNone, SessionDep
|
||||||
|
from app.schemas.event import EventRead
|
||||||
|
from app.schemas.name import NameRead
|
||||||
|
from app.schemas.person import PersonRead
|
||||||
|
from app.schemas.relationship import RelationshipRead
|
||||||
|
from app.schemas.tree import PublicTreeRead
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/public", tags=["public"])
|
||||||
|
|
||||||
|
|
||||||
|
def _vid(viewer: CurrentUserOrNone) -> uuid.UUID | None:
|
||||||
|
return viewer.id if viewer else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees", response_model=list[PublicTreeRead])
|
||||||
|
async def public_directory(
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
q: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> list[PublicTreeRead]:
|
||||||
|
trees = await public_view_service.list_public_trees(
|
||||||
|
session, viewer_id=_vid(viewer), q=q, limit=limit, offset=offset
|
||||||
|
)
|
||||||
|
return [PublicTreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}", response_model=PublicTreeRead)
|
||||||
|
async def public_tree(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> PublicTreeRead:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
return PublicTreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
|
async def public_persons(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[PersonRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
persons = await public_view_service.list_public_persons(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/relationships", response_model=list[RelationshipRead])
|
||||||
|
async def public_relationships(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[RelationshipRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
rels = await public_view_service.list_public_relationships(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [RelationshipRead.model_validate(r) for r in rels]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/events", response_model=list[EventRead])
|
||||||
|
async def public_events(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, viewer: CurrentUserOrNone
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
events = await public_view_service.list_public_events(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}", response_model=PersonRead)
|
||||||
|
async def public_person(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> PersonRead:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
person = await public_view_service.get_public_person(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}/names", response_model=list[NameRead])
|
||||||
|
async def public_person_names(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> list[NameRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
names = await public_view_service.list_public_person_names(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [NameRead.model_validate(n) for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/trees/{tree_id}/persons/{person_id}/events", response_model=list[EventRead])
|
||||||
|
async def public_person_events(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
session: SessionDep,
|
||||||
|
viewer: CurrentUserOrNone,
|
||||||
|
) -> list[EventRead]:
|
||||||
|
tree = await public_view_service.get_public_tree(
|
||||||
|
session, viewer_id=_vid(viewer), tree_id=tree_id
|
||||||
|
)
|
||||||
|
events = await public_view_service.list_public_person_events(
|
||||||
|
session, viewer_id=_vid(viewer), tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
return [EventRead.model_validate(e) for e in events]
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.relationship import RelationshipCreate, RelationshipRead, RelationshipUpdate
|
||||||
|
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.patch("/{tree_id}/relationships/{relationship_id}", response_model=RelationshipRead)
|
||||||
|
async def update_relationship(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
relationship_id: uuid.UUID,
|
||||||
|
data: RelationshipUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> RelationshipRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
rel = await relationship_service.update_relationship(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
relationship_id=relationship_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
return RelationshipRead.model_validate(rel)
|
||||||
|
|
||||||
|
|
||||||
|
@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,67 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.source import SourceCreate, SourceRead, SourceUpdate
|
||||||
|
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.patch("/{tree_id}/sources/{source_id}", response_model=SourceRead)
|
||||||
|
async def update_source(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
data: SourceUpdate,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
) -> SourceRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
source = await source_service.update_source(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
source_id=source_id,
|
||||||
|
changes=data.model_dump(exclude_unset=True),
|
||||||
|
)
|
||||||
|
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,74 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep
|
||||||
|
from app.schemas.tree import TreeCreate, TreePurge, TreeRead, TreeUpdate
|
||||||
|
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.patch("/{tree_id}", response_model=TreeRead)
|
||||||
|
async def update_tree(
|
||||||
|
tree_id: uuid.UUID, data: TreeUpdate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> TreeRead:
|
||||||
|
tree = await tree_service.update_tree(
|
||||||
|
session, actor=current, tree_id=tree_id, changes=data.model_dump(exclude_unset=True)
|
||||||
|
)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{tree_id}/purge", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def purge_tree(
|
||||||
|
tree_id: uuid.UUID,
|
||||||
|
data: TreePurge,
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
) -> None:
|
||||||
|
"""Permanently delete a soft-deleted tree and all its data — irreversible.
|
||||||
|
Owner-only; the tree must be in the trash and `confirm_name` must match."""
|
||||||
|
await tree_service.purge_tree(
|
||||||
|
session, store, actor=current, tree_id=tree_id, confirm_name=data.confirm_name
|
||||||
|
)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from fastapi import APIRouter, File, Form, Response, UploadFile
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep, is_instance_owner
|
||||||
|
from app.schemas.user import UserRead, UserSelfPersonUpdate
|
||||||
|
from app.services import account_service, user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
def _me(user) -> UserRead:
|
||||||
|
out = UserRead.model_validate(user)
|
||||||
|
out.is_instance_owner = is_instance_owner(user)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def read_me(current: CurrentUser) -> UserRead:
|
||||||
|
return _me(current)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/self-person", response_model=UserRead)
|
||||||
|
async def set_self_person(
|
||||||
|
data: UserSelfPersonUpdate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> UserRead:
|
||||||
|
"""Link (or unlink) the Person record that represents this account."""
|
||||||
|
user = await user_service.set_self_person(
|
||||||
|
session, user=current, person_id=data.self_person_id
|
||||||
|
)
|
||||||
|
return _me(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me/export")
|
||||||
|
async def export_account(
|
||||||
|
session: SessionDep, current: CurrentUser, store: ObjectStoreDep
|
||||||
|
) -> Response:
|
||||||
|
"""Download a full backup (JSON + media) of every tree the user owns."""
|
||||||
|
data = await account_service.export_account(session, store, user=current)
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="provenance-export.zip"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/import")
|
||||||
|
async def import_account(
|
||||||
|
session: SessionDep,
|
||||||
|
current: CurrentUser,
|
||||||
|
store: ObjectStoreDep,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict:
|
||||||
|
"""Restore a previously-exported backup into new trees (non-destructive)."""
|
||||||
|
raw = await file.read()
|
||||||
|
return await account_service.import_account(session, store, user=current, raw_zip=raw)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me", status_code=204)
|
||||||
|
async def delete_account(
|
||||||
|
session: SessionDep, current: CurrentUser, confirm_email: str = Form(...)
|
||||||
|
) -> None:
|
||||||
|
"""Delete the account: the user, their owned trees, and their sessions.
|
||||||
|
Requires retyping the account email as a guard."""
|
||||||
|
await account_service.delete_account(session, user=current, confirm_email=confirm_email)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""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")
|
||||||
|
|
||||||
|
# --- Instance owner / operator ---
|
||||||
|
# Email(s) of the instance owner(s) — the operator(s) who run this server.
|
||||||
|
# The matching account(s) get instance-admin rights (instance-wide settings;
|
||||||
|
# see /api/v1/admin). Comma-separated for several. Empty = no designated
|
||||||
|
# owner (the instance has no operator account). Derived at request time, so
|
||||||
|
# changing it takes effect immediately with no migration or DB state.
|
||||||
|
owner_email: str = ""
|
||||||
|
|
||||||
|
def owner_emails(self) -> frozenset[str]:
|
||||||
|
"""Normalized (lowercased, trimmed) owner emails; empty if none set."""
|
||||||
|
return frozenset(e.strip().lower() for e in self.owner_email.split(",") if e.strip())
|
||||||
|
|
||||||
|
# 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) ---
|
||||||
|
# When true, a user with no verified email gets no active session (login is
|
||||||
|
# refused and existing sessions stop resolving). Default false so self-hosts
|
||||||
|
# without SMTP — and accounts created before this gate existed — aren't
|
||||||
|
# locked out; operators turn it on once mail works and accounts are verified.
|
||||||
|
require_email_verification: bool = False
|
||||||
|
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>"
|
||||||
|
|
||||||
|
# --- Model providers (AI assistant + match-ranking embeddings) ---
|
||||||
|
# Configure as many as you like; each is enabled when its credentials are
|
||||||
|
# present. `default_*_provider` picks which one is used by default. LLM and
|
||||||
|
# embeddings are independent (Anthropic has no embeddings endpoint).
|
||||||
|
default_llm_provider: str = "null" # null | anthropic | openai | xai | ollama
|
||||||
|
default_embedding_provider: str = "null" # null | openai | ollama
|
||||||
|
llm_max_tokens: int = 4096
|
||||||
|
embedding_dimensions: int = 1536 # must match the embedding model + pgvector column
|
||||||
|
|
||||||
|
# Anthropic (LLM only)
|
||||||
|
anthropic_api_key: str | None = None
|
||||||
|
anthropic_model: str = "claude-opus-4-8"
|
||||||
|
|
||||||
|
# OpenAI (LLM + embeddings)
|
||||||
|
openai_api_key: str | None = None
|
||||||
|
openai_base_url: str = "https://api.openai.com/v1"
|
||||||
|
openai_model: str = "gpt-4o"
|
||||||
|
openai_embedding_model: str = "text-embedding-3-small"
|
||||||
|
|
||||||
|
# xAI / Grok — OpenAI-compatible (LLM)
|
||||||
|
xai_api_key: str | None = None
|
||||||
|
xai_base_url: str = "https://api.x.ai/v1"
|
||||||
|
xai_model: str = "grok-2-latest" # set to your account's current Grok model
|
||||||
|
|
||||||
|
# Ollama — local, OpenAI-compatible, no key (LLM + embeddings)
|
||||||
|
ollama_enabled: bool = False
|
||||||
|
ollama_base_url: str = "http://localhost:11434/v1"
|
||||||
|
ollama_model: str = "llama3.1"
|
||||||
|
ollama_embedding_model: str = "nomic-embed-text"
|
||||||
|
|
||||||
|
|
||||||
|
@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,59 @@
|
|||||||
|
"""Schema-drift detection — a safety net for the deploy pipeline.
|
||||||
|
|
||||||
|
If a deploy ships code whose models reference a column a migration hasn't added
|
||||||
|
yet (the code is ahead of the DB), every query against that table 500s with an
|
||||||
|
opaque ``UndefinedColumnError``. That is exactly the failure that took the tree
|
||||||
|
list down once: the backend image advanced but ``alembic upgrade head`` hadn't
|
||||||
|
run on the server.
|
||||||
|
|
||||||
|
The real prevention is auto-migrate on deploy (the entrypoint runs
|
||||||
|
``alembic upgrade head`` when ``RUN_MIGRATIONS=1``). This module is defense in
|
||||||
|
depth: it makes the drift *loud and explicit* — a readiness failure and a
|
||||||
|
CRITICAL startup log — instead of a silent storm of 500s, so a half-applied
|
||||||
|
deploy is obvious within seconds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection
|
||||||
|
|
||||||
|
# app/core/schema_version.py -> backend/ (parents: core, app, backend)
|
||||||
|
_MIGRATIONS_DIR = Path(__file__).resolve().parents[2] / "migrations"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def expected_heads() -> frozenset[str]:
|
||||||
|
"""Revision head(s) baked into this image's migration scripts. Static for a
|
||||||
|
given build, so cache it."""
|
||||||
|
from alembic.config import Config
|
||||||
|
from alembic.script import ScriptDirectory
|
||||||
|
|
||||||
|
cfg = Config()
|
||||||
|
cfg.set_main_option("script_location", str(_MIGRATIONS_DIR))
|
||||||
|
return frozenset(ScriptDirectory.from_config(cfg).get_heads())
|
||||||
|
|
||||||
|
|
||||||
|
async def db_heads(conn: AsyncConnection) -> frozenset[str] | None:
|
||||||
|
"""Revision(s) the database is stamped at, or ``None`` when the DB is not
|
||||||
|
Alembic-managed (no ``alembic_version`` table — e.g. a test DB built straight
|
||||||
|
from ``create_all``). ``to_regclass`` returns NULL rather than erroring when
|
||||||
|
the table is absent, so this never poisons the caller's transaction."""
|
||||||
|
if await conn.scalar(text("SELECT to_regclass('public.alembic_version')")) is None:
|
||||||
|
return None
|
||||||
|
result = await conn.execute(text("SELECT version_num FROM alembic_version"))
|
||||||
|
return frozenset(row[0] for row in result)
|
||||||
|
|
||||||
|
|
||||||
|
async def schema_is_current(
|
||||||
|
conn: AsyncConnection,
|
||||||
|
) -> tuple[bool, frozenset[str], frozenset[str]]:
|
||||||
|
"""``(ok, db, expected)``. ``ok`` is True when the DB is stamped at the
|
||||||
|
code's head(s). A DB with no ``alembic_version`` table is treated as current
|
||||||
|
(not Alembic-managed → nothing to compare), so this stays quiet in tests."""
|
||||||
|
expected = expected_heads()
|
||||||
|
current = await db_heads(conn)
|
||||||
|
if current is None:
|
||||||
|
return True, frozenset(), expected
|
||||||
|
return current == expected, current, expected
|
||||||
@@ -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,24 @@
|
|||||||
|
"""Anthropic LLM provider (official SDK). Self-hosters who want everything to
|
||||||
|
stay on their own metal would configure a local provider instead (e.g. Ollama) —
|
||||||
|
that's a future implementation of the same LLMProvider interface."""
|
||||||
|
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
|
||||||
|
from app.integrations.models.base import LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicLLMProvider(LLMProvider):
|
||||||
|
def __init__(self, *, api_key: str, model: str, max_tokens: int = 4096) -> None:
|
||||||
|
self._client = AsyncAnthropic(api_key=api_key)
|
||||||
|
self._model = model
|
||||||
|
self._max_tokens = max_tokens
|
||||||
|
|
||||||
|
async def complete(self, *, prompt: str, system: str | None = None) -> str:
|
||||||
|
resp = await self._client.messages.create(
|
||||||
|
model=self._model,
|
||||||
|
max_tokens=self._max_tokens,
|
||||||
|
system=system or "",
|
||||||
|
messages=[{"role": "user", "content": prompt}],
|
||||||
|
)
|
||||||
|
# content is a list of blocks; concatenate the text ones.
|
||||||
|
return "".join(b.text for b in resp.content if b.type == "text")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Model-provider interfaces — the seam the AI assistant and match ranking plug
|
||||||
|
into. LLM (text) and embeddings are *separate* abstractions: Anthropic offers no
|
||||||
|
embeddings endpoint, so the two are configured independently (twelve-factor,
|
||||||
|
CLAUDE.md #7) and a deployment may run one without the other.
|
||||||
|
|
||||||
|
These providers are read-only text/vector producers. They MUST NOT mutate tree
|
||||||
|
data — the assistant's writes go through a ChangeProposal a human approves
|
||||||
|
(CLAUDE.md #1). Nothing here touches the database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(ABC):
|
||||||
|
"""Text in, text out. Implementations wrap a chat/completion model."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def complete(self, *, prompt: str, system: str | None = None) -> str:
|
||||||
|
"""Return the model's text response to a single user prompt."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class EmbeddingProvider(ABC):
|
||||||
|
"""Text in, vectors out — for pgvector-backed match ranking."""
|
||||||
|
|
||||||
|
#: Dimensionality of the returned vectors (for the pgvector column).
|
||||||
|
dimensions: int
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
"""Return one embedding vector per input text, in order."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class ModelProviderNotConfigured(RuntimeError):
|
||||||
|
"""Raised when an AI capability is used but no provider is configured."""
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Default providers when no model backend is configured — AI features are off.
|
||||||
|
|
||||||
|
They fail loudly (rather than silently doing nothing) so a caller that reaches
|
||||||
|
for an unconfigured capability gets a clear, actionable error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.integrations.models.base import (
|
||||||
|
EmbeddingProvider,
|
||||||
|
LLMProvider,
|
||||||
|
ModelProviderNotConfigured,
|
||||||
|
)
|
||||||
|
|
||||||
|
_MSG = (
|
||||||
|
"No model provider configured. Set MODEL_PROVIDER (e.g. 'anthropic') and the "
|
||||||
|
"provider's credentials to enable AI features."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NullLLMProvider(LLMProvider):
|
||||||
|
async def complete(self, *, prompt: str, system: str | None = None) -> str:
|
||||||
|
raise ModelProviderNotConfigured(_MSG)
|
||||||
|
|
||||||
|
|
||||||
|
class NullEmbeddingProvider(EmbeddingProvider):
|
||||||
|
dimensions = 0
|
||||||
|
|
||||||
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
raise ModelProviderNotConfigured(
|
||||||
|
"No embedding provider configured. Set EMBEDDING_PROVIDER and its "
|
||||||
|
"credentials to enable match ranking."
|
||||||
|
)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""OpenAI-compatible providers (one implementation, many vendors).
|
||||||
|
|
||||||
|
OpenAI, xAI (api.x.ai/v1), Ollama (…:11434/v1), OpenRouter, Together, vLLM, etc.
|
||||||
|
all speak the OpenAI Chat Completions / Embeddings API — they differ only by
|
||||||
|
base URL, key, and model name. So a single class, parameterized by those, plugs
|
||||||
|
in every one of them via the official `openai` SDK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
|
from app.integrations.models.base import EmbeddingProvider, LLMProvider
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatibleLLMProvider(LLMProvider):
|
||||||
|
def __init__(self, *, api_key: str | None, base_url: str, model: str, max_tokens: int = 4096) -> None:
|
||||||
|
# Local backends (Ollama) ignore the key but the SDK requires a non-empty one.
|
||||||
|
self._client = AsyncOpenAI(api_key=api_key or "not-needed", base_url=base_url)
|
||||||
|
self._model = model
|
||||||
|
self._max_tokens = max_tokens
|
||||||
|
|
||||||
|
async def complete(self, *, prompt: str, system: str | None = None) -> str:
|
||||||
|
messages: list[dict] = []
|
||||||
|
if system:
|
||||||
|
messages.append({"role": "system", "content": system})
|
||||||
|
messages.append({"role": "user", "content": prompt})
|
||||||
|
resp = await self._client.chat.completions.create(
|
||||||
|
model=self._model, max_tokens=self._max_tokens, messages=messages
|
||||||
|
)
|
||||||
|
return resp.choices[0].message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAICompatibleEmbeddingProvider(EmbeddingProvider):
|
||||||
|
def __init__(self, *, api_key: str | None, base_url: str, model: str, dimensions: int) -> None:
|
||||||
|
self._client = AsyncOpenAI(api_key=api_key or "not-needed", base_url=base_url)
|
||||||
|
self._model = model
|
||||||
|
self.dimensions = dimensions
|
||||||
|
|
||||||
|
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||||||
|
resp = await self._client.embeddings.create(model=self._model, input=texts)
|
||||||
|
return [d.embedding for d in resp.data]
|
||||||
@@ -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,91 @@
|
|||||||
|
"""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 contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
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.core.db import get_engine
|
||||||
|
from app.core.schema_version import schema_is_current
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_schema_drift() -> None:
|
||||||
|
"""On startup, shout if the DB schema is behind the code. The entrypoint
|
||||||
|
runs migrations when RUN_MIGRATIONS=1; this catches the case where that
|
||||||
|
didn't happen, so a half-applied deploy is obvious in the logs instead of a
|
||||||
|
silent storm of 500s. Never blocks startup — purely advisory."""
|
||||||
|
logger = logging.getLogger("provenance")
|
||||||
|
try:
|
||||||
|
async with get_engine().connect() as conn:
|
||||||
|
ok, db, expected = await schema_is_current(conn)
|
||||||
|
if not ok:
|
||||||
|
logger.critical(
|
||||||
|
"SCHEMA DRIFT: database is at %s but this build expects %s. "
|
||||||
|
"Run 'alembic upgrade head' — queries will fail until migrated.",
|
||||||
|
sorted(db) or ["none"],
|
||||||
|
sorted(expected),
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 — advisory only; never block startup
|
||||||
|
logger.warning("schema drift check skipped: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _lifespan(app: FastAPI):
|
||||||
|
await _check_schema_drift()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
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.",
|
||||||
|
lifespan=_lifespan,
|
||||||
|
)
|
||||||
|
app.include_router(health_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
_register_error_handlers(app)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""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.change_proposal import ChangeProposal
|
||||||
|
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",
|
||||||
|
"ChangeProposal",
|
||||||
|
]
|
||||||
@@ -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,48 @@
|
|||||||
|
"""ChangeProposal — a structured diff the AI assistant (or an untrusted
|
||||||
|
contributor) proposes, which a human approves/edits/rejects. Applying it routes
|
||||||
|
each operation through the normal editing services, so the change passes the
|
||||||
|
privacy engine and is audited as the approving human's action. See
|
||||||
|
docs/design/change-proposal.md and CLAUDE.md non-negotiable #1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text
|
||||||
|
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 ChangeProposalOrigin, ChangeProposalStatus
|
||||||
|
from app.models.mixins import SoftDelete, TenantScoped, Timestamps, UUIDPrimaryKey
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposal(Base, UUIDPrimaryKey, TenantScoped, Timestamps, SoftDelete):
|
||||||
|
__tablename__ = "change_proposals"
|
||||||
|
|
||||||
|
status: Mapped[ChangeProposalStatus] = mapped_column(
|
||||||
|
SAEnum(ChangeProposalStatus, name="change_proposal_status"),
|
||||||
|
default=ChangeProposalStatus.pending,
|
||||||
|
server_default=ChangeProposalStatus.pending.value,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
origin: Mapped[ChangeProposalOrigin] = mapped_column(
|
||||||
|
SAEnum(ChangeProposalOrigin, name="change_proposal_origin"),
|
||||||
|
default=ChangeProposalOrigin.assistant,
|
||||||
|
server_default=ChangeProposalOrigin.assistant.value,
|
||||||
|
)
|
||||||
|
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
summary: Mapped[str] = mapped_column(String(512))
|
||||||
|
rationale: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# The structured diff: a list of {op, entity_type, entity_id?, payload} dicts.
|
||||||
|
operations: Mapped[list] = mapped_column(JSONB, nullable=False)
|
||||||
|
|
||||||
|
reviewed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
reviewed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
review_note: Mapped[str | None] = mapped_column(String(512))
|
||||||
|
apply_error: Mapped[str | None] = mapped_column(Text)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""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" # anyone on the web (anonymous), listed + search-indexable
|
||||||
|
site_members = "site_members" # any authenticated user of this instance
|
||||||
|
unlisted = "unlisted" # anyone with the link (anonymous), not listed/indexed
|
||||||
|
private = "private" # members only (default)
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalStatus(enum.StrEnum):
|
||||||
|
pending = "pending"
|
||||||
|
applied = "applied"
|
||||||
|
rejected = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalOrigin(enum.StrEnum):
|
||||||
|
assistant = "assistant" # the AI assistant, acting on behalf of a user
|
||||||
|
contributor = "contributor" # an untrusted human edit awaiting moderation
|
||||||
@@ -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,58 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
|
# The person a tree opens focused on (its "home"/root person). Cleared if
|
||||||
|
# that person is deleted. use_alter + name: trees<->persons form an FK cycle.
|
||||||
|
home_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey(
|
||||||
|
"persons.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_trees_home_person_id",
|
||||||
|
use_alter=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Per-tree AI model policy (owner-configured). The names reference configured
|
||||||
|
# providers from the registry; null = that role has no model. The owner may
|
||||||
|
# use any configured provider; these limit members + the recommender.
|
||||||
|
ai_member_provider: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
ai_recommender_provider: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
|
||||||
|
|
||||||
|
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,34 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, 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))
|
||||||
|
# The Person record that *is* this user ("home person"). Cleared if that
|
||||||
|
# person is deleted, so the link can never dangle.
|
||||||
|
self_person_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
# use_alter + explicit name: users<->persons<->trees form an FK cycle,
|
||||||
|
# so this constraint must be created/dropped via ALTER, not inline.
|
||||||
|
ForeignKey(
|
||||||
|
"persons.id",
|
||||||
|
ondelete="SET NULL",
|
||||||
|
name="fk_users_self_person_id",
|
||||||
|
use_alter=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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,20 @@
|
|||||||
|
"""Instance-admin schemas. Operator-facing, owner-only — operational status and
|
||||||
|
config, never tree data or PII (instance ownership doesn't bypass privacy)."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.schemas.ai_policy import ConfiguredProvider
|
||||||
|
|
||||||
|
|
||||||
|
class InstanceStatus(BaseModel):
|
||||||
|
version: str
|
||||||
|
env: str
|
||||||
|
# Operator account(s) — the email(s) named in OWNER_EMAIL.
|
||||||
|
owner_emails: list[str]
|
||||||
|
require_email_verification: bool
|
||||||
|
# Aggregate, non-identifying counts (live rows only).
|
||||||
|
user_count: int
|
||||||
|
tree_count: int
|
||||||
|
# Instance-wide AI configuration (no secrets).
|
||||||
|
default_llm_provider: str
|
||||||
|
ai_providers: list[ConfiguredProvider]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ConfiguredProvider(BaseModel):
|
||||||
|
name: str
|
||||||
|
model: str
|
||||||
|
|
||||||
|
|
||||||
|
class TreeAiPolicyRead(BaseModel):
|
||||||
|
# The model non-owners' assistant uses (null = none).
|
||||||
|
member_provider: str | None
|
||||||
|
# The model the association/recommendation engine uses (null = none).
|
||||||
|
recommender_provider: str | None
|
||||||
|
# Providers the operator has configured (from env). The owner may use any of
|
||||||
|
# these; the two settings above restrict members and the recommender to one.
|
||||||
|
configured_providers: list[ConfiguredProvider]
|
||||||
|
default_provider: str
|
||||||
|
|
||||||
|
|
||||||
|
class TreeAiPolicyUpdate(BaseModel):
|
||||||
|
member_provider: str | None = None
|
||||||
|
recommender_provider: str | None = None
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRead(BaseModel):
|
||||||
|
user: UserRead
|
||||||
|
token: str
|
||||||
|
expires_at: datetime
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import ChangeProposalOrigin, ChangeProposalStatus
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalOperation(BaseModel):
|
||||||
|
op: str # create | update | delete
|
||||||
|
entity_type: str # person | name | event | relationship | source | citation
|
||||||
|
entity_id: uuid.UUID | None = None
|
||||||
|
payload: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalCreate(BaseModel):
|
||||||
|
summary: str
|
||||||
|
rationale: str | None = None
|
||||||
|
origin: ChangeProposalOrigin = ChangeProposalOrigin.contributor
|
||||||
|
operations: list[ProposalOperation]
|
||||||
|
|
||||||
|
|
||||||
|
class ProposalReview(BaseModel):
|
||||||
|
note: str | None = None
|
||||||
|
# Optional edited operations to apply instead of the original (approve-with-edits).
|
||||||
|
operations: list[ProposalOperation] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeProposalRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
status: ChangeProposalStatus
|
||||||
|
origin: ChangeProposalOrigin
|
||||||
|
created_by_user_id: uuid.UUID | None
|
||||||
|
summary: str
|
||||||
|
rationale: str | None
|
||||||
|
operations: list
|
||||||
|
reviewed_by_user_id: uuid.UUID | None
|
||||||
|
reviewed_at: datetime | None
|
||||||
|
review_note: str | None
|
||||||
|
apply_error: str | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class DeceasedCandidate(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
birth_year: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeceasedByChildCandidate(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
child_birth_year: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeceasedApply(BaseModel):
|
||||||
|
person_ids: list[uuid.UUID]
|
||||||
|
|
||||||
|
|
||||||
|
class GenderProposal(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
proposed_gender: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenderUpdate(BaseModel):
|
||||||
|
person_id: uuid.UUID
|
||||||
|
gender: str
|
||||||
|
|
||||||
|
|
||||||
|
class GenderApply(BaseModel):
|
||||||
|
updates: list[GenderUpdate]
|
||||||
|
|
||||||
|
|
||||||
|
class NameIssue(BaseModel):
|
||||||
|
name_id: uuid.UUID
|
||||||
|
person_id: uuid.UUID
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
issue: str
|
||||||
|
|
||||||
|
|
||||||
|
class NameEdit(BaseModel):
|
||||||
|
name_id: uuid.UUID
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NameApply(BaseModel):
|
||||||
|
edits: list[NameEdit]
|
||||||
|
|
||||||
|
|
||||||
|
class CleanupResult(BaseModel):
|
||||||
|
updated: int
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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 EventUpdate(BaseModel):
|
||||||
|
# All optional; only fields explicitly sent are changed (PATCH semantics).
|
||||||
|
event_type: str | 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 | None = None
|
||||||
|
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,25 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ImportReport(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
unmapped_tags: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateMatch(BaseModel):
|
||||||
|
# An incoming GEDCOM person that resembles an existing one in the tree.
|
||||||
|
xref: str
|
||||||
|
incoming_name: str
|
||||||
|
incoming_birth_year: str | None = None
|
||||||
|
existing_person_id: uuid.UUID
|
||||||
|
existing_name: str
|
||||||
|
existing_birth_year: str | None = None
|
||||||
|
score: str # "high" | "medium"
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreview(BaseModel):
|
||||||
|
counts: dict[str, int]
|
||||||
|
potential_duplicates: list[DuplicateMatch]
|
||||||
|
unmapped_tags: list[str]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class MediaUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
person_id: uuid.UUID | None = None
|
||||||
|
event_id: uuid.UUID | None = None
|
||||||
|
source_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
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,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
|
||||||
|
|
||||||
|
class MembershipRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
role: MembershipRole
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAdd(BaseModel):
|
||||||
|
email: str
|
||||||
|
role: MembershipRole = MembershipRole.viewer
|
||||||
|
|
||||||
|
|
||||||
|
class MemberRoleUpdate(BaseModel):
|
||||||
|
role: MembershipRole
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class NameCreate(BaseModel):
|
||||||
|
# Open vocabulary: birth/maiden, married, alias, religious, nickname, ...
|
||||||
|
name_type: str = "birth"
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
prefix: str | None = None
|
||||||
|
suffix: str | None = None
|
||||||
|
nickname: str | None = None
|
||||||
|
is_primary: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class NameUpdate(BaseModel):
|
||||||
|
name_type: str | None = None
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
prefix: str | None = None
|
||||||
|
suffix: str | None = None
|
||||||
|
nickname: str | None = None
|
||||||
|
is_primary: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NameRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
person_id: uuid.UUID
|
||||||
|
name_type: str
|
||||||
|
given: str | None
|
||||||
|
surname: str | None
|
||||||
|
prefix: str | None
|
||||||
|
suffix: str | None
|
||||||
|
nickname: str | None
|
||||||
|
is_primary: bool
|
||||||
|
sort_order: int
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
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 PersonUpdate(BaseModel):
|
||||||
|
# Person fields + the primary name's parts; only sent fields are changed.
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
is_living: bool | None = None
|
||||||
|
privacy: PersonPrivacy | None = None
|
||||||
|
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,33 @@
|
|||||||
|
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 RelationshipUpdate(BaseModel):
|
||||||
|
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,78 @@
|
|||||||
|
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 SourceUpdate(BaseModel):
|
||||||
|
title: str | None = None
|
||||||
|
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 CitationUpdate(BaseModel):
|
||||||
|
page: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
confidence: CitationConfidence | None = None
|
||||||
|
|
||||||
|
|
||||||
|
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,49 @@
|
|||||||
|
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 TreeUpdate(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
description: str | None = None
|
||||||
|
visibility: TreeVisibility | None = None
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TreePurge(BaseModel):
|
||||||
|
# Retype the tree's name to confirm a permanent, irreversible delete.
|
||||||
|
confirm_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class TreeRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
visibility: TreeVisibility
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PublicTreeRead(BaseModel):
|
||||||
|
"""Tree projection for the public surface — deliberately omits owner_id so a
|
||||||
|
public/unlisted tree doesn't reveal which account owns it."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
visibility: TreeVisibility
|
||||||
|
home_person_id: uuid.UUID | None = None
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
self_person_id: uuid.UUID | None = None
|
||||||
|
created_at: datetime
|
||||||
|
# Operational role, not a DB column: true when this account's email is named
|
||||||
|
# in OWNER_EMAIL. Set by the API layer (see users.read_me).
|
||||||
|
is_instance_owner: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserSelfPersonUpdate(BaseModel):
|
||||||
|
# null clears the link; otherwise the Person that represents this account.
|
||||||
|
self_person_id: uuid.UUID | None = None
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""Account-level data portability: export the signed-in user's owned trees as a
|
||||||
|
zip (JSON + media bytes), restore such a zip into a brand-new tree
|
||||||
|
(non-destructive), and delete the account.
|
||||||
|
|
||||||
|
The export format is a zip containing ``account.json`` plus ``media/<id>`` blobs.
|
||||||
|
Restore always creates new trees and remaps ids, so it can't clobber existing
|
||||||
|
data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import zipfile
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.integrations.objectstore.base import ObjectStore
|
||||||
|
from app.models.auth import Session as SessionModel
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
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
|
||||||
|
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
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
|
||||||
|
EXPORT_VERSION = 1
|
||||||
|
_DROP = {"created_at", "updated_at", "deleted_at", "tree_id"}
|
||||||
|
# Media columns rebuilt on import (storage is re-keyed, checksum recomputed).
|
||||||
|
_MEDIA_DROP = _DROP | {"uploader_id", "storage_key", "byte_size", "checksum_sha256"}
|
||||||
|
_DATE_FIELDS = {"date_start", "date_end"}
|
||||||
|
|
||||||
|
|
||||||
|
def _row(obj, drop: set[str]) -> dict:
|
||||||
|
out: dict = {}
|
||||||
|
for col in obj.__table__.columns.keys(): # noqa: SIM118
|
||||||
|
if col in drop:
|
||||||
|
continue
|
||||||
|
out[col] = getattr(obj, col)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _entities(session: AsyncSession, model, tree_id: uuid.UUID):
|
||||||
|
stmt = select(model).where(model.tree_id == tree_id, model.deleted_at.is_(None))
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def export_account(session: AsyncSession, store: ObjectStore, *, user: User) -> bytes:
|
||||||
|
"""Build a zip of every tree the user owns: account.json + media blobs."""
|
||||||
|
trees = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Tree).where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
payload: dict = {
|
||||||
|
"version": EXPORT_VERSION,
|
||||||
|
"user": {"email": user.email, "display_name": user.display_name},
|
||||||
|
"trees": [],
|
||||||
|
}
|
||||||
|
media_blobs: list[tuple[str, bytes]] = []
|
||||||
|
|
||||||
|
for tree in trees:
|
||||||
|
media_rows = await _entities(session, Media, tree.id)
|
||||||
|
media_out = []
|
||||||
|
for m in media_rows:
|
||||||
|
ref = f"media/{m.id}"
|
||||||
|
rec = _row(m, _MEDIA_DROP)
|
||||||
|
rec["_file"] = ref
|
||||||
|
media_out.append(rec)
|
||||||
|
try:
|
||||||
|
media_blobs.append((ref, await store.get_object(key=m.storage_key)))
|
||||||
|
except Exception: # noqa: BLE001 — a missing blob shouldn't abort the export
|
||||||
|
rec["_file"] = None
|
||||||
|
|
||||||
|
payload["trees"].append({
|
||||||
|
"tree": {
|
||||||
|
"name": tree.name,
|
||||||
|
"description": tree.description,
|
||||||
|
"visibility": tree.visibility,
|
||||||
|
"home_person_id": tree.home_person_id,
|
||||||
|
},
|
||||||
|
"places": [_row(p, _DROP) for p in await _entities(session, Place, tree.id)],
|
||||||
|
"persons": [_row(p, _DROP) for p in await _entities(session, Person, tree.id)],
|
||||||
|
"names": [_row(n, _DROP) for n in await _entities(session, Name, tree.id)],
|
||||||
|
"relationships": [
|
||||||
|
_row(r, _DROP) for r in await _entities(session, Relationship, tree.id)
|
||||||
|
],
|
||||||
|
"events": [_row(e, _DROP) for e in await _entities(session, Event, tree.id)],
|
||||||
|
"sources": [_row(s, _DROP) for s in await _entities(session, Source, tree.id)],
|
||||||
|
"citations": [_row(c, _DROP) for c in await _entities(session, Citation, tree.id)],
|
||||||
|
"media": media_out,
|
||||||
|
})
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
zf.writestr("account.json", json.dumps(payload, default=str, indent=2))
|
||||||
|
for ref, blob in media_blobs:
|
||||||
|
zf.writestr(ref, blob)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_uuid(v) -> uuid.UUID | None:
|
||||||
|
return uuid.UUID(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_date(v) -> date | None:
|
||||||
|
return date.fromisoformat(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
async def import_account(
|
||||||
|
session: AsyncSession, store: ObjectStore, *, user: User, raw_zip: bytes
|
||||||
|
) -> dict:
|
||||||
|
"""Restore an exported zip into NEW trees owned by the user. Non-destructive:
|
||||||
|
every record gets a fresh id; nothing existing is touched."""
|
||||||
|
try:
|
||||||
|
zf = zipfile.ZipFile(io.BytesIO(raw_zip))
|
||||||
|
payload = json.loads(zf.read("account.json"))
|
||||||
|
except (zipfile.BadZipFile, KeyError, json.JSONDecodeError) as e:
|
||||||
|
raise NotFound("not a valid Provenance export") from e
|
||||||
|
|
||||||
|
counts: dict[str, int] = {"trees": 0, "persons": 0, "events": 0, "media": 0}
|
||||||
|
|
||||||
|
for tdata in payload.get("trees", []):
|
||||||
|
t = tdata.get("tree", {})
|
||||||
|
tree = Tree(
|
||||||
|
owner_id=user.id,
|
||||||
|
name=(t.get("name") or "Imported tree"),
|
||||||
|
description=t.get("description"),
|
||||||
|
visibility=t.get("visibility") or "private",
|
||||||
|
)
|
||||||
|
session.add(tree)
|
||||||
|
await session.flush()
|
||||||
|
session.add(
|
||||||
|
TreeMembership(tree_id=tree.id, user_id=user.id, role=MembershipRole.owner)
|
||||||
|
)
|
||||||
|
counts["trees"] += 1
|
||||||
|
|
||||||
|
# id remaps from the export's ids to the freshly created ones.
|
||||||
|
pmap: dict[str, uuid.UUID] = {}
|
||||||
|
rmap: dict[str, uuid.UUID] = {}
|
||||||
|
smap: dict[str, uuid.UUID] = {}
|
||||||
|
nmap: dict[str, uuid.UUID] = {}
|
||||||
|
emap: dict[str, uuid.UUID] = {}
|
||||||
|
plmap: dict[str, uuid.UUID] = {}
|
||||||
|
|
||||||
|
for pl in tdata.get("places", []):
|
||||||
|
obj = Place(
|
||||||
|
tree_id=tree.id,
|
||||||
|
name=pl.get("name") or "",
|
||||||
|
place_type=pl.get("place_type"),
|
||||||
|
latitude=pl.get("latitude"),
|
||||||
|
longitude=pl.get("longitude"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
plmap[pl["id"]] = obj.id
|
||||||
|
|
||||||
|
for p in tdata.get("persons", []):
|
||||||
|
obj = Person(
|
||||||
|
tree_id=tree.id,
|
||||||
|
gender=p.get("gender"),
|
||||||
|
is_living=p.get("is_living"),
|
||||||
|
privacy=p.get("privacy") or "inherit",
|
||||||
|
notes=p.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
pmap[p["id"]] = obj.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
|
||||||
|
for n in tdata.get("names", []):
|
||||||
|
pid = pmap.get(n.get("person_id"))
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
obj = Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=pid,
|
||||||
|
name_type=n.get("name_type") or "birth",
|
||||||
|
given=n.get("given"),
|
||||||
|
surname=n.get("surname"),
|
||||||
|
prefix=n.get("prefix"),
|
||||||
|
suffix=n.get("suffix"),
|
||||||
|
nickname=n.get("nickname"),
|
||||||
|
display_name=n.get("display_name"),
|
||||||
|
is_primary=bool(n.get("is_primary")),
|
||||||
|
sort_order=n.get("sort_order") or 0,
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
nmap[n["id"]] = obj.id
|
||||||
|
|
||||||
|
for r in tdata.get("relationships", []):
|
||||||
|
a = pmap.get(r.get("person_from_id"))
|
||||||
|
b = pmap.get(r.get("person_to_id"))
|
||||||
|
if a is None or b is None:
|
||||||
|
continue
|
||||||
|
obj = Relationship(
|
||||||
|
tree_id=tree.id,
|
||||||
|
type=r.get("type"),
|
||||||
|
person_from_id=a,
|
||||||
|
person_to_id=b,
|
||||||
|
qualifier=r.get("qualifier"),
|
||||||
|
notes=r.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
rmap[r["id"]] = obj.id
|
||||||
|
|
||||||
|
for e in tdata.get("events", []):
|
||||||
|
obj = Event(
|
||||||
|
tree_id=tree.id,
|
||||||
|
event_type=e.get("event_type") or "other",
|
||||||
|
person_id=pmap.get(e.get("person_id")),
|
||||||
|
relationship_id=rmap.get(e.get("relationship_id")),
|
||||||
|
place_id=plmap.get(e.get("place_id")),
|
||||||
|
date_value=e.get("date_value"),
|
||||||
|
date_start=_as_date(e.get("date_start")),
|
||||||
|
date_end=_as_date(e.get("date_end")),
|
||||||
|
date_precision=e.get("date_precision"),
|
||||||
|
calendar=e.get("calendar") or "gregorian",
|
||||||
|
detail=e.get("detail"),
|
||||||
|
notes=e.get("notes"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
emap[e["id"]] = obj.id
|
||||||
|
counts["events"] += 1
|
||||||
|
|
||||||
|
for s in tdata.get("sources", []):
|
||||||
|
obj = Source(
|
||||||
|
tree_id=tree.id,
|
||||||
|
title=s.get("title") or "Untitled source",
|
||||||
|
author=s.get("author"),
|
||||||
|
source_type=s.get("source_type"),
|
||||||
|
repository=s.get("repository"),
|
||||||
|
url=s.get("url"),
|
||||||
|
citation_text=s.get("citation_text"),
|
||||||
|
publication_info=s.get("publication_info"),
|
||||||
|
quality_note=s.get("quality_note"),
|
||||||
|
)
|
||||||
|
session.add(obj)
|
||||||
|
await session.flush()
|
||||||
|
smap[s["id"]] = obj.id
|
||||||
|
|
||||||
|
for c in tdata.get("citations", []):
|
||||||
|
sid = smap.get(c.get("source_id"))
|
||||||
|
if sid is None:
|
||||||
|
continue
|
||||||
|
session.add(
|
||||||
|
Citation(
|
||||||
|
tree_id=tree.id,
|
||||||
|
source_id=sid,
|
||||||
|
person_id=pmap.get(c.get("person_id")),
|
||||||
|
event_id=emap.get(c.get("event_id")),
|
||||||
|
name_id=nmap.get(c.get("name_id")),
|
||||||
|
relationship_id=rmap.get(c.get("relationship_id")),
|
||||||
|
page=c.get("page"),
|
||||||
|
detail=c.get("detail"),
|
||||||
|
confidence=c.get("confidence"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for m in tdata.get("media", []):
|
||||||
|
ref = m.get("_file")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
blob = zf.read(ref)
|
||||||
|
except KeyError:
|
||||||
|
continue
|
||||||
|
media_id = uuid.uuid4()
|
||||||
|
filename = m.get("original_filename") or "upload"
|
||||||
|
key = f"{tree.id}/{media_id}/{filename}"
|
||||||
|
await store.ensure_bucket()
|
||||||
|
await store.put_object(
|
||||||
|
key=key,
|
||||||
|
data=blob,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
Media(
|
||||||
|
id=media_id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
uploader_id=user.id,
|
||||||
|
storage_key=key,
|
||||||
|
original_filename=filename,
|
||||||
|
content_type=m.get("content_type") or "application/octet-stream",
|
||||||
|
byte_size=len(blob),
|
||||||
|
checksum_sha256=hashlib.sha256(blob).hexdigest(),
|
||||||
|
title=m.get("title"),
|
||||||
|
person_id=pmap.get(m.get("person_id")),
|
||||||
|
event_id=emap.get(m.get("event_id")),
|
||||||
|
source_id=smap.get(m.get("source_id")),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["media"] += 1
|
||||||
|
|
||||||
|
# Remap the home person last, once persons exist.
|
||||||
|
home = t.get("home_person_id")
|
||||||
|
if home and home in pmap:
|
||||||
|
tree.home_person_id = pmap[home]
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="import",
|
||||||
|
entity_type="Account",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after=counts,
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_account(session: AsyncSession, *, user: User, confirm_email: str) -> None:
|
||||||
|
"""Soft-delete the account: the user, the trees they own, and all their
|
||||||
|
sessions. Requires the user to retype their email as a guard."""
|
||||||
|
if confirm_email.strip().lower() != user.email.lower():
|
||||||
|
raise Forbidden("email confirmation does not match")
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Tree)
|
||||||
|
.where(Tree.owner_id == user.id, Tree.deleted_at.is_(None))
|
||||||
|
.values(deleted_at=now)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
update(SessionModel)
|
||||||
|
.where(SessionModel.user_id == user.id, SessionModel.revoked_at.is_(None))
|
||||||
|
.values(revoked_at=now)
|
||||||
|
)
|
||||||
|
user.deleted_at = now
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Per-tree AI model policy — owner-only. Assigns which configured provider
|
||||||
|
members and the recommender use; the owner may use any configured provider.
|
||||||
|
|
||||||
|
The operator decides which providers exist (env / registry); the tree owner
|
||||||
|
decides who uses which. See app/api/deps.py for the registry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import configured_llm_providers
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.exceptions import Forbidden
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_owner(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||||
|
role = await privacy.get_membership_role(session, actor.id, tree.id)
|
||||||
|
if role is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the tree owner can configure AI")
|
||||||
|
|
||||||
|
|
||||||
|
def _names() -> set[str]:
|
||||||
|
return {p["name"] for p in configured_llm_providers()}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_policy(session: AsyncSession, *, actor: User, tree: Tree) -> dict:
|
||||||
|
await _require_owner(session, actor=actor, tree=tree)
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
return {
|
||||||
|
"member_provider": tree.ai_member_provider,
|
||||||
|
"recommender_provider": tree.ai_recommender_provider,
|
||||||
|
"configured_providers": configured_llm_providers(),
|
||||||
|
"default_provider": get_settings().default_llm_provider,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_policy(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
member_provider: str | None,
|
||||||
|
recommender_provider: str | None,
|
||||||
|
) -> dict:
|
||||||
|
await _require_owner(session, actor=actor, tree=tree)
|
||||||
|
valid = _names()
|
||||||
|
for value in (member_provider, recommender_provider):
|
||||||
|
if value is not None and value not in valid:
|
||||||
|
raise Forbidden(f"'{value}' is not a configured provider")
|
||||||
|
tree.ai_member_provider = member_provider
|
||||||
|
tree.ai_recommender_provider = recommender_provider
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(tree)
|
||||||
|
return await get_policy(session, actor=actor, tree=tree)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Resolution helpers (for the future assistant / recommender) -------------
|
||||||
|
|
||||||
|
def provider_name_for_member(tree: Tree) -> str | None:
|
||||||
|
"""Provider an ordinary member's assistant should use, if any."""
|
||||||
|
return tree.ai_member_provider
|
||||||
|
|
||||||
|
|
||||||
|
def provider_name_for_recommender(tree: Tree) -> str | None:
|
||||||
|
return tree.ai_recommender_provider
|
||||||
|
|
||||||
|
|
||||||
|
def provider_name_for_owner(tree: Tree, requested: str | None = None) -> str | None:
|
||||||
|
"""The owner may use any configured provider; default to the requested one."""
|
||||||
|
if requested and requested in _names():
|
||||||
|
return requested
|
||||||
|
return tree.ai_member_provider # fall back to the member model
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""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 json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit import AuditEntry
|
||||||
|
from app.models.enums import AuditActorType
|
||||||
|
|
||||||
|
|
||||||
|
def _json_safe(d: dict | None) -> dict | None:
|
||||||
|
"""Coerce a change dict to JSON-native types (UUIDs, enums, dates -> str) so
|
||||||
|
it lands in the JSON audit column regardless of what the caller passed."""
|
||||||
|
if d is None:
|
||||||
|
return None
|
||||||
|
return json.loads(json.dumps(d, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
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=_json_safe(before),
|
||||||
|
after=_json_safe(after),
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""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, verify_password
|
||||||
|
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, Forbidden, 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
|
||||||
|
if get_settings().require_email_verification and user.email_verified_at is None:
|
||||||
|
raise Forbidden("email not verified — check your inbox for the verification link")
|
||||||
|
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
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.id == record.user_id, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
# The single read-side enforcement: an unverified user has no active session
|
||||||
|
# when verification is required. Gates every authenticated request at once.
|
||||||
|
if user is not None and get_settings().require_email_verification and user.email_verified_at is None:
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
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 change_password(
|
||||||
|
session: AsyncSession, *, user: User, current_password: str, new_password: str
|
||||||
|
) -> None:
|
||||||
|
"""Change a logged-in user's password after re-verifying the current one.
|
||||||
|
Revokes other sessions so a changed password takes effect everywhere."""
|
||||||
|
if not user.hashed_password or not verify_password(
|
||||||
|
user.hashed_password, current_password
|
||||||
|
):
|
||||||
|
raise Forbidden("current password is incorrect")
|
||||||
|
user.hashed_password = hash_password(new_password)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="change_password",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
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,355 @@
|
|||||||
|
"""ChangeProposal lifecycle: propose (assistant/contributor) → review → apply/reject.
|
||||||
|
|
||||||
|
The structural guarantee (CLAUDE.md #1): a proposal's operations are executed
|
||||||
|
ONLY by ``apply()``, which requires the actor be an editor and dispatches every
|
||||||
|
op through the normal editing services — so each change passes the privacy
|
||||||
|
engine and is audited as the approving human. ``propose()`` only inserts a
|
||||||
|
pending row; it performs no domain mutation. See docs/design/change-proposal.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.change_proposal import ChangeProposal
|
||||||
|
from app.models.enums import (
|
||||||
|
ChangeProposalOrigin,
|
||||||
|
ChangeProposalStatus,
|
||||||
|
CitationConfidence,
|
||||||
|
ParentChildQualifier,
|
||||||
|
RelationshipType,
|
||||||
|
)
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import (
|
||||||
|
citation_service,
|
||||||
|
event_service,
|
||||||
|
name_service,
|
||||||
|
person_service,
|
||||||
|
privacy,
|
||||||
|
relationship_service,
|
||||||
|
source_service,
|
||||||
|
)
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def _uuid(v) -> uuid.UUID | None:
|
||||||
|
return uuid.UUID(str(v)) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_member(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> None:
|
||||||
|
# Proposals can reference unredacted facts → members only.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
raise Forbidden("only members can see change proposals")
|
||||||
|
|
||||||
|
|
||||||
|
async def _load(
|
||||||
|
session: AsyncSession, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> ChangeProposal:
|
||||||
|
cp = (
|
||||||
|
await session.execute(
|
||||||
|
select(ChangeProposal).where(
|
||||||
|
ChangeProposal.id == proposal_id,
|
||||||
|
ChangeProposal.tree_id == tree.id,
|
||||||
|
ChangeProposal.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if cp is None:
|
||||||
|
raise NotFound("proposal not found")
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def propose(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
tree: Tree,
|
||||||
|
origin: ChangeProposalOrigin,
|
||||||
|
created_by: uuid.UUID | None,
|
||||||
|
summary: str,
|
||||||
|
rationale: str | None,
|
||||||
|
operations: list[dict],
|
||||||
|
) -> ChangeProposal:
|
||||||
|
"""Insert a pending proposal. The ONLY mutation here is the proposal row — no
|
||||||
|
tree data changes. (No edit-rights check: proposing isn't writing.)"""
|
||||||
|
cp = ChangeProposal(
|
||||||
|
tree_id=tree.id,
|
||||||
|
origin=origin,
|
||||||
|
created_by_user_id=created_by,
|
||||||
|
summary=summary,
|
||||||
|
rationale=rationale,
|
||||||
|
operations=operations,
|
||||||
|
status=ChangeProposalStatus.pending,
|
||||||
|
)
|
||||||
|
session.add(cp)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def list_proposals(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
viewer_id: uuid.UUID,
|
||||||
|
tree: Tree,
|
||||||
|
status: ChangeProposalStatus | None = None,
|
||||||
|
) -> list[ChangeProposal]:
|
||||||
|
await _require_member(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
stmt = select(ChangeProposal).where(
|
||||||
|
ChangeProposal.tree_id == tree.id, ChangeProposal.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
if status is not None:
|
||||||
|
stmt = stmt.where(ChangeProposal.status == status)
|
||||||
|
stmt = stmt.order_by(ChangeProposal.created_at.desc())
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_proposal(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_member(session, viewer_id=viewer_id, tree=tree)
|
||||||
|
return await _load(session, tree, proposal_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def reject(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
note: str | None = None,
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
if cp.status is not ChangeProposalStatus.pending:
|
||||||
|
raise Conflict("proposal is not pending")
|
||||||
|
cp.status = ChangeProposalStatus.rejected
|
||||||
|
cp.reviewed_by_user_id = actor.id
|
||||||
|
cp.reviewed_at = _now()
|
||||||
|
cp.review_note = note
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def apply(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
proposal_id: uuid.UUID,
|
||||||
|
edited_operations: list[dict] | None = None,
|
||||||
|
) -> ChangeProposal:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
if cp.status is not ChangeProposalStatus.pending:
|
||||||
|
raise Conflict("proposal is not pending")
|
||||||
|
ops = edited_operations if edited_operations is not None else list(cp.operations)
|
||||||
|
try:
|
||||||
|
for op in ops:
|
||||||
|
await _dispatch(session, actor=actor, tree=tree, op=op)
|
||||||
|
except Conflict:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 — record the failure on the proposal
|
||||||
|
err = f"{type(exc).__name__}: {exc}"[:2000]
|
||||||
|
# The editing services raise (NotFound/Forbidden/validation) before
|
||||||
|
# committing, so the transaction is clean — record the error and commit.
|
||||||
|
# If a later op did write before failing, those ops already committed
|
||||||
|
# (v1 isn't cross-op transactional; see the design note).
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
cp.apply_error = err
|
||||||
|
await session.commit()
|
||||||
|
raise Conflict(f"could not apply proposal: {err}") from exc
|
||||||
|
if edited_operations is not None:
|
||||||
|
cp.operations = edited_operations
|
||||||
|
cp.status = ChangeProposalStatus.applied
|
||||||
|
cp.reviewed_by_user_id = actor.id
|
||||||
|
cp.reviewed_at = _now()
|
||||||
|
cp.apply_error = None
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(cp)
|
||||||
|
return cp
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_proposal(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, proposal_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
cp = await _load(session, tree, proposal_id)
|
||||||
|
cp.deleted_at = _now()
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _bad(entity_type: str, action: str) -> Conflict:
|
||||||
|
return Conflict(f"unsupported operation '{action}' on '{entity_type}'")
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch(session: AsyncSession, *, actor: User, tree: Tree, op: dict) -> None:
|
||||||
|
"""Route one operation through the matching editing service (privacy + audit)."""
|
||||||
|
et = op.get("entity_type")
|
||||||
|
action = op.get("op")
|
||||||
|
payload = op.get("payload") or {}
|
||||||
|
eid = op.get("entity_id")
|
||||||
|
|
||||||
|
if et == "person":
|
||||||
|
if action == "create":
|
||||||
|
await person_service.create_person(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
given=payload.get("given"),
|
||||||
|
surname=payload.get("surname"),
|
||||||
|
gender=payload.get("gender"),
|
||||||
|
is_living=payload.get("is_living"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
await person_service.update_person(
|
||||||
|
session, actor=actor, tree=tree, person_id=_uuid(eid), changes=payload
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await person_service.delete_person(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(eid),
|
||||||
|
cascade=bool(payload.get("cascade", False)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "event":
|
||||||
|
if action == "create":
|
||||||
|
await event_service.create_event(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
event_type=payload["event_type"],
|
||||||
|
person_id=_uuid(payload.get("person_id")),
|
||||||
|
relationship_id=_uuid(payload.get("relationship_id")),
|
||||||
|
date_value=payload.get("date_value"),
|
||||||
|
date_precision=payload.get("date_precision"),
|
||||||
|
detail=payload.get("detail"),
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
await event_service.update_event(
|
||||||
|
session, actor=actor, tree=tree, event_id=_uuid(eid), changes=payload
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await event_service.delete_event(
|
||||||
|
session, actor=actor, tree=tree, event_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "relationship":
|
||||||
|
if action == "create":
|
||||||
|
await relationship_service.create_relationship(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
type=RelationshipType(payload["type"]),
|
||||||
|
person_from_id=_uuid(payload["person_from_id"]),
|
||||||
|
person_to_id=_uuid(payload["person_to_id"]),
|
||||||
|
qualifier=ParentChildQualifier(payload["qualifier"])
|
||||||
|
if payload.get("qualifier")
|
||||||
|
else None,
|
||||||
|
notes=payload.get("notes"),
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await relationship_service.delete_relationship(
|
||||||
|
session, actor=actor, tree=tree, relationship_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "name":
|
||||||
|
if action == "create":
|
||||||
|
await name_service.create_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_type=payload.get("name_type", "birth"),
|
||||||
|
given=payload.get("given"),
|
||||||
|
surname=payload.get("surname"),
|
||||||
|
prefix=payload.get("prefix"),
|
||||||
|
suffix=payload.get("suffix"),
|
||||||
|
nickname=payload.get("nickname"),
|
||||||
|
is_primary=bool(payload.get("is_primary", False)),
|
||||||
|
)
|
||||||
|
elif action == "update":
|
||||||
|
changes = {k: v for k, v in payload.items() if k != "person_id"}
|
||||||
|
await name_service.update_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_id=_uuid(eid),
|
||||||
|
changes=changes,
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await name_service.delete_name(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
person_id=_uuid(payload["person_id"]),
|
||||||
|
name_id=_uuid(eid),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "source":
|
||||||
|
if action == "create":
|
||||||
|
await source_service.create_source(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
title=payload["title"],
|
||||||
|
author=payload.get("author"),
|
||||||
|
source_type=payload.get("source_type"),
|
||||||
|
repository=payload.get("repository"),
|
||||||
|
url=payload.get("url"),
|
||||||
|
citation_text=payload.get("citation_text"),
|
||||||
|
publication_info=payload.get("publication_info"),
|
||||||
|
quality_note=payload.get("quality_note"),
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await source_service.delete_source(
|
||||||
|
session, actor=actor, tree=tree, source_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
elif et == "citation":
|
||||||
|
if action == "create":
|
||||||
|
await citation_service.create_citation(
|
||||||
|
session,
|
||||||
|
actor=actor,
|
||||||
|
tree=tree,
|
||||||
|
source_id=_uuid(payload["source_id"]),
|
||||||
|
person_id=_uuid(payload.get("person_id")),
|
||||||
|
event_id=_uuid(payload.get("event_id")),
|
||||||
|
name_id=_uuid(payload.get("name_id")),
|
||||||
|
relationship_id=_uuid(payload.get("relationship_id")),
|
||||||
|
page=payload.get("page"),
|
||||||
|
detail=payload.get("detail"),
|
||||||
|
confidence=CitationConfidence(payload["confidence"])
|
||||||
|
if payload.get("confidence")
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
elif action == "delete":
|
||||||
|
await citation_service.delete_citation(
|
||||||
|
session, actor=actor, tree=tree, citation_id=_uuid(eid)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise _bad(et, action)
|
||||||
|
else:
|
||||||
|
raise Conflict(f"unsupported entity type '{et}'")
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""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")
|
||||||
|
# Non-members get only citations whose cited fact resolves to a full-
|
||||||
|
# visibility person — a citation on a redacted living person's fact would
|
||||||
|
# otherwise leak that the person has that sourced fact.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_citations(
|
||||||
|
session, viewer_id=viewer_id, tree=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 update_citation(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, citation_id: uuid.UUID, changes: dict
|
||||||
|
) -> Citation:
|
||||||
|
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")
|
||||||
|
for key in {"page", "detail", "confidence"} & changes.keys():
|
||||||
|
setattr(citation, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Citation",
|
||||||
|
entity_id=citation.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(citation)
|
||||||
|
return citation
|
||||||
|
|
||||||
|
|
||||||
|
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,382 @@
|
|||||||
|
"""Bulk tree cleanup — preview/apply pairs for common import messes.
|
||||||
|
|
||||||
|
Per the project's #1 rule (the assistant proposes, humans approve), each fix has
|
||||||
|
a *preview* that returns the proposed changes and an *apply* that commits only
|
||||||
|
the ids/edits the user confirmed. Nothing here mutates without an explicit apply
|
||||||
|
call carrying the user's selections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import RelationshipType
|
||||||
|
from app.models.event import Event
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.relationship import Relationship
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import gedcom, privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden, NotFound
|
||||||
|
from app.services.name_gender_data import guess_sex
|
||||||
|
|
||||||
|
|
||||||
|
async def _require_editor(session: AsyncSession, *, actor: User, tree: Tree) -> None:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
|
||||||
|
async def _persons(session: AsyncSession, tree_id: uuid.UUID) -> list[Person]:
|
||||||
|
return list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.tree_id == tree_id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _primary_name_by_person(
|
||||||
|
session: AsyncSession, tree_id: uuid.UUID
|
||||||
|
) -> dict[uuid.UUID, Name]:
|
||||||
|
names = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.tree_id == tree_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: dict[uuid.UUID, Name] = {}
|
||||||
|
for n in names:
|
||||||
|
out.setdefault(n.person_id, n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _birth_year_by_person(session: AsyncSession, tree_id: uuid.UUID) -> dict[uuid.UUID, int]:
|
||||||
|
evs = (
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.tree_id == tree_id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
Event.event_type == "birth",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: dict[uuid.UUID, int] = {}
|
||||||
|
for e in evs:
|
||||||
|
if not e.person_id or e.person_id in out:
|
||||||
|
continue
|
||||||
|
y = e.date_start.year if e.date_start else None
|
||||||
|
if y is None:
|
||||||
|
ys = gedcom._year(e.date_value)
|
||||||
|
y = int(ys) if ys else None
|
||||||
|
if y is not None:
|
||||||
|
out[e.person_id] = y
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _display(n: Name | None) -> str:
|
||||||
|
if n is None:
|
||||||
|
return "Unnamed"
|
||||||
|
return " ".join(x for x in (n.given, n.surname) if x) or (n.display_name or "Unnamed")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1. Mark deceased by birth year -------------------------------------------------
|
||||||
|
|
||||||
|
async def preview_deceased(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, year: int
|
||||||
|
) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
years = await _birth_year_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.is_living is False: # already deceased
|
||||||
|
continue
|
||||||
|
by = years.get(p.id)
|
||||||
|
if by is not None and by <= year:
|
||||||
|
out.append(
|
||||||
|
{"person_id": str(p.id), "name": _display(names.get(p.id)), "birth_year": by}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: r["birth_year"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_deceased(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, person_ids: list[uuid.UUID]
|
||||||
|
) -> int:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
persons = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
Person.id.in_(person_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for p in persons:
|
||||||
|
p.is_living = False
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_deceased",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(persons)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(persons)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 1b. Mark deceased by a CHILD's birth year -------------------------------------
|
||||||
|
# For parents whose own birth date is missing (so the birth-year rule can't reach
|
||||||
|
# them) but who have a child born long ago — they're necessarily deceased. Applies
|
||||||
|
# through the same apply_deceased() path.
|
||||||
|
|
||||||
|
async def preview_deceased_by_child(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, year: int
|
||||||
|
) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
years = await _birth_year_by_person(session, tree.id)
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
Relationship.type == RelationshipType.parent_child,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
# parent id -> earliest child birth year, among children born on/before `year`.
|
||||||
|
earliest_child: dict[uuid.UUID, int] = {}
|
||||||
|
for r in rels:
|
||||||
|
cy = years.get(r.person_to_id) # the child's birth year
|
||||||
|
if cy is None or cy > year:
|
||||||
|
continue
|
||||||
|
if r.person_from_id not in earliest_child or cy < earliest_child[r.person_from_id]:
|
||||||
|
earliest_child[r.person_from_id] = cy
|
||||||
|
persons = {p.id: p for p in await _persons(session, tree.id)}
|
||||||
|
out: list[dict] = []
|
||||||
|
for parent_id, cy in earliest_child.items():
|
||||||
|
p = persons.get(parent_id)
|
||||||
|
if p is None or p.is_living is False: # gone or already deceased
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"person_id": str(parent_id),
|
||||||
|
"name": _display(names.get(parent_id)),
|
||||||
|
"child_birth_year": cy,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: r["child_birth_year"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 2. Re-derive gender from a source GEDCOM (matches by name) ----------------------
|
||||||
|
|
||||||
|
async def preview_gender(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, gedcom_text: str
|
||||||
|
) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
name2sex: dict[str, str] = {}
|
||||||
|
for rec in gedcom.parse_records(gedcom_text):
|
||||||
|
if rec.tag != "INDI":
|
||||||
|
continue
|
||||||
|
summ = gedcom._person_summary(rec)
|
||||||
|
sex = gedcom._sex(rec.text("SEX"))
|
||||||
|
if sex and summ["norm"]:
|
||||||
|
name2sex.setdefault(summ["norm"], sex)
|
||||||
|
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.gender: # only fill in what's missing
|
||||||
|
continue
|
||||||
|
nm = names.get(p.id)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
proposed = name2sex.get(gedcom._norm(nm.given, nm.surname))
|
||||||
|
if proposed:
|
||||||
|
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def guess_gender_by_name(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Best-guess sex from the first given name for people who don't have it set,
|
||||||
|
using the bundled name dictionary. Ambiguous/unknown names are skipped."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
out: list[dict] = []
|
||||||
|
for p in await _persons(session, tree.id):
|
||||||
|
if p.gender:
|
||||||
|
continue
|
||||||
|
nm = names.get(p.id)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
proposed = guess_sex(nm.given)
|
||||||
|
if proposed:
|
||||||
|
out.append({"person_id": str(p.id), "name": _display(nm), "proposed_gender": proposed})
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def guess_gender_by_spouse(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Infer the sex of a person who has none set from a partner whose sex IS set
|
||||||
|
(couples in a tree are opposite-sex in practice — e.g. a confirmed-male
|
||||||
|
husband implies a female wife). People whose known partners disagree are
|
||||||
|
ambiguous and skipped; the result is a preview to review, not an auto-write."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
persons = await _persons(session, tree.id)
|
||||||
|
gender = {p.id: p.gender for p in persons}
|
||||||
|
names = await _primary_name_by_person(session, tree.id)
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
Relationship.type == RelationshipType.partnership,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
opp = {"male": "female", "female": "male"}
|
||||||
|
proposals: dict[uuid.UUID, set[str]] = {}
|
||||||
|
for r in rels:
|
||||||
|
for me_id, other_id in (
|
||||||
|
(r.person_from_id, r.person_to_id),
|
||||||
|
(r.person_to_id, r.person_from_id),
|
||||||
|
):
|
||||||
|
if gender.get(me_id):
|
||||||
|
continue # this person already has a sex
|
||||||
|
other_sex = str(gender.get(other_id) or "")
|
||||||
|
if other_sex in opp:
|
||||||
|
proposals.setdefault(me_id, set()).add(opp[other_sex])
|
||||||
|
out: list[dict] = []
|
||||||
|
for pid, sexes in proposals.items():
|
||||||
|
if len(sexes) != 1:
|
||||||
|
continue # partners of differing known sex → ambiguous
|
||||||
|
nm = names.get(pid)
|
||||||
|
if nm is None:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{"person_id": str(pid), "name": _display(nm), "proposed_gender": next(iter(sexes))}
|
||||||
|
)
|
||||||
|
out.sort(key=lambda r: r["name"])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_gender(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, updates: list[dict]
|
||||||
|
) -> int:
|
||||||
|
"""updates: [{person_id, gender}]."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
wanted = {uuid.UUID(str(u["person_id"])): u["gender"] for u in updates if u.get("gender")}
|
||||||
|
persons = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(
|
||||||
|
Person.tree_id == tree.id,
|
||||||
|
Person.deleted_at.is_(None),
|
||||||
|
Person.id.in_(wanted.keys()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for p in persons:
|
||||||
|
p.gender = wanted[p.id]
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_gender",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(persons)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(persons)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 3. Flag malformed names for review --------------------------------------------
|
||||||
|
|
||||||
|
_YEAR_RE = re.compile(r"\b\d{3,4}\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _name_issue(n: Name) -> str | None:
|
||||||
|
given = (n.given or "").strip()
|
||||||
|
surname = (n.surname or "").strip()
|
||||||
|
if _YEAR_RE.search(surname) or re.search(r"\d", surname):
|
||||||
|
return "date_in_surname"
|
||||||
|
if re.search(r"\d", given):
|
||||||
|
return "date_in_given"
|
||||||
|
# A given name with many tokens often means a maiden+married name was packed
|
||||||
|
# in (e.g. "Mary Smith Jones") — surface it for a human to split.
|
||||||
|
if surname == "" and len(given.split()) >= 2:
|
||||||
|
return "no_surname"
|
||||||
|
if len(given.split()) >= 3:
|
||||||
|
return "packed_given"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_names(session: AsyncSession, *, actor: User, tree: Tree) -> list[dict]:
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
names = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(Name.tree_id == tree.id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
out: list[dict] = []
|
||||||
|
for n in names:
|
||||||
|
issue = _name_issue(n)
|
||||||
|
if issue:
|
||||||
|
out.append({
|
||||||
|
"name_id": str(n.id),
|
||||||
|
"person_id": str(n.person_id),
|
||||||
|
"given": n.given,
|
||||||
|
"surname": n.surname,
|
||||||
|
"issue": issue,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_names(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, edits: list[dict]
|
||||||
|
) -> int:
|
||||||
|
"""edits: [{name_id, given, surname}] — the user's corrected values."""
|
||||||
|
await _require_editor(session, actor=actor, tree=tree)
|
||||||
|
by_id = {uuid.UUID(str(e["name_id"])): e for e in edits}
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
Name.id.in_(by_id.keys()),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
if len(rows) != len(by_id):
|
||||||
|
raise NotFound("one or more names not found in this tree")
|
||||||
|
for n in rows:
|
||||||
|
e = by_id[n.id]
|
||||||
|
n.given = (e.get("given") or "").strip() or None
|
||||||
|
n.surname = (e.get("surname") or "").strip() or None
|
||||||
|
n.display_name = None # rebuild from parts
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="cleanup_names",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"count": len(rows)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return len(rows)
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""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 or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import RelationshipType
|
||||||
|
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")
|
||||||
|
# Non-members get the redacted projection (no living-person dates).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_events(
|
||||||
|
session, viewer_id=viewer_id, tree=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")
|
||||||
|
# Non-members only see a full-visibility person's events (redacted → none).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_events(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
# Member view: this person's own events PLUS their partnership events (which
|
||||||
|
# live on the relationship and show on both partners). Returning both here
|
||||||
|
# means the person page doesn't have to load every event in the tree.
|
||||||
|
partner_rel_ids = (
|
||||||
|
select(Relationship.id)
|
||||||
|
.where(
|
||||||
|
Relationship.tree_id == tree.id,
|
||||||
|
Relationship.type == RelationshipType.partnership,
|
||||||
|
Relationship.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Relationship.person_from_id == person_id,
|
||||||
|
Relationship.person_to_id == person_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Event)
|
||||||
|
.where(
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
or_(
|
||||||
|
Event.person_id == person_id,
|
||||||
|
Event.relationship_id.in_(partner_rel_ids),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.order_by(Event.date_start.nulls_last(), Event.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def update_event(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
event_id: uuid.UUID,
|
||||||
|
changes: dict,
|
||||||
|
) -> Event:
|
||||||
|
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")
|
||||||
|
if "place_id" in changes and changes["place_id"] is not None:
|
||||||
|
if not await _belongs_to_tree(session, Place, changes["place_id"], tree.id):
|
||||||
|
raise NotFound("place not found in this tree")
|
||||||
|
for key, value in changes.items():
|
||||||
|
setattr(event, key, value)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Event",
|
||||||
|
entity_id=event.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(event)
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
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,841 @@
|
|||||||
|
"""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 is duplicate-aware: ``preview_gedcom`` reports incoming people that look
|
||||||
|
like existing ones, and ``import_gedcom`` applies a per-record resolution
|
||||||
|
(new / skip / merge / overwrite). Names carry their GEDCOM type (a married name
|
||||||
|
imports as a typed alternate, not a second primary).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import UTC, date, datetime
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
from sqlalchemy import or_, select, update
|
||||||
|
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", "RELI": "religion",
|
||||||
|
}
|
||||||
|
# INDI attribute tags whose line VALUE is the fact (no date), stored in detail.
|
||||||
|
VALUE_EVENTS = {"RELI", "OCCU", "EDUC"}
|
||||||
|
# INDI sub-tags consumed elsewhere or intentionally ignored (not "unmapped").
|
||||||
|
INDI_SKIP_TAGS = {
|
||||||
|
"NAME", "SEX", "SOUR", "FAMC", "FAMS", "CHAN", "OBJE", "_UID", "_MARNM", "NOTE",
|
||||||
|
}
|
||||||
|
# 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()}
|
||||||
|
|
||||||
|
# GEDCOM NAME TYPE (or _MARNM-derived) -> our Name.name_type vocabulary.
|
||||||
|
NAME_TYPE_MAP = {
|
||||||
|
"birth": "birth", "maiden": "birth", "married": "married",
|
||||||
|
"aka": "alias", "also known as": "alias", "nickname": "nickname",
|
||||||
|
"religious": "religious", "immigrant": "immigration",
|
||||||
|
"immigration": "immigration", "professional": "alias", "other": "alias",
|
||||||
|
}
|
||||||
|
# Our type -> GEDCOM TYPE on export (birth is the default; emit nothing).
|
||||||
|
EXPORT_TYPE_MAP = {
|
||||||
|
"married": "married", "alias": "aka", "nickname": "nickname",
|
||||||
|
"religious": "religious", "immigration": "immigrant",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 _parse_marnm(value: str, base_given: str | None) -> tuple[str | None, str | None]:
|
||||||
|
"""A _MARNM value is sometimes a full name ("Jane /Smith/") and sometimes
|
||||||
|
just the married surname ("Smith"). Keep the given name from the base name
|
||||||
|
in the latter case."""
|
||||||
|
v = (value or "").strip()
|
||||||
|
if "/" in v:
|
||||||
|
g, s = _parse_name(v)
|
||||||
|
return (g or base_given), s
|
||||||
|
return base_given, (v or None)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_names(rec: GedcomNode) -> list[dict]:
|
||||||
|
"""All names for an INDI, typed. Multiple NAME records (each with an optional
|
||||||
|
TYPE) plus any _MARNM (married name) subtags become separate Name rows. The
|
||||||
|
first birth/maiden name is primary."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for nm in rec.all("NAME"):
|
||||||
|
g, s = _parse_name(nm.value)
|
||||||
|
t = (nm.text("TYPE") or "").strip().lower()
|
||||||
|
ntype = NAME_TYPE_MAP.get(t, t or "birth")
|
||||||
|
out.append({"type": ntype, "given": g, "surname": s, "display": nm.value or None,
|
||||||
|
"nickname": nm.text("NICK")})
|
||||||
|
for mar in nm.all("_MARNM"):
|
||||||
|
mg, ms = _parse_marnm(mar.value, g)
|
||||||
|
out.append({"type": "married", "given": mg, "surname": ms,
|
||||||
|
"display": mar.value or None, "nickname": None})
|
||||||
|
for mar in rec.all("_MARNM"):
|
||||||
|
base_g = out[0]["given"] if out else None
|
||||||
|
mg, ms = _parse_marnm(mar.value, base_g)
|
||||||
|
out.append({"type": "married", "given": mg, "surname": ms,
|
||||||
|
"display": mar.value or None, "nickname": None})
|
||||||
|
if not out:
|
||||||
|
return out
|
||||||
|
primary_idx = next((i for i, n in enumerate(out) if n["type"] == "birth"), 0)
|
||||||
|
for i, n in enumerate(out):
|
||||||
|
n["is_primary"] = i == primary_idx
|
||||||
|
n["sort"] = i
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(given: str | None, surname: str | None) -> str:
|
||||||
|
return re.sub(r"\s+", " ", f"{given or ''} {surname or ''}".strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _notes_text(rec: GedcomNode) -> str | None:
|
||||||
|
"""Join an INDI's NOTE lines (which pack confidence / findagrave / fs_pid /
|
||||||
|
free text) into the person's notes field."""
|
||||||
|
vals = [n.value.strip() for n in rec.all("NOTE") if n.value and n.value.strip()]
|
||||||
|
return "\n".join(vals) or None
|
||||||
|
|
||||||
|
|
||||||
|
def _person_summary(rec: GedcomNode) -> dict:
|
||||||
|
"""Display name + birth year for an incoming INDI, for duplicate matching."""
|
||||||
|
names = _extract_names(rec)
|
||||||
|
primary = next((n for n in names if n.get("is_primary")), names[0] if names else None)
|
||||||
|
g = primary["given"] if primary else None
|
||||||
|
s = primary["surname"] if primary else None
|
||||||
|
disp = " ".join(x for x in (g, s) if x)
|
||||||
|
if not disp and primary:
|
||||||
|
disp = primary.get("display") or ""
|
||||||
|
birth = rec.first("BIRT")
|
||||||
|
year = _year(birth.text("DATE")) if birth else None
|
||||||
|
return {"names": names, "norm": _norm(g, s), "name": disp or "(no name)", "year": year}
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_existing_index(session: AsyncSession, tree: Tree) -> list[dict]:
|
||||||
|
"""Existing (non-deleted) people with a display name + birth year, for
|
||||||
|
matching incoming records against."""
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
name_by_person: dict[uuid.UUID, Name] = {}
|
||||||
|
for n in sorted(names, key=lambda n: (not n.is_primary, n.sort_order)):
|
||||||
|
name_by_person.setdefault(n.person_id, n)
|
||||||
|
births = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Event).where(
|
||||||
|
Event.tree_id == tree.id,
|
||||||
|
Event.deleted_at.is_(None),
|
||||||
|
Event.event_type == "birth",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
year_by_person: dict[uuid.UUID, str] = {}
|
||||||
|
for e in births:
|
||||||
|
if e.person_id and e.person_id not in year_by_person:
|
||||||
|
y = str(e.date_start.year) if e.date_start else _year(e.date_value)
|
||||||
|
if y:
|
||||||
|
year_by_person[e.person_id] = y
|
||||||
|
|
||||||
|
index: list[dict] = []
|
||||||
|
for p in persons:
|
||||||
|
nm = name_by_person.get(p.id)
|
||||||
|
g = nm.given if nm else None
|
||||||
|
s = nm.surname if nm else None
|
||||||
|
disp = " ".join(x for x in (g, s) if x) or (nm.display_name if nm else None)
|
||||||
|
index.append({
|
||||||
|
"id": p.id,
|
||||||
|
"norm": _norm(g, s),
|
||||||
|
"name": disp or "(no name)",
|
||||||
|
"year": year_by_person.get(p.id),
|
||||||
|
})
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _best_match(norm: str, year: str | None, index: list[dict]) -> tuple[dict | None, str | None]:
|
||||||
|
"""Closest existing person by name similarity, rejecting clear birth-year
|
||||||
|
conflicts. Returns (entry, "high"|"medium") or (None, None)."""
|
||||||
|
if not norm:
|
||||||
|
return None, None
|
||||||
|
best: dict | None = None
|
||||||
|
best_r = 0.0
|
||||||
|
for e in index:
|
||||||
|
if not e["norm"]:
|
||||||
|
continue
|
||||||
|
r = SequenceMatcher(None, norm, e["norm"]).ratio()
|
||||||
|
if r < 0.88:
|
||||||
|
continue
|
||||||
|
if year and e["year"] and abs(int(year) - int(e["year"])) > 1:
|
||||||
|
continue # same-ish name but different birth year — not a duplicate
|
||||||
|
if r > best_r:
|
||||||
|
best_r = r
|
||||||
|
best = e
|
||||||
|
if best is None:
|
||||||
|
return None, None
|
||||||
|
year_match = bool(year and best["year"] and abs(int(year) - int(best["year"])) <= 1)
|
||||||
|
both_unknown = not year and not best["year"]
|
||||||
|
score = "high" if best_r >= 0.93 and (year_match or both_unknown) else "medium"
|
||||||
|
return best, score
|
||||||
|
|
||||||
|
|
||||||
|
def _relkey(rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID) -> tuple:
|
||||||
|
if rtype == RelationshipType.parent_child:
|
||||||
|
return ("pc", str(a), str(b))
|
||||||
|
return (rtype.value, *sorted([str(a), str(b)]))
|
||||||
|
|
||||||
|
|
||||||
|
def _count_incoming(roots: list[GedcomNode]) -> tuple[dict, list[str]]:
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "INDI" and rec.xref:
|
||||||
|
counts["persons"] += 1
|
||||||
|
counts["names"] += len(_extract_names(rec))
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
counts["events"] += 1
|
||||||
|
elif child.tag not in INDI_SKIP_TAGS:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
elif rec.tag == "FAM":
|
||||||
|
counts["families"] += 1
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in FAM_EVENTS:
|
||||||
|
counts["events"] += 1
|
||||||
|
elif rec.tag == "SOUR" and rec.xref:
|
||||||
|
counts["sources"] += 1
|
||||||
|
return dict(counts), sorted(unmapped)
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_gedcom(session: AsyncSession, *, actor: User, tree: Tree, text: str) -> dict:
|
||||||
|
"""Dry run: what would import, and which incoming people look like existing
|
||||||
|
ones. No writes."""
|
||||||
|
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, unmapped = _count_incoming(roots)
|
||||||
|
index = await _build_existing_index(session, tree)
|
||||||
|
|
||||||
|
duplicates: list[dict] = []
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
summ = _person_summary(rec)
|
||||||
|
entry, score = _best_match(summ["norm"], summ["year"], index)
|
||||||
|
if entry is None:
|
||||||
|
continue
|
||||||
|
duplicates.append({
|
||||||
|
"xref": rec.xref,
|
||||||
|
"incoming_name": summ["name"],
|
||||||
|
"incoming_birth_year": summ["year"],
|
||||||
|
"existing_person_id": entry["id"],
|
||||||
|
"existing_name": entry["name"],
|
||||||
|
"existing_birth_year": entry["year"],
|
||||||
|
"score": score,
|
||||||
|
})
|
||||||
|
return {"counts": counts, "potential_duplicates": duplicates, "unmapped_tags": unmapped}
|
||||||
|
|
||||||
|
|
||||||
|
async def import_gedcom(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
text: str,
|
||||||
|
default_action: str = "new",
|
||||||
|
resolutions: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Import records. ``default_action`` (new|skip|merge|overwrite) applies to
|
||||||
|
incoming people that match an existing one; ``resolutions`` overrides it per
|
||||||
|
GEDCOM xref ({xref: {action, target_id}}). 'skip' links families to the
|
||||||
|
existing person but copies nothing; 'merge' also copies the incoming names
|
||||||
|
(as alternates), events and citations onto them; 'overwrite' deletes the
|
||||||
|
existing person and imports the incoming one fresh."""
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
resolutions = resolutions or {}
|
||||||
|
roots = parse_records(text)
|
||||||
|
counts: dict[str, int] = defaultdict(int)
|
||||||
|
unmapped: set[str] = set()
|
||||||
|
place_cache: dict[str, uuid.UUID] = {}
|
||||||
|
source_map: dict[str, uuid.UUID] = {}
|
||||||
|
person_map: dict[str, uuid.UUID] = {}
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
index = await _build_existing_index(session, tree)
|
||||||
|
|
||||||
|
# Pre-load existing relationship keys so a merge doesn't create dup edges.
|
||||||
|
existing_rels = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Relationship).where(
|
||||||
|
Relationship.tree_id == tree.id, Relationship.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
)
|
||||||
|
rel_keys = {_relkey(r.type, r.person_from_id, r.person_to_id) for r in existing_rels}
|
||||||
|
|
||||||
|
def add_relationship(
|
||||||
|
rtype: RelationshipType, a: uuid.UUID, b: uuid.UUID, **kw
|
||||||
|
) -> Relationship | None:
|
||||||
|
key = _relkey(rtype, a, b)
|
||||||
|
if key in rel_keys:
|
||||||
|
return None
|
||||||
|
rel = Relationship(tree_id=tree.id, type=rtype, person_from_id=a, person_to_id=b, **kw)
|
||||||
|
session.add(rel)
|
||||||
|
rel_keys.add(key)
|
||||||
|
counts["relationships"] += 1
|
||||||
|
return rel
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
def add_names(person_id: uuid.UUID, names: list[dict], *, set_primary: bool) -> None:
|
||||||
|
for nd in names:
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person_id,
|
||||||
|
name_type=nd["type"],
|
||||||
|
given=nd["given"],
|
||||||
|
surname=nd["surname"],
|
||||||
|
nickname=nd.get("nickname"),
|
||||||
|
display_name=nd.get("display"),
|
||||||
|
is_primary=set_primary and nd.get("is_primary", False),
|
||||||
|
sort_order=nd.get("sort", 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
counts["names"] += 1
|
||||||
|
|
||||||
|
async def add_events(rec: GedcomNode, person_id: uuid.UUID) -> None:
|
||||||
|
for child in rec.children:
|
||||||
|
if child.tag in INDI_EVENTS:
|
||||||
|
dv = child.text("DATE")
|
||||||
|
# Attribute-style facts (RELI, OCCU, EDUC) carry their value on
|
||||||
|
# the line itself; store it in detail.
|
||||||
|
detail = child.value.strip() if child.tag in VALUE_EVENTS else None
|
||||||
|
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")),
|
||||||
|
detail=detail or None,
|
||||||
|
notes=child.text("NOTE"),
|
||||||
|
)
|
||||||
|
session.add(ev)
|
||||||
|
await session.flush()
|
||||||
|
counts["events"] += 1
|
||||||
|
await add_citations(child, event_id=ev.id)
|
||||||
|
elif child.tag in INDI_SKIP_TAGS:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
unmapped.add(child.tag)
|
||||||
|
|
||||||
|
async def soft_delete_existing(person_id: uuid.UUID) -> None:
|
||||||
|
p = (
|
||||||
|
await session.execute(
|
||||||
|
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if p is None:
|
||||||
|
return
|
||||||
|
p.deleted_at = now
|
||||||
|
rels = (
|
||||||
|
await session.execute(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
for r in rels:
|
||||||
|
r.deleted_at = now
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.self_person_id == person_id).values(self_person_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Precompute the best match per incoming xref (for default-policy resolution).
|
||||||
|
matches: dict[str, dict] = {}
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag == "INDI" and rec.xref:
|
||||||
|
summ = _person_summary(rec)
|
||||||
|
entry, _score = _best_match(summ["norm"], summ["year"], index)
|
||||||
|
if entry is not None:
|
||||||
|
matches[rec.xref] = entry
|
||||||
|
|
||||||
|
def resolve(xref: str) -> tuple[str, uuid.UUID | None]:
|
||||||
|
ov = resolutions.get(xref)
|
||||||
|
if ov:
|
||||||
|
action = ov.get("action", "new")
|
||||||
|
tid = ov.get("target_id")
|
||||||
|
target = uuid.UUID(tid) if tid else (matches[xref]["id"] if xref in matches else None)
|
||||||
|
if action in ("skip", "merge", "overwrite") and target is None:
|
||||||
|
return "new", None
|
||||||
|
return action, target
|
||||||
|
if default_action != "new" and xref in matches:
|
||||||
|
return default_action, matches[xref]["id"]
|
||||||
|
return "new", None
|
||||||
|
|
||||||
|
# Individuals.
|
||||||
|
for rec in roots:
|
||||||
|
if rec.tag != "INDI" or not rec.xref:
|
||||||
|
continue
|
||||||
|
names = _extract_names(rec)
|
||||||
|
action, target = resolve(rec.xref)
|
||||||
|
|
||||||
|
if action == "skip" and target is not None:
|
||||||
|
person_map[rec.xref] = target
|
||||||
|
counts["skipped"] += 1
|
||||||
|
continue
|
||||||
|
if action == "merge" and target is not None:
|
||||||
|
person_map[rec.xref] = target
|
||||||
|
add_names(target, names, set_primary=False)
|
||||||
|
await add_events(rec, target)
|
||||||
|
await add_citations(rec, person_id=target)
|
||||||
|
note = _notes_text(rec)
|
||||||
|
if note:
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(Person).where(Person.id == target))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
existing.notes = "\n".join(filter(None, [existing.notes, note]))
|
||||||
|
counts["merged"] += 1
|
||||||
|
continue
|
||||||
|
if action == "overwrite" and target is not None:
|
||||||
|
await soft_delete_existing(target)
|
||||||
|
counts["overwritten"] += 1
|
||||||
|
|
||||||
|
person = Person(tree_id=tree.id, gender=_sex(rec.text("SEX")), notes=_notes_text(rec))
|
||||||
|
session.add(person)
|
||||||
|
await session.flush()
|
||||||
|
person_map[rec.xref] = person.id
|
||||||
|
counts["persons"] += 1
|
||||||
|
add_names(person.id, names, set_primary=True)
|
||||||
|
await add_citations(rec, person_id=person.id)
|
||||||
|
await add_events(rec, person.id)
|
||||||
|
|
||||||
|
# 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 and husb != wife:
|
||||||
|
rel = add_relationship(RelationshipType.partnership, husb, wife)
|
||||||
|
if rel is not None:
|
||||||
|
await session.flush()
|
||||||
|
partnership_id = rel.id
|
||||||
|
if partnership_id is None and husb and wife:
|
||||||
|
# Edge already existed — find it so marriage events can attach.
|
||||||
|
existing = next(
|
||||||
|
(
|
||||||
|
r for r in existing_rels
|
||||||
|
if r.type == RelationshipType.partnership
|
||||||
|
and {r.person_from_id, r.person_to_id} == {husb, wife}
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
partnership_id = existing.id if existing else None
|
||||||
|
|
||||||
|
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:
|
||||||
|
add_relationship(
|
||||||
|
RelationshipType.parent_child,
|
||||||
|
parent,
|
||||||
|
cp,
|
||||||
|
qualifier=ParentChildQualifier.biological,
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
citations = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Citation).where(
|
||||||
|
Citation.tree_id == tree.id, Citation.deleted_at.is_(None)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).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)}
|
||||||
|
# Citations grouped by the fact they sit on, so each fact can emit its SOUR
|
||||||
|
# links (dropping these is the round-trip data loss this fixes). Skip any
|
||||||
|
# whose source didn't export.
|
||||||
|
cite_by_person: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_name: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_event: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
cite_by_rel: dict[uuid.UUID, list[Citation]] = defaultdict(list)
|
||||||
|
for c in citations:
|
||||||
|
if c.source_id not in sxref:
|
||||||
|
continue
|
||||||
|
if c.person_id:
|
||||||
|
cite_by_person[c.person_id].append(c)
|
||||||
|
elif c.event_id:
|
||||||
|
cite_by_event[c.event_id].append(c)
|
||||||
|
elif c.name_id:
|
||||||
|
cite_by_name[c.name_id].append(c)
|
||||||
|
elif c.relationship_id:
|
||||||
|
cite_by_rel[c.relationship_id].append(c)
|
||||||
|
|
||||||
|
def cite_lines(cites: list[Citation], depth: int) -> list[str]:
|
||||||
|
lines: list[str] = []
|
||||||
|
for c in cites:
|
||||||
|
lines.append(f"{depth} SOUR {sxref[c.source_id]}")
|
||||||
|
if c.page:
|
||||||
|
lines.append(f"{depth + 1} PAGE {c.page}")
|
||||||
|
return lines
|
||||||
|
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}")
|
||||||
|
ged_type = EXPORT_TYPE_MAP.get(n.name_type)
|
||||||
|
if ged_type:
|
||||||
|
out.append(f"2 TYPE {ged_type}")
|
||||||
|
out += cite_lines(cite_by_name.get(n.id, []), 2)
|
||||||
|
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}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_person.get(p.id, []), 1)
|
||||||
|
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}")
|
||||||
|
out += cite_lines(cite_by_event.get(e.id, []), 2)
|
||||||
|
out += cite_lines(cite_by_rel.get(f["rel_id"], []), 1)
|
||||||
|
|
||||||
|
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,170 @@
|
|||||||
|
"""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")
|
||||||
|
# Non-members only see media of a FULL-visibility person (no living-person photos).
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_media(
|
||||||
|
session, viewer_id=viewer_id, tree=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")
|
||||||
|
# Non-members may only see/download media of a FULL-visibility person. 404
|
||||||
|
# (not 403) so the item's existence isn't revealed. This gates media_content.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
if not await public_view_service.can_view_media(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, media=media
|
||||||
|
):
|
||||||
|
raise NotFound("media not found")
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
|
async def update_media(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, media_id: uuid.UUID, changes: dict
|
||||||
|
) -> Media:
|
||||||
|
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")
|
||||||
|
for key in {"title", "person_id", "event_id", "source_id"} & changes.keys():
|
||||||
|
setattr(media, key, changes[key])
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Media",
|
||||||
|
entity_id=media.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(media)
|
||||||
|
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,156 @@
|
|||||||
|
"""Tree membership management: list / add / change-role / remove.
|
||||||
|
|
||||||
|
Only an owner may change membership. A tree must always keep at least one owner.
|
||||||
|
The member list (which exposes user emails) is visible only to members — never
|
||||||
|
to a non-member viewing a public/unlisted tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
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 _require_owner(session: AsyncSession, *, actor_id: uuid.UUID, tree: Tree) -> None:
|
||||||
|
if await privacy.get_membership_role(session, actor_id, tree.id) is not MembershipRole.owner:
|
||||||
|
raise Forbidden("only the owner can manage members")
|
||||||
|
|
||||||
|
|
||||||
|
async def _owner_count(session: AsyncSession, tree_id: uuid.UUID) -> int:
|
||||||
|
return (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TreeMembership)
|
||||||
|
.where(TreeMembership.tree_id == tree_id, TreeMembership.role == MembershipRole.owner)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
def _row(m: TreeMembership, u: User) -> dict:
|
||||||
|
return {
|
||||||
|
"id": m.id,
|
||||||
|
"user_id": u.id,
|
||||||
|
"email": u.email,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"role": m.role,
|
||||||
|
"created_at": m.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def list_members(session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree) -> list[dict]:
|
||||||
|
# Member-only: the list exposes emails, so a non-member (even on a public
|
||||||
|
# tree) must not see it.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
raise Forbidden("only members can see the member list")
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(TreeMembership, User)
|
||||||
|
.join(User, User.id == TreeMembership.user_id)
|
||||||
|
.where(TreeMembership.tree_id == tree.id)
|
||||||
|
.order_by(TreeMembership.created_at)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
return [_row(m, u) for m, u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
async def add_member(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, email: str, role: MembershipRole
|
||||||
|
) -> dict:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
user = (
|
||||||
|
await session.execute(
|
||||||
|
select(User).where(User.email == email, User.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
raise NotFound("no user with that email on this instance")
|
||||||
|
if await privacy.get_membership_role(session, user.id, tree.id) is not None:
|
||||||
|
raise Conflict("that user is already a member")
|
||||||
|
m = TreeMembership(tree_id=tree.id, user_id=user.id, role=role)
|
||||||
|
session.add(m)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="add_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"user_id": str(user.id), "role": role.value},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(m)
|
||||||
|
return _row(m, user)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_membership(
|
||||||
|
session: AsyncSession, tree: Tree, membership_id: uuid.UUID
|
||||||
|
) -> TreeMembership:
|
||||||
|
m = (
|
||||||
|
await session.execute(
|
||||||
|
select(TreeMembership).where(
|
||||||
|
TreeMembership.id == membership_id, TreeMembership.tree_id == tree.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if m is None:
|
||||||
|
raise NotFound("member not found")
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
async def update_member_role(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
membership_id: uuid.UUID,
|
||||||
|
role: MembershipRole,
|
||||||
|
) -> dict:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
m = await _get_membership(session, tree, membership_id)
|
||||||
|
if (
|
||||||
|
m.role == MembershipRole.owner
|
||||||
|
and role != MembershipRole.owner
|
||||||
|
and await _owner_count(session, tree.id) <= 1
|
||||||
|
):
|
||||||
|
raise Conflict("a tree must keep at least one owner")
|
||||||
|
m.role = role
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"membership_id": str(m.id), "role": role.value},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(m)
|
||||||
|
u = (await session.execute(select(User).where(User.id == m.user_id))).scalar_one()
|
||||||
|
return _row(m, u)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_member(
|
||||||
|
session: AsyncSession, *, actor: User, tree: Tree, membership_id: uuid.UUID
|
||||||
|
) -> None:
|
||||||
|
await _require_owner(session, actor_id=actor.id, tree=tree)
|
||||||
|
m = await _get_membership(session, tree, membership_id)
|
||||||
|
if m.role == MembershipRole.owner and await _owner_count(session, tree.id) <= 1:
|
||||||
|
raise Conflict("a tree must keep at least one owner")
|
||||||
|
await session.delete(m)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="remove_member",
|
||||||
|
entity_type="Tree",
|
||||||
|
entity_id=tree.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"membership_id": str(membership_id)},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""A curated given-name -> sex lookup for best-guessing a person's sex from
|
||||||
|
their first name. Weighted toward English + German names (this codebase's first
|
||||||
|
real tree is a German-American family). Deterministic and offline — no model
|
||||||
|
needed; the Cleanup tool previews every guess before anything is applied.
|
||||||
|
|
||||||
|
Genuinely ambiguous names (Marion, Frances/Francis, Jordan, Jamie, Robin, Leslie,
|
||||||
|
Dana, …) are intentionally left out of BOTH sets so they aren't guessed — better
|
||||||
|
a human decides those than a coin flip.
|
||||||
|
"""
|
||||||
|
|
||||||
|
MALE_NAMES: set[str] = {
|
||||||
|
# English / common US
|
||||||
|
"james", "john", "robert", "michael", "william", "david", "richard", "joseph",
|
||||||
|
"thomas", "charles", "christopher", "daniel", "matthew", "anthony", "donald",
|
||||||
|
"mark", "paul", "steven", "andrew", "kenneth", "george", "joshua", "kevin",
|
||||||
|
"brian", "edward", "ronald", "timothy", "jason", "jeffrey", "gary", "ryan",
|
||||||
|
"nicholas", "eric", "stephen", "jacob", "larry", "frank", "jonathan", "scott",
|
||||||
|
"raymond", "gregory", "samuel", "benjamin", "patrick", "jack", "dennis", "jerry",
|
||||||
|
"alexander", "tyler", "henry", "douglas", "peter", "adam", "harold", "albert",
|
||||||
|
"arthur", "carl", "ralph", "roy", "eugene", "louis", "philip", "bobby", "walter",
|
||||||
|
"willie", "wayne", "fred", "howard", "ernest", "earl", "clarence", "leon",
|
||||||
|
"leonard", "lewis", "floyd", "leroy", "elmer", "homer", "orrin", "josias",
|
||||||
|
"emerson", "dale", "bernard", "vernon", "virgil", "wilbur", "russell",
|
||||||
|
"harvey", "herbert", "melvin", "lloyd", "marvin", "norman", "stanley",
|
||||||
|
# German
|
||||||
|
"hans", "karl", "wilhelm", "friedrich", "heinrich", "otto", "hermann", "gustav",
|
||||||
|
"ludwig", "ernst", "fritz", "johann", "conrad", "konrad", "reinhold", "rudolf",
|
||||||
|
"rudolph", "gerhard", "helmut", "horst", "klaus", "kurt", "dieter", "günther",
|
||||||
|
"gunther", "manfred", "siegfried", "hilgard", "christian", "august", "wolfgang",
|
||||||
|
"jürgen", "jurgen", "matthias", "lothar", "bruno", "gottlieb", "reinhard",
|
||||||
|
}
|
||||||
|
|
||||||
|
FEMALE_NAMES: set[str] = {
|
||||||
|
# English / common US
|
||||||
|
"mary", "patricia", "jennifer", "linda", "elizabeth", "barbara", "susan",
|
||||||
|
"jessica", "sarah", "karen", "nancy", "lisa", "betty", "margaret", "sandra",
|
||||||
|
"ashley", "kimberly", "emily", "donna", "michelle", "carol", "amanda", "dorothy",
|
||||||
|
"melissa", "deborah", "stephanie", "rebecca", "sharon", "laura", "cynthia",
|
||||||
|
"kathleen", "amy", "angela", "shirley", "anna", "ruth", "brenda", "pamela",
|
||||||
|
"nicole", "katherine", "virginia", "catherine", "helen", "debra", "rachel",
|
||||||
|
"carolyn", "janet", "maria", "heather", "diane", "julie", "joyce", "victoria",
|
||||||
|
"kelly", "christina", "joan", "evelyn", "judith", "megan", "alice", "frances",
|
||||||
|
"marie", "florence", "flora", "zella", "thelma", "ellen", "althea", "della",
|
||||||
|
"beatrice", "pauline", "hedwig", "florentine", "wilhelmina", "augusta", "bertha",
|
||||||
|
"gladys", "mildred", "lucille", "edith", "esther", "irene", "hazel", "doris",
|
||||||
|
"rose", "rita", "norma", "june", "lois", "marjorie",
|
||||||
|
# German
|
||||||
|
"greta", "ilse", "ursula", "gertrud", "gertrude", "frieda", "frida", "else",
|
||||||
|
"hilda", "hilde", "hildegard", "ingrid", "helga", "renate", "monika", "sieglinde",
|
||||||
|
"brigitte", "gisela", "elke", "anneliese", "waltraud", "edeltraud", "johanna",
|
||||||
|
"katharina", "margarethe", "wilhelmine", "emilie", "auguste",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def guess_sex(given: str | None) -> str | None:
|
||||||
|
"""Best-guess "male"/"female" from the first token of a given name, or None
|
||||||
|
if unknown/ambiguous."""
|
||||||
|
if not given:
|
||||||
|
return None
|
||||||
|
first = given.strip().split()[0].lower() if given.strip() else ""
|
||||||
|
# Strip trailing punctuation/initials like "wm." -> "wm".
|
||||||
|
first = first.strip(".,'\"")
|
||||||
|
if not first:
|
||||||
|
return None
|
||||||
|
if first in MALE_NAMES:
|
||||||
|
return "male"
|
||||||
|
if first in FEMALE_NAMES:
|
||||||
|
return "female"
|
||||||
|
return None
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Name service. A Person carries one or more Name rows — a primary (typically
|
||||||
|
the birth/maiden name) plus typed alternates (married, alias, religious, …).
|
||||||
|
Exactly one name is primary at a time; it drives display everywhere. Writes
|
||||||
|
require editor rights; reads go through the tree's view check.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_person(session: AsyncSession, *, tree: Tree, person_id: uuid.UUID) -> Person:
|
||||||
|
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")
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def _clear_primary(
|
||||||
|
session: AsyncSession, *, person_id: uuid.UUID, keep: uuid.UUID | None
|
||||||
|
) -> None:
|
||||||
|
"""Demote every other name so exactly one stays primary."""
|
||||||
|
stmt = (
|
||||||
|
update(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None), Name.is_primary.is_(True))
|
||||||
|
.values(is_primary=False)
|
||||||
|
)
|
||||||
|
if keep is not None:
|
||||||
|
stmt = stmt.where(Name.id != keep)
|
||||||
|
await session.execute(stmt)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_names(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree, person_id: uuid.UUID
|
||||||
|
) -> list[Name]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
await _get_person(session, tree=tree, person_id=person_id)
|
||||||
|
# Non-members: a redacted/hidden person's real names must not leak.
|
||||||
|
if await privacy.get_membership_role(session, viewer_id, tree.id) is None:
|
||||||
|
from app.services import public_view_service
|
||||||
|
|
||||||
|
return await public_view_service.list_public_person_names(
|
||||||
|
session, viewer_id=viewer_id, tree=tree, person_id=person_id
|
||||||
|
)
|
||||||
|
stmt = (
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order, Name.created_at)
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def create_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_type: str = "birth",
|
||||||
|
given: str | None = None,
|
||||||
|
surname: str | None = None,
|
||||||
|
prefix: str | None = None,
|
||||||
|
suffix: str | None = None,
|
||||||
|
nickname: str | None = None,
|
||||||
|
is_primary: bool = False,
|
||||||
|
) -> Name:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
await _get_person(session, tree=tree, person_id=person_id)
|
||||||
|
|
||||||
|
# First name for a person is always primary; otherwise honor the flag.
|
||||||
|
existing = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name.id).where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
primary = is_primary or existing is None
|
||||||
|
if primary:
|
||||||
|
await _clear_primary(session, person_id=person_id, keep=None)
|
||||||
|
|
||||||
|
name = Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person_id,
|
||||||
|
name_type=name_type,
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
prefix=prefix,
|
||||||
|
suffix=suffix,
|
||||||
|
nickname=nickname,
|
||||||
|
is_primary=primary,
|
||||||
|
)
|
||||||
|
session.add(name)
|
||||||
|
await session.flush()
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"name_type": name_type, "given": given, "surname": surname},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_FIELDS = {"name_type", "given", "surname", "prefix", "suffix", "nickname"}
|
||||||
|
|
||||||
|
|
||||||
|
async def update_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_id: uuid.UUID,
|
||||||
|
changes: dict,
|
||||||
|
) -> Name:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
name = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.id == name_id,
|
||||||
|
Name.person_id == person_id,
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if name is None:
|
||||||
|
raise NotFound("name not found")
|
||||||
|
|
||||||
|
for key in _NAME_FIELDS & changes.keys():
|
||||||
|
setattr(name, key, changes[key])
|
||||||
|
if changes.get("is_primary") is True:
|
||||||
|
await _clear_primary(session, person_id=person_id, keep=name.id)
|
||||||
|
name.is_primary = True
|
||||||
|
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="update",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after=changes,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
person_id: uuid.UUID,
|
||||||
|
name_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")
|
||||||
|
name = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name).where(
|
||||||
|
Name.id == name_id,
|
||||||
|
Name.person_id == person_id,
|
||||||
|
Name.tree_id == tree.id,
|
||||||
|
Name.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if name is None:
|
||||||
|
raise NotFound("name not found")
|
||||||
|
name.deleted_at = datetime.now(UTC)
|
||||||
|
was_primary = name.is_primary
|
||||||
|
name.is_primary = False
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="delete",
|
||||||
|
entity_type="Name",
|
||||||
|
entity_id=name.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
)
|
||||||
|
# Promote another name to primary so the person never loses their display name.
|
||||||
|
if was_primary:
|
||||||
|
nxt = (
|
||||||
|
await session.execute(
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person_id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.sort_order, Name.created_at)
|
||||||
|
)
|
||||||
|
).scalars().first()
|
||||||
|
if nxt is not None:
|
||||||
|
nxt.is_primary = True
|
||||||
|
await session.commit()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user