"""Project Templates API endpoints. Provides CRUD operations for project templates and the ability to create projects from templates. """ import uuid from typing import List, Optional from fastapi import APIRouter, Depends, HTTPException, status, Request, Query from sqlalchemy.orm import Session from app.core.database import get_db from app.models import ( User, Space, Project, TaskStatus, CustomField, ProjectTemplate, AuditAction ) from app.schemas.project_template import ( ProjectTemplateCreate, ProjectTemplateUpdate, ProjectTemplateResponse, ProjectTemplateWithOwner, ProjectTemplateListResponse, CreateProjectFromTemplateRequest, CreateProjectFromTemplateResponse, ) from app.middleware.auth import get_current_user, check_space_access from app.middleware.audit import get_audit_metadata from app.services.audit_service import AuditService router = APIRouter(prefix="/api/templates", tags=["Project Templates"]) def can_view_template(user: User, template: ProjectTemplate) -> bool: """Check if a user can view a template.""" if template.is_public: return True if template.owner_id == user.id: return True if user.is_system_admin: return True return False def can_edit_template(user: User, template: ProjectTemplate) -> bool: """Check if a user can edit a template.""" if template.owner_id == user.id: return True if user.is_system_admin: return True return False @router.get("", response_model=ProjectTemplateListResponse) async def list_templates( include_private: bool = Query(False, description="Include user's private templates"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List available project templates. By default, only returns public templates. Set include_private=true to also include the user's private templates. """ query = db.query(ProjectTemplate).filter(ProjectTemplate.is_active == True) if include_private: # Public templates OR user's own templates query = query.filter( (ProjectTemplate.is_public == True) | (ProjectTemplate.owner_id == current_user.id) ) else: # Only public templates query = query.filter(ProjectTemplate.is_public == True) templates = query.order_by(ProjectTemplate.name).all() result = [] for template in templates: result.append(ProjectTemplateWithOwner( id=template.id, name=template.name, description=template.description, is_public=template.is_public, task_statuses=template.task_statuses, custom_fields=template.custom_fields, default_security_level=template.default_security_level, owner_id=template.owner_id, is_active=template.is_active, created_at=template.created_at, updated_at=template.updated_at, owner_name=template.owner.name if template.owner else None, )) return ProjectTemplateListResponse( templates=result, total=len(result), ) @router.post("", response_model=ProjectTemplateResponse, status_code=status.HTTP_201_CREATED) async def create_template( template_data: ProjectTemplateCreate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Create a new project template. The template can include predefined task statuses and custom fields that will be copied when creating a project from this template. """ # Convert Pydantic models to dict for JSON storage task_statuses_json = None if template_data.task_statuses: task_statuses_json = [ts.model_dump() for ts in template_data.task_statuses] custom_fields_json = None if template_data.custom_fields: custom_fields_json = [cf.model_dump() for cf in template_data.custom_fields] template = ProjectTemplate( id=str(uuid.uuid4()), name=template_data.name, description=template_data.description, owner_id=current_user.id, is_public=template_data.is_public, task_statuses=task_statuses_json, custom_fields=custom_fields_json, default_security_level=template_data.default_security_level, ) db.add(template) # Audit log AuditService.log_event( db=db, event_type="template.create", resource_type="project_template", action=AuditAction.CREATE, user_id=current_user.id, resource_id=template.id, changes=[{"field": "name", "old_value": None, "new_value": template.name}], request_metadata=get_audit_metadata(request), ) db.commit() db.refresh(template) return template @router.get("/{template_id}", response_model=ProjectTemplateWithOwner) async def get_template( template_id: str, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Get a project template by ID. """ template = db.query(ProjectTemplate).filter( ProjectTemplate.id == template_id, ProjectTemplate.is_active == True ).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found", ) if not can_view_template(current_user, template): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied", ) return ProjectTemplateWithOwner( id=template.id, name=template.name, description=template.description, is_public=template.is_public, task_statuses=template.task_statuses, custom_fields=template.custom_fields, default_security_level=template.default_security_level, owner_id=template.owner_id, is_active=template.is_active, created_at=template.created_at, updated_at=template.updated_at, owner_name=template.owner.name if template.owner else None, ) @router.patch("/{template_id}", response_model=ProjectTemplateResponse) async def update_template( template_id: str, template_data: ProjectTemplateUpdate, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Update a project template. Only the template owner or system admin can update a template. """ template = db.query(ProjectTemplate).filter( ProjectTemplate.id == template_id, ProjectTemplate.is_active == True ).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found", ) if not can_edit_template(current_user, template): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only template owner can update", ) # Capture old values for audit old_values = { "name": template.name, "description": template.description, "is_public": template.is_public, } # Update fields update_data = template_data.model_dump(exclude_unset=True) # Convert Pydantic models to dict for JSON storage if "task_statuses" in update_data and update_data["task_statuses"]: update_data["task_statuses"] = [ts.model_dump() if hasattr(ts, 'model_dump') else ts for ts in update_data["task_statuses"]] if "custom_fields" in update_data and update_data["custom_fields"]: update_data["custom_fields"] = [cf.model_dump() if hasattr(cf, 'model_dump') else cf for cf in update_data["custom_fields"]] for field, value in update_data.items(): setattr(template, field, value) # Log changes new_values = { "name": template.name, "description": template.description, "is_public": template.is_public, } changes = AuditService.detect_changes(old_values, new_values) if changes: AuditService.log_event( db=db, event_type="template.update", resource_type="project_template", action=AuditAction.UPDATE, user_id=current_user.id, resource_id=template.id, changes=changes, request_metadata=get_audit_metadata(request), ) db.commit() db.refresh(template) return template @router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_template( template_id: str, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Delete a project template (soft delete). Only the template owner or system admin can delete a template. """ template = db.query(ProjectTemplate).filter( ProjectTemplate.id == template_id, ProjectTemplate.is_active == True ).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found", ) if not can_edit_template(current_user, template): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Only template owner can delete", ) # Audit log AuditService.log_event( db=db, event_type="template.delete", resource_type="project_template", action=AuditAction.DELETE, user_id=current_user.id, resource_id=template.id, changes=[{"field": "is_active", "old_value": True, "new_value": False}], request_metadata=get_audit_metadata(request), ) # Soft delete template.is_active = False db.commit() return None @router.post("/create-project", response_model=CreateProjectFromTemplateResponse, status_code=status.HTTP_201_CREATED) async def create_project_from_template( data: CreateProjectFromTemplateRequest, request: Request, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ Create a new project from a template. This will: 1. Create a new project with the specified title and description 2. Copy all task statuses from the template 3. Copy all custom field definitions from the template """ # Get the template template = db.query(ProjectTemplate).filter( ProjectTemplate.id == data.template_id, ProjectTemplate.is_active == True ).first() if not template: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Template not found", ) if not can_view_template(current_user, template): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to template", ) # Check space access space = db.query(Space).filter( Space.id == data.space_id, Space.is_active == True ).first() if not space: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Space not found", ) if not check_space_access(current_user, space): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to space", ) # Create the project project = Project( id=str(uuid.uuid4()), space_id=data.space_id, title=data.title, description=data.description, owner_id=current_user.id, security_level=template.default_security_level or "department", department_id=data.department_id or current_user.department_id, ) db.add(project) db.flush() # Get project ID # Copy task statuses from template task_statuses_created = 0 if template.task_statuses: for status_data in template.task_statuses: task_status = TaskStatus( id=str(uuid.uuid4()), project_id=project.id, name=status_data.get("name", "Unnamed"), color=status_data.get("color", "#808080"), position=status_data.get("position", 0), is_done=status_data.get("is_done", False), ) db.add(task_status) task_statuses_created += 1 # Copy custom fields from template custom_fields_created = 0 if template.custom_fields: for field_data in template.custom_fields: custom_field = CustomField( id=str(uuid.uuid4()), project_id=project.id, name=field_data.get("name", "Unnamed"), field_type=field_data.get("field_type", "text"), options=field_data.get("options"), formula=field_data.get("formula"), is_required=field_data.get("is_required", False), position=field_data.get("position", 0), ) db.add(custom_field) custom_fields_created += 1 # Audit log AuditService.log_event( db=db, event_type="project.create_from_template", resource_type="project", action=AuditAction.CREATE, user_id=current_user.id, resource_id=project.id, changes=[ {"field": "title", "old_value": None, "new_value": project.title}, {"field": "template_id", "old_value": None, "new_value": template.id}, ], request_metadata=get_audit_metadata(request), ) db.commit() return CreateProjectFromTemplateResponse( id=project.id, title=project.title, template_id=template.id, template_name=template.name, task_statuses_created=task_statuses_created, custom_fields_created=custom_fields_created, )