feat: implement custom fields, gantt view, calendar view, and file encryption

- Custom Fields (FEAT-001):
  - CustomField and TaskCustomValue models with formula support
  - CRUD API for custom field management
  - Formula engine for calculated fields
  - Frontend: CustomFieldEditor, CustomFieldInput, ProjectSettings page
  - Task list API now includes custom_values
  - KanbanBoard displays custom field values

- Gantt View (FEAT-003):
  - TaskDependency model with FS/SS/FF/SF dependency types
  - Dependency CRUD API with cycle detection
  - start_date field added to tasks
  - GanttChart component with Frappe Gantt integration
  - Dependency type selector in UI

- Calendar View (FEAT-004):
  - CalendarView component with FullCalendar integration
  - Date range filtering API for tasks
  - Drag-and-drop date updates
  - View mode switching in Tasks page

- File Encryption (FEAT-010):
  - AES-256-GCM encryption service
  - EncryptionKey model with key rotation support
  - Admin API for key management
  - Encrypted upload/download for confidential projects

- Migrations: 011 (custom fields), 012 (encryption keys), 013 (task dependencies)
- Updated issues.md with completion status

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-05 23:39:12 +08:00
parent 69b81d9241
commit 2d80a8384e
65 changed files with 11045 additions and 82 deletions

View File

@@ -20,3 +20,14 @@ AUTH_API_URL=https://pj-auth-api.vercel.app
# System Admin # System Admin
SYSTEM_ADMIN_EMAIL=ymirliu@panjit.com.tw 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=

View File

@@ -0,0 +1 @@
# Admin API module

View File

@@ -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}

View File

