diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 58be4fe..0c61ead 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -1,8 +1,8 @@ -from fastapi import APIRouter +from fastapi import APIRouter, File, Form, Response, UploadFile -from app.api.deps import CurrentUser, SessionDep +from app.api.deps import CurrentUser, ObjectStoreDep, SessionDep from app.schemas.user import UserRead, UserSelfPersonUpdate -from app.services import user_service +from app.services import account_service, user_service router = APIRouter(prefix="/users", tags=["users"]) @@ -21,3 +21,37 @@ async def set_self_person( session, user=current, person_id=data.self_person_id ) return UserRead.model_validate(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) diff --git a/backend/app/services/account_service.py b/backend/app/services/account_service.py new file mode 100644 index 0000000..c7a1ac0 --- /dev/null +++ b/backend/app/services/account_service.py @@ -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/`` 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() diff --git a/backend/tests/test_account_portability.py b/backend/tests/test_account_portability.py new file mode 100644 index 0000000..539b8cd --- /dev/null +++ b/backend/tests/test_account_portability.py @@ -0,0 +1,86 @@ +"""Account export -> restore round-trip, and account deletion.""" + +from tests.conftest import auth, register + + +async def _seed(client, h): + tid = (await client.post("/api/v1/trees", json={"name": "Fam"}, headers=h)).json()["id"] + p1 = ( + await client.post( + f"/api/v1/trees/{tid}/persons", json={"given": "Ada", "surname": "Lovelace"}, headers=h + ) + ).json()["id"] + p2 = ( + await client.post(f"/api/v1/trees/{tid}/persons", json={"given": "Kid"}, headers=h) + ).json()["id"] + await client.post( + f"/api/v1/trees/{tid}/relationships", + json={"type": "parent_child", "person_from_id": p1, "person_to_id": p2}, + headers=h, + ) + await client.post( + f"/api/v1/trees/{tid}/events", + json={"event_type": "birth", "person_id": p1, "date_value": "1815"}, + headers=h, + ) + await client.post( + f"/api/v1/trees/{tid}/media", + files={"file": ("scan.txt", b"hello", "text/plain")}, + data={"title": "Scan", "person_id": p1}, + headers=h, + ) + return tid + + +async def test_export_then_restore_roundtrip(client): + h = auth(await register(client, "exp@example.com")) + await _seed(client, h) + + export = await client.get("/api/v1/users/me/export", headers=h) + assert export.status_code == 200 + assert export.headers["content-type"] == "application/zip" + blob = export.content + assert blob[:2] == b"PK" # zip magic + + # Restore into new trees (non-destructive: the original stays). + r = await client.post( + "/api/v1/users/me/import", + files={"file": ("provenance-export.zip", blob, "application/zip")}, + headers=h, + ) + assert r.status_code == 200, r.text + counts = r.json() + assert counts["trees"] == 1 and counts["persons"] == 2 + assert counts["events"] == 1 and counts["media"] == 1 + + trees = (await client.get("/api/v1/trees", headers=h)).json() + assert len(trees) == 2 # original + restored + + # The restored tree has the people, with a working relationship and media. + restored = [t for t in trees if t["name"] == "Fam"][1]["id"] + ppl = (await client.get(f"/api/v1/trees/{restored}/persons", headers=h)).json() + assert {p["primary_name"] for p in ppl} == {"Ada Lovelace", "Kid"} + rels = (await client.get(f"/api/v1/trees/{restored}/relationships", headers=h)).json() + assert len(rels) == 1 + med = (await client.get(f"/api/v1/trees/{restored}/media", headers=h)).json() + assert len(med) == 1 and med[0]["title"] == "Scan" + + +async def test_delete_account_requires_email_then_revokes(client): + token = await register(client, "del@example.com") + h = auth(token) + await _seed(client, h) + + # Wrong email is rejected. + bad = await client.request( + "DELETE", "/api/v1/users/me", data={"confirm_email": "nope@example.com"}, headers=h + ) + assert bad.status_code == 403 + + ok = await client.request( + "DELETE", "/api/v1/users/me", data={"confirm_email": "del@example.com"}, headers=h + ) + assert ok.status_code == 204 + + # Session is revoked — the token no longer works. + assert (await client.get("/api/v1/users/me", headers=h)).status_code == 401 diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 109eb33..1fd3a0b 100644 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -1,6 +1,7 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; import { api } from "@/lib/api/client"; import { Button } from "@/components/ui/button"; @@ -8,6 +9,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; export default function SettingsPage() { + const router = useRouter(); const [me, setMe] = useState<{ display_name: string | null; email: string } | null>(null); const [current, setCurrent] = useState(""); @@ -16,10 +18,72 @@ export default function SettingsPage() { const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); const [busy, setBusy] = useState(false); + // Data export / restore / delete. + const [exporting, setExporting] = useState(false); + const [restoreMsg, setRestoreMsg] = useState(null); + const [restoring, setRestoring] = useState(false); + const restoreRef = useRef(null); + const [deleteConfirm, setDeleteConfirm] = useState(""); + const [deleting, setDeleting] = useState(false); + useEffect(() => { api.GET("/api/v1/users/me").then((r) => setMe(r.data ?? null)); }, []); + async function exportData() { + setExporting(true); + const resp = await fetch("/api/v1/users/me/export", { credentials: "include" }); + if (resp.ok) { + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "provenance-export.zip"; + a.click(); + URL.revokeObjectURL(url); + } + setExporting(false); + } + + async function restoreData(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (restoreRef.current) restoreRef.current.value = ""; + if (!file) return; + setRestoring(true); + setRestoreMsg(null); + const fd = new FormData(); + fd.append("file", file); + const resp = await fetch("/api/v1/users/me/import", { + method: "POST", + body: fd, + credentials: "include", + }); + setRestoring(false); + if (resp.ok) { + const c = await resp.json(); + setRestoreMsg(`Restored ${c.trees} tree(s), ${c.persons} people into new trees.`); + } else { + setRestoreMsg("That doesn't look like a valid Provenance export."); + } + } + + async function deleteAccount() { + if (deleteConfirm !== me?.email) return; + setDeleting(true); + const fd = new FormData(); + fd.append("confirm_email", deleteConfirm); + const resp = await fetch("/api/v1/users/me", { + method: "DELETE", + body: fd, + credentials: "include", + }); + setDeleting(false); + if (resp.ok) { + await api.POST("/api/v1/auth/logout"); + router.push("/register"); + } + } + async function changePassword(e: React.FormEvent) { e.preventDefault(); setMsg(null); @@ -109,10 +173,55 @@ export default function SettingsPage() { Your data - + +
+

