Files
FabledScribe/src/scribe/services/sharing.py
T
bvandeusenandClaude Fable 5 7d48eb0b1b
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 12s
CI & Build / integration (push) Successful in 24s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 24s
refactor(services): one periodic-task shape, one APScheduler job shape, one token hash, one summary rule — the services pass of the shape audit (#2830, milestone 296)
- background.start_periodic(interval, work, label=) replaces the three hand-rolled
  while-True/sleep/try loops in logging, auth and notifications.
- services/scheduler.ScheduledJob replaces the four private BackgroundScheduler
  copies in recurrence/version_pinning/trash/db_maintenance schedulers; public
  start_/stop_/reschedule_ surfaces unchanged.
- api_keys.hash_token is the one sha256 helper; auth.py used to inline it 5x.
- auth.is_registration_open reads via settings.get_admin_setting; notification
  prefs read via settings.get_setting; _fire_share_email uses _get_user_email.
- projects.get_project_summary / milestones.get_project_milestone_summary are
  now the one-id view of their batch siblings instead of a second copy of the
  queries; sharing.best_permission_by (was _deduplicate_by_permission) is the
  one rank-dedup, now also used by list_projects_for_user.
- backup: the row builders for every section both exporters carry are named
  functions, so a column added to one export cannot silently miss the other.
- iso() from models.base replaces the attr.isoformat()-if-attr-else-None idiom
  and db_maintenance._iso across services; backup keeps its explicit shape.
- trash.py hoists the sql_delete/timedelta imports it re-imported per function.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:34:28 -04:00

263 lines
9.1 KiB
Python

"""Sharing service — create/manage project and note shares."""
import logging
from sqlalchemy import select
from scribe.models import async_session
from scribe.models.group import Group, GroupMembership
from scribe.models.note import Note
from scribe.models.project import Project
from scribe.models.share import NoteShare, ProjectShare
from scribe.models.user import User
from scribe.models.base import iso
logger = logging.getLogger(__name__)
VALID_PERMISSIONS = {"viewer", "editor", "admin"}
async def _enrich_shares(session, shares) -> list[dict]:
"""Attach username / group_name to a list of share rows (ProjectShare or NoteShare)."""
result = []
for s in shares:
d = s.to_dict()
if s.shared_with_user_id:
user = await session.get(User, s.shared_with_user_id)
d["username"] = user.username if user else None
if s.shared_with_group_id:
group = await session.get(Group, s.shared_with_group_id)
d["group_name"] = group.name if group else None
result.append(d)
return result
def best_permission_by(shares, id_attr: str) -> dict[int, str]:
"""Return {resource_id: best_permission} keeping the highest-ranked permission per resource."""
from scribe.services.access import PERMISSION_RANK
seen: dict[int, str] = {}
for share in shares:
rid = getattr(share, id_attr)
prev = seen.get(rid)
if prev is None or PERMISSION_RANK[share.permission] > PERMISSION_RANK[prev]:
seen[rid] = share.permission
return seen
# ---------------------------------------------------------------------------
# Project shares
# ---------------------------------------------------------------------------
async def share_project(
acting_user_id: int,
project_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, project_id):
return None
async with async_session() as session:
share = ProjectShare(
project_id=project_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_project_share(
acting_user_id: int, share_id: int, permission: str
) -> ProjectShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return None
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_project_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(ProjectShare, share_id)
if not share:
return False
from scribe.services.access import can_admin_project
if not await can_admin_project(acting_user_id, share.project_id):
return False
await session.delete(share)
await session.commit()
return True
async def list_project_shares(project_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(ProjectShare).where(ProjectShare.project_id == project_id)
)).scalars().all()
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
# Note shares
# ---------------------------------------------------------------------------
async def share_note(
acting_user_id: int,
note_id: int,
*,
target_user_id: int | None = None,
target_group_id: int | None = None,
permission: str = "viewer",
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, note_id)
if perm not in ("admin", "owner"):
return None
async with async_session() as session:
share = NoteShare(
note_id=note_id,
shared_with_user_id=target_user_id,
shared_with_group_id=target_group_id,
permission=permission,
invited_by=acting_user_id,
)
session.add(share)
await session.commit()
await session.refresh(share)
return share
async def update_note_share(
acting_user_id: int, share_id: int, permission: str
) -> NoteShare | None:
if permission not in VALID_PERMISSIONS:
return None
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return None
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return None
share.permission = permission
await session.commit()
await session.refresh(share)
return share
async def remove_note_share(acting_user_id: int, share_id: int) -> bool:
async with async_session() as session:
share = await session.get(NoteShare, share_id)
if not share:
return False
from scribe.services.access import get_note_permission
perm = await get_note_permission(acting_user_id, share.note_id)
if perm not in ("admin", "owner"):
return False
await session.delete(share)
await session.commit()
return True
async def list_note_shares(note_id: int) -> list[dict]:
async with async_session() as session:
shares = (await session.execute(
select(NoteShare).where(NoteShare.note_id == note_id)
)).scalars().all()
return await _enrich_shares(session, shares)
# ---------------------------------------------------------------------------
# Shared-with-me
# ---------------------------------------------------------------------------
async def list_shared_with_me(user_id: int) -> dict:
"""All projects and notes shared with the user (directly or via group)."""
async with async_session() as session:
user_group_ids = (await session.execute(
select(GroupMembership.group_id).where(GroupMembership.user_id == user_id)
)).scalars().all()
# --- Projects ---
proj_direct = (await session.execute(
select(ProjectShare).where(ProjectShare.shared_with_user_id == user_id)
)).scalars().all()
proj_group: list[ProjectShare] = []
if user_group_ids:
proj_group = (await session.execute(
select(ProjectShare).where(
ProjectShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_projects = best_permission_by(list(proj_direct) + list(proj_group), "project_id")
projects = []
for pid, perm in seen_projects.items():
proj = await session.get(Project, pid)
if proj:
owner = await session.get(User, proj.user_id)
projects.append({
"id": proj.id,
"title": proj.title,
"description": proj.description,
"status": proj.status,
"color": proj.color,
"updated_at": iso(proj.updated_at),
"owner_username": owner.username if owner else None,
"permission": perm,
})
# --- Notes / Tasks ---
note_direct = (await session.execute(
select(NoteShare).where(NoteShare.shared_with_user_id == user_id)
)).scalars().all()
note_group: list[NoteShare] = []
if user_group_ids:
note_group = (await session.execute(
select(NoteShare).where(
NoteShare.shared_with_group_id.in_(user_group_ids)
)
)).scalars().all()
seen_notes = best_permission_by(list(note_direct) + list(note_group), "note_id")
notes = []
for nid, perm in seen_notes.items():
note = await session.get(Note, nid)
if note:
owner = await session.get(User, note.user_id)
notes.append({
"id": note.id,
"title": note.title,
"is_task": note.is_task,
"project_id": note.project_id,
"updated_at": iso(note.updated_at),
"owner_username": owner.username if owner else None,
"permission": perm,
})
return {"projects": projects, "notes": notes}