Enables multi-user project collaboration mirroring the shared calendar pattern. Includes ProjectMember model with permission levels, task assignment with auto-membership, optimistic locking, field allowlist for assignees, disconnect cascade, delta polling for projects and calendars, and full frontend integration with share sheet, assignment picker, permission gating, and notification handling. Migrations: 057 (indexes + version + comment user_id), 058 (project_members), 059 (project_task_assignments) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
268 lines
8.8 KiB
Python
268 lines
8.8 KiB
Python
"""
|
|
Project sharing service — permission checks, auto-membership, disconnect cascade.
|
|
|
|
All functions accept an AsyncSession and do NOT commit — callers manage transactions.
|
|
"""
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import delete, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.project import Project
|
|
from app.models.project_member import ProjectMember
|
|
from app.models.project_task import ProjectTask
|
|
from app.models.project_task_assignment import ProjectTaskAssignment
|
|
from app.models.user_connection import UserConnection
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PERMISSION_RANK = {"read_only": 1, "create_modify": 2}
|
|
|
|
# Fields task assignees (from assignment, not project membership) may edit
|
|
ASSIGNEE_ALLOWED_FIELDS = {"title", "description", "status", "priority", "due_date"}
|
|
|
|
|
|
async def get_project_permission(
|
|
db: AsyncSession, project_id: int, user_id: int
|
|
) -> str | None:
|
|
"""
|
|
Returns 'owner', 'create_modify', 'read_only', or None.
|
|
Single query with LEFT JOIN (mirrors calendar_sharing pattern).
|
|
"""
|
|
result = await db.execute(
|
|
select(
|
|
Project.user_id,
|
|
ProjectMember.permission,
|
|
)
|
|
.outerjoin(
|
|
ProjectMember,
|
|
(ProjectMember.project_id == Project.id)
|
|
& (ProjectMember.user_id == user_id)
|
|
& (ProjectMember.status == "accepted"),
|
|
)
|
|
.where(Project.id == project_id)
|
|
)
|
|
row = result.one_or_none()
|
|
if not row:
|
|
return None
|
|
owner_id, member_permission = row.tuple()
|
|
if owner_id == user_id:
|
|
return "owner"
|
|
return member_permission
|
|
|
|
|
|
async def require_project_permission(
|
|
db: AsyncSession, project_id: int, user_id: int, min_level: str
|
|
) -> str:
|
|
"""
|
|
Raises 404 if project doesn't exist or user has no access.
|
|
Raises 403 if user has insufficient permission.
|
|
Returns the actual permission string (or 'owner').
|
|
"""
|
|
perm = await get_project_permission(db, project_id, user_id)
|
|
if perm is None:
|
|
raise HTTPException(status_code=404, detail="Project not found")
|
|
if perm == "owner":
|
|
return "owner"
|
|
if min_level == "owner":
|
|
raise HTTPException(status_code=403, detail="Only the project owner can perform this action")
|
|
if PERMISSION_RANK.get(perm, 0) < PERMISSION_RANK.get(min_level, 0):
|
|
raise HTTPException(status_code=403, detail="Insufficient permission on this project")
|
|
return perm
|
|
|
|
|
|
async def get_accessible_project_ids(db: AsyncSession, user_id: int) -> set[int]:
|
|
"""Returns owned + accepted membership project IDs."""
|
|
result = await db.execute(
|
|
select(Project.id).where(Project.user_id == user_id)
|
|
.union(
|
|
select(ProjectMember.project_id).where(
|
|
ProjectMember.user_id == user_id,
|
|
ProjectMember.status == "accepted",
|
|
)
|
|
)
|
|
)
|
|
return {r[0] for r in result.all()}
|
|
|
|
|
|
async def validate_project_connections(
|
|
db: AsyncSession, owner_id: int, user_ids: list[int]
|
|
) -> None:
|
|
"""Validates all target users are active connections of the owner. Raises 400 on failure."""
|
|
if not user_ids:
|
|
return
|
|
result = await db.execute(
|
|
select(UserConnection.connected_user_id).where(
|
|
UserConnection.user_id == owner_id,
|
|
UserConnection.connected_user_id.in_(user_ids),
|
|
)
|
|
)
|
|
connected = {r[0] for r in result.all()}
|
|
missing = set(user_ids) - connected
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Users {sorted(missing)} are not your connections",
|
|
)
|
|
|
|
|
|
async def get_effective_task_permission(
|
|
db: AsyncSession, user_id: int, task_id: int, project_id: int
|
|
) -> str | None:
|
|
"""
|
|
Returns effective permission for a specific task:
|
|
1. Get project-level permission (owner/create_modify/read_only)
|
|
2. If user is assigned to THIS task → max(project_perm, create_modify)
|
|
3. If task has parent and user assigned to PARENT → same as above
|
|
4. Return effective permission
|
|
"""
|
|
project_perm = await get_project_permission(db, project_id, user_id)
|
|
if project_perm is None:
|
|
return None
|
|
if project_perm == "owner":
|
|
return "owner"
|
|
|
|
# Check direct assignment on this task
|
|
task_result = await db.execute(
|
|
select(ProjectTask.parent_task_id).where(ProjectTask.id == task_id)
|
|
)
|
|
task_row = task_result.one_or_none()
|
|
if not task_row:
|
|
return project_perm
|
|
|
|
parent_task_id = task_row[0]
|
|
|
|
# Check assignment on this task or its parent
|
|
check_task_ids = [task_id]
|
|
if parent_task_id is not None:
|
|
check_task_ids.append(parent_task_id)
|
|
|
|
assignment_result = await db.execute(
|
|
select(ProjectTaskAssignment.id).where(
|
|
ProjectTaskAssignment.task_id.in_(check_task_ids),
|
|
ProjectTaskAssignment.user_id == user_id,
|
|
).limit(1)
|
|
)
|
|
if assignment_result.scalar_one_or_none() is not None:
|
|
# Assignment grants at least create_modify
|
|
if PERMISSION_RANK.get(project_perm, 0) >= PERMISSION_RANK["create_modify"]:
|
|
return project_perm
|
|
return "create_modify"
|
|
|
|
return project_perm
|
|
|
|
|
|
async def ensure_auto_membership(
|
|
db: AsyncSession, project_id: int, user_id: int, invited_by: int
|
|
) -> None:
|
|
"""
|
|
When assigning a user to a task, ensure they have a ProjectMember row.
|
|
If none exists, create one with read_only + auto_assigned + accepted (no invite flow).
|
|
"""
|
|
existing = await db.execute(
|
|
select(ProjectMember.id).where(
|
|
ProjectMember.project_id == project_id,
|
|
ProjectMember.user_id == user_id,
|
|
)
|
|
)
|
|
if existing.scalar_one_or_none() is not None:
|
|
return
|
|
|
|
member = ProjectMember(
|
|
project_id=project_id,
|
|
user_id=user_id,
|
|
invited_by=invited_by,
|
|
permission="read_only",
|
|
status="accepted",
|
|
source="auto_assigned",
|
|
accepted_at=datetime.now(),
|
|
)
|
|
db.add(member)
|
|
|
|
|
|
async def cleanup_auto_membership(
|
|
db: AsyncSession, project_id: int, user_id: int
|
|
) -> None:
|
|
"""
|
|
After removing a task assignment, check if user has any remaining assignments
|
|
in this project. If not and membership is auto_assigned, remove it.
|
|
"""
|
|
remaining = await db.execute(
|
|
select(ProjectTaskAssignment.id)
|
|
.join(ProjectTask, ProjectTaskAssignment.task_id == ProjectTask.id)
|
|
.where(
|
|
ProjectTask.project_id == project_id,
|
|
ProjectTaskAssignment.user_id == user_id,
|
|
)
|
|
.limit(1)
|
|
)
|
|
if remaining.scalar_one_or_none() is not None:
|
|
return # Still has assignments
|
|
|
|
# Remove auto_assigned membership only
|
|
await db.execute(
|
|
delete(ProjectMember).where(
|
|
ProjectMember.project_id == project_id,
|
|
ProjectMember.user_id == user_id,
|
|
ProjectMember.source == "auto_assigned",
|
|
)
|
|
)
|
|
|
|
|
|
async def cascade_projects_on_disconnect(
|
|
db: AsyncSession, user_a_id: int, user_b_id: int
|
|
) -> None:
|
|
"""
|
|
When a connection is severed:
|
|
1. Find all ProjectMember rows where one user is a member of the other's projects
|
|
2. Find all ProjectTaskAssignment rows for those memberships
|
|
3. Remove assignments, then remove memberships
|
|
"""
|
|
# Find projects owned by each user
|
|
a_proj_result = await db.execute(
|
|
select(Project.id).where(Project.user_id == user_a_id)
|
|
)
|
|
a_proj_ids = [r[0] for r in a_proj_result.all()]
|
|
|
|
b_proj_result = await db.execute(
|
|
select(Project.id).where(Project.user_id == user_b_id)
|
|
)
|
|
b_proj_ids = [r[0] for r in b_proj_result.all()]
|
|
|
|
# Remove user_b's assignments + memberships on user_a's projects
|
|
if a_proj_ids:
|
|
# Delete task assignments first
|
|
await db.execute(
|
|
delete(ProjectTaskAssignment).where(
|
|
ProjectTaskAssignment.user_id == user_b_id,
|
|
ProjectTaskAssignment.task_id.in_(
|
|
select(ProjectTask.id).where(ProjectTask.project_id.in_(a_proj_ids))
|
|
),
|
|
)
|
|
)
|
|
await db.execute(
|
|
delete(ProjectMember).where(
|
|
ProjectMember.project_id.in_(a_proj_ids),
|
|
ProjectMember.user_id == user_b_id,
|
|
)
|
|
)
|
|
|
|
# Remove user_a's assignments + memberships on user_b's projects
|
|
if b_proj_ids:
|
|
await db.execute(
|
|
delete(ProjectTaskAssignment).where(
|
|
ProjectTaskAssignment.user_id == user_a_id,
|
|
ProjectTaskAssignment.task_id.in_(
|
|
select(ProjectTask.id).where(ProjectTask.project_id.in_(b_proj_ids))
|
|
),
|
|
)
|
|
)
|
|
await db.execute(
|
|
delete(ProjectMember).where(
|
|
ProjectMember.project_id.in_(b_proj_ids),
|
|
ProjectMember.user_id == user_a_id,
|
|
)
|
|
)
|