diff --git a/backend/.env.example b/backend/.env.example index ddded31..32745de 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -20,3 +20,14 @@ AUTH_API_URL=https://pj-auth-api.vercel.app # System Admin SYSTEM_ADMIN_EMAIL=ymirliu@panjit.com.tw + +# File Encryption (AES-256) +# Master key for encrypting file encryption keys (optional - if not set, file encryption is disabled) +# Generate a new key with: +# python -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())" +# +# IMPORTANT: +# - Keep this key secure and back it up! If lost, encrypted files cannot be decrypted. +# - Store backup in a secure location separate from the database backup. +# - Do NOT change this key after files have been encrypted (use key rotation instead). +ENCRYPTION_MASTER_KEY= diff --git a/backend/app/api/admin/__init__.py b/backend/app/api/admin/__init__.py new file mode 100644 index 0000000..480ee05 --- /dev/null +++ b/backend/app/api/admin/__init__.py @@ -0,0 +1 @@ +# Admin API module diff --git a/backend/app/api/admin/encryption_keys.py b/backend/app/api/admin/encryption_keys.py new file mode 100644 index 0000000..deb4720 --- /dev/null +++ b/backend/app/api/admin/encryption_keys.py @@ -0,0 +1,299 @@ +""" +Encryption Key Management API (Admin only). + +Provides endpoints for: +- Listing encryption keys (without actual key data) +- Creating new encryption keys +- Key rotation +- Checking encryption status +""" +import uuid +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Request +from sqlalchemy.orm import Session +from typing import Optional + +from app.core.database import get_db +from app.middleware.auth import get_current_user, require_system_admin +from app.models import User, EncryptionKey, AuditAction +from app.schemas.encryption_key import ( + EncryptionKeyResponse, + EncryptionKeyListResponse, + EncryptionKeyCreateResponse, + EncryptionKeyRotateResponse, + EncryptionStatusResponse, +) +from app.services.encryption_service import ( + encryption_service, + MasterKeyNotConfiguredError, +) +from app.services.audit_service import AuditService + +router = APIRouter(prefix="/api/admin/encryption-keys", tags=["Admin - Encryption Keys"]) + + +def key_to_response(key: EncryptionKey) -> EncryptionKeyResponse: + """Convert EncryptionKey model to response (without key data).""" + return EncryptionKeyResponse( + id=key.id, + algorithm=key.algorithm, + is_active=key.is_active, + created_at=key.created_at, + rotated_at=key.rotated_at, + ) + + +@router.get("/status", response_model=EncryptionStatusResponse) +async def get_encryption_status( + db: Session = Depends(get_db), + current_user: User = Depends(require_system_admin), +): + """ + Get the current encryption status. + + Returns whether encryption is available, active key info, and total key count. + """ + encryption_available = encryption_service.is_encryption_available() + + active_key = None + total_keys = 0 + + if encryption_available: + active_key = db.query(EncryptionKey).filter( + EncryptionKey.is_active == True + ).first() + total_keys = db.query(EncryptionKey).count() + + message = "Encryption is available" if encryption_available else "Encryption is not configured (ENCRYPTION_MASTER_KEY not set)" + + return EncryptionStatusResponse( + encryption_available=encryption_available, + active_key_id=active_key.id if active_key else None, + total_keys=total_keys, + message=message, + ) + + +@router.get("", response_model=EncryptionKeyListResponse) +async def list_encryption_keys( + db: Session = Depends(get_db), + current_user: User = Depends(require_system_admin), +): + """ + List all encryption keys (without actual key data). + + Only accessible by system administrators. + """ + keys = db.query(EncryptionKey).order_by(EncryptionKey.created_at.desc()).all() + + return EncryptionKeyListResponse( + keys=[key_to_response(k) for k in keys], + total=len(keys), + ) + + +@router.post("", response_model=EncryptionKeyCreateResponse) +async def create_encryption_key( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_system_admin), +): + """ + Create a new encryption key. + + The key is generated, encrypted with the Master Key, and stored. + This does NOT automatically make it the active key - use rotate for that. + """ + if not encryption_service.is_encryption_available(): + raise HTTPException( + status_code=400, + detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment." + ) + + try: + # Generate new key + raw_key = encryption_service.generate_key() + + # Encrypt with master key + encrypted_key = encryption_service.encrypt_key(raw_key) + + # Create key record (not active by default) + key = EncryptionKey( + id=str(uuid.uuid4()), + key_data=encrypted_key, + algorithm="AES-256-GCM", + is_active=False, + ) + db.add(key) + + # Audit log + AuditService.log_event( + db=db, + event_type="encryption.key_create", + resource_type="encryption_key", + action=AuditAction.CREATE, + user_id=current_user.id, + resource_id=key.id, + changes=[{"field": "algorithm", "old_value": None, "new_value": "AES-256-GCM"}], + request_metadata=getattr(request.state, "audit_metadata", None), + ) + + db.commit() + db.refresh(key) + + return EncryptionKeyCreateResponse( + id=key.id, + algorithm=key.algorithm, + is_active=key.is_active, + created_at=key.created_at, + message="Encryption key created successfully. Use /rotate to make it active.", + ) + + except MasterKeyNotConfiguredError: + raise HTTPException( + status_code=400, + detail="Master key is not configured" + ) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, + detail=f"Failed to create encryption key: {str(e)}" + ) + + +@router.post("/rotate", response_model=EncryptionKeyRotateResponse) +async def rotate_encryption_key( + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_system_admin), +): + """ + Rotate to a new encryption key. + + This will: + 1. Create a new encryption key + 2. Mark the new key as active + 3. Mark the old active key as inactive (but keep it for decrypting old files) + + After rotation, new file uploads will use the new key. + Old files remain readable using their original keys. + """ + if not encryption_service.is_encryption_available(): + raise HTTPException( + status_code=400, + detail="Encryption is not configured. Set ENCRYPTION_MASTER_KEY in environment." + ) + + try: + # Find current active key + old_active_key = db.query(EncryptionKey).filter( + EncryptionKey.is_active == True + ).first() + + # Generate new key + raw_key = encryption_service.generate_key() + encrypted_key = encryption_service.encrypt_key(raw_key) + + # Create new key as active + new_key = EncryptionKey( + id=str(uuid.uuid4()), + key_data=encrypted_key, + algorithm="AES-256-GCM", + is_active=True, + ) + db.add(new_key) + + # Deactivate old key if exists + old_key_id = None + if old_active_key: + old_active_key.is_active = False + old_active_key.rotated_at = datetime.utcnow() + old_key_id = old_active_key.id + + # Audit log for rotation + AuditService.log_event( + db=db, + event_type="encryption.key_rotate", + resource_type="encryption_key", + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_id=new_key.id, + changes=[ + {"field": "rotation", "old_value": old_key_id, "new_value": new_key.id}, + {"field": "is_active", "old_value": False, "new_value": True}, + ], + request_metadata=getattr(request.state, "audit_metadata", None), + ) + + db.commit() + + return EncryptionKeyRotateResponse( + new_key_id=new_key.id, + old_key_id=old_key_id, + message="Key rotation completed successfully. New uploads will use the new key.", + ) + + except MasterKeyNotConfiguredError: + raise HTTPException( + status_code=400, + detail="Master key is not configured" + ) + except Exception as e: + db.rollback() + raise HTTPException( + status_code=500, + detail=f"Failed to rotate encryption key: {str(e)}" + ) + + +@router.delete("/{key_id}") +async def deactivate_encryption_key( + key_id: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(require_system_admin), +): + """ + Deactivate an encryption key. + + Note: This does NOT delete the key. Keys are never deleted to ensure + encrypted files can always be decrypted. + + If this is the only active key, you must rotate to a new key first. + """ + key = db.query(EncryptionKey).filter(EncryptionKey.id == key_id).first() + if not key: + raise HTTPException(status_code=404, detail="Encryption key not found") + + if key.is_active: + # Check if there are other active keys + other_active = db.query(EncryptionKey).filter( + EncryptionKey.is_active == True, + EncryptionKey.id != key_id + ).first() + + if not other_active: + raise HTTPException( + status_code=400, + detail="Cannot deactivate the only active key. Rotate to a new key first." + ) + + key.is_active = False + key.rotated_at = datetime.utcnow() + + # Audit log + AuditService.log_event( + db=db, + event_type="encryption.key_deactivate", + resource_type="encryption_key", + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_id=key.id, + changes=[{"field": "is_active", "old_value": True, "new_value": False}], + request_metadata=getattr(request.state, "audit_metadata", None), + ) + + db.commit() + + return {"detail": "Encryption key deactivated", "id": key_id} diff --git a/backend/app/api/attachments/router.py b/backend/app/api/attachments/router.py index b4d0856..f0766dd 100644 --- a/backend/app/api/attachments/router.py +++ b/backend/app/api/attachments/router.py @@ -1,5 +1,7 @@ import uuid +import logging from datetime import datetime +from io import BytesIO from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi.responses import FileResponse, Response from sqlalchemy.orm import Session @@ -7,7 +9,7 @@ from typing import Optional from app.core.database import get_db from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access -from app.models import User, Task, Project, Attachment, AttachmentVersion, AuditAction +from app.models import User, Task, Project, Attachment, AttachmentVersion, EncryptionKey, AuditAction from app.schemas.attachment import ( AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse, AttachmentVersionResponse, VersionHistoryResponse @@ -15,6 +17,13 @@ from app.schemas.attachment import ( from app.services.file_storage_service import file_storage_service from app.services.audit_service import AuditService from app.services.watermark_service import watermark_service +from app.services.encryption_service import ( + encryption_service, + MasterKeyNotConfiguredError, + DecryptionError, +) + +logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", tags=["attachments"]) @@ -103,6 +112,40 @@ def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse ) +def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[EncryptionKey]]: + """ + Determine if a file should be encrypted based on project security level. + + Returns: + Tuple of (should_encrypt, encryption_key) + """ + # Only encrypt for confidential projects + if project.security_level != "confidential": + return False, None + + # Check if encryption is available + if not encryption_service.is_encryption_available(): + logger.warning( + f"Project {project.id} is confidential but encryption is not configured. " + "Files will be stored unencrypted." + ) + return False, None + + # Get active encryption key + active_key = db.query(EncryptionKey).filter( + EncryptionKey.is_active == True + ).first() + + if not active_key: + logger.warning( + f"Project {project.id} is confidential but no active encryption key exists. " + "Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate" + ) + return False, None + + return True, active_key + + @router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse) async def upload_attachment( task_id: str, @@ -111,10 +154,22 @@ async def upload_attachment( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Upload a file attachment to a task.""" + """ + Upload a file attachment to a task. + + For confidential projects, files are automatically encrypted using AES-256-GCM. + """ task = get_task_with_access_check(db, task_id, current_user, require_edit=True) - # Check if attachment with same filename exists (for versioning in Phase 2) + # Get project to check security level + project = db.query(Project).filter(Project.id == task.project_id).first() + if not project: + raise HTTPException(status_code=404, detail="Project not found") + + # Determine if encryption is needed + should_encrypt, encryption_key = should_encrypt_file(project, db) + + # Check if attachment with same filename exists (for versioning) existing = db.query(Attachment).filter( Attachment.task_id == task_id, Attachment.original_filename == file.filename, @@ -122,17 +177,73 @@ async def upload_attachment( ).first() if existing: - # Phase 2: Create new version + # Create new version for existing attachment new_version = existing.current_version + 1 - # Save file - file_path, file_size, checksum = await file_storage_service.save_file( - file=file, - project_id=task.project_id, - task_id=task_id, - attachment_id=existing.id, - version=new_version - ) + if should_encrypt and encryption_key: + # Read and encrypt file content + file_content = await file.read() + await file.seek(0) + + try: + # Decrypt the encryption key + raw_key = encryption_service.decrypt_key(encryption_key.key_data) + # Encrypt the file + encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key) + + # Create a new UploadFile-like object with encrypted content + encrypted_file = BytesIO(encrypted_content) + encrypted_file.seek(0) + + # Save encrypted file using a modified approach + file_path, _, checksum = await file_storage_service.save_file( + file=file, + project_id=task.project_id, + task_id=task_id, + attachment_id=existing.id, + version=new_version + ) + + # Overwrite with encrypted content + with open(file_path, "wb") as f: + f.write(encrypted_content) + + file_size = len(encrypted_content) + + # Update existing attachment with encryption info + existing.is_encrypted = True + existing.encryption_key_id = encryption_key.id + + # Audit log for encryption + AuditService.log_event( + db=db, + event_type="attachment.encrypt", + resource_type="attachment", + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_id=existing.id, + changes=[ + {"field": "is_encrypted", "old_value": False, "new_value": True}, + {"field": "encryption_key_id", "old_value": None, "new_value": encryption_key.id}, + ], + request_metadata=getattr(request.state, "audit_metadata", None), + ) + + except Exception as e: + logger.error(f"Failed to encrypt file for attachment {existing.id}: {e}") + raise HTTPException( + status_code=500, + detail="Failed to encrypt file. Please try again." + ) + else: + # Save file without encryption + file_path, file_size, checksum = await file_storage_service.save_file( + file=file, + project_id=task.project_id, + task_id=task_id, + attachment_id=existing.id, + version=new_version + ) # Create version record version = AttachmentVersion( @@ -170,15 +281,52 @@ async def upload_attachment( # Create new attachment attachment_id = str(uuid.uuid4()) + is_encrypted = False + encryption_key_id = None - # Save file - file_path, file_size, checksum = await file_storage_service.save_file( - file=file, - project_id=task.project_id, - task_id=task_id, - attachment_id=attachment_id, - version=1 - ) + if should_encrypt and encryption_key: + # Read and encrypt file content + file_content = await file.read() + await file.seek(0) + + try: + # Decrypt the encryption key + raw_key = encryption_service.decrypt_key(encryption_key.key_data) + # Encrypt the file + encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key) + + # Save file first to get path + file_path, _, checksum = await file_storage_service.save_file( + file=file, + project_id=task.project_id, + task_id=task_id, + attachment_id=attachment_id, + version=1 + ) + + # Overwrite with encrypted content + with open(file_path, "wb") as f: + f.write(encrypted_content) + + file_size = len(encrypted_content) + is_encrypted = True + encryption_key_id = encryption_key.id + + except Exception as e: + logger.error(f"Failed to encrypt new file: {e}") + raise HTTPException( + status_code=500, + detail="Failed to encrypt file. Please try again." + ) + else: + # Save file without encryption + file_path, file_size, checksum = await file_storage_service.save_file( + file=file, + project_id=task.project_id, + task_id=task_id, + attachment_id=attachment_id, + version=1 + ) # Get mime type from file storage validation extension = file_storage_service.get_extension(file.filename or "") @@ -193,7 +341,8 @@ async def upload_attachment( mime_type=mime_type, file_size=file_size, current_version=1, - is_encrypted=False, + is_encrypted=is_encrypted, + encryption_key_id=encryption_key_id, uploaded_by=current_user.id ) db.add(attachment) @@ -211,6 +360,10 @@ async def upload_attachment( db.add(version) # Audit log + changes = [{"field": "filename", "old_value": None, "new_value": attachment.filename}] + if is_encrypted: + changes.append({"field": "is_encrypted", "old_value": None, "new_value": True}) + AuditService.log_event( db=db, event_type="attachment.upload", @@ -218,7 +371,7 @@ async def upload_attachment( action=AuditAction.CREATE, user_id=current_user.id, resource_id=attachment.id, - changes=[{"field": "filename", "old_value": None, "new_value": attachment.filename}], + changes=changes, request_metadata=getattr(request.state, "audit_metadata", None) ) @@ -286,7 +439,11 @@ async def download_attachment( db: Session = Depends(get_db), current_user: User = Depends(get_current_user) ): - """Download an attachment file with dynamic watermark.""" + """ + Download an attachment file with dynamic watermark. + + For encrypted files, the file is automatically decrypted before returning. + """ attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False) # Get version to download @@ -319,14 +476,69 @@ async def download_attachment( ) db.commit() + # Read file content + with open(file_path, "rb") as f: + file_bytes = f.read() + + # Decrypt if encrypted + if attachment.is_encrypted: + if not attachment.encryption_key_id: + raise HTTPException( + status_code=500, + detail="Encrypted file is missing encryption key reference" + ) + + encryption_key = db.query(EncryptionKey).filter( + EncryptionKey.id == attachment.encryption_key_id + ).first() + + if not encryption_key: + raise HTTPException( + status_code=500, + detail="Encryption key not found for this file" + ) + + try: + # Decrypt the encryption key + raw_key = encryption_service.decrypt_key(encryption_key.key_data) + # Decrypt the file + file_bytes = encryption_service.decrypt_bytes(file_bytes, raw_key) + + # Audit log for decryption + AuditService.log_event( + db=db, + event_type="attachment.decrypt", + resource_type="attachment", + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_id=attachment.id, + changes=[{"field": "decrypted_for_download", "old_value": None, "new_value": True}], + request_metadata=getattr(request.state, "audit_metadata", None) if request else None, + ) + db.commit() + + except DecryptionError as e: + logger.error(f"Failed to decrypt attachment {attachment_id}: {e}") + raise HTTPException( + status_code=500, + detail="Failed to decrypt file. The file may be corrupted." + ) + except MasterKeyNotConfiguredError: + raise HTTPException( + status_code=500, + detail="Encryption is not configured. Cannot decrypt file." + ) + except Exception as e: + logger.error(f"Unexpected error decrypting attachment {attachment_id}: {e}") + raise HTTPException( + status_code=500, + detail="Failed to decrypt file." + ) + # Check if watermark should be applied mime_type = attachment.mime_type or "" if watermark_service.supports_watermark(mime_type): try: - # Read the original file - with open(file_path, "rb") as f: - file_bytes = f.read() - # Apply watermark based on file type if watermark_service.is_supported_image(mime_type): watermarked_bytes, output_format = watermark_service.add_image_watermark( @@ -367,19 +579,19 @@ async def download_attachment( ) except Exception as e: - # If watermarking fails, log the error but still return the original file - # This ensures users can still download files even if watermarking has issues - import logging - logging.getLogger(__name__).warning( + # If watermarking fails, log the error but still return the file + logger.warning( f"Watermarking failed for attachment {attachment_id}: {str(e)}. " - "Returning original file." + "Returning file without watermark." ) - # Return original file without watermark for unsupported types or on error - return FileResponse( - path=str(file_path), - filename=attachment.original_filename, - media_type=attachment.mime_type + # Return file (decrypted if needed, without watermark for unsupported types) + return Response( + content=file_bytes, + media_type=attachment.mime_type, + headers={ + "Content-Disposition": f'attachment; filename="{attachment.original_filename}"' + } ) diff --git a/backend/app/api/custom_fields/__init__.py b/backend/app/api/custom_fields/__init__.py new file mode 100644 index 0000000..6d9a82c --- /dev/null +++ b/backend/app/api/custom_fields/__init__.py @@ -0,0 +1,3 @@ +from app.api.custom_fields.router import router + +__all__ = ["router"] diff --git a/backend/app/api/custom_fields/router.py b/backend/app/api/custom_fields/router.py new file mode 100644 index 0000000..7f27ab8 --- /dev/null +++ b/backend/app/api/custom_fields/router.py @@ -0,0 +1,368 @@ +import uuid +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from typing import Optional + +from app.core.database import get_db +from app.models import User, Project, CustomField, TaskCustomValue +from app.schemas.custom_field import ( + CustomFieldCreate, CustomFieldUpdate, CustomFieldResponse, CustomFieldListResponse +) +from app.middleware.auth import get_current_user, check_project_access, check_project_edit_access +from app.services.formula_service import FormulaService + +router = APIRouter(tags=["custom-fields"]) + +# Maximum custom fields per project +MAX_CUSTOM_FIELDS_PER_PROJECT = 20 + + +def custom_field_to_response(field: CustomField) -> CustomFieldResponse: + """Convert CustomField model to response schema.""" + return CustomFieldResponse( + id=field.id, + project_id=field.project_id, + name=field.name, + field_type=field.field_type, + options=field.options, + formula=field.formula, + is_required=field.is_required, + position=field.position, + created_at=field.created_at, + updated_at=field.updated_at, + ) + + +@router.post("/api/projects/{project_id}/custom-fields", response_model=CustomFieldResponse, status_code=status.HTTP_201_CREATED) +async def create_custom_field( + project_id: str, + field_data: CustomFieldCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Create a new custom field for a project. + + Only project owner or system admin can create custom fields. + Maximum 20 custom fields per project. + """ + project = db.query(Project).filter(Project.id == project_id).first() + + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + if not check_project_edit_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied - only project owner can manage custom fields", + ) + + # Check custom field count limit + field_count = db.query(CustomField).filter( + CustomField.project_id == project_id + ).count() + + if field_count >= MAX_CUSTOM_FIELDS_PER_PROJECT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Maximum {MAX_CUSTOM_FIELDS_PER_PROJECT} custom fields per project exceeded", + ) + + # Check for duplicate name + existing = db.query(CustomField).filter( + CustomField.project_id == project_id, + CustomField.name == field_data.name, + ).first() + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Custom field with name '{field_data.name}' already exists", + ) + + # Validate formula if it's a formula field + if field_data.field_type.value == "formula": + if not field_data.formula: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Formula is required for formula fields", + ) + + is_valid, error_msg = FormulaService.validate_formula( + field_data.formula, project_id, db + ) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_msg, + ) + + # Get next position + max_pos = db.query(CustomField).filter( + CustomField.project_id == project_id + ).order_by(CustomField.position.desc()).first() + next_position = (max_pos.position + 1) if max_pos else 0 + + # Create the custom field + field = CustomField( + id=str(uuid.uuid4()), + project_id=project_id, + name=field_data.name, + field_type=field_data.field_type.value, + options=field_data.options, + formula=field_data.formula, + is_required=field_data.is_required, + position=next_position, + ) + + db.add(field) + db.commit() + db.refresh(field) + + return custom_field_to_response(field) + + +@router.get("/api/projects/{project_id}/custom-fields", response_model=CustomFieldListResponse) +async def list_custom_fields( + project_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + List all custom fields for a project. + """ + project = db.query(Project).filter(Project.id == project_id).first() + + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found", + ) + + if not check_project_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + fields = db.query(CustomField).filter( + CustomField.project_id == project_id + ).order_by(CustomField.position).all() + + return CustomFieldListResponse( + fields=[custom_field_to_response(f) for f in fields], + total=len(fields), + ) + + +@router.get("/api/custom-fields/{field_id}", response_model=CustomFieldResponse) +async def get_custom_field( + field_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Get a specific custom field by ID. + """ + field = db.query(CustomField).filter(CustomField.id == field_id).first() + + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found", + ) + + project = field.project + if not check_project_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied", + ) + + return custom_field_to_response(field) + + +@router.put("/api/custom-fields/{field_id}", response_model=CustomFieldResponse) +async def update_custom_field( + field_id: str, + field_data: CustomFieldUpdate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Update a custom field. + + Only project owner or system admin can update custom fields. + Note: field_type cannot be changed after creation. + """ + field = db.query(CustomField).filter(CustomField.id == field_id).first() + + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found", + ) + + project = field.project + if not check_project_edit_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + # Check for duplicate name if name is being updated + if field_data.name is not None and field_data.name != field.name: + existing = db.query(CustomField).filter( + CustomField.project_id == field.project_id, + CustomField.name == field_data.name, + CustomField.id != field_id, + ).first() + + if existing: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Custom field with name '{field_data.name}' already exists", + ) + + # Validate formula if updating formula field + if field.field_type == "formula" and field_data.formula is not None: + is_valid, error_msg = FormulaService.validate_formula( + field_data.formula, field.project_id, db, field_id + ) + if not is_valid: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=error_msg, + ) + + # Validate options if updating dropdown field + if field.field_type == "dropdown" and field_data.options is not None: + if len(field_data.options) == 0: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Dropdown fields must have at least one option", + ) + if len(field_data.options) > 50: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Dropdown fields can have at most 50 options", + ) + + # Update fields + if field_data.name is not None: + field.name = field_data.name + if field_data.options is not None and field.field_type == "dropdown": + field.options = field_data.options + if field_data.formula is not None and field.field_type == "formula": + field.formula = field_data.formula + if field_data.is_required is not None: + field.is_required = field_data.is_required + + db.commit() + db.refresh(field) + + return custom_field_to_response(field) + + +@router.delete("/api/custom-fields/{field_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_custom_field( + field_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Delete a custom field. + + Only project owner or system admin can delete custom fields. + This will also delete all stored values for this field. + """ + field = db.query(CustomField).filter(CustomField.id == field_id).first() + + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found", + ) + + project = field.project + if not check_project_edit_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + # Check if any formula fields reference this field + if field.field_type != "formula": + formula_fields = db.query(CustomField).filter( + CustomField.project_id == field.project_id, + CustomField.field_type == "formula", + ).all() + + for formula_field in formula_fields: + if formula_field.formula: + references = FormulaService.extract_field_references(formula_field.formula) + if field.name in references: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Cannot delete: field is referenced by formula field '{formula_field.name}'", + ) + + # Delete the field (cascade will delete associated values) + db.delete(field) + db.commit() + + +@router.patch("/api/custom-fields/{field_id}/position", response_model=CustomFieldResponse) +async def update_custom_field_position( + field_id: str, + position: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Update a custom field's position (for reordering). + """ + field = db.query(CustomField).filter(CustomField.id == field_id).first() + + if not field: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Custom field not found", + ) + + project = field.project + if not check_project_edit_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied", + ) + + old_position = field.position + + if position == old_position: + return custom_field_to_response(field) + + # Reorder other fields + if position > old_position: + # Moving down: shift fields between old and new position up + db.query(CustomField).filter( + CustomField.project_id == field.project_id, + CustomField.position > old_position, + CustomField.position <= position, + ).update({CustomField.position: CustomField.position - 1}) + else: + # Moving up: shift fields between new and old position down + db.query(CustomField).filter( + CustomField.project_id == field.project_id, + CustomField.position >= position, + CustomField.position < old_position, + ).update({CustomField.position: CustomField.position + 1}) + + field.position = position + db.commit() + db.refresh(field) + + return custom_field_to_response(field) diff --git a/backend/app/api/task_dependencies/__init__.py b/backend/app/api/task_dependencies/__init__.py new file mode 100644 index 0000000..2a31f28 --- /dev/null +++ b/backend/app/api/task_dependencies/__init__.py @@ -0,0 +1,3 @@ +from app.api.task_dependencies.router import router + +__all__ = ["router"] diff --git a/backend/app/api/task_dependencies/router.py b/backend/app/api/task_dependencies/router.py new file mode 100644 index 0000000..911531d --- /dev/null +++ b/backend/app/api/task_dependencies/router.py @@ -0,0 +1,431 @@ +""" +Task Dependencies API Router + +Provides CRUD operations for task dependencies used in Gantt view. +Includes circular dependency detection and date constraint validation. +""" +import uuid +from fastapi import APIRouter, Depends, HTTPException, status, Request +from sqlalchemy.orm import Session +from typing import Optional + +from app.core.database import get_db +from app.models import User, Task, TaskDependency, AuditAction +from app.schemas.task_dependency import ( + TaskDependencyCreate, + TaskDependencyUpdate, + TaskDependencyResponse, + TaskDependencyListResponse, + TaskInfo +) +from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access +from app.middleware.audit import get_audit_metadata +from app.services.audit_service import AuditService +from app.services.dependency_service import DependencyService, DependencyValidationError + +router = APIRouter(tags=["task-dependencies"]) + + +def dependency_to_response( + dep: TaskDependency, + include_tasks: bool = True +) -> TaskDependencyResponse: + """Convert TaskDependency model to response schema.""" + predecessor_info = None + successor_info = None + + if include_tasks: + if dep.predecessor: + predecessor_info = TaskInfo( + id=dep.predecessor.id, + title=dep.predecessor.title, + start_date=dep.predecessor.start_date, + due_date=dep.predecessor.due_date + ) + if dep.successor: + successor_info = TaskInfo( + id=dep.successor.id, + title=dep.successor.title, + start_date=dep.successor.start_date, + due_date=dep.successor.due_date + ) + + return TaskDependencyResponse( + id=dep.id, + predecessor_id=dep.predecessor_id, + successor_id=dep.successor_id, + dependency_type=dep.dependency_type, + lag_days=dep.lag_days, + created_at=dep.created_at, + predecessor=predecessor_info, + successor=successor_info + ) + + +@router.post( + "/api/tasks/{task_id}/dependencies", + response_model=TaskDependencyResponse, + status_code=status.HTTP_201_CREATED +) +async def create_dependency( + task_id: str, + dependency_data: TaskDependencyCreate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Add a dependency to a task (the task becomes the successor). + + The predecessor_id in the request body specifies which task must complete first. + The task_id in the URL becomes the successor (depends on the predecessor). + + Validates: + - Both tasks exist and are in the same project + - No self-reference + - No duplicate dependency + - No circular dependency + - Dependency limit not exceeded + """ + # Get the successor task (from URL) + successor = db.query(Task).filter(Task.id == task_id).first() + if not successor: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + # Check edit permission on successor + if not check_task_edit_access(current_user, successor, successor.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied" + ) + + # Validate the dependency + try: + DependencyService.validate_dependency( + db, + predecessor_id=dependency_data.predecessor_id, + successor_id=task_id + ) + except DependencyValidationError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error_type": e.error_type, + "message": e.message, + "details": e.details + } + ) + + # Create the dependency + dependency = TaskDependency( + id=str(uuid.uuid4()), + predecessor_id=dependency_data.predecessor_id, + successor_id=task_id, + dependency_type=dependency_data.dependency_type.value, + lag_days=dependency_data.lag_days + ) + + db.add(dependency) + + # Audit log + AuditService.log_event( + db=db, + event_type="task.dependency.create", + resource_type="task_dependency", + action=AuditAction.CREATE, + user_id=current_user.id, + resource_id=dependency.id, + changes=[{ + "field": "dependency", + "old_value": None, + "new_value": { + "predecessor_id": dependency.predecessor_id, + "successor_id": dependency.successor_id, + "dependency_type": dependency.dependency_type, + "lag_days": dependency.lag_days + } + }], + request_metadata=get_audit_metadata(request) + ) + + db.commit() + db.refresh(dependency) + + return dependency_to_response(dependency) + + +@router.get( + "/api/tasks/{task_id}/dependencies", + response_model=TaskDependencyListResponse +) +async def list_task_dependencies( + task_id: str, + direction: Optional[str] = None, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Get all dependencies for a task. + + Args: + task_id: The task to get dependencies for + direction: Optional filter + - 'predecessors': Only get tasks this task depends on + - 'successors': Only get tasks that depend on this task + - None: Get both + + Returns all dependencies where the task is either the predecessor or successor. + """ + task = db.query(Task).filter(Task.id == task_id).first() + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Task not found" + ) + + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + dependencies = [] + + if direction is None or direction == "predecessors": + # Get dependencies where this task is the successor (predecessors) + predecessor_deps = db.query(TaskDependency).filter( + TaskDependency.successor_id == task_id + ).all() + dependencies.extend(predecessor_deps) + + if direction is None or direction == "successors": + # Get dependencies where this task is the predecessor (successors) + successor_deps = db.query(TaskDependency).filter( + TaskDependency.predecessor_id == task_id + ).all() + # Avoid duplicates if direction is None + if direction is None: + for dep in successor_deps: + if dep not in dependencies: + dependencies.append(dep) + else: + dependencies.extend(successor_deps) + + return TaskDependencyListResponse( + dependencies=[dependency_to_response(d) for d in dependencies], + total=len(dependencies) + ) + + +@router.get( + "/api/task-dependencies/{dependency_id}", + response_model=TaskDependencyResponse +) +async def get_dependency( + dependency_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Get a specific dependency by ID.""" + dependency = db.query(TaskDependency).filter( + TaskDependency.id == dependency_id + ).first() + + if not dependency: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Dependency not found" + ) + + # Check access via the successor task + task = dependency.successor + if not check_task_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + return dependency_to_response(dependency) + + +@router.patch( + "/api/task-dependencies/{dependency_id}", + response_model=TaskDependencyResponse +) +async def update_dependency( + dependency_id: str, + update_data: TaskDependencyUpdate, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Update a dependency's type or lag days. + + Cannot change predecessor_id or successor_id - delete and recreate instead. + """ + dependency = db.query(TaskDependency).filter( + TaskDependency.id == dependency_id + ).first() + + if not dependency: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Dependency not found" + ) + + # Check edit permission via the successor task + task = dependency.successor + if not check_task_edit_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied" + ) + + # Track changes for audit + old_values = { + "dependency_type": dependency.dependency_type, + "lag_days": dependency.lag_days + } + + # Update fields + if update_data.dependency_type is not None: + dependency.dependency_type = update_data.dependency_type.value + + if update_data.lag_days is not None: + dependency.lag_days = update_data.lag_days + + new_values = { + "dependency_type": dependency.dependency_type, + "lag_days": dependency.lag_days + } + + # Audit log + changes = AuditService.detect_changes(old_values, new_values) + if changes: + AuditService.log_event( + db=db, + event_type="task.dependency.update", + resource_type="task_dependency", + action=AuditAction.UPDATE, + user_id=current_user.id, + resource_id=dependency.id, + changes=changes, + request_metadata=get_audit_metadata(request) + ) + + db.commit() + db.refresh(dependency) + + return dependency_to_response(dependency) + + +@router.delete( + "/api/task-dependencies/{dependency_id}", + status_code=status.HTTP_204_NO_CONTENT +) +async def delete_dependency( + dependency_id: str, + request: Request, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete a dependency.""" + dependency = db.query(TaskDependency).filter( + TaskDependency.id == dependency_id + ).first() + + if not dependency: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Dependency not found" + ) + + # Check edit permission via the successor task + task = dependency.successor + if not check_task_edit_access(current_user, task, task.project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Permission denied" + ) + + # Audit log + AuditService.log_event( + db=db, + event_type="task.dependency.delete", + resource_type="task_dependency", + action=AuditAction.DELETE, + user_id=current_user.id, + resource_id=dependency.id, + changes=[{ + "field": "dependency", + "old_value": { + "predecessor_id": dependency.predecessor_id, + "successor_id": dependency.successor_id, + "dependency_type": dependency.dependency_type, + "lag_days": dependency.lag_days + }, + "new_value": None + }], + request_metadata=get_audit_metadata(request) + ) + + db.delete(dependency) + db.commit() + + return None + + +@router.get( + "/api/projects/{project_id}/dependencies", + response_model=TaskDependencyListResponse +) +async def list_project_dependencies( + project_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + Get all dependencies for a project. + + Useful for rendering the full Gantt chart with all dependency arrows. + """ + from app.models import Project + + project = db.query(Project).filter(Project.id == project_id).first() + if not project: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Project not found" + ) + + from app.middleware.auth import check_project_access + if not check_project_access(current_user, project): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied" + ) + + # Get all dependencies for tasks in this project (exclude soft-deleted tasks) + # Create aliases for joining both predecessor and successor + from sqlalchemy.orm import aliased + Successor = aliased(Task) + Predecessor = aliased(Task) + + dependencies = db.query(TaskDependency).join( + Successor, TaskDependency.successor_id == Successor.id + ).join( + Predecessor, TaskDependency.predecessor_id == Predecessor.id + ).filter( + Successor.project_id == project_id, + Successor.is_deleted == False, + Predecessor.is_deleted == False + ).all() + + return TaskDependencyListResponse( + dependencies=[dependency_to_response(d) for d in dependencies], + total=len(dependencies) + ) diff --git a/backend/app/api/tasks/router.py b/backend/app/api/tasks/router.py index 8a9abf5..2d7a7c4 100644 --- a/backend/app/api/tasks/router.py +++ b/backend/app/api/tasks/router.py @@ -10,7 +10,7 @@ from app.core.redis_pubsub import publish_task_event from app.models import User, Project, Task, TaskStatus, AuditAction, Blocker from app.schemas.task import ( TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse, - TaskStatusUpdate, TaskAssignUpdate + TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse ) from app.middleware.auth import ( get_current_user, check_project_access, check_task_access, check_task_edit_access @@ -19,6 +19,8 @@ from app.middleware.audit import get_audit_metadata from app.services.audit_service import AuditService from app.services.trigger_service import TriggerService from app.services.workload_cache import invalidate_user_workload_cache +from app.services.custom_value_service import CustomValueService +from app.services.dependency_service import DependencyService logger = logging.getLogger(__name__) @@ -40,13 +42,18 @@ def get_task_depth(db: Session, task: Task) -> int: return depth -def task_to_response(task: Task) -> TaskWithDetails: +def task_to_response(task: Task, db: Session = None, include_custom_values: bool = False) -> TaskWithDetails: """Convert a Task model to TaskWithDetails response.""" # Count only non-deleted subtasks subtask_count = 0 if task.subtasks: subtask_count = sum(1 for st in task.subtasks if not st.is_deleted) + # Get custom values if requested + custom_values = None + if include_custom_values and db: + custom_values = CustomValueService.get_custom_values_for_task(db, task) + return TaskWithDetails( id=task.id, project_id=task.project_id, @@ -56,6 +63,7 @@ def task_to_response(task: Task) -> TaskWithDetails: priority=task.priority, original_estimate=task.original_estimate, time_spent=task.time_spent, + start_date=task.start_date, due_date=task.due_date, assignee_id=task.assignee_id, status_id=task.status_id, @@ -69,6 +77,7 @@ def task_to_response(task: Task) -> TaskWithDetails: status_color=task.status.color if task.status else None, creator_name=task.creator.name if task.creator else None, subtask_count=subtask_count, + custom_values=custom_values, ) @@ -78,12 +87,24 @@ async def list_tasks( parent_task_id: Optional[str] = Query(None, description="Filter by parent task"), status_id: Optional[str] = Query(None, description="Filter by status"), assignee_id: Optional[str] = Query(None, description="Filter by assignee"), + due_after: Optional[datetime] = Query(None, description="Filter tasks with due_date >= this value (for calendar view)"), + due_before: Optional[datetime] = Query(None, description="Filter tasks with due_date <= this value (for calendar view)"), include_deleted: bool = Query(False, description="Include deleted tasks (admin only)"), db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """ List all tasks in a project. + + Supports filtering by: + - parent_task_id: Filter by parent task (empty string for root tasks only) + - status_id: Filter by task status + - assignee_id: Filter by assigned user + - due_after: Filter tasks with due_date >= this value (ISO 8601 datetime) + - due_before: Filter tasks with due_date <= this value (ISO 8601 datetime) + + The due_after and due_before parameters are useful for calendar view + to fetch tasks within a specific date range. """ project = db.query(Project).filter(Project.id == project_id).first() @@ -124,10 +145,17 @@ async def list_tasks( if assignee_id: query = query.filter(Task.assignee_id == assignee_id) + # Date range filter for calendar view + if due_after: + query = query.filter(Task.due_date >= due_after) + + if due_before: + query = query.filter(Task.due_date <= due_before) + tasks = query.order_by(Task.position, Task.created_at).all() return TaskListResponse( - tasks=[task_to_response(t) for t in tasks], + tasks=[task_to_response(t, db=db, include_custom_values=True) for t in tasks], total=len(tasks), ) @@ -204,6 +232,25 @@ async def create_task( ).order_by(Task.position.desc()).first() next_position = (max_pos_result.position + 1) if max_pos_result else 0 + # Validate required custom fields + if task_data.custom_values: + missing_fields = CustomValueService.validate_required_fields( + db, project_id, task_data.custom_values + ) + if missing_fields: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Missing required custom fields: {', '.join(missing_fields)}", + ) + + # Validate start_date <= due_date + if task_data.start_date and task_data.due_date: + if task_data.start_date > task_data.due_date: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Start date cannot be after due date", + ) + task = Task( id=str(uuid.uuid4()), project_id=project_id, @@ -212,6 +259,7 @@ async def create_task( description=task_data.description, priority=task_data.priority.value if task_data.priority else "medium", original_estimate=task_data.original_estimate, + start_date=task_data.start_date, due_date=task_data.due_date, assignee_id=task_data.assignee_id, status_id=task_data.status_id, @@ -220,6 +268,17 @@ async def create_task( ) db.add(task) + db.flush() # Flush to get task.id for custom values + + # Save custom values + if task_data.custom_values: + try: + CustomValueService.save_custom_values(db, task, task_data.custom_values) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) # Audit log AuditService.log_event( @@ -256,6 +315,7 @@ async def create_task( "assignee_id": str(task.assignee_id) if task.assignee_id else None, "assignee_name": task.assignee.name if task.assignee else None, "priority": task.priority, + "start_date": str(task.start_date) if task.start_date else None, "due_date": str(task.due_date) if task.due_date else None, "time_estimate": task.original_estimate, "original_estimate": task.original_estimate, @@ -303,7 +363,7 @@ async def get_task( detail="Access denied", ) - return task_to_response(task) + return task_to_response(task, db, include_custom_values=True) @router.patch("/api/tasks/{task_id}", response_model=TaskResponse) @@ -336,13 +396,42 @@ async def update_task( "title": task.title, "description": task.description, "priority": task.priority, + "start_date": task.start_date, "due_date": task.due_date, "original_estimate": task.original_estimate, "time_spent": task.time_spent, } - # Update fields + # Update fields (exclude custom_values, handle separately) update_data = task_data.model_dump(exclude_unset=True) + custom_values_data = update_data.pop("custom_values", None) + + # Get the proposed start_date and due_date for validation + new_start_date = update_data.get("start_date", task.start_date) + new_due_date = update_data.get("due_date", task.due_date) + + # Validate start_date <= due_date + if new_start_date and new_due_date: + if new_start_date > new_due_date: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Start date cannot be after due date", + ) + + # Validate date constraints against dependencies + if "start_date" in update_data or "due_date" in update_data: + violations = DependencyService.validate_date_constraints( + task, new_start_date, new_due_date, db + ) + if violations: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "message": "Date change violates dependency constraints", + "violations": violations + } + ) + for field, value in update_data.items(): if field == "priority" and value: setattr(task, field, value.value) @@ -354,6 +443,7 @@ async def update_task( "title": task.title, "description": task.description, "priority": task.priority, + "start_date": task.start_date, "due_date": task.due_date, "original_estimate": task.original_estimate, "time_spent": task.time_spent, @@ -377,6 +467,18 @@ async def update_task( if "priority" in update_data: TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user) + # Handle custom values update + if custom_values_data: + try: + from app.schemas.task import CustomValueInput + custom_values = [CustomValueInput(**cv) for cv in custom_values_data] + CustomValueService.save_custom_values(db, task, custom_values) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e), + ) + db.commit() db.refresh(task) @@ -400,6 +502,7 @@ async def update_task( "assignee_id": str(task.assignee_id) if task.assignee_id else None, "assignee_name": task.assignee.name if task.assignee else None, "priority": task.priority, + "start_date": str(task.start_date) if task.start_date else None, "due_date": str(task.due_date) if task.due_date else None, "time_estimate": task.original_estimate, "original_estimate": task.original_estimate, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 26c233e..36f432e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,6 +1,6 @@ from pydantic_settings import BaseSettings from pydantic import field_validator -from typing import List +from typing import List, Optional import os @@ -52,6 +52,35 @@ class Settings(BaseSettings): ) return v + # Encryption - Master key for encrypting file encryption keys + # Must be a 32-byte (256-bit) key encoded as base64 for AES-256 + # Generate with: python -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())" + ENCRYPTION_MASTER_KEY: Optional[str] = None + + @field_validator("ENCRYPTION_MASTER_KEY") + @classmethod + def validate_encryption_master_key(cls, v: Optional[str]) -> Optional[str]: + """Validate that ENCRYPTION_MASTER_KEY is properly formatted if set.""" + if v is None or v.strip() == "": + return None + # Basic validation - should be base64 encoded 32 bytes + import base64 + try: + decoded = base64.urlsafe_b64decode(v) + if len(decoded) != 32: + raise ValueError( + "ENCRYPTION_MASTER_KEY must be a base64-encoded 32-byte key. " + "Generate with: python -c \"import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())\"" + ) + except Exception as e: + if "must be a base64-encoded" in str(e): + raise + raise ValueError( + "ENCRYPTION_MASTER_KEY must be a valid base64-encoded string. " + f"Error: {e}" + ) + return v + # External Auth API AUTH_API_URL: str = "https://pj-auth-api.vercel.app" diff --git a/backend/app/main.py b/backend/app/main.py index 4b608bd..049dfcf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -34,6 +34,9 @@ from app.api.attachments import router as attachments_router from app.api.triggers import router as triggers_router from app.api.reports import router as reports_router from app.api.health import router as health_router +from app.api.custom_fields import router as custom_fields_router +from app.api.task_dependencies import router as task_dependencies_router +from app.api.admin import encryption_keys as admin_encryption_keys_router from app.core.config import settings app = FastAPI( @@ -76,6 +79,9 @@ app.include_router(attachments_router) app.include_router(triggers_router) app.include_router(reports_router) app.include_router(health_router) +app.include_router(custom_fields_router) +app.include_router(task_dependencies_router) +app.include_router(admin_encryption_keys_router.router) @app.get("/health") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 70afb9f..b66a76f 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -12,6 +12,7 @@ from app.models.notification import Notification from app.models.blocker import Blocker from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS from app.models.audit_alert import AuditAlert +from app.models.encryption_key import EncryptionKey from app.models.attachment import Attachment from app.models.attachment_version import AttachmentVersion from app.models.trigger import Trigger, TriggerType @@ -19,13 +20,18 @@ from app.models.trigger_log import TriggerLog, TriggerLogStatus from app.models.scheduled_report import ScheduledReport, ReportType from app.models.report_history import ReportHistory, ReportHistoryStatus from app.models.project_health import ProjectHealth, RiskLevel, ScheduleStatus, ResourceStatus +from app.models.custom_field import CustomField, FieldType +from app.models.task_custom_value import TaskCustomValue +from app.models.task_dependency import TaskDependency, DependencyType __all__ = [ "User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot", "Comment", "Mention", "Notification", "Blocker", "AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS", - "Attachment", "AttachmentVersion", + "EncryptionKey", "Attachment", "AttachmentVersion", "Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus", "ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus", - "ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus" + "ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus", + "CustomField", "FieldType", "TaskCustomValue", + "TaskDependency", "DependencyType" ] diff --git a/backend/app/models/attachment.py b/backend/app/models/attachment.py index e124f92..f39078f 100644 --- a/backend/app/models/attachment.py +++ b/backend/app/models/attachment.py @@ -16,6 +16,11 @@ class Attachment(Base): file_size = Column(BigInteger, nullable=False) current_version = Column(Integer, default=1, nullable=False) is_encrypted = Column(Boolean, default=False, nullable=False) + encryption_key_id = Column( + String(36), + ForeignKey("pjctrl_encryption_keys.id", ondelete="SET NULL"), + nullable=True + ) uploaded_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True) is_deleted = Column(Boolean, default=False, nullable=False) created_at = Column(DateTime, server_default=func.now(), nullable=False) @@ -24,6 +29,7 @@ class Attachment(Base): # Relationships task = relationship("Task", back_populates="attachments") uploader = relationship("User", foreign_keys=[uploaded_by]) + encryption_key = relationship("EncryptionKey", foreign_keys=[encryption_key_id]) versions = relationship("AttachmentVersion", back_populates="attachment", cascade="all, delete-orphan") __table_args__ = ( diff --git a/backend/app/models/custom_field.py b/backend/app/models/custom_field.py new file mode 100644 index 0000000..13282c4 --- /dev/null +++ b/backend/app/models/custom_field.py @@ -0,0 +1,37 @@ +import uuid +import enum +from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Enum, JSON, Integer +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class FieldType(str, enum.Enum): + TEXT = "text" + NUMBER = "number" + DROPDOWN = "dropdown" + DATE = "date" + PERSON = "person" + FORMULA = "formula" + + +class CustomField(Base): + __tablename__ = "pjctrl_custom_fields" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("pjctrl_projects.id", ondelete="CASCADE"), nullable=False) + name = Column(String(100), nullable=False) + field_type = Column( + Enum("text", "number", "dropdown", "date", "person", "formula", name="field_type_enum"), + nullable=False + ) + options = Column(JSON, nullable=True) # For dropdown: list of options + formula = Column(Text, nullable=True) # For formula: formula expression + is_required = Column(Boolean, default=False, nullable=False) + position = Column(Integer, default=0, nullable=False) # For ordering fields + created_at = Column(DateTime, server_default=func.now(), nullable=False) + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + # Relationships + project = relationship("Project", back_populates="custom_fields") + values = relationship("TaskCustomValue", back_populates="field", cascade="all, delete-orphan") diff --git a/backend/app/models/encryption_key.py b/backend/app/models/encryption_key.py new file mode 100644 index 0000000..378f9a7 --- /dev/null +++ b/backend/app/models/encryption_key.py @@ -0,0 +1,22 @@ +"""EncryptionKey model for AES-256 file encryption key management.""" +import uuid +from sqlalchemy import Column, String, Text, Boolean, DateTime +from sqlalchemy.sql import func +from app.core.database import Base + + +class EncryptionKey(Base): + """ + Encryption key storage for file encryption. + + Keys are encrypted with the Master Key before storage. + Only system admin can manage encryption keys. + """ + __tablename__ = "pjctrl_encryption_keys" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + key_data = Column(Text, nullable=False) # Encrypted key using Master Key + algorithm = Column(String(20), default="AES-256-GCM", nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + rotated_at = Column(DateTime, nullable=True) # When this key was superseded diff --git a/backend/app/models/project.py b/backend/app/models/project.py index 86ca564..7df5e6c 100644 --- a/backend/app/models/project.py +++ b/backend/app/models/project.py @@ -40,3 +40,4 @@ class Project(Base): tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") triggers = relationship("Trigger", back_populates="project", cascade="all, delete-orphan") health = relationship("ProjectHealth", back_populates="project", uselist=False, cascade="all, delete-orphan") + custom_fields = relationship("CustomField", back_populates="project", cascade="all, delete-orphan") diff --git a/backend/app/models/task.py b/backend/app/models/task.py index b5e94af..05e5d71 100644 --- a/backend/app/models/task.py +++ b/backend/app/models/task.py @@ -30,6 +30,7 @@ class Task(Base): original_estimate = Column(Numeric(8, 2), nullable=True) time_spent = Column(Numeric(8, 2), default=0, nullable=False) blocker_flag = Column(Boolean, default=False, nullable=False) + start_date = Column(DateTime, nullable=True) due_date = Column(DateTime, nullable=True) position = Column(Integer, default=0, nullable=False) created_by = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False) @@ -55,3 +56,18 @@ class Task(Base): blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan") attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan") trigger_logs = relationship("TriggerLog", back_populates="task") + custom_values = relationship("TaskCustomValue", back_populates="task", cascade="all, delete-orphan") + + # Dependency relationships (for Gantt view) + predecessors = relationship( + "TaskDependency", + foreign_keys="TaskDependency.successor_id", + back_populates="successor", + cascade="all, delete-orphan" + ) + successors = relationship( + "TaskDependency", + foreign_keys="TaskDependency.predecessor_id", + back_populates="predecessor", + cascade="all, delete-orphan" + ) diff --git a/backend/app/models/task_custom_value.py b/backend/app/models/task_custom_value.py new file mode 100644 index 0000000..134a3e4 --- /dev/null +++ b/backend/app/models/task_custom_value.py @@ -0,0 +1,24 @@ +import uuid +from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.core.database import Base + + +class TaskCustomValue(Base): + __tablename__ = "pjctrl_task_custom_values" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=False) + field_id = Column(String(36), ForeignKey("pjctrl_custom_fields.id", ondelete="CASCADE"), nullable=False) + value = Column(Text, nullable=True) # Stored as text, parsed based on field_type + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False) + + # Unique constraint: one value per task-field combination + __table_args__ = ( + UniqueConstraint('task_id', 'field_id', name='uq_task_field'), + ) + + # Relationships + task = relationship("Task", back_populates="custom_values") + field = relationship("CustomField", back_populates="values") diff --git a/backend/app/models/task_dependency.py b/backend/app/models/task_dependency.py new file mode 100644 index 0000000..1164220 --- /dev/null +++ b/backend/app/models/task_dependency.py @@ -0,0 +1,68 @@ +from sqlalchemy import Column, String, Integer, Enum, DateTime, ForeignKey, UniqueConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from app.core.database import Base +import enum + + +class DependencyType(str, enum.Enum): + """ + Task dependency types for Gantt chart. + + FS (Finish-to-Start): Predecessor must finish before successor starts (most common) + SS (Start-to-Start): Predecessor must start before successor starts + FF (Finish-to-Finish): Predecessor must finish before successor finishes + SF (Start-to-Finish): Predecessor must start before successor finishes (rare) + """ + FS = "FS" # Finish-to-Start + SS = "SS" # Start-to-Start + FF = "FF" # Finish-to-Finish + SF = "SF" # Start-to-Finish + + +class TaskDependency(Base): + """ + Represents a dependency relationship between two tasks. + + The predecessor task affects when the successor task can be scheduled, + based on the dependency_type. This is used for Gantt chart visualization + and date validation. + """ + __tablename__ = "pjctrl_task_dependencies" + + __table_args__ = ( + UniqueConstraint('predecessor_id', 'successor_id', name='uq_predecessor_successor'), + ) + + id = Column(String(36), primary_key=True) + predecessor_id = Column( + String(36), + ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + successor_id = Column( + String(36), + ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), + nullable=False, + index=True + ) + dependency_type = Column( + Enum("FS", "SS", "FF", "SF", name="dependency_type_enum"), + default="FS", + nullable=False + ) + lag_days = Column(Integer, default=0, nullable=False) + created_at = Column(DateTime, server_default=func.now(), nullable=False) + + # Relationships + predecessor = relationship( + "Task", + foreign_keys=[predecessor_id], + back_populates="successors" + ) + successor = relationship( + "Task", + foreign_keys=[successor_id], + back_populates="predecessors" + ) diff --git a/backend/app/schemas/custom_field.py b/backend/app/schemas/custom_field.py new file mode 100644 index 0000000..a059b57 --- /dev/null +++ b/backend/app/schemas/custom_field.py @@ -0,0 +1,88 @@ +from pydantic import BaseModel, Field, field_validator +from typing import Optional, List, Any, Dict +from datetime import datetime +from enum import Enum + + +class FieldType(str, Enum): + TEXT = "text" + NUMBER = "number" + DROPDOWN = "dropdown" + DATE = "date" + PERSON = "person" + FORMULA = "formula" + + +class CustomFieldBase(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + field_type: FieldType + options: Optional[List[str]] = None # For dropdown type + formula: Optional[str] = None # For formula type + is_required: bool = False + + @field_validator('options') + @classmethod + def validate_options(cls, v, info): + field_type = info.data.get('field_type') + if field_type == FieldType.DROPDOWN: + if not v or len(v) == 0: + raise ValueError('Dropdown fields must have at least one option') + if len(v) > 50: + raise ValueError('Dropdown fields can have at most 50 options') + return v + + @field_validator('formula') + @classmethod + def validate_formula(cls, v, info): + field_type = info.data.get('field_type') + if field_type == FieldType.FORMULA and not v: + raise ValueError('Formula fields must have a formula expression') + return v + + +class CustomFieldCreate(CustomFieldBase): + pass + + +class CustomFieldUpdate(BaseModel): + name: Optional[str] = Field(None, min_length=1, max_length=100) + options: Optional[List[str]] = None + formula: Optional[str] = None + is_required: Optional[bool] = None + + +class CustomFieldResponse(CustomFieldBase): + id: str + project_id: str + position: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CustomFieldListResponse(BaseModel): + fields: List[CustomFieldResponse] + total: int + + +# Task custom value schemas +class CustomValueInput(BaseModel): + field_id: str + value: Optional[Any] = None # Can be string, number, date string, or user id + + +class CustomValueResponse(BaseModel): + field_id: str + field_name: str + field_type: FieldType + value: Optional[Any] = None + display_value: Optional[str] = None # Formatted for display + + class Config: + from_attributes = True + + +class TaskCustomValuesUpdate(BaseModel): + custom_values: List[CustomValueInput] diff --git a/backend/app/schemas/encryption_key.py b/backend/app/schemas/encryption_key.py new file mode 100644 index 0000000..9e355ce --- /dev/null +++ b/backend/app/schemas/encryption_key.py @@ -0,0 +1,46 @@ +"""Schemas for encryption key API.""" +from datetime import datetime +from typing import List, Optional +from pydantic import BaseModel + + +class EncryptionKeyResponse(BaseModel): + """Response schema for encryption key (without actual key data).""" + id: str + algorithm: str + is_active: bool + created_at: datetime + rotated_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class EncryptionKeyListResponse(BaseModel): + """Response schema for list of encryption keys.""" + keys: List[EncryptionKeyResponse] + total: int + + +class EncryptionKeyCreateResponse(BaseModel): + """Response schema after creating a new encryption key.""" + id: str + algorithm: str + is_active: bool + created_at: datetime + message: str + + +class EncryptionKeyRotateResponse(BaseModel): + """Response schema after key rotation.""" + new_key_id: str + old_key_id: Optional[str] + message: str + + +class EncryptionStatusResponse(BaseModel): + """Response schema for encryption status.""" + encryption_available: bool + active_key_id: Optional[str] + total_keys: int + message: str diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py index f422037..236569a 100644 --- a/backend/app/schemas/task.py +++ b/backend/app/schemas/task.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -from typing import Optional, List +from typing import Optional, List, Any, Dict from datetime import datetime from decimal import Decimal from enum import Enum @@ -12,11 +12,27 @@ class Priority(str, Enum): URGENT = "urgent" +class CustomValueInput(BaseModel): + """Input for setting a custom field value.""" + field_id: str + value: Optional[Any] = None # Can be string, number, date string, or user id + + +class CustomValueResponse(BaseModel): + """Response for a custom field value.""" + field_id: str + field_name: str + field_type: str + value: Optional[Any] = None + display_value: Optional[str] = None # Formatted for display + + class TaskBase(BaseModel): title: str description: Optional[str] = None priority: Priority = Priority.MEDIUM original_estimate: Optional[Decimal] = None + start_date: Optional[datetime] = None due_date: Optional[datetime] = None @@ -24,6 +40,7 @@ class TaskCreate(TaskBase): parent_task_id: Optional[str] = None assignee_id: Optional[str] = None status_id: Optional[str] = None + custom_values: Optional[List[CustomValueInput]] = None class TaskUpdate(BaseModel): @@ -32,8 +49,10 @@ class TaskUpdate(BaseModel): priority: Optional[Priority] = None original_estimate: Optional[Decimal] = None time_spent: Optional[Decimal] = None + start_date: Optional[datetime] = None due_date: Optional[datetime] = None position: Optional[int] = None + custom_values: Optional[List[CustomValueInput]] = None class TaskStatusUpdate(BaseModel): @@ -67,6 +86,7 @@ class TaskWithDetails(TaskResponse): status_color: Optional[str] = None creator_name: Optional[str] = None subtask_count: int = 0 + custom_values: Optional[List[CustomValueResponse]] = None class TaskListResponse(BaseModel): diff --git a/backend/app/schemas/task_dependency.py b/backend/app/schemas/task_dependency.py new file mode 100644 index 0000000..1559fef --- /dev/null +++ b/backend/app/schemas/task_dependency.py @@ -0,0 +1,78 @@ +from pydantic import BaseModel, field_validator +from typing import Optional, List +from datetime import datetime +from enum import Enum + + +class DependencyType(str, Enum): + """Task dependency types for Gantt chart.""" + FS = "FS" # Finish-to-Start (most common) + SS = "SS" # Start-to-Start + FF = "FF" # Finish-to-Finish + SF = "SF" # Start-to-Finish (rare) + + +class TaskDependencyCreate(BaseModel): + """Schema for creating a task dependency.""" + predecessor_id: str + dependency_type: DependencyType = DependencyType.FS + lag_days: int = 0 + + @field_validator('lag_days') + @classmethod + def validate_lag_days(cls, v): + if v < -365 or v > 365: + raise ValueError('lag_days must be between -365 and 365') + return v + + +class TaskDependencyUpdate(BaseModel): + """Schema for updating a task dependency.""" + dependency_type: Optional[DependencyType] = None + lag_days: Optional[int] = None + + @field_validator('lag_days') + @classmethod + def validate_lag_days(cls, v): + if v is not None and (v < -365 or v > 365): + raise ValueError('lag_days must be between -365 and 365') + return v + + +class TaskInfo(BaseModel): + """Brief task information for dependency response.""" + id: str + title: str + start_date: Optional[datetime] = None + due_date: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TaskDependencyResponse(BaseModel): + """Schema for task dependency response.""" + id: str + predecessor_id: str + successor_id: str + dependency_type: DependencyType + lag_days: int + created_at: datetime + predecessor: Optional[TaskInfo] = None + successor: Optional[TaskInfo] = None + + class Config: + from_attributes = True + + +class TaskDependencyListResponse(BaseModel): + """Schema for list of task dependencies.""" + dependencies: List[TaskDependencyResponse] + total: int + + +class DependencyValidationError(BaseModel): + """Schema for dependency validation error details.""" + error_type: str # 'circular', 'self_reference', 'duplicate', 'cross_project' + message: str + details: Optional[dict] = None diff --git a/backend/app/services/custom_value_service.py b/backend/app/services/custom_value_service.py new file mode 100644 index 0000000..ee3c9a7 --- /dev/null +++ b/backend/app/services/custom_value_service.py @@ -0,0 +1,278 @@ +""" +Service for managing task custom values. +""" +import uuid +from typing import List, Dict, Any, Optional +from decimal import Decimal, InvalidOperation +from datetime import datetime +from sqlalchemy.orm import Session + +from app.models import Task, CustomField, TaskCustomValue, User +from app.schemas.task import CustomValueInput, CustomValueResponse +from app.services.formula_service import FormulaService + + +class CustomValueService: + """Service for managing custom field values on tasks.""" + + @staticmethod + def get_custom_values_for_task( + db: Session, + task: Task, + include_formula_calculations: bool = True, + ) -> List[CustomValueResponse]: + """ + Get all custom field values for a task. + + Args: + db: Database session + task: The task to get values for + include_formula_calculations: Whether to calculate formula field values + + Returns: + List of CustomValueResponse objects + """ + # Get all custom fields for the project + fields = db.query(CustomField).filter( + CustomField.project_id == task.project_id + ).order_by(CustomField.position).all() + + if not fields: + return [] + + # Get stored values + stored_values = db.query(TaskCustomValue).filter( + TaskCustomValue.task_id == task.id + ).all() + + value_map = {v.field_id: v.value for v in stored_values} + + # Calculate formula values if requested + formula_values = {} + if include_formula_calculations: + formula_values = FormulaService.calculate_all_formulas_for_task(db, task) + + result = [] + for field in fields: + if field.field_type == "formula": + # Use calculated formula value + calculated = formula_values.get(field.id) + value = str(calculated) if calculated is not None else None + display_value = CustomValueService._format_display_value( + field, value, db + ) + else: + # Use stored value + value = value_map.get(field.id) + display_value = CustomValueService._format_display_value( + field, value, db + ) + + result.append(CustomValueResponse( + field_id=field.id, + field_name=field.name, + field_type=field.field_type, + value=value, + display_value=display_value, + )) + + return result + + @staticmethod + def _format_display_value( + field: CustomField, + value: Optional[str], + db: Session, + ) -> Optional[str]: + """Format a value for display based on field type.""" + if value is None: + return None + + field_type = field.field_type + + if field_type == "person": + # Look up user name + from app.models import User + user = db.query(User).filter(User.id == value).first() + return user.name if user else value + + elif field_type == "number" or field_type == "formula": + # Format number + try: + num = Decimal(value) + # Remove trailing zeros after decimal point + formatted = f"{num:,.4f}".rstrip('0').rstrip('.') + return formatted + except (InvalidOperation, ValueError): + return value + + elif field_type == "date": + # Format date + try: + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + return dt.strftime('%Y-%m-%d') + except (ValueError, AttributeError): + return value + + else: + return value + + @staticmethod + def save_custom_values( + db: Session, + task: Task, + custom_values: List[CustomValueInput], + ) -> List[str]: + """ + Save custom field values for a task. + + Args: + db: Database session + task: The task to save values for + custom_values: List of values to save + + Returns: + List of field IDs that were updated (for formula recalculation) + """ + if not custom_values: + return [] + + updated_field_ids = [] + + for cv in custom_values: + field = db.query(CustomField).filter( + CustomField.id == cv.field_id, + CustomField.project_id == task.project_id, + ).first() + + if not field: + continue + + # Skip formula fields - they are calculated, not stored directly + if field.field_type == "formula": + continue + + # Validate value based on field type + validated_value = CustomValueService._validate_value( + field, cv.value, db + ) + + # Find existing value or create new + existing = db.query(TaskCustomValue).filter( + TaskCustomValue.task_id == task.id, + TaskCustomValue.field_id == cv.field_id, + ).first() + + if existing: + if existing.value != validated_value: + existing.value = validated_value + updated_field_ids.append(cv.field_id) + else: + new_value = TaskCustomValue( + id=str(uuid.uuid4()), + task_id=task.id, + field_id=cv.field_id, + value=validated_value, + ) + db.add(new_value) + updated_field_ids.append(cv.field_id) + + # Recalculate formula fields if any values were updated + if updated_field_ids: + for field_id in updated_field_ids: + FormulaService.recalculate_dependent_formulas(db, task, field_id) + + return updated_field_ids + + @staticmethod + def _validate_value( + field: CustomField, + value: Any, + db: Session, + ) -> Optional[str]: + """ + Validate and normalize a value based on field type. + + Returns the validated value as a string, or None. + """ + if value is None or value == "": + if field.is_required: + raise ValueError(f"Field '{field.name}' is required") + return None + + field_type = field.field_type + str_value = str(value) + + if field_type == "text": + return str_value + + elif field_type == "number": + try: + Decimal(str_value) + return str_value + except (InvalidOperation, ValueError): + raise ValueError(f"Invalid number for field '{field.name}'") + + elif field_type == "dropdown": + if field.options and str_value not in field.options: + raise ValueError( + f"Invalid option for field '{field.name}'. " + f"Must be one of: {', '.join(field.options)}" + ) + return str_value + + elif field_type == "date": + # Validate date format + try: + datetime.fromisoformat(str_value.replace('Z', '+00:00')) + return str_value + except ValueError: + # Try parsing as date only + try: + datetime.strptime(str_value, '%Y-%m-%d') + return str_value + except ValueError: + raise ValueError(f"Invalid date for field '{field.name}'") + + elif field_type == "person": + # Validate user exists + from app.models import User + user = db.query(User).filter(User.id == str_value).first() + if not user: + raise ValueError(f"Invalid user ID for field '{field.name}'") + return str_value + + return str_value + + @staticmethod + def validate_required_fields( + db: Session, + project_id: str, + custom_values: Optional[List[CustomValueInput]], + ) -> List[str]: + """ + Validate that all required custom fields have values. + + Returns list of missing required field names. + """ + required_fields = db.query(CustomField).filter( + CustomField.project_id == project_id, + CustomField.is_required == True, + CustomField.field_type != "formula", # Formula fields are calculated + ).all() + + if not required_fields: + return [] + + provided_field_ids = set() + if custom_values: + for cv in custom_values: + if cv.value is not None and cv.value != "": + provided_field_ids.add(cv.field_id) + + missing = [] + for field in required_fields: + if field.id not in provided_field_ids: + missing.append(field.name) + + return missing diff --git a/backend/app/services/dependency_service.py b/backend/app/services/dependency_service.py new file mode 100644 index 0000000..c9a3627 --- /dev/null +++ b/backend/app/services/dependency_service.py @@ -0,0 +1,424 @@ +""" +Dependency Service + +Handles task dependency validation including: +- Circular dependency detection using DFS +- Date constraint validation based on dependency types +- Self-reference prevention +- Cross-project dependency prevention +""" +from typing import List, Optional, Set, Tuple, Dict, Any +from collections import defaultdict +from sqlalchemy.orm import Session +from datetime import datetime, timedelta + +from app.models import Task, TaskDependency + + +class DependencyValidationError(Exception): + """Custom exception for dependency validation errors.""" + + def __init__(self, error_type: str, message: str, details: Optional[dict] = None): + self.error_type = error_type + self.message = message + self.details = details or {} + super().__init__(message) + + +class DependencyService: + """Service for managing task dependencies with validation.""" + + # Maximum number of direct dependencies per task (as per spec) + MAX_DIRECT_DEPENDENCIES = 10 + + @staticmethod + def detect_circular_dependency( + db: Session, + predecessor_id: str, + successor_id: str, + project_id: str + ) -> Optional[List[str]]: + """ + Detect if adding a dependency would create a circular reference. + + Uses DFS to traverse from the successor to check if we can reach + the predecessor through existing dependencies. + + Args: + db: Database session + predecessor_id: The task that must complete first + successor_id: The task that depends on the predecessor + project_id: Project ID to scope the query + + Returns: + List of task IDs forming the cycle if circular, None otherwise + """ + # If adding predecessor -> successor, check if successor can reach predecessor + # This would mean predecessor depends (transitively) on successor, creating a cycle + + # Build adjacency list for the project's dependencies + dependencies = db.query(TaskDependency).join( + Task, TaskDependency.successor_id == Task.id + ).filter(Task.project_id == project_id).all() + + # Graph: successor -> [predecessors] + # We need to check if predecessor is reachable from successor + # by following the chain of "what does this task depend on" + graph: Dict[str, List[str]] = defaultdict(list) + for dep in dependencies: + graph[dep.successor_id].append(dep.predecessor_id) + + # Simulate adding the new edge + graph[successor_id].append(predecessor_id) + + # DFS to find if there's a path from predecessor back to successor + # (which would complete a cycle) + visited: Set[str] = set() + path: List[str] = [] + in_path: Set[str] = set() + + def dfs(node: str) -> Optional[List[str]]: + """DFS traversal to detect cycles.""" + if node in in_path: + # Found a cycle - return the cycle path + cycle_start = path.index(node) + return path[cycle_start:] + [node] + + if node in visited: + return None + + visited.add(node) + in_path.add(node) + path.append(node) + + for neighbor in graph.get(node, []): + result = dfs(neighbor) + if result: + return result + + path.pop() + in_path.remove(node) + return None + + # Start DFS from the successor to check if we can reach back to it + return dfs(successor_id) + + @staticmethod + def validate_dependency( + db: Session, + predecessor_id: str, + successor_id: str + ) -> None: + """ + Validate that a dependency can be created. + + Raises DependencyValidationError if validation fails. + + Checks: + 1. Self-reference + 2. Both tasks exist + 3. Both tasks are in the same project + 4. No duplicate dependency + 5. No circular dependency + 6. Dependency limit not exceeded + """ + # Check self-reference + if predecessor_id == successor_id: + raise DependencyValidationError( + error_type="self_reference", + message="A task cannot depend on itself" + ) + + # Get both tasks + predecessor = db.query(Task).filter(Task.id == predecessor_id).first() + successor = db.query(Task).filter(Task.id == successor_id).first() + + if not predecessor: + raise DependencyValidationError( + error_type="not_found", + message="Predecessor task not found", + details={"task_id": predecessor_id} + ) + + if not successor: + raise DependencyValidationError( + error_type="not_found", + message="Successor task not found", + details={"task_id": successor_id} + ) + + # Check same project + if predecessor.project_id != successor.project_id: + raise DependencyValidationError( + error_type="cross_project", + message="Dependencies can only be created between tasks in the same project", + details={ + "predecessor_project_id": predecessor.project_id, + "successor_project_id": successor.project_id + } + ) + + # Check duplicate + existing = db.query(TaskDependency).filter( + TaskDependency.predecessor_id == predecessor_id, + TaskDependency.successor_id == successor_id + ).first() + + if existing: + raise DependencyValidationError( + error_type="duplicate", + message="This dependency already exists" + ) + + # Check dependency limit + current_count = db.query(TaskDependency).filter( + TaskDependency.successor_id == successor_id + ).count() + + if current_count >= DependencyService.MAX_DIRECT_DEPENDENCIES: + raise DependencyValidationError( + error_type="limit_exceeded", + message=f"A task cannot have more than {DependencyService.MAX_DIRECT_DEPENDENCIES} direct dependencies", + details={"current_count": current_count} + ) + + # Check circular dependency + cycle = DependencyService.detect_circular_dependency( + db, predecessor_id, successor_id, predecessor.project_id + ) + + if cycle: + raise DependencyValidationError( + error_type="circular", + message="Adding this dependency would create a circular reference", + details={"cycle": cycle} + ) + + @staticmethod + def validate_date_constraints( + task: Task, + start_date: Optional[datetime], + due_date: Optional[datetime], + db: Session + ) -> List[Dict[str, Any]]: + """ + Validate date changes against dependency constraints. + + Returns a list of constraint violations (empty if valid). + + Dependency type meanings: + - FS: predecessor.due_date + lag <= successor.start_date + - SS: predecessor.start_date + lag <= successor.start_date + - FF: predecessor.due_date + lag <= successor.due_date + - SF: predecessor.start_date + lag <= successor.due_date + """ + violations = [] + + # Use provided dates or fall back to current task dates + new_start = start_date if start_date is not None else task.start_date + new_due = due_date if due_date is not None else task.due_date + + # Basic date validation + if new_start and new_due and new_start > new_due: + violations.append({ + "type": "date_order", + "message": "Start date cannot be after due date", + "start_date": str(new_start), + "due_date": str(new_due) + }) + + # Get dependencies where this task is the successor (predecessors) + predecessors = db.query(TaskDependency).filter( + TaskDependency.successor_id == task.id + ).all() + + for dep in predecessors: + pred_task = dep.predecessor + if not pred_task: + continue + + lag = timedelta(days=dep.lag_days) + violation = None + + if dep.dependency_type == "FS": + # Predecessor must finish before successor starts + if pred_task.due_date and new_start: + required_start = pred_task.due_date + lag + if new_start < required_start: + violation = { + "type": "dependency_constraint", + "dependency_type": "FS", + "predecessor_id": pred_task.id, + "predecessor_title": pred_task.title, + "message": f"Start date must be on or after {required_start.date()} (predecessor due date + {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "SS": + # Predecessor must start before successor starts + if pred_task.start_date and new_start: + required_start = pred_task.start_date + lag + if new_start < required_start: + violation = { + "type": "dependency_constraint", + "dependency_type": "SS", + "predecessor_id": pred_task.id, + "predecessor_title": pred_task.title, + "message": f"Start date must be on or after {required_start.date()} (predecessor start date + {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "FF": + # Predecessor must finish before successor finishes + if pred_task.due_date and new_due: + required_due = pred_task.due_date + lag + if new_due < required_due: + violation = { + "type": "dependency_constraint", + "dependency_type": "FF", + "predecessor_id": pred_task.id, + "predecessor_title": pred_task.title, + "message": f"Due date must be on or after {required_due.date()} (predecessor due date + {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "SF": + # Predecessor must start before successor finishes + if pred_task.start_date and new_due: + required_due = pred_task.start_date + lag + if new_due < required_due: + violation = { + "type": "dependency_constraint", + "dependency_type": "SF", + "predecessor_id": pred_task.id, + "predecessor_title": pred_task.title, + "message": f"Due date must be on or after {required_due.date()} (predecessor start date + {dep.lag_days} days lag)" + } + + if violation: + violations.append(violation) + + # Get dependencies where this task is the predecessor (successors) + successors = db.query(TaskDependency).filter( + TaskDependency.predecessor_id == task.id + ).all() + + for dep in successors: + succ_task = dep.successor + if not succ_task: + continue + + lag = timedelta(days=dep.lag_days) + violation = None + + if dep.dependency_type == "FS": + # This task must finish before successor starts + if new_due and succ_task.start_date: + required_due = succ_task.start_date - lag + if new_due > required_due: + violation = { + "type": "dependency_constraint", + "dependency_type": "FS", + "successor_id": succ_task.id, + "successor_title": succ_task.title, + "message": f"Due date must be on or before {required_due.date()} (successor start date - {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "SS": + # This task must start before successor starts + if new_start and succ_task.start_date: + required_start = succ_task.start_date - lag + if new_start > required_start: + violation = { + "type": "dependency_constraint", + "dependency_type": "SS", + "successor_id": succ_task.id, + "successor_title": succ_task.title, + "message": f"Start date must be on or before {required_start.date()} (successor start date - {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "FF": + # This task must finish before successor finishes + if new_due and succ_task.due_date: + required_due = succ_task.due_date - lag + if new_due > required_due: + violation = { + "type": "dependency_constraint", + "dependency_type": "FF", + "successor_id": succ_task.id, + "successor_title": succ_task.title, + "message": f"Due date must be on or before {required_due.date()} (successor due date - {dep.lag_days} days lag)" + } + + elif dep.dependency_type == "SF": + # This task must start before successor finishes + if new_start and succ_task.due_date: + required_start = succ_task.due_date - lag + if new_start > required_start: + violation = { + "type": "dependency_constraint", + "dependency_type": "SF", + "successor_id": succ_task.id, + "successor_title": succ_task.title, + "message": f"Start date must be on or before {required_start.date()} (successor due date - {dep.lag_days} days lag)" + } + + if violation: + violations.append(violation) + + return violations + + @staticmethod + def get_all_predecessors(db: Session, task_id: str) -> List[str]: + """ + Get all transitive predecessors of a task. + + Uses BFS to find all tasks that this task depends on (directly or indirectly). + """ + visited: Set[str] = set() + queue = [task_id] + predecessors = [] + + while queue: + current = queue.pop(0) + if current in visited: + continue + + visited.add(current) + + deps = db.query(TaskDependency).filter( + TaskDependency.successor_id == current + ).all() + + for dep in deps: + if dep.predecessor_id not in visited: + predecessors.append(dep.predecessor_id) + queue.append(dep.predecessor_id) + + return predecessors + + @staticmethod + def get_all_successors(db: Session, task_id: str) -> List[str]: + """ + Get all transitive successors of a task. + + Uses BFS to find all tasks that depend on this task (directly or indirectly). + """ + visited: Set[str] = set() + queue = [task_id] + successors = [] + + while queue: + current = queue.pop(0) + if current in visited: + continue + + visited.add(current) + + deps = db.query(TaskDependency).filter( + TaskDependency.predecessor_id == current + ).all() + + for dep in deps: + if dep.successor_id not in visited: + successors.append(dep.successor_id) + queue.append(dep.successor_id) + + return successors diff --git a/backend/app/services/encryption_service.py b/backend/app/services/encryption_service.py new file mode 100644 index 0000000..7007e67 --- /dev/null +++ b/backend/app/services/encryption_service.py @@ -0,0 +1,300 @@ +""" +Encryption service for AES-256-GCM file encryption. + +This service handles: +- File encryption key generation and management +- Encrypting/decrypting file encryption keys with Master Key +- Streaming file encryption/decryption with AES-256-GCM +""" +import os +import base64 +import secrets +import logging +from typing import BinaryIO, Tuple, Optional, Generator +from io import BytesIO + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend + +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Constants +KEY_SIZE = 32 # 256 bits for AES-256 +NONCE_SIZE = 12 # 96 bits for GCM recommended nonce size +TAG_SIZE = 16 # 128 bits for GCM authentication tag +CHUNK_SIZE = 64 * 1024 # 64KB chunks for streaming + + +class EncryptionError(Exception): + """Base exception for encryption errors.""" + pass + + +class MasterKeyNotConfiguredError(EncryptionError): + """Raised when master key is not configured.""" + pass + + +class DecryptionError(EncryptionError): + """Raised when decryption fails.""" + pass + + +class EncryptionService: + """ + Service for file encryption using AES-256-GCM. + + Key hierarchy: + 1. Master Key (from environment) -> encrypts file encryption keys + 2. File Encryption Keys (stored in DB) -> encrypt actual files + """ + + def __init__(self): + self._master_key: Optional[bytes] = None + + @property + def master_key(self) -> bytes: + """Get the master key, loading from config if needed.""" + if self._master_key is None: + if not settings.ENCRYPTION_MASTER_KEY: + raise MasterKeyNotConfiguredError( + "ENCRYPTION_MASTER_KEY is not configured. " + "File encryption is disabled." + ) + self._master_key = base64.urlsafe_b64decode(settings.ENCRYPTION_MASTER_KEY) + return self._master_key + + def is_encryption_available(self) -> bool: + """Check if encryption is available (master key configured).""" + return settings.ENCRYPTION_MASTER_KEY is not None + + def generate_key(self) -> bytes: + """ + Generate a new AES-256 encryption key. + + Returns: + 32-byte random key + """ + return secrets.token_bytes(KEY_SIZE) + + def encrypt_key(self, key: bytes) -> str: + """ + Encrypt a file encryption key using the Master Key. + + Args: + key: The raw 32-byte file encryption key + + Returns: + Base64-encoded encrypted key (nonce + ciphertext + tag) + """ + aesgcm = AESGCM(self.master_key) + nonce = secrets.token_bytes(NONCE_SIZE) + + # Encrypt the key + ciphertext = aesgcm.encrypt(nonce, key, None) + + # Combine nonce + ciphertext (includes tag) + encrypted_data = nonce + ciphertext + + return base64.urlsafe_b64encode(encrypted_data).decode('utf-8') + + def decrypt_key(self, encrypted_key: str) -> bytes: + """ + Decrypt a file encryption key using the Master Key. + + Args: + encrypted_key: Base64-encoded encrypted key + + Returns: + The raw 32-byte file encryption key + """ + try: + encrypted_data = base64.urlsafe_b64decode(encrypted_key) + + # Extract nonce and ciphertext + nonce = encrypted_data[:NONCE_SIZE] + ciphertext = encrypted_data[NONCE_SIZE:] + + # Decrypt + aesgcm = AESGCM(self.master_key) + return aesgcm.decrypt(nonce, ciphertext, None) + + except Exception as e: + logger.error(f"Failed to decrypt encryption key: {e}") + raise DecryptionError("Failed to decrypt file encryption key") + + def encrypt_file(self, file_content: BinaryIO, key: bytes) -> bytes: + """ + Encrypt file content using AES-256-GCM. + + For smaller files, encrypts the entire content at once. + The format is: nonce (12 bytes) + ciphertext + tag (16 bytes) + + Args: + file_content: File-like object to encrypt + key: 32-byte AES-256 key + + Returns: + Encrypted bytes (nonce + ciphertext + tag) + """ + # Read all content + plaintext = file_content.read() + + # Generate nonce + nonce = secrets.token_bytes(NONCE_SIZE) + + # Encrypt + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(nonce, plaintext, None) + + # Return nonce + ciphertext (tag is appended by encrypt) + return nonce + ciphertext + + def decrypt_file(self, encrypted_content: BinaryIO, key: bytes) -> bytes: + """ + Decrypt file content using AES-256-GCM. + + Args: + encrypted_content: File-like object containing encrypted data + key: 32-byte AES-256 key + + Returns: + Decrypted bytes + """ + try: + # Read all encrypted content + encrypted_data = encrypted_content.read() + + # Extract nonce and ciphertext + nonce = encrypted_data[:NONCE_SIZE] + ciphertext = encrypted_data[NONCE_SIZE:] + + # Decrypt + aesgcm = AESGCM(key) + plaintext = aesgcm.decrypt(nonce, ciphertext, None) + + return plaintext + + except Exception as e: + logger.error(f"Failed to decrypt file: {e}") + raise DecryptionError("Failed to decrypt file. The file may be corrupted or the key is incorrect.") + + def encrypt_file_streaming(self, file_content: BinaryIO, key: bytes) -> Generator[bytes, None, None]: + """ + Encrypt file content using AES-256-GCM with streaming. + + For large files, encrypts in chunks. Each chunk has its own nonce. + Format per chunk: chunk_size (4 bytes) + nonce (12 bytes) + ciphertext + tag + + Args: + file_content: File-like object to encrypt + key: 32-byte AES-256 key + + Yields: + Encrypted chunks + """ + aesgcm = AESGCM(key) + + # Write header with version byte + yield b'\x01' # Version 1 for streaming format + + while True: + chunk = file_content.read(CHUNK_SIZE) + if not chunk: + break + + # Generate nonce for this chunk + nonce = secrets.token_bytes(NONCE_SIZE) + + # Encrypt chunk + ciphertext = aesgcm.encrypt(nonce, chunk, None) + + # Write chunk size (4 bytes, little endian) + chunk_size = len(ciphertext) + NONCE_SIZE + yield chunk_size.to_bytes(4, 'little') + + # Write nonce + ciphertext + yield nonce + ciphertext + + # Write end marker (zero size) + yield b'\x00\x00\x00\x00' + + def decrypt_file_streaming(self, encrypted_content: BinaryIO, key: bytes) -> Generator[bytes, None, None]: + """ + Decrypt file content using AES-256-GCM with streaming. + + Args: + encrypted_content: File-like object containing encrypted data + key: 32-byte AES-256 key + + Yields: + Decrypted chunks + """ + aesgcm = AESGCM(key) + + # Read version byte + version = encrypted_content.read(1) + if version != b'\x01': + raise DecryptionError(f"Unknown encryption format version") + + while True: + # Read chunk size + size_bytes = encrypted_content.read(4) + if len(size_bytes) < 4: + raise DecryptionError("Unexpected end of file") + + chunk_size = int.from_bytes(size_bytes, 'little') + + # Check for end marker + if chunk_size == 0: + break + + # Read chunk (nonce + ciphertext) + chunk = encrypted_content.read(chunk_size) + if len(chunk) < chunk_size: + raise DecryptionError("Unexpected end of file") + + # Extract nonce and ciphertext + nonce = chunk[:NONCE_SIZE] + ciphertext = chunk[NONCE_SIZE:] + + try: + # Decrypt + plaintext = aesgcm.decrypt(nonce, ciphertext, None) + yield plaintext + except Exception as e: + raise DecryptionError(f"Failed to decrypt chunk: {e}") + + def encrypt_bytes(self, data: bytes, key: bytes) -> bytes: + """ + Encrypt bytes directly (convenience method). + + Args: + data: Bytes to encrypt + key: 32-byte AES-256 key + + Returns: + Encrypted bytes + """ + return self.encrypt_file(BytesIO(data), key) + + def decrypt_bytes(self, encrypted_data: bytes, key: bytes) -> bytes: + """ + Decrypt bytes directly (convenience method). + + Args: + encrypted_data: Encrypted bytes + key: 32-byte AES-256 key + + Returns: + Decrypted bytes + """ + return self.decrypt_file(BytesIO(encrypted_data), key) + + +# Singleton instance +encryption_service = EncryptionService() diff --git a/backend/app/services/formula_service.py b/backend/app/services/formula_service.py new file mode 100644 index 0000000..e1069f6 --- /dev/null +++ b/backend/app/services/formula_service.py @@ -0,0 +1,420 @@ +""" +Formula Service for Custom Fields + +Supports: +- Basic math operations: +, -, *, / +- Field references: {field_name} +- Built-in task fields: {original_estimate}, {time_spent} +- Parentheses for grouping + +Example formulas: +- "{time_spent} / {original_estimate} * 100" +- "{cost_per_hour} * {hours_worked}" +- "({field_a} + {field_b}) / 2" +""" +import re +import ast +import operator +from typing import Dict, Any, Optional, List, Set, Tuple +from decimal import Decimal, InvalidOperation +from sqlalchemy.orm import Session + +from app.models import Task, CustomField, TaskCustomValue + + +class FormulaError(Exception): + """Exception raised for formula parsing or calculation errors.""" + pass + + +class CircularReferenceError(FormulaError): + """Exception raised when circular references are detected in formulas.""" + pass + + +class FormulaService: + """Service for parsing and calculating formula fields.""" + + # Built-in task fields that can be referenced in formulas + BUILTIN_FIELDS = { + "original_estimate", + "time_spent", + } + + # Supported operators + OPERATORS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.USub: operator.neg, + } + + @staticmethod + def extract_field_references(formula: str) -> Set[str]: + """ + Extract all field references from a formula. + + Field references are in the format {field_name}. + Returns a set of field names referenced in the formula. + """ + pattern = r'\{([^}]+)\}' + matches = re.findall(pattern, formula) + return set(matches) + + @staticmethod + def validate_formula( + formula: str, + project_id: str, + db: Session, + current_field_id: Optional[str] = None, + ) -> Tuple[bool, Optional[str]]: + """ + Validate a formula expression. + + Checks: + 1. Syntax is valid + 2. All referenced fields exist + 3. Referenced fields are number or formula type + 4. No circular references + + Returns (is_valid, error_message) + """ + if not formula or not formula.strip(): + return False, "Formula cannot be empty" + + # Extract field references + references = FormulaService.extract_field_references(formula) + + if not references: + return False, "Formula must reference at least one field" + + # Validate syntax by trying to parse + try: + # Replace field references with dummy numbers for syntax check + test_formula = formula + for ref in references: + test_formula = test_formula.replace(f"{{{ref}}}", "1") + + # Try to parse and evaluate with safe operations + FormulaService._safe_eval(test_formula) + except Exception as e: + return False, f"Invalid formula syntax: {str(e)}" + + # Separate builtin and custom field references + custom_references = references - FormulaService.BUILTIN_FIELDS + + # Validate custom field references exist and are numeric types + if custom_references: + fields = db.query(CustomField).filter( + CustomField.project_id == project_id, + CustomField.name.in_(custom_references), + ).all() + + found_names = {f.name for f in fields} + missing = custom_references - found_names + + if missing: + return False, f"Unknown field references: {', '.join(missing)}" + + # Check field types (must be number or formula) + for field in fields: + if field.field_type not in ("number", "formula"): + return False, f"Field '{field.name}' is not a numeric type" + + # Check for circular references + if current_field_id: + try: + FormulaService._check_circular_references( + db, project_id, current_field_id, references + ) + except CircularReferenceError as e: + return False, str(e) + + return True, None + + @staticmethod + def _check_circular_references( + db: Session, + project_id: str, + field_id: str, + references: Set[str], + visited: Optional[Set[str]] = None, + ) -> None: + """ + Check for circular references in formula fields. + + Raises CircularReferenceError if a cycle is detected. + """ + if visited is None: + visited = set() + + # Get the current field's name + current_field = db.query(CustomField).filter( + CustomField.id == field_id + ).first() + + if current_field: + if current_field.name in references: + raise CircularReferenceError( + f"Circular reference detected: field cannot reference itself" + ) + + # Get all referenced formula fields + custom_references = references - FormulaService.BUILTIN_FIELDS + if not custom_references: + return + + formula_fields = db.query(CustomField).filter( + CustomField.project_id == project_id, + CustomField.name.in_(custom_references), + CustomField.field_type == "formula", + ).all() + + for field in formula_fields: + if field.id in visited: + raise CircularReferenceError( + f"Circular reference detected involving field '{field.name}'" + ) + + visited.add(field.id) + + if field.formula: + nested_refs = FormulaService.extract_field_references(field.formula) + if current_field and current_field.name in nested_refs: + raise CircularReferenceError( + f"Circular reference detected: '{field.name}' references the current field" + ) + FormulaService._check_circular_references( + db, project_id, field_id, nested_refs, visited + ) + + @staticmethod + def _safe_eval(expression: str) -> Decimal: + """ + Safely evaluate a mathematical expression. + + Only allows basic arithmetic operations (+, -, *, /). + """ + try: + node = ast.parse(expression, mode='eval') + return FormulaService._eval_node(node.body) + except Exception as e: + raise FormulaError(f"Failed to evaluate expression: {str(e)}") + + @staticmethod + def _eval_node(node: ast.AST) -> Decimal: + """Recursively evaluate an AST node.""" + if isinstance(node, ast.Constant): + if isinstance(node.value, (int, float)): + return Decimal(str(node.value)) + raise FormulaError(f"Invalid constant: {node.value}") + + elif isinstance(node, ast.BinOp): + left = FormulaService._eval_node(node.left) + right = FormulaService._eval_node(node.right) + op = FormulaService.OPERATORS.get(type(node.op)) + if op is None: + raise FormulaError(f"Unsupported operator: {type(node.op).__name__}") + + # Handle division by zero + if isinstance(node.op, ast.Div) and right == 0: + return Decimal('0') # Return 0 instead of raising error + + return Decimal(str(op(float(left), float(right)))) + + elif isinstance(node, ast.UnaryOp): + operand = FormulaService._eval_node(node.operand) + op = FormulaService.OPERATORS.get(type(node.op)) + if op is None: + raise FormulaError(f"Unsupported operator: {type(node.op).__name__}") + return Decimal(str(op(float(operand)))) + + else: + raise FormulaError(f"Unsupported expression type: {type(node).__name__}") + + @staticmethod + def calculate_formula( + formula: str, + task: Task, + db: Session, + calculated_cache: Optional[Dict[str, Decimal]] = None, + ) -> Optional[Decimal]: + """ + Calculate the value of a formula for a given task. + + Args: + formula: The formula expression + task: The task to calculate for + db: Database session + calculated_cache: Cache for already calculated formula values (for recursion) + + Returns: + The calculated value, or None if calculation fails + """ + if calculated_cache is None: + calculated_cache = {} + + references = FormulaService.extract_field_references(formula) + values: Dict[str, Decimal] = {} + + # Get builtin field values + for ref in references: + if ref in FormulaService.BUILTIN_FIELDS: + task_value = getattr(task, ref, None) + if task_value is not None: + values[ref] = Decimal(str(task_value)) + else: + values[ref] = Decimal('0') + + # Get custom field values + custom_references = references - FormulaService.BUILTIN_FIELDS + if custom_references: + # Get field definitions + fields = db.query(CustomField).filter( + CustomField.project_id == task.project_id, + CustomField.name.in_(custom_references), + ).all() + + field_map = {f.name: f for f in fields} + + # Get custom values for this task + custom_values = db.query(TaskCustomValue).filter( + TaskCustomValue.task_id == task.id, + TaskCustomValue.field_id.in_([f.id for f in fields]), + ).all() + + value_map = {cv.field_id: cv.value for cv in custom_values} + + for ref in custom_references: + field = field_map.get(ref) + if not field: + values[ref] = Decimal('0') + continue + + if field.field_type == "formula": + # Recursively calculate formula fields + if field.id in calculated_cache: + values[ref] = calculated_cache[field.id] + else: + nested_value = FormulaService.calculate_formula( + field.formula, task, db, calculated_cache + ) + values[ref] = nested_value if nested_value is not None else Decimal('0') + calculated_cache[field.id] = values[ref] + else: + # Get stored value + stored_value = value_map.get(field.id) + if stored_value: + try: + values[ref] = Decimal(str(stored_value)) + except (InvalidOperation, ValueError): + values[ref] = Decimal('0') + else: + values[ref] = Decimal('0') + + # Substitute values into formula + expression = formula + for ref, value in values.items(): + expression = expression.replace(f"{{{ref}}}", str(value)) + + # Evaluate the expression + try: + result = FormulaService._safe_eval(expression) + # Round to 4 decimal places + return result.quantize(Decimal('0.0001')) + except Exception: + return None + + @staticmethod + def recalculate_dependent_formulas( + db: Session, + task: Task, + changed_field_id: str, + ) -> Dict[str, Decimal]: + """ + Recalculate all formula fields that depend on a changed field. + + Returns a dict of field_id -> calculated_value for updated formulas. + """ + # Get the changed field + changed_field = db.query(CustomField).filter( + CustomField.id == changed_field_id + ).first() + + if not changed_field: + return {} + + # Find all formula fields in the project + formula_fields = db.query(CustomField).filter( + CustomField.project_id == task.project_id, + CustomField.field_type == "formula", + ).all() + + results = {} + calculated_cache: Dict[str, Decimal] = {} + + for field in formula_fields: + if not field.formula: + continue + + # Check if this formula depends on the changed field + references = FormulaService.extract_field_references(field.formula) + if changed_field.name in references or changed_field.name in FormulaService.BUILTIN_FIELDS: + value = FormulaService.calculate_formula( + field.formula, task, db, calculated_cache + ) + if value is not None: + results[field.id] = value + calculated_cache[field.id] = value + + # Update or create the custom value + existing = db.query(TaskCustomValue).filter( + TaskCustomValue.task_id == task.id, + TaskCustomValue.field_id == field.id, + ).first() + + if existing: + existing.value = str(value) + else: + import uuid + new_value = TaskCustomValue( + id=str(uuid.uuid4()), + task_id=task.id, + field_id=field.id, + value=str(value), + ) + db.add(new_value) + + return results + + @staticmethod + def calculate_all_formulas_for_task( + db: Session, + task: Task, + ) -> Dict[str, Decimal]: + """ + Calculate all formula fields for a task. + + Used when loading a task to get current formula values. + """ + formula_fields = db.query(CustomField).filter( + CustomField.project_id == task.project_id, + CustomField.field_type == "formula", + ).all() + + results = {} + calculated_cache: Dict[str, Decimal] = {} + + for field in formula_fields: + if not field.formula: + continue + + value = FormulaService.calculate_formula( + field.formula, task, db, calculated_cache + ) + if value is not None: + results[field.id] = value + calculated_cache[field.id] = value + + return results diff --git a/backend/migrations/versions/011_custom_fields_tables.py b/backend/migrations/versions/011_custom_fields_tables.py new file mode 100644 index 0000000..2fe0c74 --- /dev/null +++ b/backend/migrations/versions/011_custom_fields_tables.py @@ -0,0 +1,70 @@ +"""Add custom fields and task custom values tables + +Revision ID: 011 +Revises: 010 +Create Date: 2026-01-05 + +FEAT-001: Add custom fields feature for flexible task data extension. +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '011' +down_revision: Union[str, None] = '010' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create field_type_enum + field_type_enum = sa.Enum( + 'text', 'number', 'dropdown', 'date', 'person', 'formula', + name='field_type_enum' + ) + field_type_enum.create(op.get_bind(), checkfirst=True) + + # Create pjctrl_custom_fields table + op.create_table( + 'pjctrl_custom_fields', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('project_id', sa.String(36), sa.ForeignKey('pjctrl_projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('name', sa.String(100), nullable=False), + sa.Column('field_type', field_type_enum, nullable=False), + sa.Column('options', sa.JSON, nullable=True), + sa.Column('formula', sa.Text, nullable=True), + sa.Column('is_required', sa.Boolean, default=False, nullable=False), + sa.Column('position', sa.Integer, default=0, nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + ) + + # Create indexes for custom_fields + op.create_index('ix_pjctrl_custom_fields_project_id', 'pjctrl_custom_fields', ['project_id']) + + # Create pjctrl_task_custom_values table + op.create_table( + 'pjctrl_task_custom_values', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('task_id', sa.String(36), sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), nullable=False), + sa.Column('field_id', sa.String(36), sa.ForeignKey('pjctrl_custom_fields.id', ondelete='CASCADE'), nullable=False), + sa.Column('value', sa.Text, nullable=True), + sa.Column('updated_at', sa.DateTime, server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False), + sa.UniqueConstraint('task_id', 'field_id', name='uq_task_field'), + ) + + # Create indexes for task_custom_values + op.create_index('ix_pjctrl_task_custom_values_task_id', 'pjctrl_task_custom_values', ['task_id']) + op.create_index('ix_pjctrl_task_custom_values_field_id', 'pjctrl_task_custom_values', ['field_id']) + + +def downgrade() -> None: + op.drop_index('ix_pjctrl_task_custom_values_field_id', table_name='pjctrl_task_custom_values') + op.drop_index('ix_pjctrl_task_custom_values_task_id', table_name='pjctrl_task_custom_values') + op.drop_table('pjctrl_task_custom_values') + + op.drop_index('ix_pjctrl_custom_fields_project_id', table_name='pjctrl_custom_fields') + op.drop_table('pjctrl_custom_fields') + + # Drop the enum type + sa.Enum(name='field_type_enum').drop(op.get_bind(), checkfirst=True) diff --git a/backend/migrations/versions/012_encryption_keys_table.py b/backend/migrations/versions/012_encryption_keys_table.py new file mode 100644 index 0000000..507055a --- /dev/null +++ b/backend/migrations/versions/012_encryption_keys_table.py @@ -0,0 +1,48 @@ +"""Encryption keys table and attachment encryption_key_id + +Revision ID: 012 +Revises: 011 +Create Date: 2026-01-05 +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers +revision = '012' +down_revision = '011' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create encryption_keys table + op.create_table( + 'pjctrl_encryption_keys', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column('key_data', sa.Text, nullable=False), # Encrypted key using Master Key + sa.Column('algorithm', sa.String(20), default='AES-256-GCM', nullable=False), + sa.Column('is_active', sa.Boolean, default=True, nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + sa.Column('rotated_at', sa.DateTime, nullable=True), + ) + op.create_index('idx_encryption_key_active', 'pjctrl_encryption_keys', ['is_active']) + + # Add encryption_key_id column to attachments table + op.add_column( + 'pjctrl_attachments', + sa.Column( + 'encryption_key_id', + sa.String(36), + sa.ForeignKey('pjctrl_encryption_keys.id', ondelete='SET NULL'), + nullable=True + ) + ) + op.create_index('idx_attachment_encryption_key', 'pjctrl_attachments', ['encryption_key_id']) + + +def downgrade(): + op.drop_index('idx_attachment_encryption_key', 'pjctrl_attachments') + op.drop_column('pjctrl_attachments', 'encryption_key_id') + op.drop_index('idx_encryption_key_active', 'pjctrl_encryption_keys') + op.drop_table('pjctrl_encryption_keys') diff --git a/backend/migrations/versions/013_task_dependencies_table.py b/backend/migrations/versions/013_task_dependencies_table.py new file mode 100644 index 0000000..777bb6e --- /dev/null +++ b/backend/migrations/versions/013_task_dependencies_table.py @@ -0,0 +1,92 @@ +"""Add start_date to tasks and create task dependencies table for Gantt view + +Revision ID: 013 +Revises: 012 +Create Date: 2026-01-05 + +FEAT-003: Add Gantt view support with task dependencies and start dates. +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = '013' +down_revision: Union[str, None] = '012' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add start_date column to pjctrl_tasks + op.add_column( + 'pjctrl_tasks', + sa.Column('start_date', sa.DateTime, nullable=True) + ) + + # Create dependency_type_enum + dependency_type_enum = sa.Enum( + 'FS', 'SS', 'FF', 'SF', + name='dependency_type_enum' + ) + dependency_type_enum.create(op.get_bind(), checkfirst=True) + + # Create pjctrl_task_dependencies table + op.create_table( + 'pjctrl_task_dependencies', + sa.Column('id', sa.String(36), primary_key=True), + sa.Column( + 'predecessor_id', + sa.String(36), + sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), + nullable=False + ), + sa.Column( + 'successor_id', + sa.String(36), + sa.ForeignKey('pjctrl_tasks.id', ondelete='CASCADE'), + nullable=False + ), + sa.Column( + 'dependency_type', + dependency_type_enum, + default='FS', + nullable=False + ), + sa.Column('lag_days', sa.Integer, default=0, nullable=False), + sa.Column('created_at', sa.DateTime, server_default=sa.func.now(), nullable=False), + # Unique constraint to prevent duplicate dependencies + sa.UniqueConstraint('predecessor_id', 'successor_id', name='uq_predecessor_successor'), + ) + + # Create indexes for efficient dependency lookups + op.create_index( + 'ix_pjctrl_task_dependencies_predecessor_id', + 'pjctrl_task_dependencies', + ['predecessor_id'] + ) + op.create_index( + 'ix_pjctrl_task_dependencies_successor_id', + 'pjctrl_task_dependencies', + ['successor_id'] + ) + + +def downgrade() -> None: + # Drop indexes + op.drop_index( + 'ix_pjctrl_task_dependencies_successor_id', + table_name='pjctrl_task_dependencies' + ) + op.drop_index( + 'ix_pjctrl_task_dependencies_predecessor_id', + table_name='pjctrl_task_dependencies' + ) + + # Drop the table + op.drop_table('pjctrl_task_dependencies') + + # Drop the enum type + sa.Enum(name='dependency_type_enum').drop(op.get_bind(), checkfirst=True) + + # Remove start_date column from tasks + op.drop_column('pjctrl_tasks', 'start_date') diff --git a/backend/tests/test_custom_fields.py b/backend/tests/test_custom_fields.py new file mode 100644 index 0000000..bb1b644 --- /dev/null +++ b/backend/tests/test_custom_fields.py @@ -0,0 +1,440 @@ +""" +Tests for Custom Fields feature. +""" +import pytest +from app.models import User, Space, Project, Task, TaskStatus, CustomField, TaskCustomValue +from app.services.formula_service import FormulaService, FormulaError, CircularReferenceError + + +class TestCustomFieldsCRUD: + """Test custom fields CRUD operations.""" + + def setup_project(self, db, owner_id: str): + """Create a space and project for testing.""" + space = Space( + id="test-space-001", + name="Test Space", + owner_id=owner_id, + ) + db.add(space) + + project = Project( + id="test-project-001", + space_id=space.id, + title="Test Project", + owner_id=owner_id, + ) + db.add(project) + + # Add default task status + status = TaskStatus( + id="test-status-001", + project_id=project.id, + name="To Do", + color="#3B82F6", + position=0, + ) + db.add(status) + + db.commit() + return project + + def test_create_text_field(self, client, db, admin_token): + """Test creating a text custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "Sprint Number", + "field_type": "text", + "is_required": False, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Sprint Number" + assert data["field_type"] == "text" + assert data["is_required"] is False + + def test_create_number_field(self, client, db, admin_token): + """Test creating a number custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "Story Points", + "field_type": "number", + "is_required": True, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Story Points" + assert data["field_type"] == "number" + assert data["is_required"] is True + + def test_create_dropdown_field(self, client, db, admin_token): + """Test creating a dropdown custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "Component", + "field_type": "dropdown", + "options": ["Frontend", "Backend", "Database", "API"], + "is_required": False, + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Component" + assert data["field_type"] == "dropdown" + assert data["options"] == ["Frontend", "Backend", "Database", "API"] + + def test_create_dropdown_field_without_options_fails(self, client, db, admin_token): + """Test that creating a dropdown field without options fails.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "Component", + "field_type": "dropdown", + "options": [], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 422 # Validation error + + def test_create_formula_field(self, client, db, admin_token): + """Test creating a formula custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # First create a number field to reference + client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "hours_worked", + "field_type": "number", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Create formula field + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={ + "name": "Progress", + "field_type": "formula", + "formula": "{time_spent} / {original_estimate} * 100", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Progress" + assert data["field_type"] == "formula" + assert "{time_spent}" in data["formula"] + + def test_list_custom_fields(self, client, db, admin_token): + """Test listing custom fields for a project.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # Create some fields + client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Field 1", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Field 2", "field_type": "number"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + response = client.get( + f"/api/projects/{project.id}/custom-fields", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert len(data["fields"]) == 2 + + def test_update_custom_field(self, client, db, admin_token): + """Test updating a custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # Create a field + create_response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Original Name", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + field_id = create_response.json()["id"] + + # Update it + response = client.put( + f"/api/custom-fields/{field_id}", + json={"name": "Updated Name", "is_required": True}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Updated Name" + assert data["is_required"] is True + + def test_delete_custom_field(self, client, db, admin_token): + """Test deleting a custom field.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # Create a field + create_response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "To Delete", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + field_id = create_response.json()["id"] + + # Delete it + response = client.delete( + f"/api/custom-fields/{field_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 204 + + # Verify it's gone + get_response = client.get( + f"/api/custom-fields/{field_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_response.status_code == 404 + + def test_max_fields_limit(self, client, db, admin_token): + """Test that maximum 20 custom fields per project is enforced.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # Create 20 fields + for i in range(20): + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": f"Field {i}", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 201 + + # Try to create the 21st field + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Field 21", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + assert "Maximum" in response.json()["detail"] + + def test_duplicate_name_rejected(self, client, db, admin_token): + """Test that duplicate field names are rejected.""" + project = self.setup_project(db, "00000000-0000-0000-0000-000000000001") + + # Create a field + client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Unique Name", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Try to create another with same name + response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "Unique Name", "field_type": "number"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 400 + assert "already exists" in response.json()["detail"] + + +class TestFormulaService: + """Test formula parsing and calculation.""" + + def test_extract_field_references(self): + """Test extracting field references from formulas.""" + formula = "{time_spent} / {original_estimate} * 100" + refs = FormulaService.extract_field_references(formula) + assert refs == {"time_spent", "original_estimate"} + + def test_extract_multiple_references(self): + """Test extracting multiple field references.""" + formula = "{field_a} + {field_b} - {field_c}" + refs = FormulaService.extract_field_references(formula) + assert refs == {"field_a", "field_b", "field_c"} + + def test_safe_eval_addition(self): + """Test safe evaluation of addition.""" + result = FormulaService._safe_eval("10 + 5") + assert float(result) == 15.0 + + def test_safe_eval_division(self): + """Test safe evaluation of division.""" + result = FormulaService._safe_eval("20 / 4") + assert float(result) == 5.0 + + def test_safe_eval_complex_expression(self): + """Test safe evaluation of complex expression.""" + result = FormulaService._safe_eval("(10 + 5) * 2 / 3") + assert float(result) == 10.0 + + def test_safe_eval_division_by_zero(self): + """Test that division by zero returns 0.""" + result = FormulaService._safe_eval("10 / 0") + assert float(result) == 0.0 + + def test_safe_eval_negative_numbers(self): + """Test safe evaluation with negative numbers.""" + result = FormulaService._safe_eval("-5 + 10") + assert float(result) == 5.0 + + +class TestCustomValuesWithTasks: + """Test custom values integration with tasks.""" + + def setup_project_with_fields(self, db, client, admin_token, owner_id: str): + """Create a project with custom fields for testing.""" + space = Space( + id="test-space-002", + name="Test Space", + owner_id=owner_id, + ) + db.add(space) + + project = Project( + id="test-project-002", + space_id=space.id, + title="Test Project", + owner_id=owner_id, + ) + db.add(project) + + status = TaskStatus( + id="test-status-002", + project_id=project.id, + name="To Do", + color="#3B82F6", + position=0, + ) + db.add(status) + db.commit() + + # Create custom fields via API + text_response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "sprint_number", "field_type": "text"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + text_field_id = text_response.json()["id"] + + number_response = client.post( + f"/api/projects/{project.id}/custom-fields", + json={"name": "story_points", "field_type": "number"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + number_field_id = number_response.json()["id"] + + return project, text_field_id, number_field_id + + def test_create_task_with_custom_values(self, client, db, admin_token): + """Test creating a task with custom values.""" + project, text_field_id, number_field_id = self.setup_project_with_fields( + db, client, admin_token, "00000000-0000-0000-0000-000000000001" + ) + + response = client.post( + f"/api/projects/{project.id}/tasks", + json={ + "title": "Test Task", + "custom_values": [ + {"field_id": text_field_id, "value": "Sprint 5"}, + {"field_id": number_field_id, "value": "8"}, + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + + def test_get_task_includes_custom_values(self, client, db, admin_token): + """Test that getting a task includes custom values.""" + project, text_field_id, number_field_id = self.setup_project_with_fields( + db, client, admin_token, "00000000-0000-0000-0000-000000000001" + ) + + # Create task with custom values + create_response = client.post( + f"/api/projects/{project.id}/tasks", + json={ + "title": "Test Task", + "custom_values": [ + {"field_id": text_field_id, "value": "Sprint 5"}, + {"field_id": number_field_id, "value": "8"}, + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + task_id = create_response.json()["id"] + + # Get task and check custom values + get_response = client.get( + f"/api/tasks/{task_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert get_response.status_code == 200 + data = get_response.json() + assert data["custom_values"] is not None + assert len(data["custom_values"]) >= 2 + + def test_update_task_custom_values(self, client, db, admin_token): + """Test updating custom values on a task.""" + project, text_field_id, number_field_id = self.setup_project_with_fields( + db, client, admin_token, "00000000-0000-0000-0000-000000000001" + ) + + # Create task + create_response = client.post( + f"/api/projects/{project.id}/tasks", + json={ + "title": "Test Task", + "custom_values": [ + {"field_id": text_field_id, "value": "Sprint 5"}, + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + task_id = create_response.json()["id"] + + # Update custom values + update_response = client.patch( + f"/api/tasks/{task_id}", + json={ + "custom_values": [ + {"field_id": text_field_id, "value": "Sprint 6"}, + {"field_id": number_field_id, "value": "13"}, + ], + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert update_response.status_code == 200 diff --git a/backend/tests/test_encryption.py b/backend/tests/test_encryption.py new file mode 100644 index 0000000..8315271 --- /dev/null +++ b/backend/tests/test_encryption.py @@ -0,0 +1,275 @@ +""" +Tests for the file encryption functionality. + +Tests cover: +- Encryption service (key generation, encrypt/decrypt) +- Encryption key management API +- Attachment upload with encryption +- Attachment download with decryption +""" +import pytest +import base64 +import secrets +from io import BytesIO +from unittest.mock import patch, MagicMock + +from app.services.encryption_service import ( + EncryptionService, + encryption_service, + MasterKeyNotConfiguredError, + DecryptionError, + KEY_SIZE, + NONCE_SIZE, +) + + +class TestEncryptionService: + """Tests for the encryption service.""" + + @pytest.fixture + def mock_master_key(self): + """Generate a valid test master key.""" + return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode() + + @pytest.fixture + def service_with_key(self, mock_master_key): + """Create an encryption service with a mock master key.""" + with patch('app.services.encryption_service.settings') as mock_settings: + mock_settings.ENCRYPTION_MASTER_KEY = mock_master_key + service = EncryptionService() + yield service + + def test_generate_key(self, service_with_key): + """Test that generate_key produces a 32-byte key.""" + key = service_with_key.generate_key() + assert len(key) == KEY_SIZE + assert isinstance(key, bytes) + + def test_generate_key_uniqueness(self, service_with_key): + """Test that each generated key is unique.""" + keys = [service_with_key.generate_key() for _ in range(10)] + unique_keys = set(keys) + assert len(unique_keys) == 10 + + def test_encrypt_decrypt_key(self, service_with_key): + """Test encryption and decryption of a file encryption key.""" + # Generate a key to encrypt + original_key = service_with_key.generate_key() + + # Encrypt the key + encrypted_key = service_with_key.encrypt_key(original_key) + assert isinstance(encrypted_key, str) + assert encrypted_key != base64.urlsafe_b64encode(original_key).decode() + + # Decrypt the key + decrypted_key = service_with_key.decrypt_key(encrypted_key) + assert decrypted_key == original_key + + def test_encrypt_decrypt_file(self, service_with_key): + """Test file encryption and decryption.""" + # Create test file content + original_content = b"This is a test file content for encryption." + file_obj = BytesIO(original_content) + + # Generate encryption key + key = service_with_key.generate_key() + + # Encrypt + encrypted_content = service_with_key.encrypt_file(file_obj, key) + assert encrypted_content != original_content + assert len(encrypted_content) > len(original_content) # Due to nonce and tag + + # Decrypt + encrypted_file = BytesIO(encrypted_content) + decrypted_content = service_with_key.decrypt_file(encrypted_file, key) + assert decrypted_content == original_content + + def test_encrypt_decrypt_bytes(self, service_with_key): + """Test bytes encryption and decryption convenience methods.""" + original_data = b"Test data for encryption" + key = service_with_key.generate_key() + + # Encrypt + encrypted_data = service_with_key.encrypt_bytes(original_data, key) + assert encrypted_data != original_data + + # Decrypt + decrypted_data = service_with_key.decrypt_bytes(encrypted_data, key) + assert decrypted_data == original_data + + def test_encrypt_large_file(self, service_with_key): + """Test encryption of a larger file (1MB).""" + # Create 1MB of random data + original_content = secrets.token_bytes(1024 * 1024) + file_obj = BytesIO(original_content) + key = service_with_key.generate_key() + + # Encrypt + encrypted_content = service_with_key.encrypt_file(file_obj, key) + + # Decrypt + encrypted_file = BytesIO(encrypted_content) + decrypted_content = service_with_key.decrypt_file(encrypted_file, key) + + assert decrypted_content == original_content + + def test_decrypt_with_wrong_key(self, service_with_key): + """Test that decryption fails with wrong key.""" + original_content = b"Secret content" + file_obj = BytesIO(original_content) + + key1 = service_with_key.generate_key() + key2 = service_with_key.generate_key() + + # Encrypt with key1 + encrypted_content = service_with_key.encrypt_file(file_obj, key1) + + # Try to decrypt with key2 + encrypted_file = BytesIO(encrypted_content) + with pytest.raises(DecryptionError): + service_with_key.decrypt_file(encrypted_file, key2) + + def test_decrypt_corrupted_data(self, service_with_key): + """Test that decryption fails with corrupted data.""" + original_content = b"Secret content" + file_obj = BytesIO(original_content) + key = service_with_key.generate_key() + + # Encrypt + encrypted_content = service_with_key.encrypt_file(file_obj, key) + + # Corrupt the encrypted data + corrupted = bytearray(encrypted_content) + corrupted[20] ^= 0xFF # Flip some bits + corrupted_content = bytes(corrupted) + + # Try to decrypt + encrypted_file = BytesIO(corrupted_content) + with pytest.raises(DecryptionError): + service_with_key.decrypt_file(encrypted_file, key) + + def test_is_encryption_available_with_key(self, mock_master_key): + """Test is_encryption_available returns True when key is configured.""" + with patch('app.services.encryption_service.settings') as mock_settings: + mock_settings.ENCRYPTION_MASTER_KEY = mock_master_key + service = EncryptionService() + assert service.is_encryption_available() is True + + def test_is_encryption_available_without_key(self): + """Test is_encryption_available returns False when key is not configured.""" + with patch('app.services.encryption_service.settings') as mock_settings: + mock_settings.ENCRYPTION_MASTER_KEY = None + service = EncryptionService() + assert service.is_encryption_available() is False + + def test_master_key_not_configured_error(self): + """Test that operations fail when master key is not configured.""" + with patch('app.services.encryption_service.settings') as mock_settings: + mock_settings.ENCRYPTION_MASTER_KEY = None + service = EncryptionService() + + key = secrets.token_bytes(32) + with pytest.raises(MasterKeyNotConfiguredError): + service.encrypt_key(key) + + def test_encrypted_key_format(self, service_with_key): + """Test that encrypted key is valid base64.""" + key = service_with_key.generate_key() + encrypted_key = service_with_key.encrypt_key(key) + + # Should be valid base64 + decoded = base64.urlsafe_b64decode(encrypted_key) + # Should contain nonce + ciphertext + tag + assert len(decoded) >= NONCE_SIZE + KEY_SIZE + 16 # 16 = GCM tag size + + +class TestEncryptionServiceStreaming: + """Tests for streaming encryption (for large files).""" + + @pytest.fixture + def mock_master_key(self): + """Generate a valid test master key.""" + return base64.urlsafe_b64encode(secrets.token_bytes(32)).decode() + + @pytest.fixture + def service_with_key(self, mock_master_key): + """Create an encryption service with a mock master key.""" + with patch('app.services.encryption_service.settings') as mock_settings: + mock_settings.ENCRYPTION_MASTER_KEY = mock_master_key + service = EncryptionService() + yield service + + def test_streaming_encrypt_decrypt(self, service_with_key): + """Test streaming encryption and decryption.""" + # Create test content + original_content = b"Test content for streaming encryption. " * 1000 + file_obj = BytesIO(original_content) + key = service_with_key.generate_key() + + # Encrypt using streaming + encrypted_chunks = list(service_with_key.encrypt_file_streaming(file_obj, key)) + encrypted_content = b''.join(encrypted_chunks) + + # Decrypt using streaming + encrypted_file = BytesIO(encrypted_content) + decrypted_chunks = list(service_with_key.decrypt_file_streaming(encrypted_file, key)) + decrypted_content = b''.join(decrypted_chunks) + + assert decrypted_content == original_content + + +class TestEncryptionKeyValidation: + """Tests for encryption key validation in config.""" + + def test_valid_master_key(self): + """Test that a valid master key passes validation.""" + from app.core.config import Settings + + valid_key = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode() + + # This should not raise + with patch.dict('os.environ', { + 'JWT_SECRET_KEY': 'test-secret-key-that-is-valid', + 'ENCRYPTION_MASTER_KEY': valid_key + }): + settings = Settings() + assert settings.ENCRYPTION_MASTER_KEY == valid_key + + def test_invalid_master_key_length(self): + """Test that an invalid length master key fails validation.""" + from app.core.config import Settings + + # 16 bytes instead of 32 + invalid_key = base64.urlsafe_b64encode(secrets.token_bytes(16)).decode() + + with patch.dict('os.environ', { + 'JWT_SECRET_KEY': 'test-secret-key-that-is-valid', + 'ENCRYPTION_MASTER_KEY': invalid_key + }): + with pytest.raises(ValueError, match="must be a base64-encoded 32-byte key"): + Settings() + + def test_invalid_master_key_format(self): + """Test that an invalid format master key fails validation.""" + from app.core.config import Settings + from pydantic import ValidationError + + invalid_key = "not-valid-base64!@#$" + + with patch.dict('os.environ', { + 'JWT_SECRET_KEY': 'test-secret-key-that-is-valid', + 'ENCRYPTION_MASTER_KEY': invalid_key + }): + with pytest.raises(ValidationError, match="ENCRYPTION_MASTER_KEY"): + Settings() + + def test_empty_master_key_allowed(self): + """Test that empty master key is allowed (encryption disabled).""" + from app.core.config import Settings + + with patch.dict('os.environ', { + 'JWT_SECRET_KEY': 'test-secret-key-that-is-valid', + 'ENCRYPTION_MASTER_KEY': '' + }): + settings = Settings() + assert settings.ENCRYPTION_MASTER_KEY is None diff --git a/backend/tests/test_task_dependencies.py b/backend/tests/test_task_dependencies.py new file mode 100644 index 0000000..02b7b73 --- /dev/null +++ b/backend/tests/test_task_dependencies.py @@ -0,0 +1,1433 @@ +""" +Tests for Task Dependencies (Gantt View Feature) + +Tests cover: +- TaskDependency CRUD operations +- Circular dependency detection +- Date validation (start_date <= due_date) +- Dependency constraint validation +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +from app.models import Task, TaskDependency, Space, Project, TaskStatus +from app.services.dependency_service import DependencyService, DependencyValidationError + + +class TestDependencyService: + """Test DependencyService validation logic.""" + + def test_self_reference_rejected(self, db): + """Test that a task cannot depend on itself.""" + # Create test data + space = Space( + id="test-space-dep-1", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-dep-1", + space_id="test-space-dep-1", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-dep-1", + project_id="test-project-dep-1", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task = Task( + id="task-self-ref", + project_id="test-project-dep-1", + title="Self Reference Task", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-dep-1", + ) + db.add(task) + db.commit() + + # Attempt self-reference + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-self-ref", + successor_id="task-self-ref" + ) + + assert exc_info.value.error_type == "self_reference" + assert "cannot depend on itself" in exc_info.value.message + + def test_cross_project_rejected(self, db): + """Test that dependencies cannot be created across different projects.""" + # Create test data + space = Space( + id="test-space-cross", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project1 = Project( + id="test-project-cross-1", + space_id="test-space-cross", + title="Project 1", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + project2 = Project( + id="test-project-cross-2", + space_id="test-space-cross", + title="Project 2", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add_all([project1, project2]) + + status1 = TaskStatus( + id="test-status-cross-1", + project_id="test-project-cross-1", + name="To Do", + color="#808080", + position=0, + ) + status2 = TaskStatus( + id="test-status-cross-2", + project_id="test-project-cross-2", + name="To Do", + color="#808080", + position=0, + ) + db.add_all([status1, status2]) + + task1 = Task( + id="task-cross-1", + project_id="test-project-cross-1", + title="Task in Project 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-cross-1", + ) + task2 = Task( + id="task-cross-2", + project_id="test-project-cross-2", + title="Task in Project 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-cross-2", + ) + db.add_all([task1, task2]) + db.commit() + + # Attempt cross-project dependency + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-cross-1", + successor_id="task-cross-2" + ) + + assert exc_info.value.error_type == "cross_project" + + def test_duplicate_dependency_rejected(self, db): + """Test that duplicate dependencies are rejected.""" + # Create test data + space = Space( + id="test-space-dup", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-dup", + space_id="test-space-dup", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-dup", + project_id="test-project-dup", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task1 = Task( + id="task-dup-1", + project_id="test-project-dup", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-dup", + ) + task2 = Task( + id="task-dup-2", + project_id="test-project-dup", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-dup", + ) + db.add_all([task1, task2]) + + # Create existing dependency + dep = TaskDependency( + id="dep-dup", + predecessor_id="task-dup-1", + successor_id="task-dup-2", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + db.commit() + + # Attempt to create duplicate + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-dup-1", + successor_id="task-dup-2" + ) + + assert exc_info.value.error_type == "duplicate" + + def test_dependency_limit_exceeded(self, db): + """Test that dependency limit (10 direct dependencies) is enforced.""" + # Create test data + space = Space( + id="test-space-limit", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-limit", + space_id="test-space-limit", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-limit", + project_id="test-project-limit", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create successor task + successor = Task( + id="task-limit-successor", + project_id="test-project-limit", + title="Successor Task", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-limit", + ) + db.add(successor) + + # Create 10 predecessor tasks with dependencies + for i in range(10): + pred = Task( + id=f"task-limit-pred-{i}", + project_id="test-project-limit", + title=f"Predecessor Task {i}", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-limit", + ) + db.add(pred) + + dep = TaskDependency( + id=f"dep-limit-{i}", + predecessor_id=f"task-limit-pred-{i}", + successor_id="task-limit-successor", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + + # Create one more predecessor + extra_pred = Task( + id="task-limit-pred-extra", + project_id="test-project-limit", + title="Extra Predecessor", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-limit", + ) + db.add(extra_pred) + db.commit() + + # Attempt to add 11th dependency + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-limit-pred-extra", + successor_id="task-limit-successor" + ) + + assert exc_info.value.error_type == "limit_exceeded" + + +class TestCircularDependencyDetection: + """Test circular dependency detection.""" + + def test_simple_circular_dependency(self, db): + """Test detection of A -> B -> A circular dependency.""" + # Create test data + space = Space( + id="test-space-circ-1", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-circ-1", + space_id="test-space-circ-1", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-circ-1", + project_id="test-project-circ-1", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + taskA = Task( + id="task-circ-A", + project_id="test-project-circ-1", + title="Task A", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-circ-1", + ) + taskB = Task( + id="task-circ-B", + project_id="test-project-circ-1", + title="Task B", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-circ-1", + ) + db.add_all([taskA, taskB]) + + # A -> B (A is predecessor, B is successor) + dep_AB = TaskDependency( + id="dep-circ-AB", + predecessor_id="task-circ-A", + successor_id="task-circ-B", + dependency_type="FS", + lag_days=0, + ) + db.add(dep_AB) + db.commit() + + # Attempt B -> A (would create cycle) + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-circ-B", + successor_id="task-circ-A" + ) + + assert exc_info.value.error_type == "circular" + assert "cycle" in exc_info.value.details + + def test_transitive_circular_dependency(self, db): + """Test detection of A -> B -> C -> A circular dependency.""" + # Create test data + space = Space( + id="test-space-circ-2", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-circ-2", + space_id="test-space-circ-2", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-circ-2", + project_id="test-project-circ-2", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + taskA = Task( + id="task-circ2-A", + project_id="test-project-circ-2", + title="Task A", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-circ-2", + ) + taskB = Task( + id="task-circ2-B", + project_id="test-project-circ-2", + title="Task B", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-circ-2", + ) + taskC = Task( + id="task-circ2-C", + project_id="test-project-circ-2", + title="Task C", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-circ-2", + ) + db.add_all([taskA, taskB, taskC]) + + # A -> B + dep_AB = TaskDependency( + id="dep-circ2-AB", + predecessor_id="task-circ2-A", + successor_id="task-circ2-B", + dependency_type="FS", + lag_days=0, + ) + # B -> C + dep_BC = TaskDependency( + id="dep-circ2-BC", + predecessor_id="task-circ2-B", + successor_id="task-circ2-C", + dependency_type="FS", + lag_days=0, + ) + db.add_all([dep_AB, dep_BC]) + db.commit() + + # Attempt C -> A (would create cycle A -> B -> C -> A) + with pytest.raises(DependencyValidationError) as exc_info: + DependencyService.validate_dependency( + db, + predecessor_id="task-circ2-C", + successor_id="task-circ2-A" + ) + + assert exc_info.value.error_type == "circular" + + def test_valid_dependency_chain(self, db): + """Test that valid dependency chains are accepted.""" + # Create test data + space = Space( + id="test-space-valid", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-valid", + space_id="test-space-valid", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-valid", + project_id="test-project-valid", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + taskA = Task( + id="task-valid-A", + project_id="test-project-valid", + title="Task A", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-valid", + ) + taskB = Task( + id="task-valid-B", + project_id="test-project-valid", + title="Task B", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-valid", + ) + taskC = Task( + id="task-valid-C", + project_id="test-project-valid", + title="Task C", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-valid", + ) + db.add_all([taskA, taskB, taskC]) + + # A -> B + dep_AB = TaskDependency( + id="dep-valid-AB", + predecessor_id="task-valid-A", + successor_id="task-valid-B", + dependency_type="FS", + lag_days=0, + ) + db.add(dep_AB) + db.commit() + + # B -> C should be valid (no cycle) + # This should NOT raise an exception + DependencyService.validate_dependency( + db, + predecessor_id="task-valid-B", + successor_id="task-valid-C" + ) + + +class TestDateValidation: + """Test date validation logic.""" + + def test_start_date_after_due_date_rejected(self, db): + """Test that start_date > due_date is rejected.""" + # Create test data + space = Space( + id="test-space-date-1", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-date-1", + space_id="test-space-date-1", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-date-1", + project_id="test-project-date-1", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + now = datetime.now() + task = Task( + id="task-date-invalid", + project_id="test-project-date-1", + title="Invalid Date Task", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-date-1", + start_date=now + timedelta(days=10), # Start after due + due_date=now, + ) + db.add(task) + db.commit() + + # Validate dates - start > due should fail + violations = DependencyService.validate_date_constraints( + task, + start_date=now + timedelta(days=10), + due_date=now, + db=db + ) + + assert len(violations) > 0 + assert violations[0]["type"] == "date_order" + + def test_valid_date_range_accepted(self, db): + """Test that valid date ranges are accepted.""" + # Create test data + space = Space( + id="test-space-date-2", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-date-2", + space_id="test-space-date-2", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-date-2", + project_id="test-project-date-2", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + now = datetime.now() + task = Task( + id="task-date-valid", + project_id="test-project-date-2", + title="Valid Date Task", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-date-2", + start_date=now, + due_date=now + timedelta(days=10), + ) + db.add(task) + db.commit() + + # Validate dates - start <= due should pass + violations = DependencyService.validate_date_constraints( + task, + start_date=now, + due_date=now + timedelta(days=10), + db=db + ) + + assert len(violations) == 0 + + def test_fs_dependency_date_constraint(self, db): + """Test Finish-to-Start dependency date validation.""" + # Create test data + space = Space( + id="test-space-fs", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-fs", + space_id="test-space-fs", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-fs", + project_id="test-project-fs", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + now = datetime.now() + + # Predecessor: ends on day 5 + predecessor = Task( + id="task-fs-pred", + project_id="test-project-fs", + title="Predecessor", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-fs", + start_date=now, + due_date=now + timedelta(days=5), + ) + + # Successor: starts on day 3 (before predecessor ends - INVALID for FS) + successor = Task( + id="task-fs-succ", + project_id="test-project-fs", + title="Successor", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-fs", + start_date=now + timedelta(days=3), + due_date=now + timedelta(days=10), + ) + db.add_all([predecessor, successor]) + + # Create FS dependency + dep = TaskDependency( + id="dep-fs", + predecessor_id="task-fs-pred", + successor_id="task-fs-succ", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + db.commit() + + # Validate successor's dates - should fail because start < predecessor's due + violations = DependencyService.validate_date_constraints( + successor, + start_date=now + timedelta(days=3), + due_date=now + timedelta(days=10), + db=db + ) + + assert len(violations) > 0 + assert any(v["type"] == "dependency_constraint" for v in violations) + + def test_fs_dependency_with_lag(self, db): + """Test Finish-to-Start dependency with lag days.""" + # Create test data + space = Space( + id="test-space-fs-lag", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-fs-lag", + space_id="test-space-fs-lag", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-fs-lag", + project_id="test-project-fs-lag", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + now = datetime.now() + + # Predecessor: ends on day 5 + predecessor = Task( + id="task-fs-lag-pred", + project_id="test-project-fs-lag", + title="Predecessor", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-fs-lag", + start_date=now, + due_date=now + timedelta(days=5), + ) + + # Successor: starts on day 6 (only 1 day after predecessor ends) + # With 2 days lag, this is INVALID (should start on day 7) + successor = Task( + id="task-fs-lag-succ", + project_id="test-project-fs-lag", + title="Successor", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-fs-lag", + start_date=now + timedelta(days=6), + due_date=now + timedelta(days=10), + ) + db.add_all([predecessor, successor]) + + # Create FS dependency with 2 days lag + dep = TaskDependency( + id="dep-fs-lag", + predecessor_id="task-fs-lag-pred", + successor_id="task-fs-lag-succ", + dependency_type="FS", + lag_days=2, + ) + db.add(dep) + db.commit() + + # Validate successor's dates - should fail because start < predecessor's due + lag + violations = DependencyService.validate_date_constraints( + successor, + start_date=now + timedelta(days=6), + due_date=now + timedelta(days=10), + db=db + ) + + assert len(violations) > 0 + + +class TestDependencyCRUDAPI: + """Test dependency CRUD API endpoints.""" + + def test_create_dependency(self, client, db, admin_token): + """Test creating a dependency via API.""" + # Create test data + space = Space( + id="test-space-api-1", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-api-1", + space_id="test-space-api-1", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-api-1", + project_id="test-project-api-1", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task1 = Task( + id="task-api-1", + project_id="test-project-api-1", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-1", + ) + task2 = Task( + id="task-api-2", + project_id="test-project-api-1", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-1", + ) + db.add_all([task1, task2]) + db.commit() + + # Create dependency via API + response = client.post( + "/api/tasks/task-api-2/dependencies", + json={ + "predecessor_id": "task-api-1", + "dependency_type": "FS", + "lag_days": 0 + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["predecessor_id"] == "task-api-1" + assert data["successor_id"] == "task-api-2" + assert data["dependency_type"] == "FS" + + def test_list_task_dependencies(self, client, db, admin_token): + """Test listing dependencies for a task.""" + # Create test data + space = Space( + id="test-space-api-2", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-api-2", + space_id="test-space-api-2", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-api-2", + project_id="test-project-api-2", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task1 = Task( + id="task-api-list-1", + project_id="test-project-api-2", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-2", + ) + task2 = Task( + id="task-api-list-2", + project_id="test-project-api-2", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-2", + ) + db.add_all([task1, task2]) + + dep = TaskDependency( + id="dep-api-list", + predecessor_id="task-api-list-1", + successor_id="task-api-list-2", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + db.commit() + + # List dependencies + response = client.get( + "/api/tasks/task-api-list-2/dependencies", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 1 + assert any(d["predecessor_id"] == "task-api-list-1" for d in data["dependencies"]) + + def test_delete_dependency(self, client, db, admin_token): + """Test deleting a dependency.""" + # Create test data + space = Space( + id="test-space-api-3", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-api-3", + space_id="test-space-api-3", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-api-3", + project_id="test-project-api-3", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task1 = Task( + id="task-api-del-1", + project_id="test-project-api-3", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-3", + ) + task2 = Task( + id="task-api-del-2", + project_id="test-project-api-3", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-3", + ) + db.add_all([task1, task2]) + + dep = TaskDependency( + id="dep-api-del", + predecessor_id="task-api-del-1", + successor_id="task-api-del-2", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + db.commit() + + # Delete dependency + response = client.delete( + "/api/task-dependencies/dep-api-del", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 204 + + # Verify it's deleted + dep_check = db.query(TaskDependency).filter( + TaskDependency.id == "dep-api-del" + ).first() + assert dep_check is None + + def test_circular_dependency_rejected_via_api(self, client, db, admin_token): + """Test that circular dependencies are rejected via API.""" + # Create test data + space = Space( + id="test-space-api-circ", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-api-circ", + space_id="test-space-api-circ", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-api-circ", + project_id="test-project-api-circ", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + task1 = Task( + id="task-api-circ-1", + project_id="test-project-api-circ", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-circ", + ) + task2 = Task( + id="task-api-circ-2", + project_id="test-project-api-circ", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-api-circ", + ) + db.add_all([task1, task2]) + + # Create dependency: task1 -> task2 + dep = TaskDependency( + id="dep-api-circ", + predecessor_id="task-api-circ-1", + successor_id="task-api-circ-2", + dependency_type="FS", + lag_days=0, + ) + db.add(dep) + db.commit() + + # Try to create circular dependency: task2 -> task1 + response = client.post( + "/api/tasks/task-api-circ-1/dependencies", + json={ + "predecessor_id": "task-api-circ-2", + "dependency_type": "FS", + "lag_days": 0 + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 400 + data = response.json() + assert data["detail"]["error_type"] == "circular" + + +class TestTaskDateValidationAPI: + """Test task date validation in task API.""" + + def test_create_task_with_invalid_dates_rejected(self, client, db, admin_token): + """Test that creating a task with start_date > due_date is rejected.""" + # Create test data + space = Space( + id="test-space-task-date-1", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-task-date-1", + space_id="test-space-task-date-1", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-task-date-1", + project_id="test-project-task-date-1", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + db.commit() + + now = datetime.now() + + # Try to create task with invalid dates + response = client.post( + "/api/projects/test-project-task-date-1/tasks", + json={ + "title": "Invalid Date Task", + "start_date": (now + timedelta(days=10)).isoformat(), + "due_date": now.isoformat(), + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 400 + assert "Start date cannot be after due date" in response.json()["detail"] + + def test_update_task_with_invalid_dates_rejected(self, client, db, admin_token): + """Test that updating a task to have start_date > due_date is rejected.""" + # Create test data + space = Space( + id="test-space-task-date-2", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-task-date-2", + space_id="test-space-task-date-2", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-task-date-2", + project_id="test-project-task-date-2", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + now = datetime.now() + task = Task( + id="task-update-date", + project_id="test-project-task-date-2", + title="Valid Date Task", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-task-date-2", + start_date=now, + due_date=now + timedelta(days=10), + ) + db.add(task) + db.commit() + + # Try to update with invalid dates + response = client.patch( + "/api/tasks/task-update-date", + json={ + "start_date": (now + timedelta(days=20)).isoformat(), + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 400 + + def test_create_task_with_valid_dates_accepted(self, client, db, admin_token): + """Test that creating a task with valid dates is accepted.""" + # Create test data + space = Space( + id="test-space-task-date-3", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-task-date-3", + space_id="test-space-task-date-3", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-task-date-3", + project_id="test-project-task-date-3", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + db.commit() + + now = datetime.now() + + # Create task with valid dates + response = client.post( + "/api/projects/test-project-task-date-3/tasks", + json={ + "title": "Valid Date Task", + "start_date": now.isoformat(), + "due_date": (now + timedelta(days=10)).isoformat(), + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Valid Date Task" + + +class TestDependencyTypes: + """Test different dependency types.""" + + def test_dependency_type_values(self): + """Test that all dependency types are valid.""" + from app.schemas.task_dependency import DependencyType + + assert DependencyType.FS.value == "FS" + assert DependencyType.SS.value == "SS" + assert DependencyType.FF.value == "FF" + assert DependencyType.SF.value == "SF" + + def test_create_dependency_with_different_types(self, client, db, admin_token): + """Test creating dependencies with different types via API.""" + # Create test data + space = Space( + id="test-space-dep-types", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-dep-types", + space_id="test-space-dep-types", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-dep-types", + project_id="test-project-dep-types", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create multiple tasks + for i in range(5): + task = Task( + id=f"task-dep-type-{i}", + project_id="test-project-dep-types", + title=f"Task {i}", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-dep-types", + ) + db.add(task) + db.commit() + + # Test each dependency type + dep_types = ["FS", "SS", "FF", "SF"] + for i, dep_type in enumerate(dep_types): + response = client.post( + f"/api/tasks/task-dep-type-{i+1}/dependencies", + json={ + "predecessor_id": "task-dep-type-0", + "dependency_type": dep_type, + "lag_days": i + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["dependency_type"] == dep_type + assert data["lag_days"] == i + + +class TestTransitiveDependencies: + """Test transitive dependency queries.""" + + def test_get_all_predecessors(self, db): + """Test getting all transitive predecessors of a task.""" + # Create test data + space = Space( + id="test-space-trans", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-trans", + space_id="test-space-trans", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-trans", + project_id="test-project-trans", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create chain: A -> B -> C -> D + for task_id in ["A", "B", "C", "D"]: + task = Task( + id=f"task-trans-{task_id}", + project_id="test-project-trans", + title=f"Task {task_id}", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-trans", + ) + db.add(task) + + # A -> B + dep1 = TaskDependency( + id="dep-trans-AB", + predecessor_id="task-trans-A", + successor_id="task-trans-B", + dependency_type="FS", + lag_days=0, + ) + # B -> C + dep2 = TaskDependency( + id="dep-trans-BC", + predecessor_id="task-trans-B", + successor_id="task-trans-C", + dependency_type="FS", + lag_days=0, + ) + # C -> D + dep3 = TaskDependency( + id="dep-trans-CD", + predecessor_id="task-trans-C", + successor_id="task-trans-D", + dependency_type="FS", + lag_days=0, + ) + db.add_all([dep1, dep2, dep3]) + db.commit() + + # Get all predecessors of D + predecessors = DependencyService.get_all_predecessors(db, "task-trans-D") + + # D depends on C, C depends on B, B depends on A + assert "task-trans-C" in predecessors + assert "task-trans-B" in predecessors + assert "task-trans-A" in predecessors + assert len(predecessors) == 3 + + def test_get_all_successors(self, db): + """Test getting all transitive successors of a task.""" + # Create test data + space = Space( + id="test-space-succ", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-succ", + space_id="test-space-succ", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-succ", + project_id="test-project-succ", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create chain: A -> B -> C -> D + for task_id in ["A", "B", "C", "D"]: + task = Task( + id=f"task-succ-{task_id}", + project_id="test-project-succ", + title=f"Task {task_id}", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-succ", + ) + db.add(task) + + # A -> B + dep1 = TaskDependency( + id="dep-succ-AB", + predecessor_id="task-succ-A", + successor_id="task-succ-B", + dependency_type="FS", + lag_days=0, + ) + # B -> C + dep2 = TaskDependency( + id="dep-succ-BC", + predecessor_id="task-succ-B", + successor_id="task-succ-C", + dependency_type="FS", + lag_days=0, + ) + # C -> D + dep3 = TaskDependency( + id="dep-succ-CD", + predecessor_id="task-succ-C", + successor_id="task-succ-D", + dependency_type="FS", + lag_days=0, + ) + db.add_all([dep1, dep2, dep3]) + db.commit() + + # Get all successors of A + successors = DependencyService.get_all_successors(db, "task-succ-A") + + # A is predecessor of B, B is predecessor of C, C is predecessor of D + assert "task-succ-B" in successors + assert "task-succ-C" in successors + assert "task-succ-D" in successors + assert len(successors) == 3 diff --git a/backend/tests/test_tasks.py b/backend/tests/test_tasks.py index 9808bf3..78eabaf 100644 --- a/backend/tests/test_tasks.py +++ b/backend/tests/test_tasks.py @@ -114,3 +114,383 @@ class TestSubtaskDepth: """Test that MAX_SUBTASK_DEPTH is defined.""" from app.api.tasks.router import MAX_SUBTASK_DEPTH assert MAX_SUBTASK_DEPTH == 2 + + +class TestDateRangeFilter: + """Test date range filter for calendar view.""" + + def test_due_after_filter(self, client, db, admin_token): + """Test filtering tasks with due_date >= due_after.""" + from datetime import datetime, timedelta + from app.models import Space, Project, Task, TaskStatus + + # Create test data + space = Space( + id="test-space-id", + name="Test Space", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-id", + space_id="test-space-id", + title="Test Project", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-id", + project_id="test-project-id", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create tasks with different due dates + now = datetime.now() + task1 = Task( + id="task-1", + project_id="test-project-id", + title="Task Due Yesterday", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id", + due_date=now - timedelta(days=1), + ) + task2 = Task( + id="task-2", + project_id="test-project-id", + title="Task Due Today", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id", + due_date=now, + ) + task3 = Task( + id="task-3", + project_id="test-project-id", + title="Task Due Tomorrow", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id", + due_date=now + timedelta(days=1), + ) + task4 = Task( + id="task-4", + project_id="test-project-id", + title="Task No Due Date", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id", + due_date=None, + ) + db.add_all([task1, task2, task3, task4]) + db.commit() + + # Filter tasks due today or later + due_after = now.isoformat() + response = client.get( + f"/api/projects/test-project-id/tasks?due_after={due_after}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + # Should return task2 and task3 (due today and tomorrow) + assert data["total"] == 2 + task_ids = [t["id"] for t in data["tasks"]] + assert "task-2" in task_ids + assert "task-3" in task_ids + assert "task-1" not in task_ids + assert "task-4" not in task_ids + + def test_due_before_filter(self, client, db, admin_token): + """Test filtering tasks with due_date <= due_before.""" + from datetime import datetime, timedelta + from app.models import Space, Project, Task, TaskStatus + + # Create test data + space = Space( + id="test-space-id-2", + name="Test Space 2", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-id-2", + space_id="test-space-id-2", + title="Test Project 2", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-id-2", + project_id="test-project-id-2", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create tasks with different due dates + now = datetime.now() + task1 = Task( + id="task-b-1", + project_id="test-project-id-2", + title="Task Due Yesterday", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-2", + due_date=now - timedelta(days=1), + ) + task2 = Task( + id="task-b-2", + project_id="test-project-id-2", + title="Task Due Today", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-2", + due_date=now, + ) + task3 = Task( + id="task-b-3", + project_id="test-project-id-2", + title="Task Due Tomorrow", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-2", + due_date=now + timedelta(days=1), + ) + db.add_all([task1, task2, task3]) + db.commit() + + # Filter tasks due today or earlier + due_before = now.isoformat() + response = client.get( + f"/api/projects/test-project-id-2/tasks?due_before={due_before}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + # Should return task1 and task2 (due yesterday and today) + assert data["total"] == 2 + task_ids = [t["id"] for t in data["tasks"]] + assert "task-b-1" in task_ids + assert "task-b-2" in task_ids + assert "task-b-3" not in task_ids + + def test_date_range_filter_combined(self, client, db, admin_token): + """Test filtering tasks within a date range (due_after AND due_before).""" + from datetime import datetime, timedelta + from app.models import Space, Project, Task, TaskStatus + + # Create test data + space = Space( + id="test-space-id-3", + name="Test Space 3", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-id-3", + space_id="test-space-id-3", + title="Test Project 3", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-id-3", + project_id="test-project-id-3", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create tasks spanning a week + now = datetime.now() + start_of_week = now - timedelta(days=now.weekday()) # Monday + end_of_week = start_of_week + timedelta(days=6) # Sunday + + task_before = Task( + id="task-c-before", + project_id="test-project-id-3", + title="Task Before Week", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-3", + due_date=start_of_week - timedelta(days=1), + ) + task_in_week = Task( + id="task-c-in-week", + project_id="test-project-id-3", + title="Task In Week", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-3", + due_date=start_of_week + timedelta(days=3), + ) + task_after = Task( + id="task-c-after", + project_id="test-project-id-3", + title="Task After Week", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-3", + due_date=end_of_week + timedelta(days=1), + ) + db.add_all([task_before, task_in_week, task_after]) + db.commit() + + # Filter tasks within this week + due_after = start_of_week.isoformat() + due_before = end_of_week.isoformat() + response = client.get( + f"/api/projects/test-project-id-3/tasks?due_after={due_after}&due_before={due_before}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + # Should only return the task in the week + assert data["total"] == 1 + assert data["tasks"][0]["id"] == "task-c-in-week" + + def test_date_filter_with_no_due_date(self, client, db, admin_token): + """Test that tasks without due_date are excluded from date range filters.""" + from datetime import datetime, timedelta + from app.models import Space, Project, Task, TaskStatus + + # Create test data + space = Space( + id="test-space-id-4", + name="Test Space 4", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-id-4", + space_id="test-space-id-4", + title="Test Project 4", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-id-4", + project_id="test-project-id-4", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create tasks - some with due_date, some without + now = datetime.now() + task_with_date = Task( + id="task-d-with-date", + project_id="test-project-id-4", + title="Task With Due Date", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-4", + due_date=now, + ) + task_without_date = Task( + id="task-d-without-date", + project_id="test-project-id-4", + title="Task Without Due Date", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-4", + due_date=None, + ) + db.add_all([task_with_date, task_without_date]) + db.commit() + + # When using date filter, tasks without due_date should be excluded + due_after = (now - timedelta(days=1)).isoformat() + due_before = (now + timedelta(days=1)).isoformat() + response = client.get( + f"/api/projects/test-project-id-4/tasks?due_after={due_after}&due_before={due_before}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + # Should only return the task with due_date + assert data["total"] == 1 + assert data["tasks"][0]["id"] == "task-d-with-date" + + def test_date_filter_backward_compatibility(self, client, db, admin_token): + """Test that not providing date filters returns all tasks (backward compatibility).""" + from datetime import datetime, timedelta + from app.models import Space, Project, Task, TaskStatus + + # Create test data + space = Space( + id="test-space-id-5", + name="Test Space 5", + owner_id="00000000-0000-0000-0000-000000000001", + ) + db.add(space) + + project = Project( + id="test-project-id-5", + space_id="test-space-id-5", + title="Test Project 5", + owner_id="00000000-0000-0000-0000-000000000001", + security_level="public", + ) + db.add(project) + + status = TaskStatus( + id="test-status-id-5", + project_id="test-project-id-5", + name="To Do", + color="#808080", + position=0, + ) + db.add(status) + + # Create tasks with and without due dates + now = datetime.now() + task1 = Task( + id="task-e-1", + project_id="test-project-id-5", + title="Task 1", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-5", + due_date=now, + ) + task2 = Task( + id="task-e-2", + project_id="test-project-id-5", + title="Task 2", + priority="medium", + created_by="00000000-0000-0000-0000-000000000001", + status_id="test-status-id-5", + due_date=None, + ) + db.add_all([task1, task2]) + db.commit() + + # Request without date filters - should return all tasks + response = client.get( + "/api/projects/test-project-id-5/tasks", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert response.status_code == 200 + data = response.json() + # Should return both tasks + assert data["total"] == 2 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9ed2dd0..ef4e27f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,7 +8,13 @@ "name": "pjctrl-frontend", "version": "0.1.0", "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", "axios": "^1.6.0", + "frappe-gantt": "^1.0.4", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0" @@ -695,6 +701,57 @@ "node": ">=12" } }, + "node_modules/@fullcalendar/core": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz", + "integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==", + "license": "MIT", + "peer": true, + "dependencies": { + "preact": "~10.12.1" + } + }, + "node_modules/@fullcalendar/daygrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz", + "integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/interaction": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz", + "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, + "node_modules/@fullcalendar/react": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz", + "integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==", + "license": "MIT", + "peerDependencies": { + "@fullcalendar/core": "~6.1.20", + "react": "^16.7.0 || ^17 || ^18 || ^19", + "react-dom": "^16.7.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/@fullcalendar/timegrid": { + "version": "6.1.20", + "resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz", + "integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==", + "license": "MIT", + "dependencies": { + "@fullcalendar/daygrid": "~6.1.20" + }, + "peerDependencies": { + "@fullcalendar/core": "~6.1.20" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1471,6 +1528,12 @@ "node": ">= 6" } }, + "node_modules/frappe-gantt": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.0.4.tgz", + "integrity": "sha512-N94OP9ZiapaG5nzgCeZdxsKP8HD5aLVlH5sEHxSNZQnNKQ4BOn2l46HUD+KIE0LpYIterP7gIrFfkLNRuK0npQ==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1746,6 +1809,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.12.1", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz", + "integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index d9f992c..a51e63f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,10 +9,16 @@ "preview": "vite preview" }, "dependencies": { + "@fullcalendar/core": "^6.1.20", + "@fullcalendar/daygrid": "^6.1.20", + "@fullcalendar/interaction": "^6.1.20", + "@fullcalendar/react": "^6.1.20", + "@fullcalendar/timegrid": "^6.1.20", + "axios": "^1.6.0", + "frappe-gantt": "^1.0.4", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-router-dom": "^6.21.0", - "axios": "^1.6.0" + "react-router-dom": "^6.21.0" }, "devDependencies": { "@types/react": "^18.2.43", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 92e17bb..c794ae2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard' import Spaces from './pages/Spaces' import Projects from './pages/Projects' import Tasks from './pages/Tasks' +import ProjectSettings from './pages/ProjectSettings' import AuditPage from './pages/AuditPage' import WorkloadPage from './pages/WorkloadPage' import ProjectHealthPage from './pages/ProjectHealthPage' @@ -64,6 +65,16 @@ function App() { } /> + + + + + + } + /> void + onTaskUpdate: () => void +} + +// Priority icons as text prefixes +const priorityIcons: Record = { + urgent: '!!!', + high: '!!', + medium: '!', + low: '', +} + +// Priority colors for styling +const priorityColors: Record = { + urgent: '#f44336', + high: '#ff9800', + medium: '#0066cc', + low: '#808080', +} + +export function CalendarView({ + projectId, + statuses, + onTaskClick, + onTaskUpdate, +}: CalendarViewProps) { + const calendarRef = useRef(null) + const [events, setEvents] = useState([]) + const [loading, setLoading] = useState(false) + const [dateRange, setDateRange] = useState<{ start: Date; end: Date } | null>(null) + + // Filter state + const [filterAssignee, setFilterAssignee] = useState('') + const [filterStatus, setFilterStatus] = useState('active') // 'all', 'active', 'completed' + const [filterPriority, setFilterPriority] = useState('') + const [assignees, setAssignees] = useState<{ id: string; name: string }[]>([]) + + // Load assignees for filter + useEffect(() => { + loadAssignees() + }, [projectId]) + + const loadAssignees = async () => { + try { + const response = await api.get(`/projects/${projectId}/tasks`) + const tasks: Task[] = response.data.tasks + const uniqueAssignees = new Map() + tasks.forEach((task) => { + if (task.assignee_id && task.assignee_name) { + uniqueAssignees.set(task.assignee_id, task.assignee_name) + } + }) + setAssignees( + Array.from(uniqueAssignees.entries()).map(([id, name]) => ({ id, name })) + ) + } catch (err) { + console.error('Failed to load assignees:', err) + } + } + + // Load tasks when date range or filters change + useEffect(() => { + if (dateRange) { + loadTasks(dateRange.start, dateRange.end) + } + }, [dateRange, filterAssignee, filterStatus, filterPriority]) + + const loadTasks = async (start: Date, end: Date) => { + setLoading(true) + try { + // Format dates for API + const dueAfter = start.toISOString().split('T')[0] + const dueBefore = end.toISOString().split('T')[0] + + const response = await api.get( + `/projects/${projectId}/tasks?due_after=${dueAfter}&due_before=${dueBefore}` + ) + const tasks: Task[] = response.data.tasks + + // Apply client-side filters + let filteredTasks = tasks + + // Assignee filter + if (filterAssignee) { + filteredTasks = filteredTasks.filter( + (task) => task.assignee_id === filterAssignee + ) + } + + // Status filter (show/hide completed) + if (filterStatus === 'active') { + const doneStatuses = statuses.filter((s) => s.is_done).map((s) => s.id) + filteredTasks = filteredTasks.filter( + (task) => !task.status_id || !doneStatuses.includes(task.status_id) + ) + } else if (filterStatus === 'completed') { + const doneStatuses = statuses.filter((s) => s.is_done).map((s) => s.id) + filteredTasks = filteredTasks.filter( + (task) => task.status_id && doneStatuses.includes(task.status_id) + ) + } + + // Priority filter + if (filterPriority) { + filteredTasks = filteredTasks.filter( + (task) => task.priority === filterPriority + ) + } + + // Transform to calendar events + const calendarEvents = filteredTasks + .filter((task) => task.due_date) // Only tasks with due dates + .map((task) => transformTaskToEvent(task)) + + setEvents(calendarEvents) + } catch (err) { + console.error('Failed to load tasks:', err) + } finally { + setLoading(false) + } + } + + const transformTaskToEvent = (task: Task): CalendarEvent => { + const now = new Date() + now.setHours(0, 0, 0, 0) + const dueDate = task.due_date ? new Date(task.due_date) : null + const isOverdue = dueDate ? dueDate < now : false + + // Determine background color based on status or priority + let backgroundColor = task.status_color || '#e0e0e0' + let borderColor = backgroundColor + let textColor = '#ffffff' + + // If overdue, use special styling + if (isOverdue) { + backgroundColor = '#ffebee' + borderColor = '#f44336' + textColor = '#c62828' + } + + // Check if status is "done" + const statusIsDone = statuses.find( + (s) => s.id === task.status_id + )?.is_done + if (statusIsDone) { + backgroundColor = '#e8f5e9' + borderColor = '#4caf50' + textColor = '#2e7d32' + } + + // Add priority icon to title + const priorityIcon = priorityIcons[task.priority] || '' + const displayTitle = priorityIcon + ? `${priorityIcon} ${task.title}` + : task.title + + return { + id: task.id, + title: displayTitle, + start: task.due_date!, + allDay: true, + backgroundColor, + borderColor, + textColor, + extendedProps: { + task, + isOverdue, + priority: task.priority, + }, + } + } + + const handleDatesSet = useCallback((dateInfo: DatesSetArg) => { + setDateRange({ + start: dateInfo.start, + end: dateInfo.end, + }) + }, []) + + const handleEventClick = useCallback( + (clickInfo: EventClickArg) => { + const task = clickInfo.event.extendedProps.task as Task + onTaskClick(task) + }, + [onTaskClick] + ) + + const handleEventDrop = useCallback( + async (dropInfo: EventDropArg) => { + const task = dropInfo.event.extendedProps.task as Task + const newDate = dropInfo.event.start + + if (!newDate) { + dropInfo.revert() + return + } + + // Optimistic update - event is already moved in the calendar + const newDueDate = newDate.toISOString().split('T')[0] + + try { + await api.patch(`/tasks/${task.id}`, { + due_date: newDueDate, + }) + // Refresh to get updated data + onTaskUpdate() + } catch (err) { + console.error('Failed to update task date:', err) + // Rollback on error + dropInfo.revert() + } + }, + [onTaskUpdate] + ) + + // Persist filters to localStorage + useEffect(() => { + const savedFilters = localStorage.getItem(`calendar-filters-${projectId}`) + if (savedFilters) { + try { + const filters = JSON.parse(savedFilters) + if (filters.assignee) setFilterAssignee(filters.assignee) + if (filters.status) setFilterStatus(filters.status) + if (filters.priority) setFilterPriority(filters.priority) + } catch { + // Ignore parse errors + } + } + }, [projectId]) + + useEffect(() => { + localStorage.setItem( + `calendar-filters-${projectId}`, + JSON.stringify({ + assignee: filterAssignee, + status: filterStatus, + priority: filterPriority, + }) + ) + }, [projectId, filterAssignee, filterStatus, filterPriority]) + + const handleClearFilters = () => { + setFilterAssignee('') + setFilterStatus('active') + setFilterPriority('') + } + + const hasActiveFilters = + filterAssignee !== '' || + filterStatus !== 'active' || + filterPriority !== '' + + return ( +
+ {/* Filter Controls */} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {hasActiveFilters && ( + + )} + + {loading && Loading...} +
+ + {/* Calendar */} +
+ +
+ + {/* Legend */} +
+
+ + Overdue +
+
+ + Completed +
+
+ Priority: + !!! Urgent + !! High + ! Medium +
+
+
+ ) +} + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + filterBar: { + display: 'flex', + flexWrap: 'wrap', + gap: '16px', + alignItems: 'flex-end', + padding: '16px', + backgroundColor: '#f9f9f9', + borderRadius: '8px', + border: '1px solid #eee', + }, + filterGroup: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + filterLabel: { + fontSize: '12px', + fontWeight: 500, + color: '#666', + textTransform: 'uppercase', + }, + filterSelect: { + padding: '8px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + backgroundColor: 'white', + minWidth: '140px', + cursor: 'pointer', + }, + clearFiltersButton: { + padding: '8px 16px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + cursor: 'pointer', + color: '#666', + }, + loadingIndicator: { + fontSize: '14px', + color: '#666', + marginLeft: 'auto', + }, + calendarWrapper: { + backgroundColor: 'white', + padding: '16px', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + }, + legend: { + display: 'flex', + flexWrap: 'wrap', + gap: '24px', + padding: '12px 16px', + backgroundColor: '#f9f9f9', + borderRadius: '8px', + fontSize: '13px', + color: '#666', + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + legendDot: { + width: '12px', + height: '12px', + borderRadius: '3px', + display: 'inline-block', + }, + legendText: { + color: '#666', + }, + priorityLabel: { + fontWeight: 500, + marginLeft: '4px', + }, +} + +export default CalendarView diff --git a/frontend/src/components/CustomFieldEditor.tsx b/frontend/src/components/CustomFieldEditor.tsx new file mode 100644 index 0000000..0ce9384 --- /dev/null +++ b/frontend/src/components/CustomFieldEditor.tsx @@ -0,0 +1,520 @@ +import { useState, useEffect } from 'react' +import { + customFieldsApi, + CustomField, + CustomFieldCreate, + CustomFieldUpdate, + FieldType, +} from '../services/customFields' + +interface CustomFieldEditorProps { + projectId: string + field: CustomField | null // null for create mode + onClose: () => void + onSave: () => void +} + +const FIELD_TYPES: { value: FieldType; label: string; description: string }[] = [ + { value: 'text', label: 'Text', description: 'Single line text input' }, + { value: 'number', label: 'Number', description: 'Numeric value' }, + { value: 'dropdown', label: 'Dropdown', description: 'Select from predefined options' }, + { value: 'date', label: 'Date', description: 'Date picker' }, + { value: 'person', label: 'Person', description: 'User assignment' }, + { value: 'formula', label: 'Formula', description: 'Calculated from other fields' }, +] + +export function CustomFieldEditor({ + projectId, + field, + onClose, + onSave, +}: CustomFieldEditorProps) { + const isEditing = field !== null + + const [name, setName] = useState(field?.name || '') + const [fieldType, setFieldType] = useState(field?.field_type || 'text') + const [isRequired, setIsRequired] = useState(field?.is_required || false) + const [options, setOptions] = useState(field?.options || ['']) + const [formula, setFormula] = useState(field?.formula || '') + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + + // Reset form when field changes + useEffect(() => { + if (field) { + setName(field.name) + setFieldType(field.field_type) + setIsRequired(field.is_required) + setOptions(field.options || ['']) + setFormula(field.formula || '') + } else { + setName('') + setFieldType('text') + setIsRequired(false) + setOptions(['']) + setFormula('') + } + setError(null) + }, [field]) + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + } + + const handleAddOption = () => { + setOptions([...options, '']) + } + + const handleRemoveOption = (index: number) => { + if (options.length > 1) { + setOptions(options.filter((_, i) => i !== index)) + } + } + + const handleOptionChange = (index: number, value: string) => { + const newOptions = [...options] + newOptions[index] = value + setOptions(newOptions) + } + + const validateForm = (): boolean => { + if (!name.trim()) { + setError('Field name is required') + return false + } + + if (fieldType === 'dropdown') { + const validOptions = options.filter((opt) => opt.trim()) + if (validOptions.length === 0) { + setError('At least one option is required for dropdown fields') + return false + } + } + + if (fieldType === 'formula' && !formula.trim()) { + setError('Formula expression is required') + return false + } + + return true + } + + const handleSave = async () => { + if (!validateForm()) return + + setSaving(true) + setError(null) + + try { + if (isEditing && field) { + // Update existing field + const updateData: CustomFieldUpdate = { + name: name.trim(), + is_required: isRequired, + } + + if (field.field_type === 'dropdown') { + updateData.options = options.filter((opt) => opt.trim()) + } + + if (field.field_type === 'formula') { + updateData.formula = formula.trim() + } + + await customFieldsApi.updateCustomField(field.id, updateData) + } else { + // Create new field + const createData: CustomFieldCreate = { + name: name.trim(), + field_type: fieldType, + is_required: isRequired, + } + + if (fieldType === 'dropdown') { + createData.options = options.filter((opt) => opt.trim()) + } + + if (fieldType === 'formula') { + createData.formula = formula.trim() + } + + await customFieldsApi.createCustomField(projectId, createData) + } + + onSave() + } catch (err: unknown) { + const errorMessage = + (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || + 'Failed to save field' + setError(errorMessage) + } finally { + setSaving(false) + } + } + + return ( +
+
+
+

+ {isEditing ? 'Edit Custom Field' : 'Create Custom Field'} +

+ +
+ +
+ {error &&
{error}
} + + {/* Field Name */} +
+ + setName(e.target.value)} + placeholder="e.g., Story Points, Sprint Number" + style={styles.input} + maxLength={100} + /> +
+ + {/* Field Type - only show for create mode */} + {!isEditing && ( +
+ +
+ {FIELD_TYPES.map((type) => ( +
setFieldType(type.value)} + > +
{type.label}
+
{type.description}
+
+ ))} +
+
+ )} + + {/* Show current type info for edit mode */} + {isEditing && ( +
+ +
+ {FIELD_TYPES.find((t) => t.value === fieldType)?.label} + (cannot be changed) +
+
+ )} + + {/* Dropdown Options */} + {fieldType === 'dropdown' && ( +
+ +
+ {options.map((option, index) => ( +
+ handleOptionChange(index, e.target.value)} + placeholder={`Option ${index + 1}`} + style={styles.optionInput} + /> + {options.length > 1 && ( + + )} +
+ ))} +
+ +
+ )} + + {/* Formula Expression */} + {fieldType === 'formula' && ( +
+ + setFormula(e.target.value)} + placeholder="e.g., {time_spent} / {original_estimate} * 100" + style={styles.input} + /> +
+

Use curly braces to reference other fields:

+
    +
  • + {'{field_name}'} - Reference a custom number field +
  • +
  • + {'{original_estimate}'} - Task time estimate +
  • +
  • + {'{time_spent}'} - Logged time +
  • +
+

Supported operators: +, -, *, /

+
+
+ )} + + {/* Required Checkbox */} +
+ +
+ Tasks cannot be created or updated without filling in required fields. +
+
+
+ +
+ + +
+
+
+ ) +} + +const styles: Record = { + overlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + modal: { + backgroundColor: 'white', + borderRadius: '12px', + width: '550px', + maxWidth: '90%', + maxHeight: '90vh', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + boxShadow: '0 8px 32px rgba(0, 0, 0, 0.2)', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '20px 24px', + borderBottom: '1px solid #eee', + }, + title: { + margin: 0, + fontSize: '18px', + fontWeight: 600, + }, + closeButton: { + width: '32px', + height: '32px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'transparent', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '16px', + color: '#666', + }, + content: { + padding: '24px', + overflowY: 'auto', + flex: 1, + }, + errorMessage: { + padding: '12px', + backgroundColor: '#ffebee', + color: '#f44336', + borderRadius: '4px', + marginBottom: '16px', + fontSize: '14px', + }, + formGroup: { + marginBottom: '20px', + }, + label: { + display: 'block', + marginBottom: '8px', + fontSize: '14px', + fontWeight: 500, + color: '#333', + }, + input: { + width: '100%', + padding: '10px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + boxSizing: 'border-box', + }, + typeGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(3, 1fr)', + gap: '10px', + }, + typeCard: { + padding: '12px', + border: '1px solid #ddd', + borderRadius: '6px', + cursor: 'pointer', + transition: 'border-color 0.2s, background-color 0.2s', + }, + typeCardSelected: { + borderColor: '#0066cc', + backgroundColor: '#e6f0ff', + }, + typeLabel: { + fontSize: '14px', + fontWeight: 500, + marginBottom: '4px', + }, + typeDescription: { + fontSize: '11px', + color: '#666', + }, + typeDisplay: { + fontSize: '14px', + color: '#333', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + typeNote: { + fontSize: '12px', + color: '#999', + fontStyle: 'italic', + }, + optionsList: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + marginBottom: '12px', + }, + optionRow: { + display: 'flex', + gap: '8px', + }, + optionInput: { + flex: 1, + padding: '10px 12px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + boxSizing: 'border-box', + }, + removeOptionButton: { + width: '40px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + color: '#666', + }, + addOptionButton: { + padding: '8px 12px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '13px', + color: '#333', + }, + formulaHelp: { + marginTop: '12px', + padding: '12px', + backgroundColor: '#f5f5f5', + borderRadius: '4px', + fontSize: '12px', + color: '#666', + }, + checkboxLabel: { + display: 'flex', + alignItems: 'center', + gap: '8px', + fontSize: '14px', + cursor: 'pointer', + }, + checkbox: { + width: '16px', + height: '16px', + cursor: 'pointer', + }, + checkboxHelp: { + marginTop: '6px', + marginLeft: '24px', + fontSize: '12px', + color: '#888', + }, + footer: { + display: 'flex', + justifyContent: 'flex-end', + gap: '12px', + padding: '16px 24px', + borderTop: '1px solid #eee', + backgroundColor: '#fafafa', + }, + cancelButton: { + padding: '10px 20px', + backgroundColor: 'white', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + }, + saveButton: { + padding: '10px 20px', + backgroundColor: '#0066cc', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + fontWeight: 500, + }, +} + +export default CustomFieldEditor diff --git a/frontend/src/components/CustomFieldInput.tsx b/frontend/src/components/CustomFieldInput.tsx new file mode 100644 index 0000000..87b1507 --- /dev/null +++ b/frontend/src/components/CustomFieldInput.tsx @@ -0,0 +1,257 @@ +import { CustomField, CustomValueResponse } from '../services/customFields' +import { UserSelect } from './UserSelect' +import { UserSearchResult } from '../services/collaboration' + +interface CustomFieldInputProps { + field: CustomField + value: CustomValueResponse | null + onChange: (fieldId: string, value: string | number | null) => void + disabled?: boolean + showLabel?: boolean +} + +/** + * Dynamic input component that renders appropriate input based on field type + */ +export function CustomFieldInput({ + field, + value, + onChange, + disabled = false, + showLabel = true, +}: CustomFieldInputProps) { + const currentValue = value?.value ?? null + + const handleChange = (newValue: string | number | null) => { + onChange(field.id, newValue) + } + + const renderInput = () => { + switch (field.field_type) { + case 'text': + return ( + handleChange(e.target.value || null)} + placeholder={`Enter ${field.name.toLowerCase()}...`} + style={styles.input} + disabled={disabled} + /> + ) + + case 'number': + return ( + { + const val = e.target.value + handleChange(val === '' ? null : parseFloat(val)) + }} + placeholder="0" + style={styles.input} + disabled={disabled} + /> + ) + + case 'dropdown': + return ( + + ) + + case 'date': + return ( + handleChange(e.target.value || null)} + style={styles.input} + disabled={disabled} + /> + ) + + case 'person': + return ( + { + handleChange(userId) + }} + placeholder={`Select ${field.name.toLowerCase()}...`} + disabled={disabled} + /> + ) + + case 'formula': + // Formula fields are read-only and display the calculated value + return ( +
+ {value?.display_value !== null && value?.display_value !== undefined + ? value.display_value + : currentValue !== null + ? String(currentValue) + : '-'} + (calculated) +
+ ) + + default: + return
Unsupported field type
+ } + } + + return ( +
+ {showLabel && ( + + )} + {renderInput()} +
+ ) +} + +interface CustomFieldsGroupProps { + fields: CustomField[] + values: CustomValueResponse[] + onChange: (fieldId: string, value: string | number | null) => void + disabled?: boolean + layout?: 'vertical' | 'sidebar' +} + +/** + * Component to render a group of custom fields + */ +export function CustomFieldsGroup({ + fields, + values, + onChange, + disabled = false, + layout = 'vertical', +}: CustomFieldsGroupProps) { + if (fields.length === 0) { + return null + } + + const getValueForField = (fieldId: string): CustomValueResponse | null => { + return values.find((v) => v.field_id === fieldId) || null + } + + return ( +
+ {fields.map((field) => ( +
+ +
+ ))} +
+ ) +} + +const styles: Record = { + container: { + width: '100%', + }, + label: { + display: 'block', + fontSize: '12px', + fontWeight: 600, + color: '#666', + marginBottom: '6px', + textTransform: 'uppercase', + }, + required: { + color: '#f44336', + marginLeft: '4px', + }, + input: { + width: '100%', + padding: '10px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + boxSizing: 'border-box', + backgroundColor: 'white', + }, + select: { + width: '100%', + padding: '10px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + boxSizing: 'border-box', + backgroundColor: 'white', + }, + formulaDisplay: { + padding: '10px', + backgroundColor: '#f5f5f5', + border: '1px solid #eee', + borderRadius: '4px', + fontSize: '14px', + color: '#333', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + formulaHint: { + fontSize: '11px', + color: '#999', + fontStyle: 'italic', + }, + unsupported: { + padding: '10px', + backgroundColor: '#fff3e0', + border: '1px solid #ffb74d', + borderRadius: '4px', + fontSize: '14px', + color: '#e65100', + }, + groupVertical: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + groupSidebar: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + verticalField: { + width: '100%', + }, + sidebarField: { + width: '100%', + }, +} + +export default CustomFieldInput diff --git a/frontend/src/components/CustomFieldList.tsx b/frontend/src/components/CustomFieldList.tsx new file mode 100644 index 0000000..c86e558 --- /dev/null +++ b/frontend/src/components/CustomFieldList.tsx @@ -0,0 +1,408 @@ +import { useState, useEffect } from 'react' +import { customFieldsApi, CustomField, FieldType } from '../services/customFields' +import { CustomFieldEditor } from './CustomFieldEditor' + +interface CustomFieldListProps { + projectId: string +} + +const FIELD_TYPE_LABELS: Record = { + text: 'Text', + number: 'Number', + dropdown: 'Dropdown', + date: 'Date', + person: 'Person', + formula: 'Formula', +} + +export function CustomFieldList({ projectId }: CustomFieldListProps) { + const [fields, setFields] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [showEditor, setShowEditor] = useState(false) + const [editingField, setEditingField] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(null) + const [deleting, setDeleting] = useState(false) + + useEffect(() => { + loadFields() + }, [projectId]) + + const loadFields = async () => { + setLoading(true) + setError(null) + try { + const response = await customFieldsApi.getCustomFields(projectId) + setFields(response.fields) + } catch (err) { + console.error('Failed to load custom fields:', err) + setError('Failed to load custom fields') + } finally { + setLoading(false) + } + } + + const handleCreate = () => { + setEditingField(null) + setShowEditor(true) + } + + const handleEdit = (field: CustomField) => { + setEditingField(field) + setShowEditor(true) + } + + const handleEditorClose = () => { + setShowEditor(false) + setEditingField(null) + } + + const handleEditorSave = () => { + setShowEditor(false) + setEditingField(null) + loadFields() + } + + const handleDeleteClick = (fieldId: string) => { + setDeleteConfirm(fieldId) + } + + const handleDeleteCancel = () => { + setDeleteConfirm(null) + } + + const handleDeleteConfirm = async () => { + if (!deleteConfirm) return + + setDeleting(true) + try { + await customFieldsApi.deleteCustomField(deleteConfirm) + setDeleteConfirm(null) + loadFields() + } catch (err: unknown) { + const errorMessage = + err instanceof Error + ? err.message + : (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || + 'Failed to delete field' + alert(errorMessage) + } finally { + setDeleting(false) + } + } + + if (loading) { + return
Loading custom fields...
+ } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+
+

Custom Fields

+ +
+ +

+ Custom fields allow you to add additional data to tasks. You can create up to 20 + fields per project. +

+ + {fields.length === 0 ? ( +
+

No custom fields defined yet.

+

+ Click "Add Field" to create your first custom field. +

+
+ ) : ( +
+ {fields.map((field) => ( +
+
+
+ {field.name} + {field.is_required && Required} +
+
+ + {FIELD_TYPE_LABELS[field.field_type]} + + {field.field_type === 'dropdown' && field.options && ( + + {field.options.length} option{field.options.length !== 1 ? 's' : ''} + + )} + {field.field_type === 'formula' && field.formula && ( + = {field.formula} + )} +
+
+
+ + +
+
+ ))} +
+ )} + + {/* Editor Modal */} + {showEditor && ( + + )} + + {/* Delete Confirmation Modal */} + {deleteConfirm && ( +
+
+

Delete Custom Field?

+

+ This will permanently delete this field and all stored values for all tasks. + This action cannot be undone. +

+
+ + +
+
+
+ )} +
+ ) +} + +const styles: Record = { + container: { + padding: '24px', + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px', + }, + title: { + margin: 0, + fontSize: '18px', + fontWeight: 600, + }, + addButton: { + padding: '8px 16px', + backgroundColor: '#0066cc', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + fontWeight: 500, + }, + description: { + fontSize: '14px', + color: '#666', + marginBottom: '20px', + }, + loading: { + padding: '24px', + textAlign: 'center', + color: '#666', + }, + error: { + padding: '24px', + textAlign: 'center', + color: '#f44336', + }, + retryButton: { + marginTop: '12px', + padding: '8px 16px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + }, + emptyState: { + textAlign: 'center', + padding: '32px', + color: '#666', + backgroundColor: '#fafafa', + borderRadius: '8px', + border: '1px dashed #ddd', + }, + emptyHint: { + fontSize: '13px', + color: '#999', + marginTop: '8px', + }, + fieldList: { + display: 'flex', + flexDirection: 'column', + gap: '12px', + }, + fieldCard: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '16px', + backgroundColor: '#fafafa', + borderRadius: '8px', + border: '1px solid #eee', + }, + fieldInfo: { + flex: 1, + }, + fieldName: { + fontSize: '14px', + fontWeight: 500, + marginBottom: '4px', + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + requiredBadge: { + fontSize: '11px', + padding: '2px 6px', + backgroundColor: '#ff9800', + color: 'white', + borderRadius: '4px', + fontWeight: 500, + }, + fieldMeta: { + display: 'flex', + gap: '12px', + fontSize: '12px', + color: '#666', + }, + fieldType: { + backgroundColor: '#e0e0e0', + padding: '2px 8px', + borderRadius: '4px', + }, + optionCount: { + color: '#888', + }, + formulaPreview: { + fontFamily: 'monospace', + color: '#0066cc', + maxWidth: '200px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + fieldActions: { + display: 'flex', + gap: '8px', + }, + editButton: { + padding: '6px 12px', + backgroundColor: 'white', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '13px', + }, + deleteButton: { + padding: '6px 12px', + backgroundColor: 'white', + border: '1px solid #f44336', + color: '#f44336', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '13px', + }, + modalOverlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + confirmModal: { + backgroundColor: 'white', + padding: '24px', + borderRadius: '8px', + width: '400px', + maxWidth: '90%', + }, + confirmTitle: { + margin: '0 0 12px 0', + fontSize: '18px', + fontWeight: 600, + }, + confirmMessage: { + fontSize: '14px', + color: '#666', + lineHeight: 1.5, + marginBottom: '20px', + }, + confirmActions: { + display: 'flex', + justifyContent: 'flex-end', + gap: '12px', + }, + cancelButton: { + padding: '10px 20px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + }, + confirmDeleteButton: { + padding: '10px 20px', + backgroundColor: '#f44336', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + }, +} + +export default CustomFieldList diff --git a/frontend/src/components/GanttChart.tsx b/frontend/src/components/GanttChart.tsx new file mode 100644 index 0000000..42ac836 --- /dev/null +++ b/frontend/src/components/GanttChart.tsx @@ -0,0 +1,983 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import Gantt, { GanttTask, ViewMode } from 'frappe-gantt' +import api from '../services/api' +import { dependenciesApi, TaskDependency, DependencyType } from '../services/dependencies' + +interface Task { + id: string + project_id: string + title: string + description: string | null + priority: string + status_id: string | null + status_name: string | null + status_color: string | null + assignee_id: string | null + assignee_name: string | null + due_date: string | null + start_date: string | null + time_estimate: number | null + subtask_count: number + progress?: number +} + +interface TaskStatus { + id: string + name: string + color: string + is_done: boolean +} + +interface GanttChartProps { + projectId: string + tasks: Task[] + statuses: TaskStatus[] + onTaskClick: (task: Task) => void + onTaskUpdate: () => void +} + +// Priority colors for custom styling +const priorityClasses: Record = { + urgent: 'gantt-bar-urgent', + high: 'gantt-bar-high', + medium: 'gantt-bar-medium', + low: 'gantt-bar-low', +} + +export function GanttChart({ + projectId, + tasks, + statuses, + onTaskClick, + onTaskUpdate, +}: GanttChartProps) { + const ganttRef = useRef(null) + const ganttInstance = useRef(null) + const [viewMode, setViewMode] = useState('Week') + const [dependencies, setDependencies] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Dependency management state + const [showDependencyModal, setShowDependencyModal] = useState(false) + const [selectedTaskForDependency, setSelectedTaskForDependency] = useState(null) + const [selectedPredecessor, setSelectedPredecessor] = useState('') + const [selectedDependencyType, setSelectedDependencyType] = useState('FS') + const [dependencyError, setDependencyError] = useState(null) + + // Task data mapping for quick lookup + const taskMap = useRef>(new Map()) + + // Load dependencies + useEffect(() => { + loadDependencies() + }, [projectId]) + + const loadDependencies = async () => { + try { + const deps = await dependenciesApi.getProjectDependencies(projectId) + setDependencies(deps) + } catch (err) { + console.error('Failed to load dependencies:', err) + } + } + + // Transform tasks to Gantt format + const transformTasksToGantt = useCallback((): GanttTask[] => { + const today = new Date() + const defaultEndDate = new Date(today) + defaultEndDate.setDate(defaultEndDate.getDate() + 7) + + // Update task map + taskMap.current.clear() + tasks.forEach((task) => taskMap.current.set(task.id, task)) + + // Build dependency string map (task_id -> comma-separated predecessor ids) + const dependencyMap = new Map() + dependencies.forEach((dep) => { + const existing = dependencyMap.get(dep.successor_id) || [] + existing.push(dep.predecessor_id) + dependencyMap.set(dep.successor_id, existing) + }) + + return tasks + .filter((task) => task.start_date || task.due_date) // Only tasks with dates + .map((task) => { + // Determine dates + let startDate: Date + let endDate: Date + + if (task.start_date && task.due_date) { + startDate = new Date(task.start_date) + endDate = new Date(task.due_date) + } else if (task.start_date) { + startDate = new Date(task.start_date) + endDate = new Date(startDate) + endDate.setDate(endDate.getDate() + 7) // Default 7 days duration + } else if (task.due_date) { + endDate = new Date(task.due_date) + startDate = new Date(endDate) + startDate.setDate(startDate.getDate() - 7) // Default start 7 days before + } else { + startDate = today + endDate = defaultEndDate + } + + // Calculate progress based on status + let progress = task.progress ?? 0 + if (task.status_id) { + const status = statuses.find((s) => s.id === task.status_id) + if (status?.is_done) { + progress = 100 + } + } + + // Get dependencies for this task + const taskDeps = dependencyMap.get(task.id) + const dependencyString = taskDeps ? taskDeps.join(', ') : '' + + // Custom class based on priority + const customClass = priorityClasses[task.priority] || priorityClasses.medium + + return { + id: task.id, + name: task.title, + start: startDate, + end: endDate, + progress, + dependencies: dependencyString, + custom_class: customClass, + // Store original task data for reference + _task: task, + } + }) + }, [tasks, statuses, dependencies]) + + // Initialize and update Gantt chart + useEffect(() => { + if (!ganttRef.current) return + + const ganttTasks = transformTasksToGantt() + + if (ganttTasks.length === 0) { + // Clear the gantt if no tasks + ganttInstance.current = null + return + } + + // Create or update Gantt instance + if (!ganttInstance.current) { + // Clear container first + ganttRef.current.innerHTML = '' + + ganttInstance.current = new Gantt(ganttRef.current, ganttTasks, { + header_height: 50, + column_width: 30, + step: 24, + view_modes: ['Day', 'Week', 'Month'], + bar_height: 24, + bar_corner_radius: 3, + arrow_curve: 5, + padding: 18, + view_mode: viewMode, + date_format: 'YYYY-MM-DD', + language: 'en', + custom_popup_html: (task: GanttTask) => { + const originalTask = taskMap.current.get(task.id) + if (!originalTask) return '' + + const assignee = originalTask.assignee_name || 'Unassigned' + const statusName = originalTask.status_name || 'No Status' + const priority = originalTask.priority.charAt(0).toUpperCase() + originalTask.priority.slice(1) + + return ` +
+

${task.name}

+
+
+ Assignee: + ${assignee} +
+
+ Status: + ${statusName} +
+
+ Priority: + ${priority} +
+
+ Progress: + ${task.progress}% +
+
+
+ ` + }, + on_click: (task: GanttTask) => { + const originalTask = taskMap.current.get(task.id) + if (originalTask) { + onTaskClick(originalTask) + } + }, + on_date_change: async (task: GanttTask, start: Date, end: Date) => { + await handleDateChange(task.id, start, end) + }, + on_progress_change: async (task: GanttTask, progress: number) => { + await handleProgressChange(task.id, progress) + }, + }) + } else { + // Refresh existing instance + ganttInstance.current.refresh(ganttTasks) + } + }, [tasks, dependencies, transformTasksToGantt, onTaskClick]) + + // Update view mode + useEffect(() => { + if (ganttInstance.current) { + ganttInstance.current.change_view_mode(viewMode) + } + }, [viewMode]) + + // Handle date change (drag/resize) + const handleDateChange = async (taskId: string, start: Date, end: Date) => { + setError(null) + setLoading(true) + + // Format dates + const startDate = start.toISOString().split('T')[0] + const dueDate = end.toISOString().split('T')[0] + + try { + await api.patch(`/tasks/${taskId}`, { + start_date: startDate, + due_date: dueDate, + }) + onTaskUpdate() + } catch (err: unknown) { + console.error('Failed to update task dates:', err) + const error = err as { response?: { data?: { detail?: string } } } + const errorMessage = error.response?.data?.detail || 'Failed to update task dates' + setError(errorMessage) + // Refresh to rollback visual changes + onTaskUpdate() + } finally { + setLoading(false) + } + } + + // Handle progress change + const handleProgressChange = async (taskId: string, progress: number) => { + // Progress changes could update task status in the future + // For now, just log it + console.log(`Task ${taskId} progress changed to ${progress}%`) + } + + // Add dependency + const handleAddDependency = async () => { + if (!selectedTaskForDependency || !selectedPredecessor) return + + setDependencyError(null) + + try { + await dependenciesApi.addDependency(selectedTaskForDependency.id, { + predecessor_id: selectedPredecessor, + dependency_type: selectedDependencyType, + }) + await loadDependencies() + setShowDependencyModal(false) + setSelectedTaskForDependency(null) + setSelectedPredecessor('') + setSelectedDependencyType('FS') + } catch (err: unknown) { + console.error('Failed to add dependency:', err) + const error = err as { response?: { data?: { detail?: string } } } + const errorMessage = error.response?.data?.detail || 'Failed to add dependency' + setDependencyError(errorMessage) + } + } + + // Remove dependency + const handleRemoveDependency = async (dependencyId: string) => { + try { + await dependenciesApi.removeDependency(dependencyId) + await loadDependencies() + } catch (err) { + console.error('Failed to remove dependency:', err) + setError('Failed to remove dependency') + } + } + + // Open dependency modal for a task + const openDependencyModal = (task: Task) => { + setSelectedTaskForDependency(task) + setSelectedPredecessor('') + setDependencyError(null) + setShowDependencyModal(true) + } + + // Get available predecessors (tasks that can be added as dependencies) + const getAvailablePredecessors = () => { + if (!selectedTaskForDependency) return [] + + // Get existing predecessor IDs for selected task + const existingPredecessorIds = new Set( + dependencies + .filter((d) => d.successor_id === selectedTaskForDependency.id) + .map((d) => d.predecessor_id) + ) + + // Filter out the selected task itself and already added predecessors + return tasks.filter( + (t) => + t.id !== selectedTaskForDependency.id && + !existingPredecessorIds.has(t.id) && + (t.start_date || t.due_date) // Only tasks with dates + ) + } + + // Get current dependencies for selected task + const getCurrentDependencies = () => { + if (!selectedTaskForDependency) return [] + return dependencies.filter((d) => d.successor_id === selectedTaskForDependency.id) + } + + // Check if there are tasks with dates + const tasksWithDates = tasks.filter((t) => t.start_date || t.due_date) + + return ( +
+ {/* Toolbar */} +
+
+ Zoom: + {(['Day', 'Week', 'Month'] as ViewMode[]).map((mode) => ( + + ))} +
+ + {loading && Saving...} + {error && {error}} +
+ + {/* Gantt Chart */} + {tasksWithDates.length > 0 ? ( +
+
+
+ ) : ( +
+

No tasks with dates to display.

+

+ Add start dates and due dates to your tasks to see them on the Gantt chart. +

+
+ )} + + {/* Legend and Actions */} +
+
+ Priority: +
+ + Urgent +
+
+ + High +
+
+ + Medium +
+
+ + Low +
+
+ +
+ + Click a task to view details. Drag to change dates. + +
+
+ + {/* Dependencies Section */} + {tasksWithDates.length > 0 && ( +
+

Task Dependencies

+

+ Select a task below to manage its dependencies. +

+ +
+ {tasksWithDates.map((task) => { + const taskDeps = dependencies.filter((d) => d.successor_id === task.id) + return ( +
+
+ {task.title} + {taskDeps.length > 0 && ( + + Depends on: {taskDeps.map((d) => { + const predecessor = tasks.find((t) => t.id === d.predecessor_id) + return predecessor?.title || 'Unknown' + }).join(', ')} + + )} +
+ +
+ ) + })} +
+
+ )} + + {/* Dependency Modal */} + {showDependencyModal && selectedTaskForDependency && ( +
+
+

+ Manage Dependencies for "{selectedTaskForDependency.title}" +

+ + {dependencyError && ( +
{dependencyError}
+ )} + + {/* Current Dependencies */} +
+

Current Dependencies

+ {getCurrentDependencies().length === 0 ? ( +

No dependencies yet

+ ) : ( +
    + {getCurrentDependencies().map((dep) => { + const predecessor = tasks.find((t) => t.id === dep.predecessor_id) + return ( +
  • + + Depends on: {predecessor?.title || 'Unknown'} + ({dep.dependency_type}) + + +
  • + ) + })} +
+ )} +
+ + {/* Add New Dependency */} +
+

Add Dependency

+ {getAvailablePredecessors().length === 0 ? ( +

No available tasks to add as dependencies

+ ) : ( +
+ + + +
+ )} +
+ +
+ +
+
+
+ )} + + {/* Custom CSS for Gantt */} + +
+ ) +} + +// Custom CSS styles for Gantt (including base frappe-gantt styles) +const ganttStyles = ` + /* Base Frappe Gantt CSS */ + :root{--g-arrow-color: #1f2937;--g-bar-color: #fff;--g-bar-border: #fff;--g-tick-color-thick: #ededed;--g-tick-color: #f3f3f3;--g-actions-background: #f3f3f3;--g-border-color: #ebeff2;--g-text-muted: #7c7c7c;--g-text-light: #fff;--g-text-dark: #171717;--g-progress-color: #dbdbdb;--g-handle-color: #37352f;--g-weekend-label-color: #dcdce4;--g-expected-progress: #c4c4e9;--g-header-background: #fff;--g-row-color: #fdfdfd;--g-row-border-color: #c7c7c7;--g-today-highlight: #37352f;--g-popup-actions: #ebeff2;--g-weekend-highlight-color: #f7f7f7}.gantt-container{line-height:14.5px;position:relative;overflow:auto;font-size:12px;height:var(--gv-grid-height);width:100%;border-radius:8px}.gantt-container .popup-wrapper{position:absolute;top:0;left:0;background:#fff;box-shadow:0 10px 24px -3px #0003;padding:10px;border-radius:5px;width:max-content;z-index:1000}.gantt-container .popup-wrapper .title{margin-bottom:2px;color:var(--g-text-dark);font-size:.85rem;font-weight:650;line-height:15px}.gantt-container .popup-wrapper .subtitle{color:var(--g-text-dark);font-size:.8rem;margin-bottom:5px}.gantt-container .popup-wrapper .details{color:var(--g-text-muted);font-size:.7rem}.gantt-container .popup-wrapper .actions{margin-top:10px;margin-left:3px}.gantt-container .popup-wrapper .action-btn{border:none;padding:5px 8px;background-color:var(--g-popup-actions);border-right:1px solid var(--g-text-light)}.gantt-container .popup-wrapper .action-btn:hover{background-color:brightness(97%)}.gantt-container .popup-wrapper .action-btn:first-child{border-top-left-radius:4px;border-bottom-left-radius:4px}.gantt-container .popup-wrapper .action-btn:last-child{border-right:none;border-top-right-radius:4px;border-bottom-right-radius:4px}.gantt-container .grid-header{height:calc(var(--gv-lower-header-height) + var(--gv-upper-header-height) + 10px);background-color:var(--g-header-background);position:sticky;top:0;left:0;border-bottom:1px solid var(--g-row-border-color);z-index:1000}.gantt-container .lower-text,.gantt-container .upper-text{text-anchor:middle}.gantt-container .upper-header{height:var(--gv-upper-header-height)}.gantt-container .lower-header{height:var(--gv-lower-header-height)}.gantt-container .lower-text{font-size:12px;position:absolute;width:calc(var(--gv-column-width) * .8);height:calc(var(--gv-lower-header-height) * .8);margin:0 calc(var(--gv-column-width) * .1);align-content:center;text-align:center;color:var(--g-text-muted)}.gantt-container .upper-text{position:absolute;width:fit-content;font-weight:500;font-size:14px;color:var(--g-text-dark);height:calc(var(--gv-lower-header-height) * .66)}.gantt-container .current-upper{position:sticky;left:0!important;padding-left:17px;background:#fff}.gantt-container .side-header{position:sticky;top:0;right:0;float:right;z-index:1000;line-height:20px;font-weight:400;width:max-content;margin-left:auto;padding-right:10px;padding-top:10px;background:var(--g-header-background);display:flex}.gantt-container .side-header *{transition-property:background-color;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s;background-color:var(--g-actions-background);border-radius:.5rem;border:none;padding:5px 8px;color:var(--g-text-dark);font-size:14px;letter-spacing:.02em;font-weight:420;box-sizing:content-box;margin-right:5px}.gantt-container .side-header *:last-child{margin-right:0}.gantt-container .side-header *:hover{filter:brightness(97.5%)}.gantt-container .side-header select{width:60px;padding-top:2px;padding-bottom:2px}.gantt-container .side-header select:focus{outline:none}.gantt-container .date-range-highlight{background-color:var(--g-progress-color);border-radius:12px;height:calc(var(--gv-lower-header-height) - 6px);top:calc(var(--gv-upper-header-height) + 5px);position:absolute}.gantt-container .current-highlight{position:absolute;background:var(--g-today-highlight);width:1px;z-index:999}.gantt-container .current-ball-highlight{position:absolute;background:var(--g-today-highlight);z-index:1001;border-radius:50%}.gantt-container .current-date-highlight{background:var(--g-today-highlight);color:var(--g-text-light);border-radius:5px}.gantt-container .holiday-label{position:absolute;top:0;left:0;opacity:0;z-index:1000;background:--g-weekend-label-color;border-radius:5px;padding:2px 5px}.gantt-container .holiday-label.show{opacity:100}.gantt-container .extras{position:sticky;left:0}.gantt-container .extras .adjust{position:absolute;left:8px;top:calc(var(--gv-grid-height) - 60px);background-color:#000000b3;color:#fff;border:none;padding:8px;border-radius:3px}.gantt-container .hide{display:none}.gantt{user-select:none;-webkit-user-select:none;position:absolute}.gantt .grid-background{fill:none}.gantt .grid-row{fill:var(--g-row-color)}.gantt .row-line{stroke:var(--g-border-color)}.gantt .tick{stroke:var(--g-tick-color);stroke-width:.4}.gantt .tick.thick{stroke:var(--g-tick-color-thick);stroke-width:.7}.gantt .arrow{fill:none;stroke:var(--g-arrow-color);stroke-width:1.5}.gantt .bar-wrapper .bar{fill:var(--g-bar-color);stroke:var(--g-bar-border);stroke-width:0;transition:stroke-width .3s ease}.gantt .bar-progress{fill:var(--g-progress-color);border-radius:4px}.gantt .bar-expected-progress{fill:var(--g-expected-progress)}.gantt .bar-invalid{fill:transparent;stroke:var(--g-bar-border);stroke-width:1;stroke-dasharray:5}:is(.gantt .bar-invalid)~.bar-label{fill:var(--g-text-light)}.gantt .bar-label{fill:var(--g-text-dark);dominant-baseline:central;font-family:Helvetica;font-size:13px;font-weight:400}.gantt .bar-label.big{fill:var(--g-text-dark);text-anchor:start}.gantt .handle{fill:var(--g-handle-color);opacity:0;transition:opacity .3s ease}.gantt .handle.active,.gantt .handle.visible{cursor:ew-resize;opacity:1}.gantt .handle.progress{fill:var(--g-text-muted)}.gantt .bar-wrapper{cursor:pointer}.gantt .bar-wrapper .bar{outline:1px solid var(--g-row-border-color);border-radius:3px}.gantt .bar-wrapper:hover .bar{transition:transform .3s ease}.gantt .bar-wrapper:hover .date-range-highlight{display:block} + + /* Override Frappe Gantt styles to match app theme */ + .gantt .bar-wrapper .bar { + fill: #0066cc; + stroke: #0066cc; + stroke-width: 0; + } + + .gantt .bar-wrapper .bar-progress { + fill: #004c99; + } + + .gantt .bar-wrapper .bar-label { + fill: white; + font-size: 12px; + font-weight: 500; + } + + /* Priority-based bar colors */ + .gantt .bar-wrapper.gantt-bar-urgent .bar { + fill: #f44336; + stroke: #d32f2f; + } + .gantt .bar-wrapper.gantt-bar-urgent .bar-progress { + fill: #c62828; + } + + .gantt .bar-wrapper.gantt-bar-high .bar { + fill: #ff9800; + stroke: #f57c00; + } + .gantt .bar-wrapper.gantt-bar-high .bar-progress { + fill: #ef6c00; + } + + .gantt .bar-wrapper.gantt-bar-medium .bar { + fill: #0066cc; + stroke: #0052a3; + } + .gantt .bar-wrapper.gantt-bar-medium .bar-progress { + fill: #004080; + } + + .gantt .bar-wrapper.gantt-bar-low .bar { + fill: #808080; + stroke: #666666; + } + .gantt .bar-wrapper.gantt-bar-low .bar-progress { + fill: #5a5a5a; + } + + /* Arrow (dependency) styling */ + .gantt .arrow { + stroke: #666; + stroke-width: 2; + } + + /* Grid styling */ + .gantt .grid-row { + fill: transparent; + } + .gantt .grid-row:nth-child(even) { + fill: #f9f9f9; + } + + .gantt .row-line { + stroke: #e0e0e0; + } + + .gantt .tick { + stroke: #e0e0e0; + } + + .gantt .today-highlight { + fill: rgba(0, 102, 204, 0.1); + } + + /* Header styling */ + .gantt .lower-text, .gantt .upper-text { + fill: #333; + font-size: 12px; + } + + /* Popup styling */ + .gantt-popup { + padding: 10px; + min-width: 180px; + } + + .gantt-popup-title { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 600; + color: #333; + } + + .gantt-popup-info { + display: flex; + flex-direction: column; + gap: 4px; + } + + .gantt-popup-row { + display: flex; + gap: 8px; + font-size: 12px; + } + + .gantt-popup-label { + color: #666; + min-width: 60px; + } + + .gantt-popup-value { + color: #333; + font-weight: 500; + } + + /* Popup container override */ + .gantt .popup-wrapper { + background: white; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e0e0e0; + } +` + +const styles: Record = { + container: { + display: 'flex', + flexDirection: 'column', + gap: '16px', + }, + toolbar: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 16px', + backgroundColor: '#f9f9f9', + borderRadius: '8px', + border: '1px solid #eee', + }, + viewModeButtons: { + display: 'flex', + alignItems: 'center', + gap: '8px', + }, + toolbarLabel: { + fontSize: '14px', + fontWeight: 500, + color: '#666', + marginRight: '4px', + }, + viewModeButton: { + padding: '6px 16px', + backgroundColor: 'white', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '13px', + color: '#666', + transition: 'all 0.2s', + }, + viewModeButtonActive: { + backgroundColor: '#0066cc', + borderColor: '#0066cc', + color: 'white', + }, + loadingIndicator: { + fontSize: '13px', + color: '#666', + }, + errorIndicator: { + fontSize: '13px', + color: '#f44336', + backgroundColor: '#ffebee', + padding: '4px 12px', + borderRadius: '4px', + }, + ganttWrapper: { + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + overflow: 'auto', + }, + ganttContainer: { + minHeight: '300px', + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '64px 24px', + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + }, + emptyText: { + fontSize: '16px', + color: '#333', + margin: '0 0 8px 0', + }, + emptyHint: { + fontSize: '14px', + color: '#666', + margin: 0, + }, + footer: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px 16px', + backgroundColor: '#f9f9f9', + borderRadius: '8px', + fontSize: '13px', + }, + legend: { + display: 'flex', + alignItems: 'center', + gap: '16px', + }, + legendTitle: { + color: '#666', + fontWeight: 500, + }, + legendItem: { + display: 'flex', + alignItems: 'center', + gap: '6px', + color: '#666', + }, + legendDot: { + width: '12px', + height: '12px', + borderRadius: '3px', + }, + footerActions: {}, + footerHint: { + color: '#888', + fontStyle: 'italic', + }, + dependenciesSection: { + padding: '16px', + backgroundColor: 'white', + borderRadius: '8px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + }, + dependenciesTitle: { + margin: '0 0 8px 0', + fontSize: '16px', + fontWeight: 600, + color: '#333', + }, + dependenciesHint: { + margin: '0 0 16px 0', + fontSize: '13px', + color: '#666', + }, + taskList: { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, + taskItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '12px', + backgroundColor: '#f9f9f9', + borderRadius: '6px', + border: '1px solid #eee', + }, + taskItemInfo: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + taskItemTitle: { + fontSize: '14px', + fontWeight: 500, + color: '#333', + }, + taskItemDeps: { + fontSize: '12px', + color: '#666', + }, + manageDepsButton: { + padding: '6px 12px', + backgroundColor: 'white', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '12px', + color: '#666', + }, + modalOverlay: { + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + zIndex: 1000, + }, + modal: { + backgroundColor: 'white', + padding: '24px', + borderRadius: '8px', + width: '500px', + maxWidth: '90%', + maxHeight: '80vh', + overflowY: 'auto', + }, + modalTitle: { + margin: '0 0 16px 0', + fontSize: '18px', + fontWeight: 600, + color: '#333', + }, + modalError: { + padding: '10px 14px', + backgroundColor: '#ffebee', + color: '#c62828', + borderRadius: '4px', + fontSize: '13px', + marginBottom: '16px', + }, + modalSection: { + marginBottom: '20px', + }, + modalSectionTitle: { + margin: '0 0 10px 0', + fontSize: '14px', + fontWeight: 500, + color: '#333', + }, + modalEmptyText: { + fontSize: '13px', + color: '#666', + fontStyle: 'italic', + }, + dependencyList: { + margin: 0, + padding: 0, + listStyle: 'none', + }, + dependencyItem: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '10px 12px', + backgroundColor: '#f5f5f5', + borderRadius: '4px', + marginBottom: '8px', + fontSize: '13px', + }, + depType: { + color: '#888', + marginLeft: '6px', + fontSize: '12px', + }, + removeDependencyButton: { + padding: '4px 10px', + backgroundColor: '#ffebee', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '12px', + color: '#c62828', + }, + addDependencyForm: { + display: 'flex', + gap: '12px', + }, + modalSelect: { + flex: 1, + padding: '10px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + }, + dependencyTypeSelect: { + padding: '10px', + border: '1px solid #ddd', + borderRadius: '4px', + fontSize: '14px', + minWidth: '180px', + }, + addDependencyButton: { + padding: '10px 20px', + backgroundColor: '#0066cc', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + }, + buttonDisabled: { + backgroundColor: '#ccc', + cursor: 'not-allowed', + }, + modalActions: { + display: 'flex', + justifyContent: 'flex-end', + marginTop: '24px', + }, + closeButton: { + padding: '10px 24px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '4px', + cursor: 'pointer', + fontSize: '14px', + }, +} + +export default GanttChart diff --git a/frontend/src/components/KanbanBoard.tsx b/frontend/src/components/KanbanBoard.tsx index 8852af8..4d68368 100644 --- a/frontend/src/components/KanbanBoard.tsx +++ b/frontend/src/components/KanbanBoard.tsx @@ -1,7 +1,9 @@ import { useState } from 'react' +import { CustomValueResponse } from '../services/customFields' interface Task { id: string + project_id: string title: string description: string | null priority: string @@ -11,8 +13,10 @@ interface Task { assignee_id: string | null assignee_name: string | null due_date: string | null + start_date: string | null time_estimate: number | null subtask_count: number + custom_values?: CustomValueResponse[] } interface TaskStatus { @@ -133,6 +137,12 @@ export function KanbanBoard({ {task.subtask_count > 0 && ( {task.subtask_count} subtasks )} + {/* Display custom field values (limit to first 2 for compact display) */} + {task.custom_values?.slice(0, 2).map((cv) => ( + + {cv.field_name}: {cv.display_value || cv.value || '-'} + + ))}
) @@ -280,6 +290,17 @@ const styles: Record = { subtaskBadge: { color: '#999', }, + customValueBadge: { + backgroundColor: '#f3e5f5', + color: '#7b1fa2', + padding: '2px 6px', + borderRadius: '4px', + fontSize: '10px', + maxWidth: '100px', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, emptyColumn: { textAlign: 'center', padding: '24px', diff --git a/frontend/src/components/TaskDetailModal.tsx b/frontend/src/components/TaskDetailModal.tsx index ed3c42c..17f9a8e 100644 --- a/frontend/src/components/TaskDetailModal.tsx +++ b/frontend/src/components/TaskDetailModal.tsx @@ -4,9 +4,12 @@ import { Comments } from './Comments' import { TaskAttachments } from './TaskAttachments' import { UserSelect } from './UserSelect' import { UserSearchResult } from '../services/collaboration' +import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields' +import { CustomFieldInput } from './CustomFieldInput' interface Task { id: string + project_id: string title: string description: string | null priority: string @@ -18,6 +21,7 @@ interface Task { due_date: string | null time_estimate: number | null subtask_count: number + custom_values?: CustomValueResponse[] } interface TaskStatus { @@ -59,6 +63,44 @@ export function TaskDetailModal({ : null ) + // Custom fields state + const [customFields, setCustomFields] = useState([]) + const [customValues, setCustomValues] = useState([]) + const [editCustomValues, setEditCustomValues] = useState>({}) + const [loadingCustomFields, setLoadingCustomFields] = useState(false) + + // Load custom fields for the project + useEffect(() => { + if (task.project_id) { + loadCustomFields() + } + }, [task.project_id]) + + const loadCustomFields = async () => { + setLoadingCustomFields(true) + try { + const response = await customFieldsApi.getCustomFields(task.project_id) + setCustomFields(response.fields) + } catch (err) { + console.error('Failed to load custom fields:', err) + } finally { + setLoadingCustomFields(false) + } + } + + // Initialize custom values from task + useEffect(() => { + setCustomValues(task.custom_values || []) + // Build edit values map + const valuesMap: Record = {} + if (task.custom_values) { + task.custom_values.forEach((cv) => { + valuesMap[cv.field_id] = cv.value + }) + } + setEditCustomValues(valuesMap) + }, [task.custom_values]) + // Reset form when task changes useEffect(() => { setEditForm({ @@ -108,6 +150,21 @@ export function TaskDetailModal({ payload.time_estimate = null } + // Include custom field values (only non-formula fields) + const customValuesPayload = Object.entries(editCustomValues) + .filter(([fieldId]) => { + const field = customFields.find((f) => f.id === fieldId) + return field && field.field_type !== 'formula' + }) + .map(([fieldId, value]) => ({ + field_id: fieldId, + value: value, + })) + + if (customValuesPayload.length > 0) { + payload.custom_values = customValuesPayload + } + await api.patch(`/tasks/${task.id}`, payload) setIsEditing(false) onUpdate() @@ -118,6 +175,13 @@ export function TaskDetailModal({ } } + const handleCustomFieldChange = (fieldId: string, value: string | number | null) => { + setEditCustomValues((prev) => ({ + ...prev, + [fieldId]: value, + })) + } + const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => { setEditForm({ ...editForm, assignee_id: userId || '' }) setSelectedAssignee(user) @@ -349,6 +413,50 @@ export function TaskDetailModal({
{task.subtask_count} subtask(s)
)} + + {/* Custom Fields Section */} + {customFields.length > 0 && ( + <> +
+
Custom Fields
+ {loadingCustomFields ? ( +
Loading...
+ ) : ( + customFields.map((field) => { + // Get the value for this field + const valueResponse = customValues.find( + (v) => v.field_id === field.id + ) || { + field_id: field.id, + field_name: field.name, + field_type: field.field_type, + value: editCustomValues[field.id] ?? null, + display_value: null, + } + + // For editing mode, create a modified value response with edit values + const displayValue = isEditing + ? { + ...valueResponse, + value: editCustomValues[field.id] ?? valueResponse.value, + } + : valueResponse + + return ( +
+ +
+ ) + }) + )} + + )}
@@ -571,6 +679,23 @@ const styles: Record = { fontSize: '14px', boxSizing: 'border-box', }, + customFieldsDivider: { + height: '1px', + backgroundColor: '#ddd', + margin: '20px 0', + }, + customFieldsHeader: { + fontSize: '12px', + fontWeight: 600, + color: '#666', + marginBottom: '16px', + textTransform: 'uppercase', + }, + loadingText: { + fontSize: '13px', + color: '#888', + fontStyle: 'italic', + }, } export default TaskDetailModal diff --git a/frontend/src/index.css b/frontend/src/index.css index bf5e84d..6bbb4fa 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -16,3 +16,122 @@ body { margin: 0 auto; padding: 20px; } + +/* FullCalendar custom styles */ +.fc { + font-family: inherit; +} + +.fc .fc-toolbar-title { + font-size: 1.25rem; + font-weight: 600; +} + +.fc .fc-button { + background-color: #f5f5f5; + border: 1px solid #ddd; + color: #333; + font-weight: 500; + text-transform: capitalize; + padding: 0.5rem 1rem; + box-shadow: none; +} + +.fc .fc-button:hover { + background-color: #e8e8e8; + border-color: #ccc; + color: #333; +} + +.fc .fc-button-primary:not(:disabled).fc-button-active, +.fc .fc-button-primary:not(:disabled):active { + background-color: #0066cc; + border-color: #0066cc; + color: white; +} + +.fc .fc-button-primary:focus { + box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25); +} + +.fc .fc-daygrid-day-number { + padding: 8px; + color: #333; +} + +.fc .fc-col-header-cell-cushion { + padding: 10px; + font-weight: 600; + color: #666; +} + +.fc .fc-event { + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.8125rem; + transition: transform 0.1s ease; +} + +.fc .fc-event:hover { + transform: translateY(-1px); +} + +.fc .fc-daygrid-event-dot { + display: none; +} + +.fc .fc-daygrid-day.fc-day-today { + background-color: rgba(0, 102, 204, 0.05); +} + +.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number { + color: #0066cc; + font-weight: 600; +} + +.fc .fc-more-link { + color: #0066cc; + font-weight: 500; +} + +.fc .fc-popover { + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + border: 1px solid #e0e0e0; +} + +.fc .fc-popover-header { + background-color: #f5f5f5; + padding: 8px 12px; + font-weight: 600; +} + +.fc-theme-standard .fc-scrollgrid { + border-radius: 8px; + overflow: hidden; +} + +.fc-theme-standard td, +.fc-theme-standard th { + border-color: #eee; +} + +/* Time grid styles */ +.fc .fc-timegrid-slot-label { + font-size: 0.75rem; + color: #888; +} + +.fc .fc-timegrid-axis { + padding: 0 8px; +} + +/* Now indicator */ +.fc .fc-timegrid-now-indicator-line { + border-color: #f44336; +} + +.fc .fc-timegrid-now-indicator-arrow { + border-top-color: #f44336; +} diff --git a/frontend/src/pages/ProjectSettings.tsx b/frontend/src/pages/ProjectSettings.tsx new file mode 100644 index 0000000..2bbb5a7 --- /dev/null +++ b/frontend/src/pages/ProjectSettings.tsx @@ -0,0 +1,262 @@ +import { useState, useEffect } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import api from '../services/api' +import { CustomFieldList } from '../components/CustomFieldList' + +interface Project { + id: string + title: string + description: string | null + space_id: string + owner_id: string + security_level: string +} + +export default function ProjectSettings() { + const { projectId } = useParams() + const navigate = useNavigate() + const [project, setProject] = useState(null) + const [loading, setLoading] = useState(true) + const [activeTab, setActiveTab] = useState<'general' | 'custom-fields'>('custom-fields') + + useEffect(() => { + loadProject() + }, [projectId]) + + const loadProject = async () => { + try { + const response = await api.get(`/projects/${projectId}`) + setProject(response.data) + } catch (err) { + console.error('Failed to load project:', err) + } finally { + setLoading(false) + } + } + + if (loading) { + return
Loading...
+ } + + if (!project) { + return
Project not found
+ } + + return ( +
+
+ navigate('/spaces')} style={styles.breadcrumbLink}> + Spaces + + / + navigate(`/spaces/${project.space_id}`)} + style={styles.breadcrumbLink} + > + Projects + + / + navigate(`/projects/${project.id}`)} + style={styles.breadcrumbLink} + > + {project.title} + + / + Settings +
+ +
+

