""" 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 ) -> tuple[str | None, str | None]: """ Returns (effective_permission, project_level_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, project_level) """ project_perm = await get_project_permission(db, project_id, user_id) if project_perm is None: return None, None if project_perm == "owner": return "owner", "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, 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, project_perm return "create_modify", project_perm return project_perm, 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, ) )