+ Download a complete backup of every tree you own — people, sources, media, and + all — as a zip. +

+ +
+ +
+

+ Restore a backup. It’s imported into new trees — nothing existing + is touched or overwritten. +

+ + + {restoreMsg &&

{restoreMsg}

} +
+
+ + + + + Delete account + +

- Full-account export, restore, and account deletion are coming next. + This deletes your account, the trees you own, and signs you out everywhere. Export + your data first if you might want it. Type {me?.email} to confirm.

+
+ setDeleteConfirm(e.target.value)} + /> + +
diff --git a/frontend/lib/api/schema.d.ts b/frontend/lib/api/schema.d.ts index 10bd503..6600916 100644 --- a/frontend/lib/api/schema.d.ts +++ b/frontend/lib/api/schema.d.ts @@ -168,7 +168,12 @@ export interface paths { get: operations["read_me_api_v1_users_me_get"]; put?: never; post?: never; - delete?: never; + /** + * Delete Account + * @description Delete the account: the user, their owned trees, and their sessions. + * Requires retyping the account email as a guard. + */ + delete: operations["delete_account_api_v1_users_me_delete"]; options?: never; head?: never; patch?: never; @@ -194,6 +199,46 @@ export interface paths { patch: operations["set_self_person_api_v1_users_me_self_person_patch"]; trace?: never; }; + "/api/v1/users/me/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Export Account + * @description Download a full backup (JSON + media) of every tree the user owns. + */ + get: operations["export_account_api_v1_users_me_export_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/users/me/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Import Account + * @description Restore a previously-exported backup into new trees (non-destructive). + */ + post: operations["import_account_api_v1_users_me_import_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/trees": { parameters: { query?: never; @@ -638,6 +683,16 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** Body_delete_account_api_v1_users_me_delete */ + Body_delete_account_api_v1_users_me_delete: { + /** Confirm Email */ + confirm_email: string; + }; + /** Body_import_account_api_v1_users_me_import_post */ + Body_import_account_api_v1_users_me_import_post: { + /** File */ + file: string; + }; /** Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post */ Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post: { /** File */ @@ -1624,6 +1679,37 @@ export interface operations { }; }; }; + delete_account_api_v1_users_me_delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/x-www-form-urlencoded": components["schemas"]["Body_delete_account_api_v1_users_me_delete"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; set_self_person_api_v1_users_me_self_person_patch: { parameters: { query?: never; @@ -1657,6 +1743,61 @@ export interface operations { }; }; }; + export_account_api_v1_users_me_export_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + import_account_api_v1_users_me_import_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_import_account_api_v1_users_me_import_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: unknown; + }; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_my_trees_api_v1_trees_get: { parameters: { query?: { diff --git a/frontend/openapi.json b/frontend/openapi.json index 32bb5b6..ba312b2 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -312,6 +312,39 @@ } } } + }, + "delete": { + "tags": [ + "users" + ], + "summary": "Delete Account", + "description": "Delete the account: the user, their owned trees, and their sessions.\nRequires retyping the account email as a guard.", + "operationId": "delete_account_api_v1_users_me_delete", + "requestBody": { + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/Body_delete_account_api_v1_users_me_delete" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/users/me/self-person": { @@ -356,6 +389,70 @@ } } }, + "/api/v1/users/me/export": { + "get": { + "tags": [ + "users" + ], + "summary": "Export Account", + "description": "Download a full backup (JSON + media) of every tree the user owns.", + "operationId": "export_account_api_v1_users_me_export_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + } + } + } + }, + "/api/v1/users/me/import": { + "post": { + "tags": [ + "users" + ], + "summary": "Import Account", + "description": "Restore a previously-exported backup into new trees (non-destructive).", + "operationId": "import_account_api_v1_users_me_import_post", + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_import_account_api_v1_users_me_import_post" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response Import Account Api V1 Users Me Import Post" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/trees": { "post": { "tags": [ @@ -2608,6 +2705,33 @@ }, "components": { "schemas": { + "Body_delete_account_api_v1_users_me_delete": { + "properties": { + "confirm_email": { + "type": "string", + "title": "Confirm Email" + } + }, + "type": "object", + "required": [ + "confirm_email" + ], + "title": "Body_delete_account_api_v1_users_me_delete" + }, + "Body_import_account_api_v1_users_me_import_post": { + "properties": { + "file": { + "type": "string", + "contentMediaType": "application/octet-stream", + "title": "File" + } + }, + "type": "object", + "required": [ + "file" + ], + "title": "Body_import_account_api_v1_users_me_import_post" + }, "Body_import_gedcom_api_v1_trees__tree_id__gedcom_import_post": { "properties": { "file": {