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
- 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>
263 lines
9.1 KiB
Python
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}
|