UMBRA/backend/app/routers/projects.py
Kyle Pope f0850ad3bf Fix MissingGreenlet in invite_members and assign_users_to_task
Both endpoints accessed ORM object IDs after db.commit(), which
expires all loaded objects in async SQLAlchemy. Added db.flush()
before commit to assign IDs while objects are still live.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 03:49:28 +08:00

1001 lines
35 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Path, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete as sa_delete, select, update
from sqlalchemy.orm import selectinload
from typing import List, Optional
from datetime import date, datetime, timedelta
from pydantic import BaseModel, ConfigDict
from app.database import get_db
from app.models.project import Project
from app.models.project_task import ProjectTask
from app.models.task_comment import TaskComment
from app.models.project_member import ProjectMember
from app.models.project_task_assignment import ProjectTaskAssignment
from app.models.settings import Settings
from app.schemas.project import ProjectCreate, ProjectUpdate, ProjectResponse, TrackedTaskResponse
from app.schemas.project_task import ProjectTaskCreate, ProjectTaskUpdate, ProjectTaskResponse
from app.schemas.task_comment import TaskCommentCreate, TaskCommentResponse
from app.schemas.project_member import (
ProjectMemberInvite, ProjectMemberUpdate, ProjectMemberRespond, ProjectMemberResponse,
)
from app.schemas.project_task_assignment import TaskAssignmentCreate, TaskAssignmentResponse
from app.services.project_sharing import (
get_project_permission, require_project_permission, get_accessible_project_ids,
validate_project_connections, get_effective_task_permission, ensure_auto_membership,
cleanup_auto_membership, ASSIGNEE_ALLOWED_FIELDS,
)
from app.services.notification import create_notification
from app.routers.auth import get_current_user
from app.models.user import User
router = APIRouter()
class ReorderItem(BaseModel):
model_config = ConfigDict(extra="forbid")
id: int
sort_order: int
def _project_load_options():
"""All load options needed for project responses (tasks + subtasks + comments + assignments)."""
return [
selectinload(Project.tasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
selectinload(Project.tasks).selectinload(ProjectTask.assignments),
selectinload(Project.tasks).selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments),
selectinload(Project.members),
]
def _task_load_options():
"""All load options needed for task responses."""
return [
selectinload(ProjectTask.comments).selectinload(TaskComment.user),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.comments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.subtasks),
selectinload(ProjectTask.assignments),
selectinload(ProjectTask.subtasks).selectinload(ProjectTask.assignments),
]
async def _get_user_name(db: AsyncSession, user_id: int) -> str | None:
"""Get display name for a user from settings.preferred_name or user.username."""
result = await db.execute(
select(Settings.preferred_name, User.username)
.outerjoin(Settings, Settings.user_id == User.id)
.where(User.id == user_id)
)
row = result.one_or_none()
if not row:
return None
preferred, username = row.tuple()
return preferred or username
# ──────────────────────────────────────────────
# PROJECT CRUD
# ──────────────────────────────────────────────
@router.get("/", response_model=List[ProjectResponse])
async def get_projects(
tracked: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get all projects the user owns or has accepted membership in."""
accessible_ids = await get_accessible_project_ids(db, current_user.id)
if not accessible_ids:
return []
query = (
select(Project)
.options(*_project_load_options())
.where(Project.id.in_(accessible_ids))
.order_by(Project.created_at.desc())
)
if tracked is not None:
query = query.where(Project.is_tracked == tracked)
result = await db.execute(query)
projects = result.scalars().unique().all()
return projects
# This route MUST be defined before /{project_id} to avoid path parameter shadowing
@router.get("/tracked-tasks", response_model=List[TrackedTaskResponse])
async def get_tracked_tasks(
days: int = Query(7, ge=1, le=90),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get tasks and subtasks from tracked projects with due dates within the next N days."""
accessible_ids = await get_accessible_project_ids(db, current_user.id)
if not accessible_ids:
return []
today = date.today()
cutoff = today + timedelta(days=days)
query = (
select(ProjectTask)
.join(Project, ProjectTask.project_id == Project.id)
.options(
selectinload(ProjectTask.project),
selectinload(ProjectTask.parent_task),
)
.where(
Project.id.in_(accessible_ids),
Project.is_tracked == True,
ProjectTask.due_date.isnot(None),
ProjectTask.due_date >= today,
ProjectTask.due_date <= cutoff,
ProjectTask.status != "completed",
)
.order_by(ProjectTask.due_date.asc())
)
result = await db.execute(query)
tasks = result.scalars().unique().all()
return [
TrackedTaskResponse(
id=t.id,
title=t.title,
status=t.status,
priority=t.priority,
due_date=t.due_date,
project_name=t.project.name,
project_id=t.project_id,
parent_task_title=t.parent_task.title if t.parent_task else None,
)
for t in tasks
]
@router.get("/shared", response_model=List[ProjectResponse])
async def get_shared_projects(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List projects where user is an accepted member (not owner)."""
member_result = await db.execute(
select(ProjectMember.project_id).where(
ProjectMember.user_id == current_user.id,
ProjectMember.status == "accepted",
)
)
project_ids = [r[0] for r in member_result.all()]
if not project_ids:
return []
result = await db.execute(
select(Project)
.options(*_project_load_options())
.where(Project.id.in_(project_ids))
.order_by(Project.created_at.desc())
)
return result.scalars().unique().all()
@router.post("/", response_model=ProjectResponse, status_code=201)
async def create_project(
project: ProjectCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new project."""
new_project = Project(**project.model_dump(), user_id=current_user.id)
db.add(new_project)
await db.commit()
query = select(Project).options(*_project_load_options()).where(Project.id == new_project.id)
result = await db.execute(query)
return result.scalar_one()
@router.get("/{project_id}", response_model=ProjectResponse)
async def get_project(
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific project by ID with its tasks."""
await require_project_permission(db, project_id, current_user.id, "read_only")
query = (
select(Project)
.options(*_project_load_options())
.where(Project.id == project_id)
)
result = await db.execute(query)
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return project
@router.put("/{project_id}", response_model=ProjectResponse)
async def update_project(
project_id: int = Path(ge=1, le=2147483647),
project_update: ProjectUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a project. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
update_data = project_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(project, key, value)
await db.commit()
query = select(Project).options(*_project_load_options()).where(Project.id == project_id)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}", status_code=204)
async def delete_project(
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a project and all its tasks. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
await db.commit()
return None
# ──────────────────────────────────────────────
# TASK CRUD (permission-aware)
# ──────────────────────────────────────────────
@router.get("/{project_id}/tasks", response_model=List[ProjectTaskResponse])
async def get_project_tasks(
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get top-level tasks for a specific project (subtasks are nested)."""
await require_project_permission(db, project_id, current_user.id, "read_only")
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None),
)
.order_by(ProjectTask.sort_order.asc())
)
result = await db.execute(query)
tasks = result.scalars().unique().all()
return tasks
@router.post("/{project_id}/tasks", response_model=ProjectTaskResponse, status_code=201)
async def create_project_task(
project_id: int = Path(ge=1, le=2147483647),
task: ProjectTaskCreate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new task or subtask for a project. Requires create_modify permission."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
# Validate parent_task_id if creating a subtask
if task.parent_task_id is not None:
parent_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task.parent_task_id,
ProjectTask.project_id == project_id,
ProjectTask.parent_task_id.is_(None), # Parent must be top-level
)
)
parent_task = parent_result.scalar_one_or_none()
if not parent_task:
raise HTTPException(
status_code=400,
detail="Parent task not found or is itself a subtask",
)
task_data = task.model_dump()
task_data["project_id"] = project_id
new_task = ProjectTask(**task_data)
db.add(new_task)
await db.commit()
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(ProjectTask.id == new_task.id)
)
result = await db.execute(query)
return result.scalar_one()
@router.put("/{project_id}/tasks/reorder", status_code=200)
async def reorder_tasks(
project_id: int = Path(ge=1, le=2147483647),
items: List[ReorderItem] = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Bulk update sort_order for tasks. Requires create_modify permission."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
# AC-4: Batch-fetch all tasks in one query instead of N sequential queries
task_ids = [item.id for item in items]
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id.in_(task_ids),
ProjectTask.project_id == project_id,
)
)
tasks_by_id = {t.id: t for t in task_result.scalars().all()}
order_map = {item.id: item.sort_order for item in items}
for task_id, task in tasks_by_id.items():
if task_id in order_map:
task.sort_order = order_map[task_id]
await db.commit()
return {"status": "ok"}
@router.put("/{project_id}/tasks/{task_id}", response_model=ProjectTaskResponse)
async def update_project_task(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
task_update: ProjectTaskUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a project task. Permission checked at project and task level."""
perm = await get_effective_task_permission(db, current_user.id, task_id, project_id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
if perm == "read_only":
raise HTTPException(status_code=403, detail="Insufficient permission")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
update_data = task_update.model_dump(exclude_unset=True)
# SEC-P02: Assignees (non-owner, non-project-member with create_modify) restricted to content fields
project_perm = await get_project_permission(db, project_id, current_user.id)
if project_perm not in ("owner", "create_modify"):
# This user's create_modify comes from task assignment — enforce allowlist
disallowed = set(update_data.keys()) - ASSIGNEE_ALLOWED_FIELDS
if disallowed:
raise HTTPException(
status_code=403,
detail=f"Task assignees cannot modify: {', '.join(sorted(disallowed))}",
)
# Optimistic locking: if version provided, check it matches
client_version = update_data.pop("version", None)
if client_version is not None and task.version != client_version:
raise HTTPException(status_code=409, detail="Task was modified by another user")
for key, value in update_data.items():
setattr(task, key, value)
task.version += 1
await db.commit()
query = (
select(ProjectTask)
.options(*_task_load_options())
.where(ProjectTask.id == task_id)
)
result = await db.execute(query)
return result.scalar_one()
@router.delete("/{project_id}/tasks/{task_id}", status_code=204)
async def delete_project_task(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a project task (cascades to subtasks). Requires create_modify permission."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
await db.delete(task)
await db.commit()
return None
# ──────────────────────────────────────────────
# COMMENTS (permission-aware)
# ──────────────────────────────────────────────
@router.post("/{project_id}/tasks/{task_id}/comments", response_model=TaskCommentResponse, status_code=201)
async def create_task_comment(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
comment: TaskCommentCreate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Add a comment to a task. All members can comment (read_only minimum)."""
await require_project_permission(db, project_id, current_user.id, "read_only")
result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id
)
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
new_comment = TaskComment(task_id=task_id, user_id=current_user.id, content=comment.content)
db.add(new_comment)
# Get author name before commit
author_name = await _get_user_name(db, current_user.id)
await db.commit()
await db.refresh(new_comment)
return TaskCommentResponse(
id=new_comment.id,
task_id=new_comment.task_id,
user_id=new_comment.user_id,
author_name=author_name,
content=new_comment.content,
created_at=new_comment.created_at,
)
@router.delete("/{project_id}/tasks/{task_id}/comments/{comment_id}", status_code=204)
async def delete_task_comment(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
comment_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a task comment. Comment author or project owner only."""
perm = await get_project_permission(db, project_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
result = await db.execute(
select(TaskComment).where(
TaskComment.id == comment_id,
TaskComment.task_id == task_id
)
)
comment = result.scalar_one_or_none()
if not comment:
raise HTTPException(status_code=404, detail="Comment not found")
# Only comment author or project owner can delete
if comment.user_id != current_user.id and perm != "owner":
raise HTTPException(status_code=403, detail="Only the comment author or project owner can delete this comment")
await db.delete(comment)
await db.commit()
return None
# ──────────────────────────────────────────────
# MEMBERSHIP ROUTES
# ──────────────────────────────────────────────
@router.post("/{project_id}/members", response_model=List[ProjectMemberResponse], status_code=201)
async def invite_members(
project_id: int = Path(ge=1, le=2147483647),
invite: ProjectMemberInvite = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Invite connection(s) to a project. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
# Validate connections
await validate_project_connections(db, current_user.id, invite.user_ids)
# Check pending invite cap (max 10 pending per project)
pending_count_result = await db.execute(
select(ProjectMember.id).where(
ProjectMember.project_id == project_id,
ProjectMember.status == "pending",
)
)
pending_count = len(pending_count_result.all())
if pending_count + len(invite.user_ids) > 10:
raise HTTPException(status_code=400, detail="Maximum 10 pending invites per project")
# Filter out self and existing members
existing_result = await db.execute(
select(ProjectMember.user_id).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id.in_(invite.user_ids),
)
)
existing_user_ids = {r[0] for r in existing_result.all()}
# Get project for notifications
project_result = await db.execute(select(Project.name).where(Project.id == project_id))
project_name = project_result.scalar_one()
inviter_name = await _get_user_name(db, current_user.id)
created_members = []
for uid in invite.user_ids:
if uid == current_user.id or uid in existing_user_ids:
continue
member = ProjectMember(
project_id=project_id,
user_id=uid,
invited_by=current_user.id,
permission=invite.permission,
status="pending",
source="invited",
)
db.add(member)
created_members.append(member)
# In-app notification
await create_notification(
db, uid, "project_invite",
f"Project invitation from {inviter_name}",
f"You've been invited to collaborate on \"{project_name}\"",
data={"project_id": project_id},
source_type="project_member",
)
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
member_ids = [m.id for m in created_members]
await db.commit()
# Re-fetch with relationships
if not created_members:
return []
result = await db.execute(
select(ProjectMember)
.options(
selectinload(ProjectMember.user),
selectinload(ProjectMember.inviter),
)
.where(ProjectMember.id.in_(member_ids))
)
members = result.scalars().all()
# Build response with names
responses = []
for m in members:
resp = ProjectMemberResponse.model_validate(m)
resp.user_name = m.user.username
resp.inviter_name = m.inviter.username if m.inviter else None
responses.append(resp)
return responses
@router.get("/{project_id}/members", response_model=List[ProjectMemberResponse])
async def get_members(
project_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List members + statuses. Any member can view."""
await require_project_permission(db, project_id, current_user.id, "read_only")
result = await db.execute(
select(ProjectMember)
.options(
selectinload(ProjectMember.user),
selectinload(ProjectMember.inviter),
)
.where(ProjectMember.project_id == project_id)
.order_by(ProjectMember.created_at.asc())
)
members = result.scalars().all()
# Batch-fetch settings for preferred_name
user_ids = [m.user_id for m in members] + [m.invited_by for m in members]
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
)
name_map = {r[0]: r[1] for r in settings_result.all()}
responses = []
for m in members:
resp = ProjectMemberResponse.model_validate(m)
resp.user_name = name_map.get(m.user_id) or m.user.username
resp.inviter_name = name_map.get(m.invited_by) or (m.inviter.username if m.inviter else None)
responses.append(resp)
return responses
@router.patch("/{project_id}/members/{user_id}", response_model=ProjectMemberResponse)
async def update_member_permission(
project_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
update: ProjectMemberUpdate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update a member's permission level. Owner only."""
await require_project_permission(db, project_id, current_user.id, "owner")
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
.where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
member.permission = update.permission
# Extract response data BEFORE commit (ORM objects expire after commit)
resp = ProjectMemberResponse.model_validate(member)
resp.user_name = member.user.username
resp.inviter_name = member.inviter.username if member.inviter else None
await db.commit()
return resp
@router.delete("/{project_id}/members/{user_id}", status_code=204)
async def remove_member(
project_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Remove a member. Owner or self (leave project)."""
perm = await get_project_permission(db, project_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
# Only owner can remove others; anyone can remove themselves
if user_id != current_user.id and perm != "owner":
raise HTTPException(status_code=403, detail="Only the project owner can remove members")
result = await db.execute(
select(ProjectMember).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == user_id,
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="Member not found")
# Remove task assignments for this user in this project
await db.execute(
sa_delete(ProjectTaskAssignment).where(
ProjectTaskAssignment.user_id == user_id,
ProjectTaskAssignment.task_id.in_(
select(ProjectTask.id).where(ProjectTask.project_id == project_id)
),
)
)
await db.delete(member)
await db.commit()
return None
@router.post("/memberships/{project_id}/respond", response_model=ProjectMemberResponse)
async def respond_to_invite(
project_id: int = Path(ge=1, le=2147483647),
respond: ProjectMemberRespond = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Accept or reject a project invite."""
result = await db.execute(
select(ProjectMember)
.options(selectinload(ProjectMember.user), selectinload(ProjectMember.inviter))
.where(
ProjectMember.project_id == project_id,
ProjectMember.user_id == current_user.id,
ProjectMember.status == "pending",
)
)
member = result.scalar_one_or_none()
if not member:
raise HTTPException(status_code=404, detail="No pending invitation found")
# Extract response data before any mutations (ORM objects expire after commit)
resp = ProjectMemberResponse.model_validate(member)
resp.user_name = member.user.username
resp.inviter_name = member.inviter.username if member.inviter else None
if respond.response == "accepted":
member.status = "accepted"
member.accepted_at = datetime.now()
# Get project owner for notification
project_result = await db.execute(
select(Project.user_id, Project.name).where(Project.id == project_id)
)
project_row = project_result.one()
owner_id, project_name = project_row.tuple()
responder_name = await _get_user_name(db, current_user.id)
await create_notification(
db, owner_id, "project_invite_accepted",
f"{responder_name} joined your project",
f"{responder_name} accepted the invitation to \"{project_name}\"",
data={"project_id": project_id},
source_type="project_member",
)
resp.status = "accepted"
else:
# Rejected — delete the row to prevent accumulation (W-06)
await db.delete(member)
resp.status = "rejected"
await db.commit()
return resp
# ──────────────────────────────────────────────
# TASK ASSIGNMENT ROUTES
# ──────────────────────────────────────────────
@router.post("/{project_id}/tasks/{task_id}/assignments", response_model=List[TaskAssignmentResponse], status_code=201)
async def assign_users_to_task(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
assignment: TaskAssignmentCreate = ...,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Assign user(s) to a task. Requires create_modify on project or be owner."""
await require_project_permission(db, project_id, current_user.id, "create_modify")
# Verify task exists in project
task_result = await db.execute(
select(ProjectTask).where(
ProjectTask.id == task_id,
ProjectTask.project_id == project_id,
)
)
task = task_result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# Get project owner for connection validation
project_result = await db.execute(
select(Project.user_id, Project.name).where(Project.id == project_id)
)
project_row = project_result.one()
owner_id, project_name = project_row.tuple()
# Validate connections (all assignees must be connections of the project owner)
non_owner_ids = [uid for uid in assignment.user_ids if uid != owner_id]
if non_owner_ids:
await validate_project_connections(db, owner_id, non_owner_ids)
# Filter out existing assignments
existing_result = await db.execute(
select(ProjectTaskAssignment.user_id).where(
ProjectTaskAssignment.task_id == task_id,
ProjectTaskAssignment.user_id.in_(assignment.user_ids),
)
)
existing_user_ids = {r[0] for r in existing_result.all()}
assigner_name = await _get_user_name(db, current_user.id)
created = []
for uid in assignment.user_ids:
if uid in existing_user_ids:
continue
# Auto-membership: ensure user has ProjectMember row
if uid != owner_id:
await ensure_auto_membership(db, project_id, uid, current_user.id)
new_assignment = ProjectTaskAssignment(
task_id=task_id,
user_id=uid,
assigned_by=current_user.id,
)
db.add(new_assignment)
created.append(new_assignment)
# Notify assignee (don't notify self)
if uid != current_user.id:
await create_notification(
db, uid, "task_assigned",
f"Task assigned by {assigner_name}",
f"You've been assigned to \"{task.title}\" in \"{project_name}\"",
data={"project_id": project_id, "task_id": task_id},
source_type="task_assignment",
)
await db.flush() # Assign IDs before commit (ORM objects expire after commit)
assignment_ids = [a.id for a in created]
await db.commit()
if not created:
return []
# Re-fetch with user info
result = await db.execute(
select(ProjectTaskAssignment)
.options(selectinload(ProjectTaskAssignment.user))
.where(ProjectTaskAssignment.id.in_(assignment_ids))
)
assignments = result.scalars().all()
# Get names
user_ids = [a.user_id for a in assignments]
settings_result = await db.execute(
select(Settings.user_id, Settings.preferred_name).where(Settings.user_id.in_(user_ids))
)
name_map = {r[0]: r[1] for r in settings_result.all()}
return [
TaskAssignmentResponse(
id=a.id,
task_id=a.task_id,
user_id=a.user_id,
assigned_by=a.assigned_by,
user_name=name_map.get(a.user_id) or a.user.username,
created_at=a.created_at,
)
for a in assignments
]
@router.delete("/{project_id}/tasks/{task_id}/assignments/{user_id}", status_code=204)
async def remove_task_assignment(
project_id: int = Path(ge=1, le=2147483647),
task_id: int = Path(ge=1, le=2147483647),
user_id: int = Path(ge=1, le=2147483647),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Remove a task assignment. Owner, create_modify member, or the assignee themselves."""
perm = await get_project_permission(db, project_id, current_user.id)
if perm is None:
raise HTTPException(status_code=404, detail="Project not found")
# Self-unassign is always allowed; otherwise need create_modify or owner
if user_id != current_user.id and perm not in ("owner", "create_modify"):
raise HTTPException(status_code=403, detail="Insufficient permission")
result = await db.execute(
sa_delete(ProjectTaskAssignment)
.where(
ProjectTaskAssignment.task_id == task_id,
ProjectTaskAssignment.user_id == user_id,
)
.returning(ProjectTaskAssignment.id)
)
if not result.scalar_one_or_none():
raise HTTPException(status_code=404, detail="Assignment not found")
# Cleanup auto-membership if no more assignments
await cleanup_auto_membership(db, project_id, user_id)
await db.commit()
return None
# ──────────────────────────────────────────────
# DELTA POLLING
# ──────────────────────────────────────────────
class PollResponse(BaseModel):
has_changes: bool
project_updated_at: str | None = None
changed_task_ids: list[int] = []
@router.get("/{project_id}/poll", response_model=PollResponse)
async def poll_project(
project_id: int = Path(ge=1, le=2147483647),
since: str = Query(..., description="ISO timestamp to check for changes since"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Lightweight poll endpoint — returns changed task IDs since timestamp."""
await require_project_permission(db, project_id, current_user.id, "read_only")
try:
since_dt = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ISO timestamp")
# Check project-level update
proj_result = await db.execute(
select(Project.updated_at).where(Project.id == project_id)
)
project_updated = proj_result.scalar_one_or_none()
if not project_updated:
raise HTTPException(status_code=404, detail="Project not found")
project_changed = project_updated > since_dt
# Check task-level changes using the index
task_result = await db.execute(
select(ProjectTask.id).where(
ProjectTask.project_id == project_id,
ProjectTask.updated_at > since_dt,
)
)
changed_task_ids = [r[0] for r in task_result.all()]
has_changes = project_changed or len(changed_task_ids) > 0
return PollResponse(
has_changes=has_changes,
project_updated_at=project_updated.isoformat() if project_updated else None,
changed_task_ids=changed_task_ids,
)