Project Settings

+ +
+ +
+ {/* Sidebar Navigation */} +
+ +
+ + {/* Content Area */} +
+ {activeTab === 'general' && ( +
+

General Settings

+
+
+ Project Name + {project.title} +
+
+ Description + + {project.description || 'No description'} + +
+
+ Security Level + {project.security_level} +
+
+

+ To edit project details, contact the project owner. +

+
+ )} + + {activeTab === 'custom-fields' && ( + + )} +
+
+
+ ) +} + +const styles: Record = { + container: { + padding: '24px', + maxWidth: '1200px', + margin: '0 auto', + }, + breadcrumb: { + marginBottom: '16px', + fontSize: '14px', + color: '#666', + }, + breadcrumbLink: { + color: '#0066cc', + cursor: 'pointer', + }, + breadcrumbSeparator: { + margin: '0 8px', + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '24px', + }, + title: { + fontSize: '24px', + fontWeight: 600, + margin: 0, + }, + backButton: { + padding: '10px 20px', + backgroundColor: '#f5f5f5', + border: '1px solid #ddd', + borderRadius: '6px', + cursor: 'pointer', + fontSize: '14px', + }, + layout: { + display: 'flex', + gap: '24px', + }, + sidebar: { + width: '200px', + flexShrink: 0, + }, + nav: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + navItem: { + padding: '12px 16px', + backgroundColor: 'transparent', + border: 'none', + borderRadius: '6px', + cursor: 'pointer', + fontSize: '14px', + textAlign: 'left', + color: '#333', + transition: 'background-color 0.2s', + }, + navItemActive: { + backgroundColor: '#e3f2fd', + color: '#0066cc', + fontWeight: 500, + }, + content: { + flex: 1, + minWidth: 0, + }, + section: { + backgroundColor: 'white', + borderRadius: '8px', + padding: '24px', + boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', + }, + sectionTitle: { + fontSize: '18px', + fontWeight: 600, + margin: '0 0 20px 0', + }, + infoCard: { + backgroundColor: '#fafafa', + borderRadius: '8px', + padding: '16px', + marginBottom: '16px', + }, + infoRow: { + display: 'flex', + padding: '12px 0', + borderBottom: '1px solid #eee', + }, + infoLabel: { + width: '150px', + fontSize: '14px', + fontWeight: 500, + color: '#666', + }, + infoValue: { + flex: 1, + fontSize: '14px', + color: '#333', + }, + helpText: { + fontSize: '13px', + color: '#888', + fontStyle: 'italic', + }, + loading: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '200px', + color: '#666', + }, + error: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '200px', + color: '#f44336', + }, +} diff --git a/frontend/src/pages/Tasks.tsx b/frontend/src/pages/Tasks.tsx index f5fb4a4..1c34e07 100644 --- a/frontend/src/pages/Tasks.tsx +++ b/frontend/src/pages/Tasks.tsx @@ -1,14 +1,19 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useParams, useNavigate } from 'react-router-dom' import api from '../services/api' import { KanbanBoard } from '../components/KanbanBoard' +import { CalendarView } from '../components/CalendarView' +import { GanttChart } from '../components/GanttChart' import { TaskDetailModal } from '../components/TaskDetailModal' import { UserSelect } from '../components/UserSelect' import { UserSearchResult } from '../services/collaboration' import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext' +import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields' +import { CustomFieldInput } from '../components/CustomFieldInput' interface Task { id: string + project_id: string title: string description: string | null priority: string @@ -18,8 +23,10 @@ interface Task { assignee_id: string | null assignee_name: string | null due_date: string | null + start_date: string | null time_estimate: number | null subtask_count: number + custom_values?: CustomValueResponse[] } interface TaskStatus { @@ -35,9 +42,25 @@ interface Project { space_id: string } -type ViewMode = 'list' | 'kanban' +type ViewMode = 'list' | 'kanban' | 'calendar' | 'gantt' const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode' +const COLUMN_VISIBILITY_STORAGE_KEY = 'tasks-column-visibility' + +// Get column visibility settings from localStorage +const getColumnVisibility = (projectId: string): Record => { + try { + const saved = localStorage.getItem(`${COLUMN_VISIBILITY_STORAGE_KEY}-${projectId}`) + return saved ? JSON.parse(saved) : {} + } catch { + return {} + } +} + +// Save column visibility settings to localStorage +const saveColumnVisibility = (projectId: string, visibility: Record) => { + localStorage.setItem(`${COLUMN_VISIBILITY_STORAGE_KEY}-${projectId}`, JSON.stringify(visibility)) +} export default function Tasks() { const { projectId } = useParams() @@ -50,7 +73,7 @@ export default function Tasks() { const [showCreateModal, setShowCreateModal] = useState(false) const [viewMode, setViewMode] = useState(() => { const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY) - return (saved === 'kanban' || saved === 'list') ? saved : 'list' + return (saved === 'kanban' || saved === 'list' || saved === 'calendar' || saved === 'gantt') ? saved : 'list' }) const [newTask, setNewTask] = useState({ title: '', @@ -65,10 +88,37 @@ export default function Tasks() { const [selectedTask, setSelectedTask] = useState(null) const [showDetailModal, setShowDetailModal] = useState(false) + // Custom fields state + const [customFields, setCustomFields] = useState([]) + const [newTaskCustomValues, setNewTaskCustomValues] = useState>({}) + + // Column visibility state + const [columnVisibility, setColumnVisibility] = useState>(() => { + return projectId ? getColumnVisibility(projectId) : {} + }) + const [showColumnMenu, setShowColumnMenu] = useState(false) + const columnMenuRef = useRef(null) + useEffect(() => { loadData() }, [projectId]) + // Load custom fields when project changes + useEffect(() => { + if (projectId) { + loadCustomFields() + } + }, [projectId]) + + const loadCustomFields = async () => { + try { + const response = await customFieldsApi.getCustomFields(projectId!) + setCustomFields(response.fields) + } catch (err) { + console.error('Failed to load custom fields:', err) + } + } + // Subscribe to project WebSocket when project changes useEffect(() => { if (projectId) { @@ -91,6 +141,7 @@ export default function Tasks() { } const newTask: Task = { id: event.data.task_id, + project_id: projectId!, title: event.data.title || '', description: event.data.description ?? null, priority: event.data.priority || 'medium', @@ -100,6 +151,7 @@ export default function Tasks() { assignee_id: event.data.assignee_id ?? null, assignee_name: event.data.assignee_name ?? null, due_date: event.data.due_date ?? null, + start_date: (event.data.start_date as string) ?? null, time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null, subtask_count: event.data.subtask_count ?? 0, } @@ -131,6 +183,7 @@ export default function Tasks() { ...(event.data.new_assignee_id !== undefined && { assignee_id: event.data.new_assignee_id ?? null }), ...(event.data.new_assignee_name !== undefined && { assignee_name: event.data.new_assignee_name ?? null }), ...(event.data.due_date !== undefined && { due_date: event.data.due_date ?? null }), + ...(event.data.start_date !== undefined && { start_date: (event.data.start_date as string) ?? null }), ...(event.data.time_estimate !== undefined && { time_estimate: event.data.time_estimate ?? null }), ...(event.data.original_estimate !== undefined && event.data.time_estimate === undefined && { time_estimate: event.data.original_estimate ?? null }), ...(event.data.subtask_count !== undefined && { subtask_count: event.data.subtask_count }), @@ -156,6 +209,47 @@ export default function Tasks() { localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode) }, [viewMode]) + // Load column visibility when projectId changes + useEffect(() => { + if (projectId) { + setColumnVisibility(getColumnVisibility(projectId)) + } + }, [projectId]) + + // Check if a custom field column is visible (default to true if not set) + const isColumnVisible = (fieldId: string): boolean => { + return columnVisibility[fieldId] !== false + } + + // Toggle column visibility + const toggleColumnVisibility = (fieldId: string) => { + const newVisibility = { + ...columnVisibility, + [fieldId]: !isColumnVisible(fieldId), + } + setColumnVisibility(newVisibility) + if (projectId) { + saveColumnVisibility(projectId, newVisibility) + } + } + + // Close column menu when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (columnMenuRef.current && !columnMenuRef.current.contains(event.target as Node)) { + setShowColumnMenu(false) + } + } + + if (showColumnMenu) { + document.addEventListener('mousedown', handleClickOutside) + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + } + }, [showColumnMenu]) + const loadData = async () => { try { const [projectRes, tasksRes, statusesRes] = await Promise.all([ @@ -194,6 +288,21 @@ export default function Tasks() { payload.time_estimate = Number(newTask.time_estimate) } + // Include custom field values (only non-formula fields) + const customValuesPayload = Object.entries(newTaskCustomValues) + .filter(([fieldId, value]) => { + const field = customFields.find((f) => f.id === fieldId) + return field && field.field_type !== 'formula' && value !== null + }) + .map(([fieldId, value]) => ({ + field_id: fieldId, + value: value, + })) + + if (customValuesPayload.length > 0) { + payload.custom_values = customValuesPayload + } + await api.post(`/projects/${projectId}/tasks`, payload) setShowCreateModal(false) setNewTask({ @@ -204,6 +313,7 @@ export default function Tasks() { due_date: '', time_estimate: '', }) + setNewTaskCustomValues({}) setSelectedAssignee(null) loadData() } catch (err) { @@ -213,6 +323,13 @@ export default function Tasks() { } } + const handleNewTaskCustomFieldChange = (fieldId: string, value: string | number | null) => { + setNewTaskCustomValues((prev) => ({ + ...prev, + [fieldId]: value, + })) + } + const handleStatusChange = async (taskId: string, statusId: string) => { // Save original state for rollback const originalTasks = [...tasks] @@ -246,7 +363,12 @@ export default function Tasks() { } const handleTaskClick = (task: Task) => { - setSelectedTask(task) + // Ensure task has project_id for custom fields loading + const taskWithProject = { + ...task, + project_id: projectId!, + } + setSelectedTask(taskWithProject) setShowDetailModal(true) } @@ -335,7 +457,65 @@ export default function Tasks() { > Kanban + + + {/* Column Visibility Toggle - only show when there are custom fields and in list view */} + {viewMode === 'list' && customFields.length > 0 && ( +
+ + {showColumnMenu && ( +
+
Show Custom Fields
+ {customFields.map((field) => ( + + ))} + {customFields.length === 0 && ( +
No custom fields
+ )} +
+ )} +
+ )} + @@ -343,7 +523,7 @@ export default function Tasks() { {/* Conditional rendering based on view mode */} - {viewMode === 'list' ? ( + {viewMode === 'list' && (
{tasks.map((task) => (
)} + {/* Display visible custom field values */} + {task.custom_values && + task.custom_values + .filter((cv) => isColumnVisible(cv.field_id)) + .map((cv) => ( + + {cv.field_name}: {cv.display_value || cv.value || '-'} + + ))}