@@ -1,5 +1,7 @@
import uuid import uuid
import logging
from datetime import datetime from datetime import datetime
from io import BytesIO
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
from fastapi.responses import FileResponse, Response from fastapi.responses import FileResponse, Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -7,7 +9,7 @@ from typing import Optional
from app.core.database import get_db from app.core.database import get_db
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access 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 ( from app.schemas.attachment import (
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse, AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
AttachmentVersionResponse, VersionHistoryResponse AttachmentVersionResponse, VersionHistoryResponse
@@ -15,6 +17,13 @@ from app.schemas.attachment import (
from app.services.file_storage_service import file_storage_service from app.services.file_storage_service import file_storage_service
from app.services.audit_service import AuditService from app.services.audit_service import AuditService
from app.services.watermark_service import watermark_service 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"]) 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) @router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
async def upload_attachment( async def upload_attachment(
task_id: str, task_id: str,
@@ -111,10 +154,22 @@ async def upload_attachment(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) 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) 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( existing = db.query(Attachment).filter(
Attachment.task_id == task_id, Attachment.task_id == task_id,
Attachment.original_filename == file.filename, Attachment.original_filename == file.filename,
@@ -122,10 +177,66 @@ async def upload_attachment(
).first() ).first()
if existing: if existing:
# Phase 2: Create new version # Create new version for existing attachment
new_version = existing.current_version + 1 new_version = existing.current_version + 1
# Save file 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_path, file_size, checksum = await file_storage_service.save_file(
file=file, file=file,
project_id=task.project_id, project_id=task.project_id,
@@ -170,8 +281,45 @@ async def upload_attachment(
# Create new attachment # Create new attachment
attachment_id = str(uuid.uuid4()) attachment_id = str(uuid.uuid4())
is_encrypted = False
encryption_key_id = None
# Save file 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_path, file_size, checksum = await file_storage_service.save_file(
file=file, file=file,
project_id=task.project_id, project_id=task.project_id,
@@ -193,7 +341,8 @@ async def upload_attachment(
mime_type=mime_type, mime_type=mime_type,
file_size=file_size, file_size=file_size,
current_version=1, current_version=1,
is_encrypted=False, is_encrypted=is_encrypted,
encryption_key_id=encryption_key_id,
uploaded_by=current_user.id uploaded_by=current_user.id
) )
db.add(attachment) db.add(attachment)
@@ -211,6 +360,10 @@ async def upload_attachment(
db.add(version) db.add(version)
# Audit log # 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( AuditService.log_event(
db=db, db=db,
event_type="attachment.upload", event_type="attachment.upload",
@@ -218,7 +371,7 @@ async def upload_attachment(
action=AuditAction.CREATE, action=AuditAction.CREATE,
user_id=current_user.id, user_id=current_user.id,
resource_id=attachment.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) request_metadata=getattr(request.state, "audit_metadata", None)
) )
@@ -286,7 +439,11 @@ async def download_attachment(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user) 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) attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
# Get version to download # Get version to download
@@ -319,14 +476,69 @@ async def download_attachment(
) )
db.commit() 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 # Check if watermark should be applied
mime_type = attachment.mime_type or "" mime_type = attachment.mime_type or ""
if watermark_service.supports_watermark(mime_type): if watermark_service.supports_watermark(mime_type):
try: try:
# Read the original file
with open(file_path, "rb") as f:
file_bytes = f.read()
# Apply watermark based on file type # Apply watermark based on file type
if watermark_service.is_supported_image(mime_type): if watermark_service.is_supported_image(mime_type):
watermarked_bytes, output_format = watermark_service.add_image_watermark( watermarked_bytes, output_format = watermark_service.add_image_watermark(
@@ -367,19 +579,19 @@ async def download_attachment(
) )
except Exception as e: except Exception as e:
# If watermarking fails, log the error but still return the original file # If watermarking fails, log the error but still return the file
# This ensures users can still download files even if watermarking has issues logger.warning(
import logging
logging.getLogger(__name__).warning(
f"Watermarking failed for attachment {attachment_id}: {str(e)}. " 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 file (decrypted if needed, without watermark for unsupported types)
return FileResponse( return Response(
path=str(file_path), content=file_bytes,
filename=attachment.original_filename, media_type=attachment.mime_type,
media_type=attachment.mime_type headers={
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
}
) )

View File

@@ -0,0 +1,3 @@
from app.api.custom_fields.router import router
__all__ = ["router"]

View File

@@ -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)

View File

@@ -0,0 +1,3 @@
from app.api.task_dependencies.router import router
__all__ = ["router"]

View File

@@ -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)
)

View File

@@ -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.models import User, Project, Task, TaskStatus, AuditAction, Blocker
from app.schemas.task import ( from app.schemas.task import (
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse, TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
TaskStatusUpdate, TaskAssignUpdate TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse
) )
from app.middleware.auth import ( from app.middleware.auth import (
get_current_user, check_project_access, check_task_access, check_task_edit_access 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.audit_service import AuditService
from app.services.trigger_service import TriggerService from app.services.trigger_service import TriggerService
from app.services.workload_cache import invalidate_user_workload_cache 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__) logger = logging.getLogger(__name__)
@@ -40,13 +42,18 @@ def get_task_depth(db: Session, task: Task) -> int:
return depth 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.""" """Convert a Task model to TaskWithDetails response."""
# Count only non-deleted subtasks # Count only non-deleted subtasks
subtask_count = 0 subtask_count = 0
if task.subtasks: if task.subtasks:
subtask_count = sum(1 for st in task.subtasks if not st.is_deleted) 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( return TaskWithDetails(
id=task.id, id=task.id,
project_id=task.project_id, project_id=task.project_id,
@@ -56,6 +63,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
priority=task.priority, priority=task.priority,
original_estimate=task.original_estimate, original_estimate=task.original_estimate,
time_spent=task.time_spent, time_spent=task.time_spent,
start_date=task.start_date,
due_date=task.due_date, due_date=task.due_date,
assignee_id=task.assignee_id, assignee_id=task.assignee_id,
status_id=task.status_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, status_color=task.status.color if task.status else None,
creator_name=task.creator.name if task.creator else None, creator_name=task.creator.name if task.creator else None,
subtask_count=subtask_count, 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"), parent_task_id: Optional[str] = Query(None, description="Filter by parent task"),
status_id: Optional[str] = Query(None, description="Filter by status"), status_id: Optional[str] = Query(None, description="Filter by status"),
assignee_id: Optional[str] = Query(None, description="Filter by assignee"), 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)"), include_deleted: bool = Query(False, description="Include deleted tasks (admin only)"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
""" """
List all tasks in a project. 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() project = db.query(Project).filter(Project.id == project_id).first()
@@ -124,10 +145,17 @@ async def list_tasks(
if assignee_id: if assignee_id:
query = query.filter(Task.assignee_id == 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() tasks = query.order_by(Task.position, Task.created_at).all()
return TaskListResponse( 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), total=len(tasks),
) )
@@ -204,6 +232,25 @@ async def create_task(
).order_by(Task.position.desc()).first() ).order_by(Task.position.desc()).first()
next_position = (max_pos_result.position + 1) if max_pos_result else 0 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( task = Task(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
project_id=project_id, project_id=project_id,
@@ -212,6 +259,7 @@ async def create_task(
description=task_data.description, description=task_data.description,
priority=task_data.priority.value if task_data.priority else "medium", priority=task_data.priority.value if task_data.priority else "medium",
original_estimate=task_data.original_estimate, original_estimate=task_data.original_estimate,
start_date=task_data.start_date,
due_date=task_data.due_date, due_date=task_data.due_date,
assignee_id=task_data.assignee_id, assignee_id=task_data.assignee_id,
status_id=task_data.status_id, status_id=task_data.status_id,
@@ -220,6 +268,17 @@ async def create_task(
) )
db.add(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 # Audit log
AuditService.log_event( AuditService.log_event(
@@ -256,6 +315,7 @@ async def create_task(
"assignee_id": str(task.assignee_id) if task.assignee_id else None, "assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": task.assignee.name if task.assignee else None, "assignee_name": task.assignee.name if task.assignee else None,
"priority": task.priority, "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, "due_date": str(task.due_date) if task.due_date else None,
"time_estimate": task.original_estimate, "time_estimate": task.original_estimate,
"original_estimate": task.original_estimate, "original_estimate": task.original_estimate,
@@ -303,7 +363,7 @@ async def get_task(
detail="Access denied", 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) @router.patch("/api/tasks/{task_id}", response_model=TaskResponse)
@@ -336,13 +396,42 @@ async def update_task(
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"priority": task.priority, "priority": task.priority,
"start_date": task.start_date,
"due_date": task.due_date, "due_date": task.due_date,
"original_estimate": task.original_estimate, "original_estimate": task.original_estimate,
"time_spent": task.time_spent, "time_spent": task.time_spent,
} }
# Update fields # Update fields (exclude custom_values, handle separately)
update_data = task_data.model_dump(exclude_unset=True) 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(): for field, value in update_data.items():
if field == "priority" and value: if field == "priority" and value:
setattr(task, field, value.value) setattr(task, field, value.value)
@@ -354,6 +443,7 @@ async def update_task(
"title": task.title, "title": task.title,
"description": task.description, "description": task.description,
"priority": task.priority, "priority": task.priority,
"start_date": task.start_date,
"due_date": task.due_date, "due_date": task.due_date,
"original_estimate": task.original_estimate, "original_estimate": task.original_estimate,
"time_spent": task.time_spent, "time_spent": task.time_spent,
@@ -377,6 +467,18 @@ async def update_task(
if "priority" in update_data: if "priority" in update_data:
TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user) 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.commit()
db.refresh(task) db.refresh(task)
@@ -400,6 +502,7 @@ async def update_task(
"assignee_id": str(task.assignee_id) if task.assignee_id else None, "assignee_id": str(task.assignee_id) if task.assignee_id else None,
"assignee_name": task.assignee.name if task.assignee else None, "assignee_name": task.assignee.name if task.assignee else None,
"priority": task.priority, "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, "due_date": str(task.due_date) if task.due_date else None,
"time_estimate": task.original_estimate, "time_estimate": task.original_estimate,
"original_estimate": task.original_estimate, "original_estimate": task.original_estimate,

View File

@@ -1,6 +1,6 @@
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from pydantic import field_validator from pydantic import field_validator
from typing import List from typing import List, Optional
import os import os
@@ -52,6 +52,35 @@ class Settings(BaseSettings):
) )
return v 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 # External Auth API
AUTH_API_URL: str = "https://pj-auth-api.vercel.app" AUTH_API_URL: str = "https://pj-auth-api.vercel.app"

View File

@@ -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.triggers import router as triggers_router
from app.api.reports import router as reports_router from app.api.reports import router as reports_router
from app.api.health import router as health_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 from app.core.config import settings
app = FastAPI( app = FastAPI(
@@ -76,6 +79,9 @@ app.include_router(attachments_router)
app.include_router(triggers_router) app.include_router(triggers_router)
app.include_router(reports_router) app.include_router(reports_router)
app.include_router(health_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") @app.get("/health")

View File

@@ -12,6 +12,7 @@ from app.models.notification import Notification
from app.models.blocker import Blocker from app.models.blocker import Blocker
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
from app.models.audit_alert import AuditAlert from app.models.audit_alert import AuditAlert
from app.models.encryption_key import EncryptionKey
from app.models.attachment import Attachment from app.models.attachment import Attachment
from app.models.attachment_version import AttachmentVersion from app.models.attachment_version import AttachmentVersion
from app.models.trigger import Trigger, TriggerType 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.scheduled_report import ScheduledReport, ReportType
from app.models.report_history import ReportHistory, ReportHistoryStatus from app.models.report_history import ReportHistory, ReportHistoryStatus
from app.models.project_health import ProjectHealth, RiskLevel, ScheduleStatus, ResourceStatus 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__ = [ __all__ = [
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot", "User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
"Comment", "Mention", "Notification", "Blocker", "Comment", "Mention", "Notification", "Blocker",
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS", "AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
"Attachment", "AttachmentVersion", "EncryptionKey", "Attachment", "AttachmentVersion",
"Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus", "Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus",
"ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus", "ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus",
"ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus" "ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus",
"CustomField", "FieldType", "TaskCustomValue",
"TaskDependency", "DependencyType"
] ]

View File

@@ -16,6 +16,11 @@ class Attachment(Base):
file_size = Column(BigInteger, nullable=False) file_size = Column(BigInteger, nullable=False)
current_version = Column(Integer, default=1, nullable=False) current_version = Column(Integer, default=1, nullable=False)
is_encrypted = Column(Boolean, default=False, 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) uploaded_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
is_deleted = Column(Boolean, default=False, nullable=False) is_deleted = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False) created_at = Column(DateTime, server_default=func.now(), nullable=False)
@@ -24,6 +29,7 @@ class Attachment(Base):
# Relationships # Relationships
task = relationship("Task", back_populates="attachments") task = relationship("Task", back_populates="attachments")
uploader = relationship("User", foreign_keys=[uploaded_by]) 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") versions = relationship("AttachmentVersion", back_populates="attachment", cascade="all, delete-orphan")
__table_args__ = ( __table_args__ = (

View File

@@ -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")

View File

@@ -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

View File

@@ -40,3 +40,4 @@ class Project(Base):
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan") tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
triggers = relationship("Trigger", 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") health = relationship("ProjectHealth", back_populates="project", uselist=False, cascade="all, delete-orphan")
custom_fields = relationship("CustomField", back_populates="project", cascade="all, delete-orphan")

View File

@@ -30,6 +30,7 @@ class Task(Base):
original_estimate = Column(Numeric(8, 2), nullable=True) original_estimate = Column(Numeric(8, 2), nullable=True)
time_spent = Column(Numeric(8, 2), default=0, nullable=False) time_spent = Column(Numeric(8, 2), default=0, nullable=False)
blocker_flag = Column(Boolean, default=False, nullable=False) blocker_flag = Column(Boolean, default=False, nullable=False)
start_date = Column(DateTime, nullable=True)
due_date = Column(DateTime, nullable=True) due_date = Column(DateTime, nullable=True)
position = Column(Integer, default=0, nullable=False) position = Column(Integer, default=0, nullable=False)
created_by = Column(String(36), ForeignKey("pjctrl_users.id"), 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") blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan")
attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan") attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan")
trigger_logs = relationship("TriggerLog", back_populates="task") 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"
)

View File

@@ -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")

View File

@@ -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"
)

View File

@@ -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]

View File

@@ -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

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List from typing import Optional, List, Any, Dict
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from enum import Enum from enum import Enum
@@ -12,11 +12,27 @@ class Priority(str, Enum):
URGENT = "urgent" 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): class TaskBase(BaseModel):
title: str title: str
description: Optional[str] = None description: Optional[str] = None
priority: Priority = Priority.MEDIUM priority: Priority = Priority.MEDIUM
original_estimate: Optional[Decimal] = None original_estimate: Optional[Decimal] = None
start_date: Optional[datetime] = None
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
@@ -24,6 +40,7 @@ class TaskCreate(TaskBase):
parent_task_id: Optional[str] = None parent_task_id: Optional[str] = None
assignee_id: Optional[str] = None assignee_id: Optional[str] = None
status_id: Optional[str] = None status_id: Optional[str] = None
custom_values: Optional[List[CustomValueInput]] = None
class TaskUpdate(BaseModel): class TaskUpdate(BaseModel):
@@ -32,8 +49,10 @@ class TaskUpdate(BaseModel):
priority: Optional[Priority] = None priority: Optional[Priority] = None
original_estimate: Optional[Decimal] = None original_estimate: Optional[Decimal] = None
time_spent: Optional[Decimal] = None time_spent: Optional[Decimal] = None
start_date: Optional[datetime] = None
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
position: Optional[int] = None position: Optional[int] = None
custom_values: Optional[List[CustomValueInput]] = None
class TaskStatusUpdate(BaseModel): class TaskStatusUpdate(BaseModel):
@@ -67,6 +86,7 @@ class TaskWithDetails(TaskResponse):
status_color: Optional[str] = None status_color: Optional[str] = None
creator_name: Optional[str] = None creator_name: Optional[str] = None
subtask_count: int = 0 subtask_count: int = 0
custom_values: Optional[List[CustomValueResponse]] = None
class TaskListResponse(BaseModel): class TaskListResponse(BaseModel):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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')

View File

@@ -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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -114,3 +114,383 @@ class TestSubtaskDepth:
"""Test that MAX_SUBTASK_DEPTH is defined.""" """Test that MAX_SUBTASK_DEPTH is defined."""
from app.api.tasks.router import MAX_SUBTASK_DEPTH from app.api.tasks.router import MAX_SUBTASK_DEPTH
assert MAX_SUBTASK_DEPTH == 2 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

View File

@@ -8,7 +8,13 @@
"name": "pjctrl-frontend", "name": "pjctrl-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "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", "axios": "^1.6.0",
"frappe-gantt": "^1.0.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0" "react-router-dom": "^6.21.0"
@@ -695,6 +701,57 @@
"node": ">=12" "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": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1471,6 +1528,12 @@
"node": ">= 6" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1746,6 +1809,16 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",

View File

@@ -9,10 +9,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.21.0", "react-router-dom": "^6.21.0"
"axios": "^1.6.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard'
import Spaces from './pages/Spaces' import Spaces from './pages/Spaces'
import Projects from './pages/Projects' import Projects from './pages/Projects'
import Tasks from './pages/Tasks' import Tasks from './pages/Tasks'
import ProjectSettings from './pages/ProjectSettings'
import AuditPage from './pages/AuditPage' import AuditPage from './pages/AuditPage'
import WorkloadPage from './pages/WorkloadPage' import WorkloadPage from './pages/WorkloadPage'
import ProjectHealthPage from './pages/ProjectHealthPage' import ProjectHealthPage from './pages/ProjectHealthPage'
@@ -64,6 +65,16 @@ function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/projects/:projectId/settings"
element={
<ProtectedRoute>
<Layout>
<ProjectSettings />
</Layout>
</ProtectedRoute>
}
/>
<Route <Route
path="/audit" path="/audit"
element={ element={

View File

@@ -0,0 +1,497 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import FullCalendar from '@fullcalendar/react'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import { EventClickArg, EventDropArg, DatesSetArg } from '@fullcalendar/core'
import api from '../services/api'
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
}
interface TaskStatus {
id: string
name: string
color: string
is_done: boolean
}
interface CalendarEvent {
id: string
title: string
start: string
allDay: boolean
backgroundColor: string
borderColor: string
textColor: string
extendedProps: {
task: Task
isOverdue: boolean
priority: string
}
}
interface CalendarViewProps {
projectId: string
statuses: TaskStatus[]
onTaskClick: (task: Task) => void
onTaskUpdate: () => void
}
// Priority icons as text prefixes
const priorityIcons: Record<string, string> = {
urgent: '!!!',
high: '!!',
medium: '!',
low: '',
}
// Priority colors for styling
const priorityColors: Record<string, string> = {
urgent: '#f44336',
high: '#ff9800',
medium: '#0066cc',
low: '#808080',
}
export function CalendarView({
projectId,
statuses,
onTaskClick,
onTaskUpdate,
}: CalendarViewProps) {
const calendarRef = useRef<FullCalendar>(null)
const [events, setEvents] = useState<CalendarEvent[]>([])
const [loading, setLoading] = useState(false)
const [dateRange, setDateRange] = useState<{ start: Date; end: Date } | null>(null)
// Filter state
const [filterAssignee, setFilterAssignee] = useState<string>('')
const [filterStatus, setFilterStatus] = useState<string>('active') // 'all', 'active', 'completed'
const [filterPriority, setFilterPriority] = useState<string>('')
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<string, string>()
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 (
<div style={styles.container}>
{/* Filter Controls */}
<div style={styles.filterBar}>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Assignee</label>
<select
value={filterAssignee}
onChange={(e) => setFilterAssignee(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Assignees</option>
{assignees.map((assignee) => (
<option key={assignee.id} value={assignee.id}>
{assignee.name}
</option>
))}
</select>
</div>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Status</label>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
style={styles.filterSelect}
>
<option value="all">All Tasks</option>
<option value="active">Active Only</option>
<option value="completed">Completed Only</option>
</select>
</div>
<div style={styles.filterGroup}>
<label style={styles.filterLabel}>Priority</label>
<select
value={filterPriority}
onChange={(e) => setFilterPriority(e.target.value)}
style={styles.filterSelect}
>
<option value="">All Priorities</option>
<option value="urgent">Urgent</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
{hasActiveFilters && (
<button onClick={handleClearFilters} style={styles.clearFiltersButton}>
Clear Filters
</button>
)}
{loading && <span style={styles.loadingIndicator}>Loading...</span>}
</div>
{/* Calendar */}
<div style={styles.calendarWrapper}>
<FullCalendar
ref={calendarRef}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
headerToolbar={{
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay',
}}
events={events}
editable={true}
droppable={true}
eventClick={handleEventClick}
eventDrop={handleEventDrop}
datesSet={handleDatesSet}
height="auto"
eventDisplay="block"
dayMaxEvents={3}
moreLinkClick="popover"
nowIndicator={true}
eventTimeFormat={{
hour: '2-digit',
minute: '2-digit',
hour12: false,
}}
/>
</div>
{/* Legend */}
<div style={styles.legend}>
<div style={styles.legendItem}>
<span style={{ ...styles.legendDot, backgroundColor: '#ffebee', border: '2px solid #f44336' }} />
<span style={styles.legendText}>Overdue</span>
</div>
<div style={styles.legendItem}>
<span style={{ ...styles.legendDot, backgroundColor: '#e8f5e9', border: '2px solid #4caf50' }} />
<span style={styles.legendText}>Completed</span>
</div>
<div style={styles.legendItem}>
<span style={styles.legendText}>Priority:</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.urgent }}>!!! Urgent</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.high }}>!! High</span>
<span style={{ ...styles.priorityLabel, color: priorityColors.medium }}>! Medium</span>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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

View File

@@ -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<FieldType>(field?.field_type || 'text')
const [isRequired, setIsRequired] = useState(field?.is_required || false)
const [options, setOptions] = useState<string[]>(field?.options || [''])
const [formula, setFormula] = useState(field?.formula || '')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div style={styles.overlay} onClick={handleOverlayClick}>
<div style={styles.modal}>
<div style={styles.header}>
<h2 style={styles.title}>
{isEditing ? 'Edit Custom Field' : 'Create Custom Field'}
</h2>
<button onClick={onClose} style={styles.closeButton} aria-label="Close">
X
</button>
</div>
<div style={styles.content}>
{error && <div style={styles.errorMessage}>{error}</div>}
{/* Field Name */}
<div style={styles.formGroup}>
<label style={styles.label}>Field Name *</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Story Points, Sprint Number"
style={styles.input}
maxLength={100}
/>
</div>
{/* Field Type - only show for create mode */}
{!isEditing && (
<div style={styles.formGroup}>
<label style={styles.label}>Field Type *</label>
<div style={styles.typeGrid}>
{FIELD_TYPES.map((type) => (
<div
key={type.value}
style={{
...styles.typeCard,
...(fieldType === type.value ? styles.typeCardSelected : {}),
}}
onClick={() => setFieldType(type.value)}
>
<div style={styles.typeLabel}>{type.label}</div>
<div style={styles.typeDescription}>{type.description}</div>
</div>
))}
</div>
</div>
)}
{/* Show current type info for edit mode */}
{isEditing && (
<div style={styles.formGroup}>
<label style={styles.label}>Field Type</label>
<div style={styles.typeDisplay}>
{FIELD_TYPES.find((t) => t.value === fieldType)?.label}
<span style={styles.typeNote}>(cannot be changed)</span>
</div>
</div>
)}
{/* Dropdown Options */}
{fieldType === 'dropdown' && (
<div style={styles.formGroup}>
<label style={styles.label}>Options *</label>
<div style={styles.optionsList}>
{options.map((option, index) => (
<div key={index} style={styles.optionRow}>
<input
type="text"
value={option}
onChange={(e) => handleOptionChange(index, e.target.value)}
placeholder={`Option ${index + 1}`}
style={styles.optionInput}
/>
{options.length > 1 && (
<button
onClick={() => handleRemoveOption(index)}
style={styles.removeOptionButton}
aria-label="Remove option"
>
X
</button>
)}
</div>
))}
</div>
<button onClick={handleAddOption} style={styles.addOptionButton}>
+ Add Option
</button>
</div>
)}
{/* Formula Expression */}
{fieldType === 'formula' && (
<div style={styles.formGroup}>
<label style={styles.label}>Formula Expression *</label>
<input
type="text"
value={formula}
onChange={(e) => setFormula(e.target.value)}
placeholder="e.g., {time_spent} / {original_estimate} * 100"
style={styles.input}
/>
<div style={styles.formulaHelp}>
<p>Use curly braces to reference other fields:</p>
<ul>
<li>
<code>{'{field_name}'}</code> - Reference a custom number field
</li>
<li>
<code>{'{original_estimate}'}</code> - Task time estimate
</li>
<li>
<code>{'{time_spent}'}</code> - Logged time
</li>
</ul>
<p>Supported operators: +, -, *, /</p>
</div>
</div>
)}
{/* Required Checkbox */}
<div style={styles.formGroup}>
<label style={styles.checkboxLabel}>
<input
type="checkbox"
checked={isRequired}
onChange={(e) => setIsRequired(e.target.checked)}
style={styles.checkbox}
/>
Required field
</label>
<div style={styles.checkboxHelp}>
Tasks cannot be created or updated without filling in required fields.
</div>
</div>
</div>
<div style={styles.footer}>
<button onClick={onClose} style={styles.cancelButton} disabled={saving}>
Cancel
</button>
<button
onClick={handleSave}
style={styles.saveButton}
disabled={saving || !name.trim()}
>
{saving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Field'}
</button>
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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

View File

@@ -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 (
<input
type="text"
value={(currentValue as string) || ''}
onChange={(e) => handleChange(e.target.value || null)}
placeholder={`Enter ${field.name.toLowerCase()}...`}
style={styles.input}
disabled={disabled}
/>
)
case 'number':
return (
<input
type="number"
value={currentValue !== null ? String(currentValue) : ''}
onChange={(e) => {
const val = e.target.value
handleChange(val === '' ? null : parseFloat(val))
}}
placeholder="0"
style={styles.input}
disabled={disabled}
/>
)
case 'dropdown':
return (
<select
value={(currentValue as string) || ''}
onChange={(e) => handleChange(e.target.value || null)}
style={styles.select}
disabled={disabled}
>
<option value="">-- Select --</option>
{field.options?.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
)
case 'date':
return (
<input
type="date"
value={(currentValue as string) || ''}
onChange={(e) => handleChange(e.target.value || null)}
style={styles.input}
disabled={disabled}
/>
)
case 'person':
return (
<UserSelect
value={(currentValue as string) || null}
onChange={(userId: string | null, _user: UserSearchResult | null) => {
handleChange(userId)
}}
placeholder={`Select ${field.name.toLowerCase()}...`}
disabled={disabled}
/>
)
case 'formula':
// Formula fields are read-only and display the calculated value
return (
<div style={styles.formulaDisplay}>
{value?.display_value !== null && value?.display_value !== undefined
? value.display_value
: currentValue !== null
? String(currentValue)
: '-'}
<span style={styles.formulaHint}>(calculated)</span>
</div>
)
default:
return <div style={styles.unsupported}>Unsupported field type</div>
}
}
return (
<div style={styles.container}>
{showLabel && (
<label style={styles.label}>
{field.name}
{field.is_required && <span style={styles.required}>*</span>}
</label>
)}
{renderInput()}
</div>
)
}
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 (
<div
style={
layout === 'vertical'
? styles.groupVertical
: styles.groupSidebar
}
>
{fields.map((field) => (
<div
key={field.id}
style={layout === 'sidebar' ? styles.sidebarField : styles.verticalField}
>
<CustomFieldInput
field={field}
value={getValueForField(field.id)}
onChange={onChange}
disabled={disabled}
showLabel={true}
/>
</div>
))}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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

View File

@@ -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<FieldType, string> = {
text: 'Text',
number: 'Number',
dropdown: 'Dropdown',
date: 'Date',
person: 'Person',
formula: 'Formula',
}
export function CustomFieldList({ projectId }: CustomFieldListProps) {
const [fields, setFields] = useState<CustomField[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showEditor, setShowEditor] = useState(false)
const [editingField, setEditingField] = useState<CustomField | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 <div style={styles.loading}>Loading custom fields...</div>
}
if (error) {
return (
<div style={styles.error}>
<p>{error}</p>
<button onClick={loadFields} style={styles.retryButton}>
Retry
</button>
</div>
)
}
return (
<div style={styles.container}>
<div style={styles.header}>
<h3 style={styles.title}>Custom Fields</h3>
<button onClick={handleCreate} style={styles.addButton}>
+ Add Field
</button>
</div>
<p style={styles.description}>
Custom fields allow you to add additional data to tasks. You can create up to 20
fields per project.
</p>
{fields.length === 0 ? (
<div style={styles.emptyState}>
<p>No custom fields defined yet.</p>
<p style={styles.emptyHint}>
Click "Add Field" to create your first custom field.
</p>
</div>
) : (
<div style={styles.fieldList}>
{fields.map((field) => (
<div key={field.id} style={styles.fieldCard}>
<div style={styles.fieldInfo}>
<div style={styles.fieldName}>
{field.name}
{field.is_required && <span style={styles.requiredBadge}>Required</span>}
</div>
<div style={styles.fieldMeta}>
<span style={styles.fieldType}>
{FIELD_TYPE_LABELS[field.field_type]}
</span>
{field.field_type === 'dropdown' && field.options && (
<span style={styles.optionCount}>
{field.options.length} option{field.options.length !== 1 ? 's' : ''}
</span>
)}
{field.field_type === 'formula' && field.formula && (
<span style={styles.formulaPreview}>= {field.formula}</span>
)}
</div>
</div>
<div style={styles.fieldActions}>
<button
onClick={() => handleEdit(field)}
style={styles.editButton}
aria-label={`Edit ${field.name}`}
>
Edit
</button>
<button
onClick={() => handleDeleteClick(field.id)}
style={styles.deleteButton}
aria-label={`Delete ${field.name}`}
>
Delete
</button>
</div>
</div>
))}
</div>
)}
{/* Editor Modal */}
{showEditor && (
<CustomFieldEditor
projectId={projectId}
field={editingField}
onClose={handleEditorClose}
onSave={handleEditorSave}
/>
)}
{/* Delete Confirmation Modal */}
{deleteConfirm && (
<div style={styles.modalOverlay}>
<div style={styles.confirmModal}>
<h3 style={styles.confirmTitle}>Delete Custom Field?</h3>
<p style={styles.confirmMessage}>
This will permanently delete this field and all stored values for all tasks.
This action cannot be undone.
</p>
<div style={styles.confirmActions}>
<button
onClick={handleDeleteCancel}
style={styles.cancelButton}
disabled={deleting}
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
style={styles.confirmDeleteButton}
disabled={deleting}
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
)}
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,9 @@
import { useState } from 'react' import { useState } from 'react'
import { CustomValueResponse } from '../services/customFields'
interface Task { interface Task {
id: string id: string
project_id: string
title: string title: string
description: string | null description: string | null
priority: string priority: string
@@ -11,8 +13,10 @@ interface Task {
assignee_id: string | null assignee_id: string | null
assignee_name: string | null assignee_name: string | null
due_date: string | null due_date: string | null
start_date: string | null
time_estimate: number | null time_estimate: number | null
subtask_count: number subtask_count: number
custom_values?: CustomValueResponse[]
} }
interface TaskStatus { interface TaskStatus {
@@ -133,6 +137,12 @@ export function KanbanBoard({
{task.subtask_count > 0 && ( {task.subtask_count > 0 && (
<span style={styles.subtaskBadge}>{task.subtask_count} subtasks</span> <span style={styles.subtaskBadge}>{task.subtask_count} subtasks</span>
)} )}
{/* Display custom field values (limit to first 2 for compact display) */}
{task.custom_values?.slice(0, 2).map((cv) => (
<span key={cv.field_id} style={styles.customValueBadge}>
{cv.field_name}: {cv.display_value || cv.value || '-'}
</span>
))}
</div> </div>
</div> </div>
) )
@@ -280,6 +290,17 @@ const styles: Record<string, React.CSSProperties> = {
subtaskBadge: { subtaskBadge: {
color: '#999', color: '#999',
}, },
customValueBadge: {
backgroundColor: '#f3e5f5',
color: '#7b1fa2',
padding: '2px 6px',
borderRadius: '4px',
fontSize: '10px',
maxWidth: '100px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
emptyColumn: { emptyColumn: {
textAlign: 'center', textAlign: 'center',
padding: '24px', padding: '24px',

View File

@@ -4,9 +4,12 @@ import { Comments } from './Comments'
import { TaskAttachments } from './TaskAttachments' import { TaskAttachments } from './TaskAttachments'
import { UserSelect } from './UserSelect' import { UserSelect } from './UserSelect'
import { UserSearchResult } from '../services/collaboration' import { UserSearchResult } from '../services/collaboration'
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
import { CustomFieldInput } from './CustomFieldInput'
interface Task { interface Task {
id: string id: string
project_id: string
title: string title: string
description: string | null description: string | null
priority: string priority: string
@@ -18,6 +21,7 @@ interface Task {
due_date: string | null due_date: string | null
time_estimate: number | null time_estimate: number | null
subtask_count: number subtask_count: number
custom_values?: CustomValueResponse[]
} }
interface TaskStatus { interface TaskStatus {
@@ -59,6 +63,44 @@ export function TaskDetailModal({
: null : null
) )
// Custom fields state
const [customFields, setCustomFields] = useState<CustomField[]>([])
const [customValues, setCustomValues] = useState<CustomValueResponse[]>([])
const [editCustomValues, setEditCustomValues] = useState<Record<string, string | number | null>>({})
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<string, string | number | null> = {}
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 // Reset form when task changes
useEffect(() => { useEffect(() => {
setEditForm({ setEditForm({
@@ -108,6 +150,21 @@ export function TaskDetailModal({
payload.time_estimate = null 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) await api.patch(`/tasks/${task.id}`, payload)
setIsEditing(false) setIsEditing(false)
onUpdate() 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) => { const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
setEditForm({ ...editForm, assignee_id: userId || '' }) setEditForm({ ...editForm, assignee_id: userId || '' })
setSelectedAssignee(user) setSelectedAssignee(user)
@@ -349,6 +413,50 @@ export function TaskDetailModal({
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div> <div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</div>
</div> </div>
)} )}
{/* Custom Fields Section */}
{customFields.length > 0 && (
<>
<div style={styles.customFieldsDivider} />
<div style={styles.customFieldsHeader}>Custom Fields</div>
{loadingCustomFields ? (
<div style={styles.loadingText}>Loading...</div>
) : (
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 (
<div key={field.id} style={styles.sidebarField}>
<CustomFieldInput
field={field}
value={displayValue}
onChange={handleCustomFieldChange}
disabled={!isEditing || field.field_type === 'formula'}
showLabel={true}
/>
</div>
)
})
)}
</>
)}
</div> </div>
</div> </div>
</div> </div>
@@ -571,6 +679,23 @@ const styles: Record<string, React.CSSProperties> = {
fontSize: '14px', fontSize: '14px',
boxSizing: 'border-box', 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 export default TaskDetailModal

View File

@@ -16,3 +16,122 @@ body {
margin: 0 auto; margin: 0 auto;
padding: 20px; 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;
}

View File

@@ -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<Project | null>(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 <div style={styles.loading}>Loading...</div>
}
if (!project) {
return <div style={styles.error}>Project not found</div>
}
return (
<div style={styles.container}>
<div style={styles.breadcrumb}>
<span onClick={() => navigate('/spaces')} style={styles.breadcrumbLink}>
Spaces
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span
onClick={() => navigate(`/spaces/${project.space_id}`)}
style={styles.breadcrumbLink}
>
Projects
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span
onClick={() => navigate(`/projects/${project.id}`)}
style={styles.breadcrumbLink}
>
{project.title}
</span>
<span style={styles.breadcrumbSeparator}>/</span>
<span>Settings</span>
</div>
<div style={styles.header}>
<h1 style={styles.title}>Project Settings</h1>
<button
onClick={() => navigate(`/projects/${project.id}`)}
style={styles.backButton}
>
Back to Tasks
</button>
</div>
<div style={styles.layout}>
{/* Sidebar Navigation */}
<div style={styles.sidebar}>
<nav style={styles.nav}>
<button
onClick={() => setActiveTab('general')}
style={{
...styles.navItem,
...(activeTab === 'general' ? styles.navItemActive : {}),
}}
>
General
</button>
<button
onClick={() => setActiveTab('custom-fields')}
style={{
...styles.navItem,
...(activeTab === 'custom-fields' ? styles.navItemActive : {}),
}}
>
Custom Fields
</button>
</nav>
</div>
{/* Content Area */}
<div style={styles.content}>
{activeTab === 'general' && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>General Settings</h2>
<div style={styles.infoCard}>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Project Name</span>
<span style={styles.infoValue}>{project.title}</span>
</div>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Description</span>
<span style={styles.infoValue}>
{project.description || 'No description'}
</span>
</div>
<div style={styles.infoRow}>
<span style={styles.infoLabel}>Security Level</span>
<span style={styles.infoValue}>{project.security_level}</span>
</div>
</div>
<p style={styles.helpText}>
To edit project details, contact the project owner.
</p>
</div>
)}
{activeTab === 'custom-fields' && (
<CustomFieldList projectId={projectId!} />
)}
</div>
</div>
</div>
)
}
const styles: Record<string, React.CSSProperties> = {
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',
},
}

View File

@@ -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 { useParams, useNavigate } from 'react-router-dom'
import api from '../services/api' import api from '../services/api'
import { KanbanBoard } from '../components/KanbanBoard' import { KanbanBoard } from '../components/KanbanBoard'
import { CalendarView } from '../components/CalendarView'
import { GanttChart } from '../components/GanttChart'
import { TaskDetailModal } from '../components/TaskDetailModal' import { TaskDetailModal } from '../components/TaskDetailModal'
import { UserSelect } from '../components/UserSelect' import { UserSelect } from '../components/UserSelect'
import { UserSearchResult } from '../services/collaboration' import { UserSearchResult } from '../services/collaboration'
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext' import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
import { CustomFieldInput } from '../components/CustomFieldInput'
interface Task { interface Task {
id: string id: string
project_id: string
title: string title: string
description: string | null description: string | null
priority: string priority: string
@@ -18,8 +23,10 @@ interface Task {
assignee_id: string | null assignee_id: string | null
assignee_name: string | null assignee_name: string | null
due_date: string | null due_date: string | null
start_date: string | null
time_estimate: number | null time_estimate: number | null
subtask_count: number subtask_count: number
custom_values?: CustomValueResponse[]
} }
interface TaskStatus { interface TaskStatus {
@@ -35,9 +42,25 @@ interface Project {
space_id: string space_id: string
} }
type ViewMode = 'list' | 'kanban' type ViewMode = 'list' | 'kanban' | 'calendar' | 'gantt'
const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode' 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<string, boolean> => {
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<string, boolean>) => {
localStorage.setItem(`${COLUMN_VISIBILITY_STORAGE_KEY}-${projectId}`, JSON.stringify(visibility))
}
export default function Tasks() { export default function Tasks() {
const { projectId } = useParams() const { projectId } = useParams()
@@ -50,7 +73,7 @@ export default function Tasks() {
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false)
const [viewMode, setViewMode] = useState<ViewMode>(() => { const [viewMode, setViewMode] = useState<ViewMode>(() => {
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY) 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({ const [newTask, setNewTask] = useState({
title: '', title: '',
@@ -65,10 +88,37 @@ export default function Tasks() {
const [selectedTask, setSelectedTask] = useState<Task | null>(null) const [selectedTask, setSelectedTask] = useState<Task | null>(null)
const [showDetailModal, setShowDetailModal] = useState(false) const [showDetailModal, setShowDetailModal] = useState(false)
// Custom fields state
const [customFields, setCustomFields] = useState<CustomField[]>([])
const [newTaskCustomValues, setNewTaskCustomValues] = useState<Record<string, string | number | null>>({})
// Column visibility state
const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>(() => {
return projectId ? getColumnVisibility(projectId) : {}
})
const [showColumnMenu, setShowColumnMenu] = useState(false)
const columnMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
loadData() loadData()
}, [projectId]) }, [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 // Subscribe to project WebSocket when project changes
useEffect(() => { useEffect(() => {
if (projectId) { if (projectId) {
@@ -91,6 +141,7 @@ export default function Tasks() {
} }
const newTask: Task = { const newTask: Task = {
id: event.data.task_id, id: event.data.task_id,
project_id: projectId!,
title: event.data.title || '', title: event.data.title || '',
description: event.data.description ?? null, description: event.data.description ?? null,
priority: event.data.priority || 'medium', priority: event.data.priority || 'medium',
@@ -100,6 +151,7 @@ export default function Tasks() {
assignee_id: event.data.assignee_id ?? null, assignee_id: event.data.assignee_id ?? null,
assignee_name: event.data.assignee_name ?? null, assignee_name: event.data.assignee_name ?? null,
due_date: event.data.due_date ?? 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, time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
subtask_count: event.data.subtask_count ?? 0, 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_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.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.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.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.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 }), ...(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) localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode)
}, [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 () => { const loadData = async () => {
try { try {
const [projectRes, tasksRes, statusesRes] = await Promise.all([ const [projectRes, tasksRes, statusesRes] = await Promise.all([
@@ -194,6 +288,21 @@ export default function Tasks() {
payload.time_estimate = Number(newTask.time_estimate) 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) await api.post(`/projects/${projectId}/tasks`, payload)
setShowCreateModal(false) setShowCreateModal(false)
setNewTask({ setNewTask({
@@ -204,6 +313,7 @@ export default function Tasks() {
due_date: '', due_date: '',
time_estimate: '', time_estimate: '',
}) })
setNewTaskCustomValues({})
setSelectedAssignee(null) setSelectedAssignee(null)
loadData() loadData()
} catch (err) { } 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) => { const handleStatusChange = async (taskId: string, statusId: string) => {
// Save original state for rollback // Save original state for rollback
const originalTasks = [...tasks] const originalTasks = [...tasks]
@@ -246,7 +363,12 @@ export default function Tasks() {
} }
const handleTaskClick = (task: Task) => { 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) setShowDetailModal(true)
} }
@@ -335,7 +457,65 @@ export default function Tasks() {
> >
Kanban Kanban
</button> </button>
<button
onClick={() => setViewMode('calendar')}
style={{
...styles.viewButton,
...(viewMode === 'calendar' ? styles.viewButtonActive : {}),
}}
aria-label="Calendar view"
>
Calendar
</button>
<button
onClick={() => setViewMode('gantt')}
style={{
...styles.viewButton,
...(viewMode === 'gantt' ? styles.viewButtonActive : {}),
}}
aria-label="Gantt view"
>
Gantt
</button>
</div> </div>
{/* Column Visibility Toggle - only show when there are custom fields and in list view */}
{viewMode === 'list' && customFields.length > 0 && (
<div style={styles.columnMenuContainer} ref={columnMenuRef}>
<button
onClick={() => setShowColumnMenu(!showColumnMenu)}
style={styles.columnMenuButton}
aria-label="Toggle columns"
>
Columns
</button>
{showColumnMenu && (
<div style={styles.columnMenuDropdown}>
<div style={styles.columnMenuHeader}>Show Custom Fields</div>
{customFields.map((field) => (
<label key={field.id} style={styles.columnMenuItem}>
<input
type="checkbox"
checked={isColumnVisible(field.id)}
onChange={() => toggleColumnVisibility(field.id)}
style={styles.columnCheckbox}
/>
<span style={styles.columnLabel}>{field.name}</span>
</label>
))}
{customFields.length === 0 && (
<div style={styles.columnMenuEmpty}>No custom fields</div>
)}
</div>
)}
</div>
)}
<button
onClick={() => navigate(`/projects/${projectId}/settings`)}
style={styles.settingsButton}
aria-label="Project settings"
>
Settings
</button>
<button onClick={() => setShowCreateModal(true)} style={styles.createButton}> <button onClick={() => setShowCreateModal(true)} style={styles.createButton}>
+ New Task + New Task
</button> </button>
@@ -343,7 +523,7 @@ export default function Tasks() {
</div> </div>
{/* Conditional rendering based on view mode */} {/* Conditional rendering based on view mode */}
{viewMode === 'list' ? ( {viewMode === 'list' && (
<div style={styles.taskList}> <div style={styles.taskList}>
{tasks.map((task) => ( {tasks.map((task) => (
<div <div
@@ -373,6 +553,15 @@ export default function Tasks() {
{task.subtask_count} subtasks {task.subtask_count} subtasks
</span> </span>
)} )}
{/* Display visible custom field values */}
{task.custom_values &&
task.custom_values
.filter((cv) => isColumnVisible(cv.field_id))
.map((cv) => (
<span key={cv.field_id} style={styles.customValueBadge}>
{cv.field_name}: {cv.display_value || cv.value || '-'}
</span>
))}
</div> </div>
</div> </div>
<select <select
@@ -402,7 +591,9 @@ export default function Tasks() {
</div> </div>
)} )}
</div> </div>
) : ( )}
{viewMode === 'kanban' && (
<KanbanBoard <KanbanBoard
tasks={tasks} tasks={tasks}
statuses={statuses} statuses={statuses}
@@ -411,6 +602,25 @@ export default function Tasks() {
/> />
)} )}
{viewMode === 'calendar' && projectId && (
<CalendarView
projectId={projectId}
statuses={statuses}
onTaskClick={handleTaskClick}
onTaskUpdate={handleTaskUpdate}
/>
)}
{viewMode === 'gantt' && projectId && (
<GanttChart
projectId={projectId}
tasks={tasks}
statuses={statuses}
onTaskClick={handleTaskClick}
onTaskUpdate={handleTaskUpdate}
/>
)}
{/* Create Task Modal */} {/* Create Task Modal */}
{showCreateModal && ( {showCreateModal && (
<div style={styles.modalOverlay}> <div style={styles.modalOverlay}>
@@ -469,6 +679,37 @@ export default function Tasks() {
style={styles.input} style={styles.input}
/> />
{/* Custom Fields */}
{customFields.filter((f) => f.field_type !== 'formula').length > 0 && (
<>
<div style={styles.customFieldsDivider} />
<div style={styles.customFieldsTitle}>Custom Fields</div>
{customFields
.filter((field) => field.field_type !== 'formula')
.map((field) => (
<div key={field.id} style={styles.customFieldContainer}>
<CustomFieldInput
field={field}
value={
newTaskCustomValues[field.id] !== undefined
? {
field_id: field.id,
field_name: field.name,
field_type: field.field_type,
value: newTaskCustomValues[field.id],
display_value: null,
}
: null
}
onChange={handleNewTaskCustomFieldChange}
disabled={false}
showLabel={true}
/>
</div>
))}
</>
)}
<div style={styles.modalActions}> <div style={styles.modalActions}>
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}> <button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
Cancel Cancel
@@ -580,6 +821,15 @@ const styles: { [key: string]: React.CSSProperties } = {
backgroundColor: '#0066cc', backgroundColor: '#0066cc',
color: 'white', color: 'white',
}, },
settingsButton: {
padding: '10px 16px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
},
createButton: { createButton: {
padding: '10px 20px', padding: '10px 20px',
backgroundColor: '#0066cc', backgroundColor: '#0066cc',
@@ -733,4 +983,90 @@ const styles: { [key: string]: React.CSSProperties } = {
borderRadius: '4px', borderRadius: '4px',
cursor: 'pointer', cursor: 'pointer',
}, },
customFieldsDivider: {
height: '1px',
backgroundColor: '#eee',
margin: '16px 0',
},
customFieldsTitle: {
fontSize: '13px',
fontWeight: 600,
color: '#666',
marginBottom: '12px',
textTransform: 'uppercase',
},
customFieldContainer: {
marginBottom: '12px',
},
customValueBadge: {
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '11px',
maxWidth: '150px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
columnMenuContainer: {
position: 'relative',
},
columnMenuButton: {
padding: '10px 16px',
backgroundColor: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
display: 'flex',
alignItems: 'center',
gap: '6px',
},
columnMenuDropdown: {
position: 'absolute',
top: '100%',
right: 0,
marginTop: '4px',
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
border: '1px solid #e0e0e0',
minWidth: '200px',
zIndex: 100,
overflow: 'hidden',
},
columnMenuHeader: {
padding: '12px 16px',
fontSize: '12px',
fontWeight: 600,
color: '#666',
textTransform: 'uppercase',
borderBottom: '1px solid #eee',
backgroundColor: '#fafafa',
},
columnMenuItem: {
display: 'flex',
alignItems: 'center',
padding: '10px 16px',
cursor: 'pointer',
transition: 'background-color 0.15s',
gap: '10px',
},
columnCheckbox: {
width: '16px',
height: '16px',
cursor: 'pointer',
},
columnLabel: {
fontSize: '14px',
color: '#333',
},
columnMenuEmpty: {
padding: '16px',
textAlign: 'center',
color: '#888',
fontSize: '13px',
},
} }

View File

@@ -0,0 +1,119 @@
import api from './api'
// Enum matching backend FieldType
export type FieldType = 'text' | 'number' | 'dropdown' | 'date' | 'person' | 'formula'
export interface CustomField {
id: string
project_id: string
name: string
field_type: FieldType
options: string[] | null
formula: string | null
is_required: boolean
position: number
created_at: string
updated_at: string
}
export interface CustomFieldCreate {
name: string
field_type: FieldType
options?: string[]
formula?: string
is_required?: boolean
}
export interface CustomFieldUpdate {
name?: string
options?: string[]
formula?: string
is_required?: boolean
}
export interface CustomFieldListResponse {
fields: CustomField[]
total: number
}
export interface CustomValueInput {
field_id: string
value: string | number | null
}
export interface CustomValueResponse {
field_id: string
field_name: string
field_type: FieldType
value: string | number | null
display_value: string | null
}
export const customFieldsApi = {
/**
* Get all custom fields for a project
*/
getCustomFields: async (projectId: string): Promise<CustomFieldListResponse> => {
const response = await api.get<CustomFieldListResponse>(
`/projects/${projectId}/custom-fields`
)
return response.data
},
/**
* Create a new custom field
*/
createCustomField: async (
projectId: string,
data: CustomFieldCreate
): Promise<CustomField> => {
const response = await api.post<CustomField>(
`/projects/${projectId}/custom-fields`,
data
)
return response.data
},
/**
* Update an existing custom field
*/
updateCustomField: async (
fieldId: string,
data: CustomFieldUpdate
): Promise<CustomField> => {
const response = await api.put<CustomField>(`/custom-fields/${fieldId}`, data)
return response.data
},
/**
* Delete a custom field
*/
deleteCustomField: async (fieldId: string): Promise<void> => {
await api.delete(`/custom-fields/${fieldId}`)
},
/**
* Get a specific custom field by ID
*/
getCustomField: async (fieldId: string): Promise<CustomField> => {
const response = await api.get<CustomField>(`/custom-fields/${fieldId}`)
return response.data
},
/**
* Update custom field position (for reordering)
*/
updateCustomFieldPosition: async (
fieldId: string,
position: number
): Promise<CustomField> => {
const response = await api.patch<CustomField>(
`/custom-fields/${fieldId}/position`,
null,
{ params: { position } }
)
return response.data
},
}
export default customFieldsApi

View File

@@ -0,0 +1,75 @@
import api from './api'
export type DependencyType = 'FS' | 'SS' | 'FF' | 'SF'
export interface TaskDependency {
id: string
predecessor_id: string
successor_id: string
dependency_type: DependencyType
lag_days: number
created_at: string
predecessor_title?: string
successor_title?: string
}
export interface CreateDependencyRequest {
predecessor_id: string
dependency_type?: DependencyType
lag_days?: number
}
export interface CreateDependencyResponse {
id: string
predecessor_id: string
successor_id: string
dependency_type: DependencyType
lag_days: number
}
// API response wrapper for list endpoints
interface DependencyListResponse {
dependencies: TaskDependency[]
total: number
}
export const dependenciesApi = {
/**
* Get all dependencies for a specific task
*/
getTaskDependencies: async (taskId: string): Promise<TaskDependency[]> => {
const response = await api.get<DependencyListResponse>(`/tasks/${taskId}/dependencies`)
return response.data.dependencies
},
/**
* Add a dependency to a task (task becomes successor)
*/
addDependency: async (
taskId: string,
data: CreateDependencyRequest
): Promise<CreateDependencyResponse> => {
const response = await api.post<CreateDependencyResponse>(
`/tasks/${taskId}/dependencies`,
data
)
return response.data
},
/**
* Remove a dependency
*/
removeDependency: async (dependencyId: string): Promise<void> => {
await api.delete(`/task-dependencies/${dependencyId}`)
},
/**
* Get all dependencies for a project
*/
getProjectDependencies: async (projectId: string): Promise<TaskDependency[]> => {
const response = await api.get<DependencyListResponse>(`/projects/${projectId}/dependencies`)
return response.data.dependencies
},
}
export default dependenciesApi

46
frontend/src/types/frappe-gantt.d.ts vendored Normal file
View File

@@ -0,0 +1,46 @@
// Type definitions for frappe-gantt
declare module 'frappe-gantt' {
export interface GanttTask {
id: string
name: string
start: string | Date
end: string | Date
progress?: number
dependencies?: string
custom_class?: string
[key: string]: unknown
}
export interface GanttOptions {
header_height?: number
column_width?: number
step?: number
view_modes?: ViewMode[]
bar_height?: number
bar_corner_radius?: number
arrow_curve?: number
padding?: number
view_mode?: ViewMode
date_format?: string
language?: string
custom_popup_html?: ((task: GanttTask) => string) | null
on_click?: (task: GanttTask) => void
on_date_change?: (task: GanttTask, start: Date, end: Date) => void
on_progress_change?: (task: GanttTask, progress: number) => void
on_view_change?: (mode: ViewMode) => void
}
export type ViewMode = 'Quarter Day' | 'Half Day' | 'Day' | 'Week' | 'Month' | 'Year'
export default class Gantt {
constructor(
wrapper: string | HTMLElement | SVGElement,
tasks: GanttTask[],
options?: GanttOptions
)
change_view_mode(mode: ViewMode): void
refresh(tasks: GanttTask[]): void
scroll_to_task(taskId: string): void
}
}

View File

@@ -1,9 +1,9 @@
# PROJECT CONTROL - Issue Tracking # PROJECT CONTROL - Issue Tracking
> 審核日期: 2026-01-04 > 審核日期: 2026-01-04
> 更新日期: 2026-01-04 > 更新日期: 2026-01-05
> 整體完成度: 約 98% > 整體完成度: 約 99%
> 已修復問題: 23 (CRIT-001~003, HIGH-001~008, MED-001~012) > 已修復問題: 25 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002)
--- ---
@@ -500,16 +500,16 @@
| ID | 模組 | 功能 | 後端 | 前端 | 優先級 | 狀態 | | ID | 模組 | 功能 | 後端 | 前端 | 優先級 | 狀態 |
|----|------|------|:----:|:----:|--------|------| |----|------|------|:----:|:----:|--------|------|
| FEAT-001 | Task Management | 自定義欄位 (Custom Fields) | | | 高 | 待開發 | | FEAT-001 | Task Management | 自定義欄位 (Custom Fields) | | | 高 | ✅ 已完成 (2026-01-05) |
| FEAT-002 | Task Management | 看板視角 (Kanban View) | 有 | 有 | 高 | ✅ 已完成 (KanbanBoard.tsx) | | FEAT-002 | Task Management | 看板視角 (Kanban View) | 有 | 有 | 高 | ✅ 已完成 (KanbanBoard.tsx) |
| FEAT-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | | 中 | 待開發 | | FEAT-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | | 中 | ✅ 已完成 (2026-01-05) |
| FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | | 中 | 待開發 | | FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | | 中 | ✅ 已完成 (2026-01-05) |
| FEAT-005 | Task Management | 子任務建立 UI | 有 | 缺 | 中 | 待開發 | | FEAT-005 | Task Management | 子任務建立 UI | 有 | 缺 | 中 | 待開發 |
| FEAT-006 | Task Management | 拖拉變更狀態 | 有 | 有 | 中 | ✅ 已完成 (KanbanBoard drag-drop) | | FEAT-006 | Task Management | 拖拉變更狀態 | 有 | 有 | 中 | ✅ 已完成 (KanbanBoard drag-drop) |
| FEAT-007 | Resource Management | 負載熱圖 UI | 有 | 有 | 高 | ✅ 已完成 (WorkloadPage.tsx) | | FEAT-007 | Resource Management | 負載熱圖 UI | 有 | 有 | 高 | ✅ 已完成 (WorkloadPage.tsx) |
| FEAT-008 | Resource Management | 專案健康看板 | 有 | 有 | 中 | ✅ 已完成 (ProjectHealthPage.tsx) | | FEAT-008 | Resource Management | 專案健康看板 | 有 | 有 | 中 | ✅ 已完成 (ProjectHealthPage.tsx) |
| FEAT-009 | Resource Management | 容量更新 API | 有 | N/A | 低 | ✅ 已完成 (PUT /api/users/{id}/capacity) | | FEAT-009 | Resource Management | 容量更新 API | 有 | N/A | 低 | ✅ 已完成 (PUT /api/users/{id}/capacity) |
| FEAT-010 | Document Management | AES-256 加密存儲 | | N/A | 高 | 待開發 | | FEAT-010 | Document Management | AES-256 加密存儲 | | N/A | 高 | ✅ 已完成 (2026-01-05) |
| FEAT-011 | Document Management | 動態浮水印 | 有 | N/A | 中 | ✅ 已完成 (watermark_service.py) | | FEAT-011 | Document Management | 動態浮水印 | 有 | N/A | 中 | ✅ 已完成 (watermark_service.py) |
| FEAT-012 | Document Management | 版本還原 UI | 有 | 缺 | 低 | 待開發 | | FEAT-012 | Document Management | 版本還原 UI | 有 | 缺 | 低 | 待開發 |
| FEAT-013 | Automation | 排程觸發器執行 | 有 | N/A | 中 | ✅ 已完成 (trigger_scheduler.py) | | FEAT-013 | Automation | 排程觸發器執行 | 有 | N/A | 中 | ✅ 已完成 (trigger_scheduler.py) |
@@ -694,6 +694,11 @@
| 2026-01-04 | add-capacity-update-api | resource-management | | 2026-01-04 | add-capacity-update-api | resource-management |
| 2026-01-04 | add-schedule-triggers | automation | | 2026-01-04 | add-schedule-triggers | automation |
| 2026-01-04 | add-watermark-feature | document-management | | 2026-01-04 | add-watermark-feature | document-management |
| 2026-01-05 | add-kanban-realtime-sync | collaboration |
| 2026-01-05 | add-file-encryption | document-management |
| 2026-01-05 | add-gantt-view | task-management |
| 2026-01-05 | add-calendar-view | task-management |
| 2026-01-05 | add-custom-fields | task-management |
--- ---
@@ -724,9 +729,17 @@
- **問題描述**: WebSocket 目前僅用於通知推送,看板視角沒有即時同步功能。當其他用戶拖拉任務變更狀態時,當前用戶的看板不會即時更新。 - **問題描述**: WebSocket 目前僅用於通知推送,看板視角沒有即時同步功能。當其他用戶拖拉任務變更狀態時,當前用戶的看板不會即時更新。
- **影響**: 多人協作時可能產生狀態衝突 - **影響**: 多人協作時可能產生狀態衝突
- **建議修復**: 擴展 WebSocket 支援任務變更事件廣播,前端訂閱並即時更新看板 - **建議修復**: 擴展 WebSocket 支援任務變更事件廣播,前端訂閱並即時更新看板
- **狀態**: [ ] 待開發 - **狀態**: [x] 已修復 (2026-01-05)
- **修復內容**:
- Backend: 新增 `/ws/projects/{project_id}` WebSocket endpoint
- Backend: 實作 Redis Pub/Sub 任務事件廣播 (`project:{project_id}:tasks` 頻道)
- Backend: 支援 task_created, task_updated, task_status_changed, task_deleted, task_assigned 事件
- Frontend: 新增 ProjectSyncContext 管理 WebSocket 連線
- Frontend: Tasks.tsx 整合即時更新,支援 event_id 去重、多分頁支援
- Frontend: 新增 Live/Offline 連線狀態指示器
- OpenSpec: 更新 collaboration spec 新增 Project Real-time Sync requirement
--- ---
*此文件由 Claude Code 自動生成於 2026-01-04* *此文件由 Claude Code 自動生成於 2026-01-04*
*更新於 2026-01-04* *更新於 2026-01-05*

View File

@@ -0,0 +1,91 @@
# Proposal: Add Calendar View
**Change ID:** `add-calendar-view`
**Issue Reference:** `FEAT-004` (issues.md)
**Status:** Draft
**Author:** Claude
**Date:** 2026-01-05
## Summary
實作行事曆視角,以月/週/日視圖呈現任務截止日期,方便使用者規劃工作時程。
## Motivation
行事曆視角提供直覺的日期導向任務規劃:
- 快速掌握本週/本月截止的任務
- 識別特定日期的工作量
- 避免任務截止日期過度集中
- 與個人行事曆習慣一致
目前系統需要在列表或看板中逐一檢視任務日期,無法一覽全局。
## Scope
### In Scope
- Frontend: Calendar 元件(月視圖、週視圖、日視圖)
- Frontend: 任務依截止日期顯示在行事曆上
- Frontend: 點擊任務開啟詳情 Modal
- Frontend: 拖拉任務更改截止日期
- Frontend: 篩選器(依指派者、狀態、優先級)
### Out of Scope
- 與外部行事曆Google Calendar、Outlook同步
- 重複任務功能
- 行事曆分享
## Design Decisions
### 前端函式庫選擇
建議使用 **FullCalendar**
| 函式庫 | 優點 | 缺點 |
|--------|------|------|
| FullCalendar | 功能完整、React 支援好、拖拉支援 | 需要 premium 功能要付費 |
| react-big-calendar | 開源、輕量 | 功能較少 |
| @schedule-x/react | 現代設計 | 社群較小 |
**建議**: 使用 FullCalendarMIT 授權核心功能足夠)
### 視圖模式
| 視圖 | 說明 |
|------|------|
| Month | 月曆格式,每格顯示當日任務(最多 3 個,其餘 "+N more"|
| Week | 週視圖,每格顯示當日所有任務 |
| Day | 日視圖,當天任務完整列表 |
### 任務顯示規則
- 依 due_date 顯示在對應日期
- 任務卡片顯示:標題、狀態顏色標記、優先級圖示
- 已完成任務可選顯示/隱藏
- 已過期未完成任務特殊標記(紅色邊框)
### 拖拉更新日期
- 拖拉任務至其他日期更新 due_date
- 使用現有 PUT /api/tasks/{id} API
- 樂觀更新,失敗時回滾
### API 使用
```
# 使用現有 API加入日期範圍篩選
GET /api/projects/{project_id}/tasks?due_after=2026-01-01&due_before=2026-01-31
```
## Affected Specs
| Spec | Change Type |
|------|-------------|
| task-management | MODIFIED (Multiple Views requirement 行事曆細節) |
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| 單日任務過多影響顯示 | 月視圖最多顯示 3 個,其餘收合 |
| 效能問題 | 只載入當前視圖月份的任務 |
| 與甘特圖日期衝突 | 行事曆編輯 due_date甘特圖可編輯 start_date + due_date |
## Dependencies
- FullCalendar或其他行事曆函式庫
- 現有 Task API 的 due_date 篩選支援

View File

@@ -0,0 +1,51 @@
## MODIFIED Requirements
### Requirement: Multiple Views
系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。
#### Scenario: 行事曆視角
- **GIVEN** 使用者選擇行事曆視角
- **WHEN** 系統載入專案任務
- **THEN** 任務依截止日期顯示在行事曆上
#### Scenario: 行事曆視圖切換
- **GIVEN** 使用者正在查看行事曆
- **WHEN** 使用者切換視圖模式(月、週、日)
- **THEN** 行事曆相應調整顯示格式
- **AND** 任務正確顯示在對應日期
#### Scenario: 月視圖任務顯示
- **GIVEN** 使用者選擇月視圖
- **WHEN** 某日有超過 3 個任務
- **THEN** 顯示前 3 個任務
- **AND** 顯示 "+N more" 連結可展開查看全部
#### Scenario: 點擊任務查看詳情
- **GIVEN** 使用者正在查看行事曆
- **WHEN** 使用者點擊任務
- **THEN** 系統開啟任務詳情 Modal
- **AND** 可在 Modal 中編輯任務
#### Scenario: 拖拉調整截止日期
- **GIVEN** 使用者正在查看行事曆
- **WHEN** 使用者拖拉任務至其他日期
- **THEN** 系統更新任務的 due_date
- **AND** 任務顯示在新日期
#### Scenario: 已過期任務標示
- **GIVEN** 任務的 due_date 已過期
- **WHEN** 任務狀態不是「已完成」
- **THEN** 任務顯示特殊標記(紅色邊框或背景)
- **AND** 提醒使用者注意
#### Scenario: 日期範圍載入
- **GIVEN** 使用者查看行事曆
- **WHEN** 系統載入任務
- **THEN** 只載入當前視圖日期範圍內的任務
- **AND** 切換月份時動態載入對應任務
#### Scenario: 行事曆篩選
- **GIVEN** 使用者正在查看行事曆
- **WHEN** 使用者設定篩選條件(指派者、狀態、優先級)
- **THEN** 行事曆只顯示符合條件的任務
- **AND** 篩選條件在視圖切換時保留

View File

@@ -0,0 +1,80 @@
# Tasks: Add Calendar View
## Backend Tasks
### 1. Add date range filter to Tasks API
- [x] Modify GET /api/projects/{project_id}/tasks to accept `due_after` and `due_before` query params
- [x] Add filter logic to query
- [x] Add API tests
- **驗證**: 可依日期範圍篩選任務 ✅
## Frontend Tasks
### 2. Install and configure FullCalendar
- [x] Install @fullcalendar/react and related packages
- [x] Install @fullcalendar/daygrid (month view)
- [x] Install @fullcalendar/timegrid (week/day view)
- [x] Install @fullcalendar/interaction (drag & drop)
- **驗證**: 套件安裝成功 ✅
### 3. Create CalendarView component
- [x] Create `frontend/src/components/CalendarView.tsx`
- [x] Configure calendar with month/week/day views
- [x] Style calendar to match application theme
- [x] Add view toggle buttons
- **驗證**: 行事曆正確渲染 ✅
### 4. Load and display tasks on calendar
- [x] Fetch tasks from API with date range
- [x] Transform tasks to FullCalendar event format
- [x] Display task title, status color, priority icon
- [x] Handle overdue tasks (special styling)
- **驗證**: 任務正確顯示在對應日期 ✅
### 5. Implement task click to open detail modal
- [x] Handle event click in calendar
- [x] Open TaskDetailModal with selected task
- [x] Refresh calendar on task update
- **驗證**: 點擊任務開啟詳情 ✅
### 6. Implement drag-to-change date
- [x] Enable drag & drop in FullCalendar
- [x] Handle event drop to get new date
- [x] Call PUT /api/tasks/{id} with new due_date
- [x] Show optimistic update, rollback on error
- **驗證**: 拖拉更新日期可正常運作 ✅
### 7. Add filter controls
- [x] Add assignee filter dropdown
- [x] Add status filter (show/hide completed)
- [x] Add priority filter
- [x] Persist filter in URL or localStorage
- **驗證**: 篩選器正確過濾任務 ✅
### 8. Integrate Calendar view into Tasks page
- [x] Add "Calendar" option to view toggle
- [x] Store view preference in localStorage
- [x] Handle view switching
- **驗證**: 可在 List/Kanban/Gantt/Calendar 之間切換 ✅
## Task Dependencies
```
[1] Date Range Filter API
[2] FullCalendar Setup → [3] CalendarView Component
[4] Task Display
[5] Click → Modal
[6] Drag to Change Date
[7] Filters
[8] View Integration
```
- Task 1 (Backend) 可平行於 Tasks 2-3 (Frontend) 開發
- Task 4 需要 Task 1 完成(需要日期範圍 API
- Tasks 5-8 為循序開發

View File

@@ -0,0 +1,83 @@
# Proposal: Add Custom Fields
**Change ID:** `add-custom-fields`
**Issue Reference:** `FEAT-001` (issues.md)
**Status:** Draft
**Author:** Claude
**Date:** 2026-01-05
## Summary
實作自定義欄位功能,允許專案管理者為任務新增自定義欄位,包含文字、數字、下拉選單、日期、人員標籤和公式等類型。
## Motivation
目前系統僅支援固定的任務欄位(標題、描述、狀態、優先級等)。不同專案可能需要追蹤特定資料,例如:
- 半導體製程:封裝類型、機台編號、預計良率
- 軟體開發Sprint 編號、Story Points、影響版本
- 行銷活動:活動代碼、預算類別、目標受眾
自定義欄位讓專案管理者可以根據需求彈性擴展任務資料結構。
## Scope
### In Scope
- Backend: CustomField 和 TaskCustomValue models
- Backend: Custom fields CRUD API endpoints
- Backend: Formula field 計算引擎
- Frontend: Custom fields 管理 UI專案設定頁面
- Frontend: Task form 動態渲染自定義欄位
- Frontend: Task list/kanban 顯示自定義欄位值
### Out of Scope
- 跨專案共用欄位定義
- 自定義欄位匯入/匯出
- 複雜公式函數(僅支援基本數學運算)
## Design Decisions
### 欄位類型
| Type | 說明 | 儲存格式 |
|------|------|----------|
| text | 單行文字 | VARCHAR |
| number | 數字 | DECIMAL |
| dropdown | 下拉選單 | VARCHAR (選項儲存於 options JSON) |
| date | 日期 | DATE |
| person | 人員標籤 | UUID (FK -> users) |
| formula | 公式計算 | DECIMAL (計算結果) |
### 公式欄位
- 支援基本數學運算:`+`, `-`, `*`, `/`
- 可引用其他數字欄位:`{field_name}`
- 可引用任務內建欄位:`{original_estimate}`, `{time_spent}`
- 範例公式:`{time_spent} / {original_estimate} * 100`
### API 設計
```
POST /api/projects/{project_id}/custom-fields # 新增欄位定義
GET /api/projects/{project_id}/custom-fields # 列出所有欄位
PUT /api/custom-fields/{field_id} # 更新欄位定義
DELETE /api/custom-fields/{field_id} # 刪除欄位(含所有值)
# 欄位值透過現有 task API 操作
PUT /api/tasks/{task_id} # body 包含 custom_values
```
## Affected Specs
| Spec | Change Type |
|------|-------------|
| task-management | MODIFIED (Custom Fields requirement 實作細節) |
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| 公式欄位循環引用 | 建立欄位時驗證公式不引用自己或形成循環 |
| 大量自定義欄位影響效能 | 限制每專案最多 20 個自定義欄位 |
| 刪除欄位遺失資料 | 刪除前顯示確認對話框,說明將刪除所有相關值 |
## Dependencies
- 無外部依賴
- 需要現有 tasks API 支援

View File

@@ -0,0 +1,54 @@
## MODIFIED Requirements
### Requirement: Custom Fields
系統 SHALL 支援自定義欄位,包含下拉選單、公式、人員標籤等類型。
#### Scenario: 新增自定義欄位
- **GIVEN** 專案管理者需要追蹤特定資料(如:封裝類型、機台編號、預計良率)
- **WHEN** 管理者在專案中新增自定義欄位
- **THEN** 系統建立欄位定義並套用至該專案所有任務
#### Scenario: 編輯自定義欄位
- **GIVEN** 專案已有自定義欄位
- **WHEN** 管理者修改欄位名稱或選項
- **THEN** 系統更新欄位定義
- **AND** 現有任務的欄位值保持不變
#### Scenario: 刪除自定義欄位
- **GIVEN** 專案已有自定義欄位且有任務包含該欄位的值
- **WHEN** 管理者刪除該欄位
- **THEN** 系統顯示確認對話框說明將刪除所有相關值
- **AND** 確認後刪除欄位定義及所有任務的該欄位值
#### Scenario: 公式欄位計算
- **GIVEN** 任務包含公式類型的自定義欄位
- **WHEN** 相依欄位的值發生變更
- **THEN** 系統自動重新計算公式欄位的值
#### Scenario: 公式欄位循環引用檢查
- **GIVEN** 管理者建立公式欄位
- **WHEN** 公式引用自己或形成循環引用
- **THEN** 系統拒絕建立並顯示錯誤訊息
#### Scenario: 人員標籤欄位
- **GIVEN** 任務包含人員標籤類型的自定義欄位
- **WHEN** 使用者選擇人員
- **THEN** 系統驗證人員存在並建立關聯
- **AND** 被標籤的人員可收到相關通知
#### Scenario: 下拉選單欄位
- **GIVEN** 任務包含下拉選單類型的自定義欄位
- **WHEN** 使用者選擇選項
- **THEN** 系統儲存選擇的值
- **AND** 選項列表由欄位定義提供
#### Scenario: 自定義欄位值顯示
- **GIVEN** 任務有自定義欄位值
- **WHEN** 使用者在列表或看板視角查看任務
- **THEN** 自定義欄位值顯示在任務資訊中
- **AND** 公式欄位顯示計算結果(唯讀)
#### Scenario: 欄位數量限制
- **GIVEN** 專案已有 20 個自定義欄位
- **WHEN** 管理者嘗試新增第 21 個欄位
- **THEN** 系統拒絕新增並顯示數量已達上限的訊息

View File

@@ -0,0 +1,82 @@
# Tasks: Add Custom Fields
## Backend Tasks
### 1. Create CustomField and TaskCustomValue models
- [x] Create `backend/app/models/custom_field.py` with CustomField model
- [x] Create `backend/app/models/task_custom_value.py` with TaskCustomValue model
- [x] Update `backend/app/models/__init__.py` to export new models
- [x] Create Alembic migration for new tables
- **驗證**: Migration 成功執行tables 建立正確
### 2. Create Custom Fields API endpoints
- [x] Create `backend/app/api/custom_fields/router.py`
- [x] Implement `POST /api/projects/{project_id}/custom-fields` - 新增欄位
- [x] Implement `GET /api/projects/{project_id}/custom-fields` - 列出欄位
- [x] Implement `PUT /api/custom-fields/{field_id}` - 更新欄位
- [x] Implement `DELETE /api/custom-fields/{field_id}` - 刪除欄位
- [x] Add permission checks (only project owner/manager can manage fields)
- [x] Register router in main.py
- **驗證**: API endpoints 可正常呼叫,權限檢查正確
### 3. Implement formula calculation engine
- [x] Create `backend/app/services/formula_service.py`
- [x] Implement formula parsing (extract field references)
- [x] Implement formula validation (detect circular references)
- [x] Implement formula calculation
- [x] Add unit tests for formula service
- **驗證**: 公式計算正確,循環引用被拒絕
### 4. Integrate custom values with Tasks API
- [x] Modify `TaskCreate` schema to accept `custom_values`
- [x] Modify `TaskUpdate` schema to accept `custom_values`
- [x] Modify `TaskResponse` schema to include `custom_values`
- [x] Update `create_task` endpoint to save custom values
- [x] Update `update_task` endpoint to save custom values
- [x] Update `get_task` endpoint to return custom values
- [x] Trigger formula recalculation on value change
- **驗證**: 任務 CRUD 包含自定義欄位值
### 5. Add backend tests
- [x] Test custom field CRUD operations
- [x] Test permission checks
- [x] Test formula calculation
- [x] Test task integration with custom values
- **驗證**: 所有測試通過 (20/20 tests passed)
## Frontend Tasks
### 6. Create Custom Fields management UI
- [x] Create `frontend/src/pages/ProjectSettings.tsx` or extend existing
- [x] Create `frontend/src/components/CustomFieldEditor.tsx` - 欄位編輯表單
- [x] Create `frontend/src/components/CustomFieldList.tsx` - 欄位列表
- [x] Add custom fields API service in `frontend/src/services/customFields.ts`
- **驗證**: 可在專案設定中管理自定義欄位
### 7. Dynamic field rendering in Task forms
- [x] Create `frontend/src/components/CustomFieldInput.tsx` - 動態欄位輸入
- [x] Integrate into TaskDetailModal.tsx
- [x] Integrate into task creation form
- [x] Handle different field types (text, number, dropdown, date, person)
- [x] Display formula fields as read-only
- **驗證**: 任務表單正確顯示和儲存自定義欄位
### 8. Display custom fields in task views
- [x] Add custom field columns to List view
- [x] Display custom field values in Kanban cards (optional, configurable)
- [x] Add column visibility toggle for custom fields
- **驗證**: 自定義欄位值在各視角中正確顯示
## Task Dependencies
```
[1] Models -> [2] API -> [4] Task Integration -> [5] Tests
\
[3] Formula Engine /
[6] Management UI -> [7] Task Forms -> [8] View Display
```
- Tasks 1-5 (Backend) 可平行於 Tasks 6-8 (Frontend) 開發
- Task 2 完成後 Task 6 可開始(需要 API
- Task 4 完成後 Task 7 可開始(需要 Task API 支援 custom values

View File

@@ -0,0 +1,110 @@
# Proposal: Add File Encryption
**Change ID:** `add-file-encryption`
**Issue Reference:** `FEAT-010` (issues.md)
**Status:** Draft
**Author:** Claude
**Date:** 2026-01-05
## Summary
實作 AES-256 加密存儲功能,對機密等級專案的附件進行加密保護,並提供金鑰管理與輪換機制。
## Motivation
半導體產業的設計文件、製程參數和良率報告屬於高度機密資料。目前系統的附件以明文形式儲存於 NAS若發生以下情況可能導致資料外洩
- NAS 遭未授權存取
- 備份媒體遺失
- 儲存設備報廢未妥善處理
透過 AES-256 加密,即使檔案被取得也無法解讀內容,提供額外的安全層。
## Scope
### In Scope
- Backend: EncryptionKey model 和 key 管理 API
- Backend: 加密服務encrypt/decrypt
- Backend: 附件上傳時自動加密(依專案機密等級)
- Backend: 附件下載時自動解密
- Backend: 金鑰輪換功能
- 稽核日誌:加密相關操作記錄
### Out of Scope
- 客戶端加密End-to-End Encryption
- Hardware Security Module (HSM) 整合
- 現有檔案的批次加密遷移(需另案處理)
## Design Decisions
### 加密策略
| 專案機密等級 | 加密行為 |
|-------------|---------|
| public | 不加密 |
| department | 不加密 |
| confidential | 強制加密 |
### 金鑰管理
- 採用對稱金鑰加密AES-256-GCM
- 每個金鑰有唯一 ID儲存於資料庫
- 金鑰本身使用 Master Key 加密後存儲
- Master Key 從環境變數讀取,不存於資料庫
### 加密流程
```
1. 上傳檔案
2. 檢查專案機密等級
3. 若為 confidential:
a. 取得當前有效金鑰
b. 使用 AES-256-GCM 加密檔案
c. 儲存加密後的檔案
d. 記錄 encryption_key_id
4. 儲存 Attachment 記錄
```
### 解密流程
```
1. 請求下載檔案
2. 驗證存取權限
3. 若 is_encrypted = true:
a. 取得 encryption_key_id 對應的金鑰
b. 解密檔案
c. 返回解密後的內容
4. 若 is_encrypted = false:
a. 直接返回檔案
```
### 金鑰輪換
- 建立新金鑰並標記為 active
- 舊金鑰標記為 inactive但保留用於解密舊檔案
- 新上傳檔案使用新金鑰
- 可選:批次重新加密舊檔案(背景任務)
### API 設計
```
# 金鑰管理 (Admin only)
GET /api/admin/encryption-keys # 列出所有金鑰
POST /api/admin/encryption-keys # 建立新金鑰
POST /api/admin/encryption-keys/rotate # 金鑰輪換
DELETE /api/admin/encryption-keys/{id} # 停用金鑰(不刪除)
# 附件加密狀態由系統自動處理,無需額外 API
```
## Affected Specs
| Spec | Change Type |
|------|-------------|
| document-management | MODIFIED (File Encryption requirement 實作細節) |
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Master Key 遺失導致資料無法解密 | 提供 Master Key 備份指引,建議異地保存 |
| 加密效能影響 | 使用串流加密避免大檔案記憶體問題 |
| 金鑰輪換中斷 | 使用資料庫交易確保原子性 |
## Dependencies
- Python cryptography 套件
- Master Key 環境變數配置

View File

@@ -0,0 +1,52 @@
## MODIFIED Requirements
### Requirement: File Encryption
系統 SHALL 對半導體敏感圖檔進行 AES-256 加密存儲。
#### Scenario: 自動加密判斷
- **GIVEN** 使用者上傳檔案至任務
- **WHEN** 該任務所屬專案的 security_level 為 "confidential"
- **THEN** 系統自動使用 AES-256-GCM 加密檔案
- **AND** 設定 is_encrypted = true 及 encryption_key_id
#### Scenario: 加密存儲
- **GIVEN** 專案設定為機密等級
- **WHEN** 使用者上傳檔案
- **THEN** 系統使用 AES-256 加密後存儲
- **AND** 加密金鑰安全管理
#### Scenario: 解密讀取
- **GIVEN** 使用者請求下載加密檔案
- **WHEN** 系統驗證權限通過
- **THEN** 系統解密檔案後提供下載
- **AND** 解密過程透明,使用者無感
#### Scenario: 串流處理大檔案
- **GIVEN** 使用者上傳或下載大型加密檔案
- **WHEN** 系統處理加密或解密
- **THEN** 使用串流方式處理避免記憶體溢出
- **AND** 效能損耗在可接受範圍內
#### Scenario: 金鑰輪換
- **GIVEN** 安全政策要求金鑰輪換
- **WHEN** 管理員執行金鑰輪換
- **THEN** 系統建立新金鑰並標記為 active
- **AND** 舊金鑰保留用於解密既有檔案
- **AND** 新上傳檔案使用新金鑰加密
#### Scenario: Master Key 管理
- **GIVEN** 系統需要加解密檔案
- **WHEN** 系統取得加密金鑰
- **THEN** 使用 Master Key 解密金鑰後使用
- **AND** Master Key 從環境變數讀取,不存於資料庫
#### Scenario: 加密操作稽核
- **GIVEN** 發生加密相關操作
- **WHEN** 操作完成
- **THEN** 系統記錄操作類型、金鑰 ID、檔案 ID、操作者、時間
- **AND** 日誌不可竄改
#### Scenario: 金鑰管理權限
- **GIVEN** 使用者嘗試管理加密金鑰
- **WHEN** 使用者不是系統管理員
- **THEN** 系統拒絕操作並返回 403 錯誤

View File

@@ -0,0 +1,89 @@
# Tasks: Add File Encryption
## Backend Tasks
### 1. Create EncryptionKey model
- [x] Create `backend/app/models/encryption_key.py`
- [x] Update `backend/app/models/__init__.py`
- [x] Add `encryption_key_id` FK to Attachment model
- [x] Create Alembic migration
- **驗證**: Migration 成功執行
### 2. Implement encryption service
- [x] Create `backend/app/services/encryption_service.py`
- [x] Add `MASTER_KEY` to config.py (from env var)
- [x] Implement `generate_key()` - 產生新的 AES-256 金鑰
- [x] Implement `encrypt_key()` - 使用 Master Key 加密金鑰
- [x] Implement `decrypt_key()` - 使用 Master Key 解密金鑰
- [x] Implement `encrypt_file()` - 串流式檔案加密 (AES-256-GCM)
- [x] Implement `decrypt_file()` - 串流式檔案解密
- [x] Add unit tests for encryption service
- **驗證**: 加密解密測試通過
### 3. Create encryption key management API
- [x] Create `backend/app/api/admin/encryption_keys.py`
- [x] Implement `GET /api/admin/encryption-keys` - 列出金鑰(不含實際金鑰值)
- [x] Implement `POST /api/admin/encryption-keys` - 建立新金鑰
- [x] Implement `POST /api/admin/encryption-keys/rotate` - 金鑰輪換
- [x] Add system admin only permission check
- [x] Register router in main.py
- **驗證**: API 可正常呼叫
### 4. Integrate encryption with attachment upload
- [x] Modify `backend/app/api/attachments/router.py` upload endpoint
- [x] Check project security_level before upload
- [x] If confidential: encrypt file using encryption service
- [x] Set is_encrypted = True and encryption_key_id
- [x] Store encrypted file to NAS
- **驗證**: 機密專案上傳的檔案為加密狀態
### 5. Integrate decryption with attachment download
- [x] Modify `backend/app/api/attachments/router.py` download endpoint
- [x] Check is_encrypted flag
- [x] If encrypted: decrypt using encryption service before returning
- [x] Maintain streaming for large files
- **驗證**: 下載加密檔案可正確解密
### 6. Add encryption audit logging
- [x] Log encryption operations (encrypt, decrypt, key_create, key_rotate)
- [x] Include key_id, file_id, user_id, timestamp
- **驗證**: 稽核日誌正確記錄加密操作
### 7. Add backend tests
- [x] Test encryption service (encrypt/decrypt)
- [x] Test key management API
- [x] Test attachment upload with encryption
- [x] Test attachment download with decryption
- [x] Test key rotation
- **驗證**: 所有測試通過
## Configuration Tasks
### 8. Environment configuration
- [x] Add `MASTER_KEY` to .env.example
- [x] Document key generation procedure
- [x] Document key backup recommendations
- **驗證**: 文件完整
## Task Dependencies
```
[1] EncryptionKey Model
[2] Encryption Service
[3] Key Management API ─────┐
↓ │
[4] Upload Integration │
↓ │
[5] Download Integration │
↓ │
[6] Audit Logging │
↓ │
[7] Tests ←─────────────────┘
[8] Configuration
```
- Tasks 1-7 為循序依賴
- Task 8 可平行進行

View File

@@ -0,0 +1,111 @@
# Proposal: Add Gantt View
**Change ID:** `add-gantt-view`
**Issue Reference:** `FEAT-003` (issues.md)
**Status:** Draft
**Author:** Claude
**Date:** 2026-01-05
## Summary
實作甘特圖視角,以時間軸方式呈現專案任務,顯示任務期間、依賴關係和里程碑。
## Motivation
甘特圖是專案管理的核心視覺化工具,可幫助團隊:
- 了解任務時程與排程
- 識別關鍵路徑
- 發現資源衝突和瓶頸
- 追蹤進度與延遲
目前系統僅有看板和列表視角,缺少時間軸視圖,無法直觀地規劃和追蹤專案進度。
## Scope
### In Scope
- Backend: Task model 新增 start_date 欄位
- Backend: TaskDependency model前置任務關係
- Backend: 依賴關係 CRUD API
- Frontend: Gantt chart 元件(使用第三方函式庫)
- Frontend: 任務時程編輯(拖拉調整日期)
- Frontend: 依賴關係視覺化(箭頭連線)
### Out of Scope
- 關鍵路徑計算
- 資源分配視圖
- 基線對比功能
- 匯出為圖片/PDF
## Design Decisions
### 任務依賴類型
| Type | 說明 | 範例 |
|------|------|------|
| finish_to_start (FS) | 前置任務完成後才能開始 | 設計完成 → 開發開始 |
| start_to_start (SS) | 前置任務開始後才能開始 | 設計開始 → 文件開始 |
| finish_to_finish (FF) | 前置任務完成後才能完成 | 開發完成 → 測試完成 |
| start_to_finish (SF) | 前置任務開始後才能完成 | 較少使用 |
**預設**: finish_to_start (FS) 為最常見類型
### Data Model 變更
```sql
-- Task 新增欄位
ALTER TABLE pjctrl_tasks ADD COLUMN start_date DATETIME;
-- 新增依賴關係表
CREATE TABLE pjctrl_task_dependencies (
id UUID PRIMARY KEY,
predecessor_id UUID REFERENCES pjctrl_tasks(id),
successor_id UUID REFERENCES pjctrl_tasks(id),
dependency_type ENUM('FS', 'SS', 'FF', 'SF') DEFAULT 'FS',
lag_days INT DEFAULT 0,
created_at TIMESTAMP
);
```
### 前端函式庫選擇
建議使用 **DHTMLX Gantt****Frappe Gantt**
| 函式庫 | 優點 | 缺點 |
|--------|------|------|
| DHTMLX Gantt | 功能完整、效能好 | 商業授權 |
| Frappe Gantt | 開源、輕量 | 功能較少 |
| React-Gantt-Chart | React 原生 | 社群較小 |
**建議**: 使用 Frappe GanttMIT 授權),足夠基本需求
### API 設計
```
# 依賴關係 API
POST /api/tasks/{task_id}/dependencies # 新增依賴
GET /api/tasks/{task_id}/dependencies # 取得依賴
DELETE /api/task-dependencies/{dependency_id} # 刪除依賴
# 任務日期更新(使用現有 API
PUT /api/tasks/{task_id} # body 包含 start_date, due_date
```
### 日期驗證規則
- start_date 不可晚於 due_date
- 有依賴關係時,根據 dependency_type 驗證日期合理性
- 循環依賴檢測
## Affected Specs
| Spec | Change Type |
|------|-------------|
| task-management | MODIFIED (Multiple Views requirement 甘特圖細節) |
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| 複雜依賴關係影響效能 | 限制單任務最多 10 個直接依賴 |
| 循環依賴 | 新增/修改依賴時進行循環檢測 |
| 大型專案載入慢 | 分頁載入或虛擬滾動 |
## Dependencies
- Frappe Gantt或其他甘特圖函式庫
- 需要 Task model 已有 due_date 欄位(✓ 已存在)

View File

@@ -0,0 +1,67 @@
## MODIFIED Requirements
### Requirement: Multiple Views
系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。
#### Scenario: 甘特圖視角
- **GIVEN** 使用者選擇甘特圖視角
- **WHEN** 系統載入專案任務
- **THEN** 任務依時間軸顯示為水平條狀
- **AND** 顯示任務相依關係與里程碑
#### Scenario: 甘特圖時間軸縮放
- **GIVEN** 使用者正在查看甘特圖
- **WHEN** 使用者切換縮放層級(日、週、月)
- **THEN** 時間軸相應調整顯示密度
- **AND** 任務條保持正確的相對位置
#### Scenario: 拖拉調整任務日期
- **GIVEN** 使用者正在查看甘特圖
- **WHEN** 使用者拖拉任務條改變位置或長度
- **THEN** 系統更新任務的 start_date 和 due_date
- **AND** 驗證日期合理性start_date <= due_date
#### Scenario: 顯示任務依賴關係
- **GIVEN** 任務之間存在依賴關係
- **WHEN** 使用者查看甘特圖
- **THEN** 系統顯示連接任務的箭頭
- **AND** 箭頭方向表示依賴方向(前置任務 → 後續任務)
#### Scenario: 新增任務依賴
- **GIVEN** 使用者在甘特圖上選擇兩個任務
- **WHEN** 使用者建立依賴關係
- **THEN** 系統儲存依賴關係
- **AND** 顯示連接箭頭
#### Scenario: 刪除任務依賴
- **GIVEN** 任務之間存在依賴關係
- **WHEN** 使用者刪除該依賴
- **THEN** 系統移除依賴記錄
- **AND** 連接箭頭消失
#### Scenario: 循環依賴檢測
- **GIVEN** 使用者嘗試建立依賴關係
- **WHEN** 該依賴會形成循環A → B → C → A
- **THEN** 系統拒絕建立並顯示錯誤訊息
- **AND** 現有依賴關係保持不變
#### Scenario: 依賴類型支援
- **GIVEN** 使用者建立任務依賴
- **WHEN** 使用者選擇依賴類型
- **THEN** 系統支援以下類型:
- Finish-to-Start (FS): 前置完成後開始
- Start-to-Start (SS): 前置開始後開始
- Finish-to-Finish (FF): 前置完成後完成
- Start-to-Finish (SF): 前置開始後完成
## ADDED Data Model
```
pjctrl_task_dependencies
├── id: UUID (PK)
├── predecessor_id: UUID (FK -> tasks)
├── successor_id: UUID (FK -> tasks)
├── dependency_type: ENUM('FS', 'SS', 'FF', 'SF') DEFAULT 'FS'
├── lag_days: INT DEFAULT 0
└── created_at: TIMESTAMP
```

View File

@@ -0,0 +1,92 @@
# Tasks: Add Gantt View
## Backend Tasks
### 1. Add start_date to Task model
- [x] Add `start_date` column to Task model
- [x] Create Alembic migration
- [x] Update TaskCreate/TaskUpdate schemas to include start_date
- [x] Update TaskResponse schema
- **驗證**: Migration 成功API 可設定 start_date
### 2. Create TaskDependency model
- [x] Create `backend/app/models/task_dependency.py`
- [x] Define predecessor_id, successor_id, dependency_type, lag_days
- [x] Update `backend/app/models/__init__.py`
- [x] Create Alembic migration
- **驗證**: Migration 成功
### 3. Implement dependency CRUD API
- [x] Create `backend/app/api/task_dependencies/router.py`
- [x] Implement `POST /api/tasks/{task_id}/dependencies` - 新增依賴
- [x] Implement `GET /api/tasks/{task_id}/dependencies` - 取得依賴
- [x] Implement `DELETE /api/task-dependencies/{dependency_id}` - 刪除依賴
- [x] Add circular dependency detection
- [x] Register router in main.py
- **驗證**: 依賴關係 CRUD 可正常操作,循環依賴被拒絕
### 4. Add date validation
- [x] Validate start_date <= due_date
- [x] Validate dependency constraints on date change
- [x] Add validation tests
- **驗證**: 不合理日期被拒絕
### 5. Add backend tests
- [x] Test TaskDependency CRUD
- [x] Test circular dependency detection
- [x] Test date validation
- **驗證**: 所有測試通過
## Frontend Tasks
### 6. Install and configure Gantt library
- [x] Install Frappe Gantt (or chosen library)
- [x] Create wrapper component for React integration
- [x] Configure styling to match application theme
- **驗證**: Gantt 元件可正常渲染
### 7. Create GanttChart component
- [x] Create `frontend/src/components/GanttChart.tsx`
- [x] Load tasks with start_date and due_date
- [x] Display tasks as horizontal bars on timeline
- [x] Show task title, assignee, progress
- [x] Support zoom levels (day, week, month)
- **驗證**: 任務正確顯示在時間軸上
### 8. Implement drag-to-edit dates
- [x] Handle bar drag to move task dates
- [x] Handle bar resize to change duration
- [x] Call API to update task dates
- [x] Show optimistic update, rollback on error
- **驗證**: 拖拉調整日期可正確更新
### 9. Implement dependency visualization
- [x] Add dependency arrows between tasks
- [x] Create dependency API service
- [x] Implement add/remove dependency UI (right-click or toolbar)
- [x] Validate and show error for circular dependencies
- **驗證**: 依賴關係正確顯示和編輯
### 10. Integrate Gantt view into Tasks page
- [x] Add "Gantt" option to view toggle
- [x] Store view preference in localStorage
- [x] Handle view switching
- **驗證**: 可在 List/Kanban/Gantt 之間切換
## Task Dependencies
```
[1] start_date 欄位
[2] TaskDependency Model → [3] Dependency API → [4] Date Validation → [5] Tests
[6] Gantt Library Setup → [7] GanttChart Component
[8] Drag Edit → [9] Dependency UI
[10] View Integration
```
- Tasks 1-5 (Backend) 可平行於 Tasks 6-10 (Frontend) 開發
- Task 7 需要 Task 1 完成(需要 start_date 欄位)
- Task 9 需要 Task 3 完成(需要依賴 API

View File

@@ -49,6 +49,12 @@
### Requirement: File Encryption ### Requirement: File Encryption
系統 SHALL 對半導體敏感圖檔進行 AES-256 加密存儲。 系統 SHALL 對半導體敏感圖檔進行 AES-256 加密存儲。
#### Scenario: 自動加密判斷
- **GIVEN** 使用者上傳檔案至任務
- **WHEN** 該任務所屬專案的 security_level 為 "confidential"
- **THEN** 系統自動使用 AES-256-GCM 加密檔案
- **AND** 設定 is_encrypted = true 及 encryption_key_id
#### Scenario: 加密存儲 #### Scenario: 加密存儲
- **GIVEN** 專案設定為機密等級 - **GIVEN** 專案設定為機密等級
- **WHEN** 使用者上傳檔案 - **WHEN** 使用者上傳檔案
@@ -61,11 +67,35 @@
- **THEN** 系統解密檔案後提供下載 - **THEN** 系統解密檔案後提供下載
- **AND** 解密過程透明,使用者無感 - **AND** 解密過程透明,使用者無感
#### Scenario: 加密金鑰輪換 #### Scenario: 串流處理大檔案
- **GIVEN** 使用者上傳或下載大型加密檔案
- **WHEN** 系統處理加密或解密
- **THEN** 使用串流方式處理避免記憶體溢出
- **AND** 效能損耗在可接受範圍內
#### Scenario: 金鑰輪換
- **GIVEN** 安全政策要求金鑰輪換 - **GIVEN** 安全政策要求金鑰輪換
- **WHEN** 管理員執行金鑰輪換 - **WHEN** 管理員執行金鑰輪換
- **THEN** 系統使用新金鑰重新加密所有檔案 - **THEN** 系統建立新金鑰並標記為 active
- **AND** 舊金鑰安全銷毀 - **AND** 舊金鑰保留用於解密既有檔案
- **AND** 新上傳檔案使用新金鑰加密
#### Scenario: Master Key 管理
- **GIVEN** 系統需要加解密檔案
- **WHEN** 系統取得加密金鑰
- **THEN** 使用 Master Key 解密金鑰後使用
- **AND** Master Key 從環境變數讀取,不存於資料庫
#### Scenario: 加密操作稽核
- **GIVEN** 發生加密相關操作
- **WHEN** 操作完成
- **THEN** 系統記錄操作類型、金鑰 ID、檔案 ID、操作者、時間
- **AND** 日誌不可竄改
#### Scenario: 金鑰管理權限
- **GIVEN** 使用者嘗試管理加密金鑰
- **WHEN** 使用者不是系統管理員
- **THEN** 系統拒絕操作並返回 403 錯誤
### Requirement: Dynamic Watermarking ### Requirement: Dynamic Watermarking
系統 SHALL 在下載時自動為檔案加上使用者浮水印。 系統 SHALL 在下載時自動為檔案加上使用者浮水印。

View File

@@ -33,42 +33,104 @@
- **WHEN** 管理者在專案中新增自定義欄位 - **WHEN** 管理者在專案中新增自定義欄位
- **THEN** 系統建立欄位定義並套用至該專案所有任務 - **THEN** 系統建立欄位定義並套用至該專案所有任務
#### Scenario: 編輯自定義欄位
- **GIVEN** 專案已有自定義欄位
- **WHEN** 管理者修改欄位名稱或選項
- **THEN** 系統更新欄位定義
- **AND** 現有任務的欄位值保持不變
#### Scenario: 刪除自定義欄位
- **GIVEN** 專案已有自定義欄位且有任務包含該欄位的值
- **WHEN** 管理者刪除該欄位
- **THEN** 系統顯示確認對話框說明將刪除所有相關值
- **AND** 確認後刪除欄位定義及所有任務的該欄位值
#### Scenario: 公式欄位計算 #### Scenario: 公式欄位計算
- **GIVEN** 任務包含公式類型的自定義欄位 - **GIVEN** 任務包含公式類型的自定義欄位
- **WHEN** 相依欄位的值發生變更 - **WHEN** 相依欄位的值發生變更
- **THEN** 系統自動重新計算公式欄位的值 - **THEN** 系統自動重新計算公式欄位的值
#### Scenario: 公式欄位循環引用檢查
- **GIVEN** 管理者建立公式欄位
- **WHEN** 公式引用自己或形成循環引用
- **THEN** 系統拒絕建立並顯示錯誤訊息
#### Scenario: 人員標籤欄位 #### Scenario: 人員標籤欄位
- **GIVEN** 任務包含人員標籤類型的自定義欄位 - **GIVEN** 任務包含人員標籤類型的自定義欄位
- **WHEN** 使用者選擇人員 - **WHEN** 使用者選擇人員
- **THEN** 系統驗證人員存在並建立關聯 - **THEN** 系統驗證人員存在並建立關聯
- **AND** 被標籤的人員可收到相關通知 - **AND** 被標籤的人員可收到相關通知
#### Scenario: 下拉選單欄位
- **GIVEN** 任務包含下拉選單類型的自定義欄位
- **WHEN** 使用者選擇選項
- **THEN** 系統儲存選擇的值
- **AND** 選項列表由欄位定義提供
#### Scenario: 自定義欄位值顯示
- **GIVEN** 任務有自定義欄位值
- **WHEN** 使用者在列表或看板視角查看任務
- **THEN** 自定義欄位值顯示在任務資訊中
- **AND** 公式欄位顯示計算結果(唯讀)
#### Scenario: 欄位數量限制
- **GIVEN** 專案已有 20 個自定義欄位
- **WHEN** 管理者嘗試新增第 21 個欄位
- **THEN** 系統拒絕新增並顯示數量已達上限的訊息
### Requirement: Multiple Views ### Requirement: Multiple Views
系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。 系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。
#### Scenario: 看板視角
- **GIVEN** 使用者選擇看板視角
- **WHEN** 系統載入專案任務
- **THEN** 任務依狀態分組顯示為卡片
- **AND** 支援拖拉變更狀態
#### Scenario: 甘特圖視角 #### Scenario: 甘特圖視角
- **GIVEN** 使用者選擇甘特圖視角 - **GIVEN** 使用者選擇甘特圖視角
- **WHEN** 系統載入專案任務 - **WHEN** 系統載入專案任務
- **THEN** 任務依時間軸顯示 - **THEN** 任務依時間軸顯示為水平條狀
- **AND** 顯示任務相依關係與里程碑 - **AND** 顯示任務相依關係與里程碑
#### Scenario: 列表視角 #### Scenario: 甘特圖時間軸縮放
- **GIVEN** 使用者選擇列表視角 - **GIVEN** 使用者正在查看甘特圖
- **WHEN** 系統載入專案任務 - **WHEN** 使用者切換縮放層級(日、週、月)
- **THEN** 任務以表格形式顯示 - **THEN** 時間軸相應調整顯示密度
- **AND** 支援欄位排序與篩選 - **AND** 任務條保持正確的相對位置
#### Scenario: 行事曆視角 #### Scenario: 拖拉調整任務日期
- **GIVEN** 使用者選擇行事曆視角 - **GIVEN** 使用者正在查看甘特圖
- **WHEN** 系統載入專案任務 - **WHEN** 使用者拖拉任務條改變位置或長度
- **THEN** 任務依截止日期顯示在行事曆上 - **THEN** 系統更新任務的 start_date 和 due_date
- **AND** 驗證日期合理性start_date <= due_date
#### Scenario: 顯示任務依賴關係
- **GIVEN** 任務之間存在依賴關係
- **WHEN** 使用者查看甘特圖
- **THEN** 系統顯示連接任務的箭頭
- **AND** 箭頭方向表示依賴方向(前置任務 → 後續任務)
#### Scenario: 新增任務依賴
- **GIVEN** 使用者在甘特圖上選擇兩個任務
- **WHEN** 使用者建立依賴關係
- **THEN** 系統儲存依賴關係
- **AND** 顯示連接箭頭
#### Scenario: 刪除任務依賴
- **GIVEN** 任務之間存在依賴關係
- **WHEN** 使用者刪除該依賴
- **THEN** 系統移除依賴記錄
- **AND** 連接箭頭消失
#### Scenario: 循環依賴檢測
- **GIVEN** 使用者嘗試建立依賴關係
- **WHEN** 該依賴會形成循環A → B → C → A
- **THEN** 系統拒絕建立並顯示錯誤訊息
- **AND** 現有依賴關係保持不變
#### Scenario: 依賴類型支援
- **GIVEN** 使用者建立任務依賴
- **WHEN** 使用者選擇依賴類型
- **THEN** 系統支援以下類型:
- Finish-to-Start (FS): 前置完成後開始
- Start-to-Start (SS): 前置開始後開始
- Finish-to-Finish (FF): 前置完成後完成
- Start-to-Finish (SF): 前置開始後完成
### Requirement: Task Status Management ### Requirement: Task Status Management
系統 SHALL 管理任務狀態,包含標準狀態與自定義狀態。 系統 SHALL 管理任務狀態,包含標準狀態與自定義狀態。