familytree

A self-hosted family tree builder.
Log | Files | Refs | README

main.py (20113B)


      1 from fastapi import FastAPI, Request, Depends, HTTPException
      2 from fastapi.staticfiles import StaticFiles
      3 from fastapi.responses import FileResponse
      4 from sqlalchemy.orm import Session
      5 from sqlalchemy.exc import IntegrityError
      6 import os
      7 
      8 from .database import get_db, engine
      9 from .models import Base, UserProfile, Tree, TreeMembership, PendingInvitation, Person, Partnership, ParentLink
     10 from .schemas import (
     11     TreeCreate, TreeRename, InviteRequest, RoleUpdate, AddMemberRequest,
     12     PersonCreate, PersonUpdate,
     13     PartnershipCreate, PartnershipUpdate,
     14     ParentLinkCreate,
     15 )
     16 
     17 Base.metadata.create_all(bind=engine)
     18 
     19 app = FastAPI(title="Arbor")
     20 
     21 STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
     22 app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
     23 
     24 
     25 @app.middleware("http")
     26 async def no_cache_static(request: Request, call_next):
     27     response = await call_next(request)
     28     if request.url.path.startswith("/static/"):
     29         response.headers["Cache-Control"] = "no-cache"
     30     return response
     31 
     32 
     33 # ── Auth helpers ──────────────────────────────────────────────────────────────
     34 
     35 def _get_identity(request: Request) -> tuple[str, str, str]:
     36     username = request.headers.get("x-authentik-username", "")
     37     email = request.headers.get("x-authentik-email", "")
     38     display_name = request.headers.get("x-authentik-name", "") or username
     39     if not username:
     40         raise HTTPException(status_code=401, detail="Not authenticated")
     41     return username, email, display_name
     42 
     43 
     44 def _upsert_display_name(db: Session, username: str, display_name: str) -> None:
     45     profile = db.query(UserProfile).filter(UserProfile.username == username).first()
     46     if profile:
     47         if profile.display_name != display_name:
     48             profile.display_name = display_name
     49             db.commit()
     50     else:
     51         db.add(UserProfile(username=username, display_name=display_name))
     52         db.commit()
     53 
     54 
     55 def _display_name(db: Session, username: str) -> str:
     56     profile = db.query(UserProfile).filter(UserProfile.username == username).first()
     57     return profile.display_name if profile and profile.display_name else username
     58 
     59 
     60 def _accept_pending_invitations(db: Session, username: str, email: str) -> None:
     61     if not email:
     62         return
     63     pending = db.query(PendingInvitation).filter(PendingInvitation.invitee_email == email).all()
     64     for inv in pending:
     65         try:
     66             db.add(TreeMembership(tree_id=inv.tree_id, username=username, role=inv.role))
     67             db.flush()
     68         except IntegrityError:
     69             db.rollback()
     70         db.delete(inv)
     71     db.commit()
     72 
     73 
     74 def _require_member(db: Session, tree_id: int, username: str) -> tuple[Tree, TreeMembership]:
     75     tree = db.query(Tree).filter(Tree.id == tree_id).first()
     76     if not tree:
     77         raise HTTPException(status_code=404, detail="Tree not found")
     78     m = db.query(TreeMembership).filter(
     79         TreeMembership.tree_id == tree_id,
     80         TreeMembership.username == username,
     81     ).first()
     82     if not m:
     83         raise HTTPException(status_code=403, detail="Access denied")
     84     return tree, m
     85 
     86 
     87 def _require_editor(db: Session, tree_id: int, username: str) -> Tree:
     88     tree, m = _require_member(db, tree_id, username)
     89     if m.role not in ("owner", "editor"):
     90         raise HTTPException(status_code=403, detail="Editor access required")
     91     return tree
     92 
     93 
     94 def _require_owner(db: Session, tree_id: int, username: str) -> Tree:
     95     tree, m = _require_member(db, tree_id, username)
     96     if m.role != "owner":
     97         raise HTTPException(status_code=403, detail="Only the owner can do this")
     98     return tree
     99 
    100 
    101 def _person_dict(p: Person) -> dict:
    102     return {
    103         "id": p.id,
    104         "tree_id": p.tree_id,
    105         "first_name": p.first_name,
    106         "last_name": p.last_name,
    107         "birth_date": p.birth_date,
    108         "death_date": p.death_date,
    109         "birth_place": p.birth_place,
    110         "burial_place": p.burial_place,
    111         "bio": p.bio,
    112     }
    113 
    114 
    115 def _partnership_dict(ps: Partnership) -> dict:
    116     return {
    117         "id": ps.id,
    118         "tree_id": ps.tree_id,
    119         "person1_id": ps.person1_id,
    120         "person2_id": ps.person2_id,
    121         "union_date": ps.union_date,
    122         "union_end_date": ps.union_end_date,
    123         "union_type": ps.union_type,
    124     }
    125 
    126 
    127 def _parentlink_dict(pl: ParentLink) -> dict:
    128     return {
    129         "id": pl.id,
    130         "child_id": pl.child_id,
    131         "partnership_id": pl.partnership_id,
    132         "parent_id": pl.parent_id,
    133     }
    134 
    135 
    136 # ── Routes ────────────────────────────────────────────────────────────────────
    137 
    138 
    139 @app.get("/", include_in_schema=False)
    140 async def root():
    141     r = FileResponse(os.path.join(STATIC_DIR, "index.html"))
    142     r.headers["Cache-Control"] = "no-cache"
    143     return r
    144 
    145 
    146 @app.get("/api/me")
    147 def get_me(request: Request, db: Session = Depends(get_db)):
    148     username, email, display_name = _get_identity(request)
    149     _upsert_display_name(db, username, display_name)
    150     _accept_pending_invitations(db, username, email)
    151     return {"username": username, "email": email, "display_name": display_name}
    152 
    153 
    154 @app.get("/api/trees")
    155 def list_trees(request: Request, db: Session = Depends(get_db)):
    156     username, _, _dn = _get_identity(request)
    157     memberships = db.query(TreeMembership).filter(TreeMembership.username == username).all()
    158     result = []
    159     for m in memberships:
    160         tree = m.tree
    161         members = [{"username": x.username, "role": x.role, "display_name": _display_name(db, x.username)} for x in tree.memberships]
    162         pending = [{"email": p.invitee_email, "role": p.role} for p in tree.pending_invitations]
    163         result.append({
    164             "id": tree.id,
    165             "name": tree.name,
    166             "role": m.role,
    167             "is_owner": tree.owner_username == username,
    168             "members": members,
    169             "pending_invitations": pending if tree.owner_username == username else [],
    170         })
    171     return result
    172 
    173 
    174 @app.post("/api/trees", status_code=201)
    175 def create_tree(body: TreeCreate, request: Request, db: Session = Depends(get_db)):
    176     username, _, _dn = _get_identity(request)
    177     tree = Tree(name=body.name, owner_username=username)
    178     db.add(tree)
    179     db.flush()
    180     db.add(TreeMembership(tree_id=tree.id, username=username, role="owner"))
    181     db.commit()
    182     db.refresh(tree)
    183     return {"id": tree.id, "name": tree.name, "role": "owner"}
    184 
    185 
    186 @app.patch("/api/trees/{tree_id}")
    187 def rename_tree(tree_id: int, body: TreeRename, request: Request, db: Session = Depends(get_db)):
    188     username, _, _dn = _get_identity(request)
    189     tree = _require_owner(db, tree_id, username)
    190     tree.name = body.name
    191     db.commit()
    192     return {"id": tree.id, "name": tree.name}
    193 
    194 
    195 @app.delete("/api/trees/{tree_id}", status_code=204)
    196 def delete_tree(tree_id: int, request: Request, db: Session = Depends(get_db)):
    197     username, _, _dn = _get_identity(request)
    198     tree = _require_owner(db, tree_id, username)
    199     db.delete(tree)
    200     db.commit()
    201 
    202 
    203 @app.post("/api/trees/{tree_id}/invite", status_code=201)
    204 def invite_member(tree_id: int, body: InviteRequest, request: Request, db: Session = Depends(get_db)):
    205     username, _, _dn = _get_identity(request)
    206     _require_owner(db, tree_id, username)
    207     email = body.email.strip().lower()
    208     if body.role not in ("editor", "viewer"):
    209         raise HTTPException(status_code=422, detail="role must be editor or viewer")
    210     try:
    211         db.add(PendingInvitation(tree_id=tree_id, invitee_email=email, role=body.role))
    212         db.commit()
    213     except IntegrityError:
    214         db.rollback()
    215         raise HTTPException(status_code=409, detail="Already invited")
    216     return {"invited": email, "role": body.role}
    217 
    218 
    219 @app.delete("/api/trees/{tree_id}/invitations/{email}", status_code=204)
    220 def revoke_invitation(tree_id: int, email: str, request: Request, db: Session = Depends(get_db)):
    221     username, _, _dn = _get_identity(request)
    222     _require_owner(db, tree_id, username)
    223     inv = db.query(PendingInvitation).filter(
    224         PendingInvitation.tree_id == tree_id,
    225         PendingInvitation.invitee_email == email,
    226     ).first()
    227     if inv:
    228         db.delete(inv)
    229         db.commit()
    230 
    231 
    232 @app.post("/api/trees/{tree_id}/members", status_code=201)
    233 def add_member(tree_id: int, body: AddMemberRequest, request: Request, db: Session = Depends(get_db)):
    234     username, _, _dn = _get_identity(request)
    235     _require_owner(db, tree_id, username)
    236     target = body.username.strip()
    237     if not target:
    238         raise HTTPException(status_code=422, detail="Username is required")
    239     if body.role not in ("editor", "viewer"):
    240         raise HTTPException(status_code=422, detail="role must be editor or viewer")
    241     if target == username:
    242         raise HTTPException(status_code=400, detail="Cannot add yourself")
    243     try:
    244         db.add(TreeMembership(tree_id=tree_id, username=target, role=body.role))
    245         db.commit()
    246     except IntegrityError:
    247         db.rollback()
    248         raise HTTPException(status_code=409, detail="User is already a member")
    249     return {"username": target, "role": body.role, "display_name": _display_name(db, target)}
    250 
    251 
    252 @app.patch("/api/trees/{tree_id}/members/{member_username}")
    253 def update_member_role(tree_id: int, member_username: str, body: RoleUpdate, request: Request, db: Session = Depends(get_db)):
    254     username, _, _dn = _get_identity(request)
    255     _require_owner(db, tree_id, username)
    256     if body.role not in ("editor", "viewer"):
    257         raise HTTPException(status_code=422, detail="role must be editor or viewer")
    258     m = db.query(TreeMembership).filter(
    259         TreeMembership.tree_id == tree_id,
    260         TreeMembership.username == member_username,
    261     ).first()
    262     if not m:
    263         raise HTTPException(status_code=404)
    264     if m.role == "owner":
    265         raise HTTPException(status_code=400, detail="Cannot change owner role")
    266     m.role = body.role
    267     db.commit()
    268     return {"username": member_username, "role": m.role}
    269 
    270 
    271 @app.delete("/api/trees/{tree_id}/members/{member_username}", status_code=204)
    272 def remove_member(tree_id: int, member_username: str, request: Request, db: Session = Depends(get_db)):
    273     username, _, _dn = _get_identity(request)
    274     _require_owner(db, tree_id, username)
    275     if member_username == username:
    276         raise HTTPException(status_code=400, detail="Cannot remove yourself as owner")
    277     m = db.query(TreeMembership).filter(
    278         TreeMembership.tree_id == tree_id,
    279         TreeMembership.username == member_username,
    280     ).first()
    281     if m:
    282         db.delete(m)
    283         db.commit()
    284 
    285 
    286 @app.delete("/api/trees/{tree_id}/leave", status_code=204)
    287 def leave_tree(tree_id: int, request: Request, db: Session = Depends(get_db)):
    288     username, _, _dn = _get_identity(request)
    289     tree, m = _require_member(db, tree_id, username)
    290     if tree.owner_username == username:
    291         raise HTTPException(status_code=400, detail="Owner cannot leave; delete the tree instead")
    292     db.delete(m)
    293     db.commit()
    294 
    295 
    296 # ── Graph ─────────────────────────────────────────────────────────────────────
    297 
    298 @app.get("/api/trees/{tree_id}/graph")
    299 def get_graph(tree_id: int, request: Request, db: Session = Depends(get_db)):
    300     username, _, _dn = _get_identity(request)
    301     _require_member(db, tree_id, username)
    302     people = db.query(Person).filter(Person.tree_id == tree_id).all()
    303     partnerships = db.query(Partnership).filter(Partnership.tree_id == tree_id).all()
    304     person_ids = {p.id for p in people}
    305     parent_links = db.query(ParentLink).filter(ParentLink.child_id.in_(person_ids)).all() if person_ids else []
    306     return {
    307         "people": [_person_dict(p) for p in people],
    308         "partnerships": [_partnership_dict(ps) for ps in partnerships],
    309         "parent_links": [_parentlink_dict(pl) for pl in parent_links],
    310     }
    311 
    312 
    313 # ── People ────────────────────────────────────────────────────────────────────
    314 
    315 @app.post("/api/trees/{tree_id}/people", status_code=201)
    316 def add_person(tree_id: int, body: PersonCreate, request: Request, db: Session = Depends(get_db)):
    317     username, _, _dn = _get_identity(request)
    318     _require_editor(db, tree_id, username)
    319     p = Person(tree_id=tree_id, **body.model_dump())
    320     db.add(p)
    321     db.commit()
    322     db.refresh(p)
    323     return _person_dict(p)
    324 
    325 
    326 @app.patch("/api/trees/{tree_id}/people/{person_id}")
    327 def update_person(tree_id: int, person_id: int, body: PersonUpdate, request: Request, db: Session = Depends(get_db)):
    328     username, _, _dn = _get_identity(request)
    329     _require_editor(db, tree_id, username)
    330     p = db.query(Person).filter(Person.id == person_id, Person.tree_id == tree_id).first()
    331     if not p:
    332         raise HTTPException(status_code=404)
    333     for field, val in body.model_dump(exclude_unset=True).items():
    334         setattr(p, field, val)
    335     db.commit()
    336     db.refresh(p)
    337     return _person_dict(p)
    338 
    339 
    340 @app.delete("/api/trees/{tree_id}/people/{person_id}", status_code=204)
    341 def delete_person(tree_id: int, person_id: int, request: Request, db: Session = Depends(get_db)):
    342     username, _, _dn = _get_identity(request)
    343     _require_editor(db, tree_id, username)
    344     p = db.query(Person).filter(Person.id == person_id, Person.tree_id == tree_id).first()
    345     if not p:
    346         raise HTTPException(status_code=404)
    347     # remove partnerships involving this person
    348     db.query(Partnership).filter(
    349         (Partnership.person1_id == person_id) | (Partnership.person2_id == person_id)
    350     ).delete(synchronize_session=False)
    351     db.delete(p)
    352     db.commit()
    353 
    354 
    355 # ── Partnerships ──────────────────────────────────────────────────────────────
    356 
    357 @app.post("/api/trees/{tree_id}/partnerships", status_code=201)
    358 def add_partnership(tree_id: int, body: PartnershipCreate, request: Request, db: Session = Depends(get_db)):
    359     username, _, _dn = _get_identity(request)
    360     _require_editor(db, tree_id, username)
    361     for pid in (body.person1_id, body.person2_id):
    362         if not db.query(Person).filter(Person.id == pid, Person.tree_id == tree_id).first():
    363             raise HTTPException(status_code=404, detail=f"Person {pid} not in tree")
    364         if db.query(Partnership).filter(
    365             ((Partnership.person1_id == pid) | (Partnership.person2_id == pid)),
    366             Partnership.tree_id == tree_id
    367         ).first():
    368             raise HTTPException(status_code=409, detail=f"Person {pid} already has a partnership")
    369     if body.union_type not in ("married", "partnered", "separated"):
    370         raise HTTPException(status_code=422, detail="Invalid union_type")
    371     try:
    372         ps = Partnership(tree_id=tree_id, **body.model_dump())
    373         db.add(ps)
    374         db.commit()
    375         db.refresh(ps)
    376     except IntegrityError:
    377         db.rollback()
    378         raise HTTPException(status_code=409, detail="Partnership already exists")
    379     return _partnership_dict(ps)
    380 
    381 
    382 @app.patch("/api/trees/{tree_id}/partnerships/{partnership_id}")
    383 def update_partnership(tree_id: int, partnership_id: int, body: PartnershipUpdate, request: Request, db: Session = Depends(get_db)):
    384     username, _, _dn = _get_identity(request)
    385     _require_editor(db, tree_id, username)
    386     ps = db.query(Partnership).filter(Partnership.id == partnership_id, Partnership.tree_id == tree_id).first()
    387     if not ps:
    388         raise HTTPException(status_code=404)
    389     for field, val in body.model_dump(exclude_unset=True).items():
    390         setattr(ps, field, val)
    391     db.commit()
    392     return _partnership_dict(ps)
    393 
    394 
    395 @app.delete("/api/trees/{tree_id}/partnerships/{partnership_id}", status_code=204)
    396 def delete_partnership(tree_id: int, partnership_id: int, request: Request, db: Session = Depends(get_db)):
    397     username, _, _dn = _get_identity(request)
    398     _require_editor(db, tree_id, username)
    399     ps = db.query(Partnership).filter(Partnership.id == partnership_id, Partnership.tree_id == tree_id).first()
    400     if not ps:
    401         raise HTTPException(status_code=404)
    402     db.delete(ps)
    403     db.commit()
    404 
    405 
    406 # ── Parent links ──────────────────────────────────────────────────────────────
    407 
    408 @app.post("/api/trees/{tree_id}/parentlinks", status_code=201)
    409 def add_parent_link(tree_id: int, body: ParentLinkCreate, request: Request, db: Session = Depends(get_db)):
    410     username, _, _dn = _get_identity(request)
    411     _require_editor(db, tree_id, username)
    412     if (body.partnership_id is None) == (body.parent_id is None):
    413         raise HTTPException(status_code=422, detail="Provide exactly one of partnership_id or parent_id")
    414     if not db.query(Person).filter(Person.id == body.child_id, Person.tree_id == tree_id).first():
    415         raise HTTPException(status_code=404, detail="Child not in tree")
    416     if body.partnership_id:
    417         if not db.query(Partnership).filter(Partnership.id == body.partnership_id, Partnership.tree_id == tree_id).first():
    418             raise HTTPException(status_code=404, detail="Partnership not in tree")
    419     if body.parent_id:
    420         if not db.query(Person).filter(Person.id == body.parent_id, Person.tree_id == tree_id).first():
    421             raise HTTPException(status_code=404, detail="Parent not in tree")
    422 
    423     # Cycle detection: reject if child is an ancestor of any proposed parent
    424     parent_ids: list[int] = []
    425     if body.partnership_id:
    426         ps = db.query(Partnership).filter(Partnership.id == body.partnership_id).first()
    427         if ps:
    428             parent_ids = [ps.person1_id, ps.person2_id]
    429     elif body.parent_id:
    430         parent_ids = [body.parent_id]
    431 
    432     if parent_ids:
    433         all_links = db.query(ParentLink).join(Person, ParentLink.child_id == Person.id).filter(Person.tree_id == tree_id).all()
    434         all_partnerships = db.query(Partnership).filter(Partnership.tree_id == tree_id).all()
    435         ps_map = {p.id: (p.person1_id, p.person2_id) for p in all_partnerships}
    436         children_of: dict[int, list[int]] = {}
    437         for link in all_links:
    438             if link.partnership_id and link.partnership_id in ps_map:
    439                 for pid in ps_map[link.partnership_id]:
    440                     children_of.setdefault(pid, []).append(link.child_id)
    441             elif link.parent_id:
    442                 children_of.setdefault(link.parent_id, []).append(link.child_id)
    443         def can_reach(start: int, targets: set[int]) -> bool:
    444             queue, seen = [start], {start}
    445             while queue:
    446                 cur = queue.pop()
    447                 for child in children_of.get(cur, []):
    448                     if child in targets:
    449                         return True
    450                     if child not in seen:
    451                         seen.add(child)
    452                         queue.append(child)
    453             return False
    454         if can_reach(body.child_id, set(parent_ids)):
    455             raise HTTPException(status_code=422, detail="Adding this link would create a cycle in the family tree")
    456 
    457     pl = ParentLink(**body.model_dump())
    458     db.add(pl)
    459     db.commit()
    460     db.refresh(pl)
    461     return _parentlink_dict(pl)
    462 
    463 
    464 @app.delete("/api/trees/{tree_id}/parentlinks/{link_id}", status_code=204)
    465 def delete_parent_link(tree_id: int, link_id: int, request: Request, db: Session = Depends(get_db)):
    466     username, _, _dn = _get_identity(request)
    467     _require_editor(db, tree_id, username)
    468     pl = db.query(ParentLink).filter(ParentLink.id == link_id).first()
    469     if not pl:
    470         raise HTTPException(status_code=404)
    471     child = db.query(Person).filter(Person.id == pl.child_id, Person.tree_id == tree_id).first()
    472     if not child:
    473         raise HTTPException(status_code=403)
    474     db.delete(pl)
    475     db.commit()