04ccdbf96a
Names (the genealogy standard: maiden name primary, married/alias as typed
alternates):
- Name model already supported multiple typed names; expose full CRUD —
NameCreate/Read/Update schemas, name_service (one-primary invariant,
promote-on-delete), nested /persons/{id}/names routes.
- Person page gains a Names card: add/edit/delete + "make primary", with a
curated name_type dropdown (birth/maiden, married, alias, nickname, …).
Self-person ("who am I"):
- users.self_person_id FK (use_alter for the users<->persons<->trees cycle)
+ migration; PATCH /users/me/self-person; "This is me" / "This is you"
on the person page. Soft-deleting the linked person clears it.
Deletion integrity (fixes the broken tree view):
- delete_person now soft-deletes the relationships touching the person, so no
dangling edges remain; family-chart also filters links to missing people.
- Optional cascade=true recursively deletes descendants (GEDCOM cleanup);
the person page asks "only this person" vs "with all descendants".
- DELETE returns {deleted: n}.
Family view surfaces "Not connected to anyone" so dangling people aren't lost.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
84 lines
2.6 KiB
Python
84 lines
2.6 KiB
Python
"""User service. Account creation here is a temporary, open dev bootstrap so we
|
|
can create tree owners before the auth slice exists; the auth slice replaces it
|
|
with the AuthProvider (password/OIDC/social) and proper verification.
|
|
"""
|
|
|
|
import uuid
|
|
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.person import Person
|
|
from app.models.tree import Tree
|
|
from app.models.user import User
|
|
from app.repositories.base import BaseRepository
|
|
from app.services import privacy
|
|
from app.services.audit import record_audit
|
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
|
|
|
|
|
async def create_user(
|
|
session: AsyncSession, *, email: str, display_name: str | None = None
|
|
) -> User:
|
|
email = email.strip().lower()
|
|
existing = (
|
|
await session.execute(select(User).where(User.email == email))
|
|
).scalar_one_or_none()
|
|
if existing is not None:
|
|
raise Conflict("email already registered")
|
|
|
|
user = User(email=email, display_name=display_name)
|
|
session.add(user)
|
|
await session.flush() # assign user.id
|
|
record_audit(
|
|
session,
|
|
action="create",
|
|
entity_type="User",
|
|
entity_id=user.id,
|
|
actor_user_id=user.id,
|
|
after={"email": email},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|
|
|
|
|
|
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
|
return await BaseRepository(session, User).get(user_id)
|
|
|
|
|
|
async def set_self_person(
|
|
session: AsyncSession, *, user: User, person_id: uuid.UUID | None
|
|
) -> User:
|
|
"""Point a user's account at the Person record that *is* them ("home
|
|
person"), or clear it with ``None``. The person must live in a tree the
|
|
user can view."""
|
|
if person_id is not None:
|
|
person = (
|
|
await session.execute(
|
|
select(Person).where(Person.id == person_id, Person.deleted_at.is_(None))
|
|
)
|
|
).scalar_one_or_none()
|
|
if person is None:
|
|
raise NotFound("person not found")
|
|
tree = (
|
|
await session.execute(select(Tree).where(Tree.id == person.tree_id))
|
|
).scalar_one_or_none()
|
|
if tree is None or not await privacy.can_view_tree(
|
|
session, user_id=user.id, tree=tree
|
|
):
|
|
raise Forbidden("not permitted to link this person")
|
|
|
|
user.self_person_id = person_id
|
|
record_audit(
|
|
session,
|
|
action="update",
|
|
entity_type="User",
|
|
entity_id=user.id,
|
|
actor_user_id=user.id,
|
|
after={"self_person_id": str(person_id) if person_id else None},
|
|
)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|