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()