Phase 0 — Foundation: backend, data model, local auth, frontend, deploy + CI #1
@@ -0,0 +1,33 @@
|
||||
"""Shared API dependencies."""
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_session
|
||||
from app.models.user import User
|
||||
from app.services.user_service import get_user
|
||||
|
||||
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
session: SessionDep,
|
||||
x_user_id: Annotated[uuid.UUID | None, Header()] = None,
|
||||
) -> User:
|
||||
"""TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id``
|
||||
header. Replaced by the AuthProvider (sessions/tokens) in the auth slice.
|
||||
The assistant principal will also be minted here, scoped to its user."""
|
||||
if x_user_id is None:
|
||||
raise HTTPException(
|
||||
status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)"
|
||||
)
|
||||
user = await get_user(session, x_user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
|
||||
return user
|
||||
|
||||
|
||||
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Versioned API surface. Mounts under /api/v1."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import persons, trees, users
|
||||
|
||||
api_router = APIRouter(prefix="/api/v1")
|
||||
api_router.include_router(users.router)
|
||||
api_router.include_router(trees.router)
|
||||
api_router.include_router(persons.router)
|
||||
@@ -0,0 +1,43 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.person import PersonCreate, PersonRead
|
||||
from app.services import person_service, tree_service
|
||||
|
||||
# Persons are nested under their tree (the tenant boundary).
|
||||
router = APIRouter(prefix="/trees", tags=["persons"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{tree_id}/persons",
|
||||
response_model=PersonRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_person(
|
||||
tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser
|
||||
) -> PersonRead:
|
||||
# get_tree enforces existence + view access; create_person enforces edit rights.
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
person = await person_service.create_person(
|
||||
session,
|
||||
actor=current,
|
||||
tree=tree,
|
||||
given=data.given,
|
||||
surname=data.surname,
|
||||
gender=data.gender,
|
||||
is_living=data.is_living,
|
||||
privacy_setting=data.privacy,
|
||||
notes=data.notes,
|
||||
)
|
||||
return PersonRead.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||
async def list_persons(
|
||||
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||
) -> list[PersonRead]:
|
||||
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||
return [PersonRead.model_validate(p) for p in persons]
|
||||
@@ -0,0 +1,33 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.tree import TreeCreate, TreeRead
|
||||
from app.services import tree_service
|
||||
|
||||
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||
|
||||
|
||||
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||
tree = await tree_service.create_tree(
|
||||
session,
|
||||
owner=current,
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
visibility=data.visibility,
|
||||
)
|
||||
return TreeRead.model_validate(tree)
|
||||
|
||||
|
||||
@router.get("", response_model=list[TreeRead])
|
||||
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
||||
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)
|
||||
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, status
|
||||
|
||||
from app.api.deps import CurrentUser, SessionDep
|
||||
from app.schemas.user import UserCreate, UserRead
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(data: UserCreate, session: SessionDep) -> UserRead:
|
||||
# Open dev bootstrap until the auth slice; lets us create tree owners.
|
||||
user = await user_service.create_user(
|
||||
session, email=data.email, display_name=data.display_name
|
||||
)
|
||||
return UserRead.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def read_me(current: CurrentUser) -> UserRead:
|
||||
return UserRead.model_validate(current)
|
||||
+30
-9
@@ -1,22 +1,43 @@
|
||||
"""Async database engine.
|
||||
"""Async database engine, session factory, and the FastAPI session dependency.
|
||||
|
||||
A single lazily-created async engine for the process. The repository layer
|
||||
(coming with the data model) will build sessions on top of this; for now it
|
||||
backs the readiness probe.
|
||||
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 sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
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,
|
||||
)
|
||||
_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
|
||||
|
||||
+23
-4
@@ -1,14 +1,31 @@
|
||||
"""FastAPI application entrypoint.
|
||||
|
||||
Thin by design: wire settings and routers, expose the OpenAPI contract. All
|
||||
domain logic lives in the service layer (added with the data model). The
|
||||
versioned API will mount under ``/api/v1``; health probes stay at the root.
|
||||
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.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.api.health import router as health_router
|
||||
from app.api.v1 import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||
|
||||
|
||||
def _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:
|
||||
@@ -19,6 +36,8 @@ def create_app() -> FastAPI:
|
||||
description="Provenance API — family and land provenance.",
|
||||
)
|
||||
app.include_router(health_router)
|
||||
app.include_router(api_router)
|
||||
_register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -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,27 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.enums import PersonPrivacy
|
||||
|
||||
|
||||
class PersonCreate(BaseModel):
|
||||
given: str | None = None
|
||||
surname: str | None = None
|
||||
gender: str | None = None
|
||||
is_living: bool | None = None
|
||||
privacy: PersonPrivacy = PersonPrivacy.inherit
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class PersonRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
tree_id: uuid.UUID
|
||||
primary_name: str | None = None
|
||||
gender: str | None
|
||||
is_living: bool | None
|
||||
privacy: PersonPrivacy
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,23 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from app.models.enums import TreeVisibility
|
||||
|
||||
|
||||
class TreeCreate(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
visibility: TreeVisibility = TreeVisibility.private
|
||||
|
||||
|
||||
class TreeRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
description: str | None
|
||||
visibility: TreeVisibility
|
||||
owner_id: uuid.UUID
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
# Note: email is a plain str for now (no email-validator dependency yet); the
|
||||
# auth slice can tighten this to EmailStr.
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
email: str
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
class UserRead(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_verified_at: datetime | None
|
||||
created_at: datetime
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Audit logging. Every mutation records an append-only AuditEntry attributing
|
||||
the change to a User (or the assistant principal acting for a User). Staged on
|
||||
the session; the caller commits as part of its unit of work.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.audit import AuditEntry
|
||||
from app.models.enums import AuditActorType
|
||||
|
||||
|
||||
def record_audit(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
action: str,
|
||||
entity_type: str,
|
||||
entity_id: uuid.UUID | None = None,
|
||||
tree_id: uuid.UUID | None = None,
|
||||
actor_user_id: uuid.UUID | None = None,
|
||||
actor_type: AuditActorType = AuditActorType.user,
|
||||
before: dict | None = None,
|
||||
after: dict | None = None,
|
||||
) -> AuditEntry:
|
||||
entry = AuditEntry(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
tree_id=tree_id,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_type=actor_type,
|
||||
before=before,
|
||||
after=after,
|
||||
)
|
||||
session.add(entry)
|
||||
return entry
|
||||
@@ -0,0 +1,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,113 @@
|
||||
"""Person service. Writes require editor rights on the tree; reads run every
|
||||
person through the privacy engine. Each returned Person gets a transient
|
||||
``primary_name`` for display (not persisted).
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import PersonPrivacy
|
||||
from app.models.person import Name, Person
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.services import privacy
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Forbidden
|
||||
from app.services.privacy import Visibility
|
||||
|
||||
|
||||
def _format_name(name: Name) -> str | None:
|
||||
parts = [name.given, name.surname]
|
||||
joined = " ".join(p for p in parts if p)
|
||||
return joined or name.display_name
|
||||
|
||||
|
||||
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
|
||||
stmt = (
|
||||
select(Name)
|
||||
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
|
||||
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||
)
|
||||
name = (await session.execute(stmt)).scalars().first()
|
||||
# Transient display attribute consumed by the PersonRead schema.
|
||||
person.primary_name = _format_name(name) if name is not None else None
|
||||
|
||||
|
||||
async def create_person(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
actor: User,
|
||||
tree: Tree,
|
||||
given: str | None = None,
|
||||
surname: str | None = None,
|
||||
gender: str | None = None,
|
||||
is_living: bool | None = None,
|
||||
privacy_setting: PersonPrivacy = PersonPrivacy.inherit,
|
||||
notes: str | None = None,
|
||||
) -> Person:
|
||||
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||
raise Forbidden("not an editor of this tree")
|
||||
|
||||
person = Person(
|
||||
tree_id=tree.id,
|
||||
gender=gender,
|
||||
is_living=is_living,
|
||||
privacy=privacy_setting,
|
||||
notes=notes,
|
||||
)
|
||||
session.add(person)
|
||||
await session.flush() # assign person.id
|
||||
|
||||
if given or surname:
|
||||
session.add(
|
||||
Name(
|
||||
tree_id=tree.id,
|
||||
person_id=person.id,
|
||||
name_type="birth",
|
||||
given=given,
|
||||
surname=surname,
|
||||
is_primary=True,
|
||||
)
|
||||
)
|
||||
record_audit(
|
||||
session,
|
||||
action="create",
|
||||
entity_type="Person",
|
||||
entity_id=person.id,
|
||||
tree_id=tree.id,
|
||||
actor_user_id=actor.id,
|
||||
after={"given": given, "surname": surname},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(person)
|
||||
await _attach_primary_name(session, person)
|
||||
return person
|
||||
|
||||
|
||||
async def list_persons(
|
||||
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||
) -> list[Person]:
|
||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||
raise Forbidden("not permitted to view this tree")
|
||||
|
||||
stmt = (
|
||||
select(Person)
|
||||
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||
.order_by(Person.created_at)
|
||||
)
|
||||
persons = list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
visible: list[Person] = []
|
||||
for person in persons:
|
||||
if (
|
||||
await privacy.person_visibility(
|
||||
session, user_id=viewer_id, tree=tree, person=person
|
||||
)
|
||||
== Visibility.hidden
|
||||
):
|
||||
continue
|
||||
await _attach_primary_name(session, person)
|
||||
visible.append(person)
|
||||
return visible
|
||||
@@ -0,0 +1,62 @@
|
||||
"""The privacy engine — the single enforcement point for visibility.
|
||||
|
||||
INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a
|
||||
query path that returns rows to a caller without first passing through this
|
||||
module. Effective visibility is a function of the viewer's role on the tree, the
|
||||
tree's visibility, the per-person override, and (Phase 2) living-person status.
|
||||
"""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
|
||||
from app.models.person import Person
|
||||
from app.models.tree import Tree, TreeMembership
|
||||
|
||||
|
||||
class Visibility(enum.StrEnum):
|
||||
full = "full"
|
||||
redacted = "redacted"
|
||||
hidden = "hidden"
|
||||
|
||||
|
||||
async def get_membership_role(
|
||||
session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID
|
||||
) -> MembershipRole | None:
|
||||
if user_id is None:
|
||||
return None
|
||||
stmt = select(TreeMembership.role).where(
|
||||
TreeMembership.tree_id == tree_id,
|
||||
TreeMembership.user_id == user_id,
|
||||
)
|
||||
return (await session.execute(stmt)).scalar_one_or_none()
|
||||
|
||||
|
||||
async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||
if tree.deleted_at is not None:
|
||||
return False
|
||||
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||
return True
|
||||
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
|
||||
|
||||
|
||||
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||
role = await get_membership_role(session, user_id, tree.id)
|
||||
return role in (MembershipRole.owner, MembershipRole.editor)
|
||||
|
||||
|
||||
async def person_visibility(
|
||||
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
|
||||
) -> Visibility:
|
||||
if not await can_view_tree(session, user_id=user_id, tree=tree):
|
||||
return Visibility.hidden
|
||||
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||
return Visibility.full
|
||||
# Non-member viewing a public/unlisted tree:
|
||||
if person.privacy == PersonPrivacy.private:
|
||||
return Visibility.hidden
|
||||
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6).
|
||||
return Visibility.full
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Tree service. Creating a tree also creates the owner's TreeMembership (the
|
||||
authorization basis) and an audit entry. Reads go through the privacy engine.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MembershipRole, TreeVisibility
|
||||
from app.models.tree import Tree, TreeMembership
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.services import privacy
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Forbidden, NotFound
|
||||
|
||||
|
||||
async def create_tree(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
owner: User,
|
||||
name: str,
|
||||
description: str | None = None,
|
||||
visibility: TreeVisibility = TreeVisibility.private,
|
||||
) -> Tree:
|
||||
tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility)
|
||||
session.add(tree)
|
||||
await session.flush() # assign tree.id
|
||||
session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner))
|
||||
record_audit(
|
||||
session,
|
||||
action="create",
|
||||
entity_type="Tree",
|
||||
entity_id=tree.id,
|
||||
tree_id=tree.id,
|
||||
actor_user_id=owner.id,
|
||||
after={"name": name, "visibility": visibility.value},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(tree)
|
||||
return tree
|
||||
|
||||
|
||||
async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
|
||||
stmt = (
|
||||
select(Tree)
|
||||
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
|
||||
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None))
|
||||
.order_by(Tree.created_at)
|
||||
)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree:
|
||||
tree = await BaseRepository(session, Tree).get(tree_id)
|
||||
if tree is None:
|
||||
raise NotFound("tree not found")
|
||||
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||
raise Forbidden("not permitted to view this tree")
|
||||
return tree
|
||||
@@ -0,0 +1,44 @@
|
||||
"""User service. Account creation here is a temporary, open dev bootstrap so we
|
||||
can create tree owners before the auth slice exists; the auth slice replaces it
|
||||
with the AuthProvider (password/OIDC/social) and proper verification.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
from app.repositories.base import BaseRepository
|
||||
from app.services.audit import record_audit
|
||||
from app.services.exceptions import Conflict
|
||||
|
||||
|
||||
async def create_user(
|
||||
session: AsyncSession, *, email: str, display_name: str | None = None
|
||||
) -> User:
|
||||
email = email.strip().lower()
|
||||
existing = (
|
||||
await session.execute(select(User).where(User.email == email))
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise Conflict("email already registered")
|
||||
|
||||
user = User(email=email, display_name=display_name)
|
||||
session.add(user)
|
||||
await session.flush() # assign user.id
|
||||
record_audit(
|
||||
session,
|
||||
action="create",
|
||||
entity_type="User",
|
||||
entity_id=user.id,
|
||||
actor_user_id=user.id,
|
||||
after={"email": email},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||
return await BaseRepository(session, User).get(user_id)
|
||||
Reference in New Issue
Block a user