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:
@@ -20,3 +20,14 @@ AUTH_API_URL=https://pj-auth-api.vercel.app
|
||||
|
||||
# System Admin
|
||||
SYSTEM_ADMIN_EMAIL=ymirliu@panjit.com.tw
|
||||
|
||||
# File Encryption (AES-256)
|
||||
# Master key for encrypting file encryption keys (optional - if not set, file encryption is disabled)
|
||||
# Generate a new key with:
|
||||
# python -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||
#
|
||||
# IMPORTANT:
|
||||
# - Keep this key secure and back it up! If lost, encrypted files cannot be decrypted.
|
||||
# - Store backup in a secure location separate from the database backup.
|
||||
# - Do NOT change this key after files have been encrypted (use key rotation instead).
|
||||
ENCRYPTION_MASTER_KEY=
|
||||
|
||||
1
backend/app/api/admin/__init__.py
Normal file
1
backend/app/api/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Admin API module
|
||||
299
backend/app/api/admin/encryption_keys.py
Normal file
299
backend/app/api/admin/encryption_keys.py
Normal 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}
|
||||
@@ -1,5 +1,7 @@
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Request
|
||||
from fastapi.responses import FileResponse, Response
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -7,7 +9,7 @@ from typing import Optional
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.middleware.auth import get_current_user, check_task_access, check_task_edit_access
|
||||
from app.models import User, Task, Project, Attachment, AttachmentVersion, AuditAction
|
||||
from app.models import User, Task, Project, Attachment, AttachmentVersion, EncryptionKey, AuditAction
|
||||
from app.schemas.attachment import (
|
||||
AttachmentResponse, AttachmentListResponse, AttachmentDetailResponse,
|
||||
AttachmentVersionResponse, VersionHistoryResponse
|
||||
@@ -15,6 +17,13 @@ from app.schemas.attachment import (
|
||||
from app.services.file_storage_service import file_storage_service
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.watermark_service import watermark_service
|
||||
from app.services.encryption_service import (
|
||||
encryption_service,
|
||||
MasterKeyNotConfiguredError,
|
||||
DecryptionError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["attachments"])
|
||||
|
||||
@@ -103,6 +112,40 @@ def version_to_response(version: AttachmentVersion) -> AttachmentVersionResponse
|
||||
)
|
||||
|
||||
|
||||
def should_encrypt_file(project: Project, db: Session) -> tuple[bool, Optional[EncryptionKey]]:
|
||||
"""
|
||||
Determine if a file should be encrypted based on project security level.
|
||||
|
||||
Returns:
|
||||
Tuple of (should_encrypt, encryption_key)
|
||||
"""
|
||||
# Only encrypt for confidential projects
|
||||
if project.security_level != "confidential":
|
||||
return False, None
|
||||
|
||||
# Check if encryption is available
|
||||
if not encryption_service.is_encryption_available():
|
||||
logger.warning(
|
||||
f"Project {project.id} is confidential but encryption is not configured. "
|
||||
"Files will be stored unencrypted."
|
||||
)
|
||||
return False, None
|
||||
|
||||
# Get active encryption key
|
||||
active_key = db.query(EncryptionKey).filter(
|
||||
EncryptionKey.is_active == True
|
||||
).first()
|
||||
|
||||
if not active_key:
|
||||
logger.warning(
|
||||
f"Project {project.id} is confidential but no active encryption key exists. "
|
||||
"Files will be stored unencrypted. Create a key using /api/admin/encryption-keys/rotate"
|
||||
)
|
||||
return False, None
|
||||
|
||||
return True, active_key
|
||||
|
||||
|
||||
@router.post("/tasks/{task_id}/attachments", response_model=AttachmentResponse)
|
||||
async def upload_attachment(
|
||||
task_id: str,
|
||||
@@ -111,10 +154,22 @@ async def upload_attachment(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Upload a file attachment to a task."""
|
||||
"""
|
||||
Upload a file attachment to a task.
|
||||
|
||||
For confidential projects, files are automatically encrypted using AES-256-GCM.
|
||||
"""
|
||||
task = get_task_with_access_check(db, task_id, current_user, require_edit=True)
|
||||
|
||||
# Check if attachment with same filename exists (for versioning in Phase 2)
|
||||
# Get project to check security level
|
||||
project = db.query(Project).filter(Project.id == task.project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Determine if encryption is needed
|
||||
should_encrypt, encryption_key = should_encrypt_file(project, db)
|
||||
|
||||
# Check if attachment with same filename exists (for versioning)
|
||||
existing = db.query(Attachment).filter(
|
||||
Attachment.task_id == task_id,
|
||||
Attachment.original_filename == file.filename,
|
||||
@@ -122,10 +177,66 @@ async def upload_attachment(
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Phase 2: Create new version
|
||||
# Create new version for existing attachment
|
||||
new_version = existing.current_version + 1
|
||||
|
||||
# Save file
|
||||
if should_encrypt and encryption_key:
|
||||
# Read and encrypt file content
|
||||
file_content = await file.read()
|
||||
await file.seek(0)
|
||||
|
||||
try:
|
||||
# Decrypt the encryption key
|
||||
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
|
||||
# Encrypt the file
|
||||
encrypted_content = encryption_service.encrypt_bytes(file_content, raw_key)
|
||||
|
||||
# Create a new UploadFile-like object with encrypted content
|
||||
encrypted_file = BytesIO(encrypted_content)
|
||||
encrypted_file.seek(0)
|
||||
|
||||
# Save encrypted file using a modified approach
|
||||
file_path, _, checksum = await file_storage_service.save_file(
|
||||
file=file,
|
||||
project_id=task.project_id,
|
||||
task_id=task_id,
|
||||
attachment_id=existing.id,
|
||||
version=new_version
|
||||
)
|
||||
|
||||
# Overwrite with encrypted content
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(encrypted_content)
|
||||
|
||||
file_size = len(encrypted_content)
|
||||
|
||||
# Update existing attachment with encryption info
|
||||
existing.is_encrypted = True
|
||||
existing.encryption_key_id = encryption_key.id
|
||||
|
||||
# Audit log for encryption
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="attachment.encrypt",
|
||||
resource_type="attachment",
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=existing.id,
|
||||
changes=[
|
||||
{"field": "is_encrypted", "old_value": False, "new_value": True},
|
||||
{"field": "encryption_key_id", "old_value": None, "new_value": encryption_key.id},
|
||||
],
|
||||
request_metadata=getattr(request.state, "audit_metadata", None),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to encrypt file for attachment {existing.id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to encrypt file. Please try again."
|
||||
)
|
||||
else:
|
||||
# Save file without encryption
|
||||
file_path, file_size, checksum = await file_storage_service.save_file(
|
||||
file=file,
|
||||
project_id=task.project_id,
|
||||
@@ -170,8 +281,45 @@ async def upload_attachment(
|
||||
|
||||
# Create new attachment
|
||||
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=file,
|
||||
project_id=task.project_id,
|
||||
@@ -193,7 +341,8 @@ async def upload_attachment(
|
||||
mime_type=mime_type,
|
||||
file_size=file_size,
|
||||
current_version=1,
|
||||
is_encrypted=False,
|
||||
is_encrypted=is_encrypted,
|
||||
encryption_key_id=encryption_key_id,
|
||||
uploaded_by=current_user.id
|
||||
)
|
||||
db.add(attachment)
|
||||
@@ -211,6 +360,10 @@ async def upload_attachment(
|
||||
db.add(version)
|
||||
|
||||
# Audit log
|
||||
changes = [{"field": "filename", "old_value": None, "new_value": attachment.filename}]
|
||||
if is_encrypted:
|
||||
changes.append({"field": "is_encrypted", "old_value": None, "new_value": True})
|
||||
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="attachment.upload",
|
||||
@@ -218,7 +371,7 @@ async def upload_attachment(
|
||||
action=AuditAction.CREATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=attachment.id,
|
||||
changes=[{"field": "filename", "old_value": None, "new_value": attachment.filename}],
|
||||
changes=changes,
|
||||
request_metadata=getattr(request.state, "audit_metadata", None)
|
||||
)
|
||||
|
||||
@@ -286,7 +439,11 @@ async def download_attachment(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Download an attachment file with dynamic watermark."""
|
||||
"""
|
||||
Download an attachment file with dynamic watermark.
|
||||
|
||||
For encrypted files, the file is automatically decrypted before returning.
|
||||
"""
|
||||
attachment = get_attachment_with_access_check(db, attachment_id, current_user, require_edit=False)
|
||||
|
||||
# Get version to download
|
||||
@@ -319,14 +476,69 @@ async def download_attachment(
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Read file content
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
# Decrypt if encrypted
|
||||
if attachment.is_encrypted:
|
||||
if not attachment.encryption_key_id:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Encrypted file is missing encryption key reference"
|
||||
)
|
||||
|
||||
encryption_key = db.query(EncryptionKey).filter(
|
||||
EncryptionKey.id == attachment.encryption_key_id
|
||||
).first()
|
||||
|
||||
if not encryption_key:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Encryption key not found for this file"
|
||||
)
|
||||
|
||||
try:
|
||||
# Decrypt the encryption key
|
||||
raw_key = encryption_service.decrypt_key(encryption_key.key_data)
|
||||
# Decrypt the file
|
||||
file_bytes = encryption_service.decrypt_bytes(file_bytes, raw_key)
|
||||
|
||||
# Audit log for decryption
|
||||
AuditService.log_event(
|
||||
db=db,
|
||||
event_type="attachment.decrypt",
|
||||
resource_type="attachment",
|
||||
action=AuditAction.UPDATE,
|
||||
user_id=current_user.id,
|
||||
resource_id=attachment.id,
|
||||
changes=[{"field": "decrypted_for_download", "old_value": None, "new_value": True}],
|
||||
request_metadata=getattr(request.state, "audit_metadata", None) if request else None,
|
||||
)
|
||||
db.commit()
|
||||
|
||||
except DecryptionError as e:
|
||||
logger.error(f"Failed to decrypt attachment {attachment_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to decrypt file. The file may be corrupted."
|
||||
)
|
||||
except MasterKeyNotConfiguredError:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Encryption is not configured. Cannot decrypt file."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error decrypting attachment {attachment_id}: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to decrypt file."
|
||||
)
|
||||
|
||||
# Check if watermark should be applied
|
||||
mime_type = attachment.mime_type or ""
|
||||
if watermark_service.supports_watermark(mime_type):
|
||||
try:
|
||||
# Read the original file
|
||||
with open(file_path, "rb") as f:
|
||||
file_bytes = f.read()
|
||||
|
||||
# Apply watermark based on file type
|
||||
if watermark_service.is_supported_image(mime_type):
|
||||
watermarked_bytes, output_format = watermark_service.add_image_watermark(
|
||||
@@ -367,19 +579,19 @@ async def download_attachment(
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# If watermarking fails, log the error but still return the original file
|
||||
# This ensures users can still download files even if watermarking has issues
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(
|
||||
# If watermarking fails, log the error but still return the file
|
||||
logger.warning(
|
||||
f"Watermarking failed for attachment {attachment_id}: {str(e)}. "
|
||||
"Returning original file."
|
||||
"Returning file without watermark."
|
||||
)
|
||||
|
||||
# Return original file without watermark for unsupported types or on error
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=attachment.original_filename,
|
||||
media_type=attachment.mime_type
|
||||
# Return file (decrypted if needed, without watermark for unsupported types)
|
||||
return Response(
|
||||
content=file_bytes,
|
||||
media_type=attachment.mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{attachment.original_filename}"'
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
3
backend/app/api/custom_fields/__init__.py
Normal file
3
backend/app/api/custom_fields/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.custom_fields.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
368
backend/app/api/custom_fields/router.py
Normal file
368
backend/app/api/custom_fields/router.py
Normal 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)
|
||||
3
backend/app/api/task_dependencies/__init__.py
Normal file
3
backend/app/api/task_dependencies/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from app.api.task_dependencies.router import router
|
||||
|
||||
__all__ = ["router"]
|
||||
431
backend/app/api/task_dependencies/router.py
Normal file
431
backend/app/api/task_dependencies/router.py
Normal 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)
|
||||
)
|
||||
@@ -10,7 +10,7 @@ from app.core.redis_pubsub import publish_task_event
|
||||
from app.models import User, Project, Task, TaskStatus, AuditAction, Blocker
|
||||
from app.schemas.task import (
|
||||
TaskCreate, TaskUpdate, TaskResponse, TaskWithDetails, TaskListResponse,
|
||||
TaskStatusUpdate, TaskAssignUpdate
|
||||
TaskStatusUpdate, TaskAssignUpdate, CustomValueResponse
|
||||
)
|
||||
from app.middleware.auth import (
|
||||
get_current_user, check_project_access, check_task_access, check_task_edit_access
|
||||
@@ -19,6 +19,8 @@ from app.middleware.audit import get_audit_metadata
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services.trigger_service import TriggerService
|
||||
from app.services.workload_cache import invalidate_user_workload_cache
|
||||
from app.services.custom_value_service import CustomValueService
|
||||
from app.services.dependency_service import DependencyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -40,13 +42,18 @@ def get_task_depth(db: Session, task: Task) -> int:
|
||||
return depth
|
||||
|
||||
|
||||
def task_to_response(task: Task) -> TaskWithDetails:
|
||||
def task_to_response(task: Task, db: Session = None, include_custom_values: bool = False) -> TaskWithDetails:
|
||||
"""Convert a Task model to TaskWithDetails response."""
|
||||
# Count only non-deleted subtasks
|
||||
subtask_count = 0
|
||||
if task.subtasks:
|
||||
subtask_count = sum(1 for st in task.subtasks if not st.is_deleted)
|
||||
|
||||
# Get custom values if requested
|
||||
custom_values = None
|
||||
if include_custom_values and db:
|
||||
custom_values = CustomValueService.get_custom_values_for_task(db, task)
|
||||
|
||||
return TaskWithDetails(
|
||||
id=task.id,
|
||||
project_id=task.project_id,
|
||||
@@ -56,6 +63,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
|
||||
priority=task.priority,
|
||||
original_estimate=task.original_estimate,
|
||||
time_spent=task.time_spent,
|
||||
start_date=task.start_date,
|
||||
due_date=task.due_date,
|
||||
assignee_id=task.assignee_id,
|
||||
status_id=task.status_id,
|
||||
@@ -69,6 +77,7 @@ def task_to_response(task: Task) -> TaskWithDetails:
|
||||
status_color=task.status.color if task.status else None,
|
||||
creator_name=task.creator.name if task.creator else None,
|
||||
subtask_count=subtask_count,
|
||||
custom_values=custom_values,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,12 +87,24 @@ async def list_tasks(
|
||||
parent_task_id: Optional[str] = Query(None, description="Filter by parent task"),
|
||||
status_id: Optional[str] = Query(None, description="Filter by status"),
|
||||
assignee_id: Optional[str] = Query(None, description="Filter by assignee"),
|
||||
due_after: Optional[datetime] = Query(None, description="Filter tasks with due_date >= this value (for calendar view)"),
|
||||
due_before: Optional[datetime] = Query(None, description="Filter tasks with due_date <= this value (for calendar view)"),
|
||||
include_deleted: bool = Query(False, description="Include deleted tasks (admin only)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""
|
||||
List all tasks in a project.
|
||||
|
||||
Supports filtering by:
|
||||
- parent_task_id: Filter by parent task (empty string for root tasks only)
|
||||
- status_id: Filter by task status
|
||||
- assignee_id: Filter by assigned user
|
||||
- due_after: Filter tasks with due_date >= this value (ISO 8601 datetime)
|
||||
- due_before: Filter tasks with due_date <= this value (ISO 8601 datetime)
|
||||
|
||||
The due_after and due_before parameters are useful for calendar view
|
||||
to fetch tasks within a specific date range.
|
||||
"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
@@ -124,10 +145,17 @@ async def list_tasks(
|
||||
if assignee_id:
|
||||
query = query.filter(Task.assignee_id == assignee_id)
|
||||
|
||||
# Date range filter for calendar view
|
||||
if due_after:
|
||||
query = query.filter(Task.due_date >= due_after)
|
||||
|
||||
if due_before:
|
||||
query = query.filter(Task.due_date <= due_before)
|
||||
|
||||
tasks = query.order_by(Task.position, Task.created_at).all()
|
||||
|
||||
return TaskListResponse(
|
||||
tasks=[task_to_response(t) for t in tasks],
|
||||
tasks=[task_to_response(t, db=db, include_custom_values=True) for t in tasks],
|
||||
total=len(tasks),
|
||||
)
|
||||
|
||||
@@ -204,6 +232,25 @@ async def create_task(
|
||||
).order_by(Task.position.desc()).first()
|
||||
next_position = (max_pos_result.position + 1) if max_pos_result else 0
|
||||
|
||||
# Validate required custom fields
|
||||
if task_data.custom_values:
|
||||
missing_fields = CustomValueService.validate_required_fields(
|
||||
db, project_id, task_data.custom_values
|
||||
)
|
||||
if missing_fields:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Missing required custom fields: {', '.join(missing_fields)}",
|
||||
)
|
||||
|
||||
# Validate start_date <= due_date
|
||||
if task_data.start_date and task_data.due_date:
|
||||
if task_data.start_date > task_data.due_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Start date cannot be after due date",
|
||||
)
|
||||
|
||||
task = Task(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
@@ -212,6 +259,7 @@ async def create_task(
|
||||
description=task_data.description,
|
||||
priority=task_data.priority.value if task_data.priority else "medium",
|
||||
original_estimate=task_data.original_estimate,
|
||||
start_date=task_data.start_date,
|
||||
due_date=task_data.due_date,
|
||||
assignee_id=task_data.assignee_id,
|
||||
status_id=task_data.status_id,
|
||||
@@ -220,6 +268,17 @@ async def create_task(
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
db.flush() # Flush to get task.id for custom values
|
||||
|
||||
# Save custom values
|
||||
if task_data.custom_values:
|
||||
try:
|
||||
CustomValueService.save_custom_values(db, task, task_data.custom_values)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
# Audit log
|
||||
AuditService.log_event(
|
||||
@@ -256,6 +315,7 @@ async def create_task(
|
||||
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
|
||||
"assignee_name": task.assignee.name if task.assignee else None,
|
||||
"priority": task.priority,
|
||||
"start_date": str(task.start_date) if task.start_date else None,
|
||||
"due_date": str(task.due_date) if task.due_date else None,
|
||||
"time_estimate": task.original_estimate,
|
||||
"original_estimate": task.original_estimate,
|
||||
@@ -303,7 +363,7 @@ async def get_task(
|
||||
detail="Access denied",
|
||||
)
|
||||
|
||||
return task_to_response(task)
|
||||
return task_to_response(task, db, include_custom_values=True)
|
||||
|
||||
|
||||
@router.patch("/api/tasks/{task_id}", response_model=TaskResponse)
|
||||
@@ -336,13 +396,42 @@ async def update_task(
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"priority": task.priority,
|
||||
"start_date": task.start_date,
|
||||
"due_date": task.due_date,
|
||||
"original_estimate": task.original_estimate,
|
||||
"time_spent": task.time_spent,
|
||||
}
|
||||
|
||||
# Update fields
|
||||
# Update fields (exclude custom_values, handle separately)
|
||||
update_data = task_data.model_dump(exclude_unset=True)
|
||||
custom_values_data = update_data.pop("custom_values", None)
|
||||
|
||||
# Get the proposed start_date and due_date for validation
|
||||
new_start_date = update_data.get("start_date", task.start_date)
|
||||
new_due_date = update_data.get("due_date", task.due_date)
|
||||
|
||||
# Validate start_date <= due_date
|
||||
if new_start_date and new_due_date:
|
||||
if new_start_date > new_due_date:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Start date cannot be after due date",
|
||||
)
|
||||
|
||||
# Validate date constraints against dependencies
|
||||
if "start_date" in update_data or "due_date" in update_data:
|
||||
violations = DependencyService.validate_date_constraints(
|
||||
task, new_start_date, new_due_date, db
|
||||
)
|
||||
if violations:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"message": "Date change violates dependency constraints",
|
||||
"violations": violations
|
||||
}
|
||||
)
|
||||
|
||||
for field, value in update_data.items():
|
||||
if field == "priority" and value:
|
||||
setattr(task, field, value.value)
|
||||
@@ -354,6 +443,7 @@ async def update_task(
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"priority": task.priority,
|
||||
"start_date": task.start_date,
|
||||
"due_date": task.due_date,
|
||||
"original_estimate": task.original_estimate,
|
||||
"time_spent": task.time_spent,
|
||||
@@ -377,6 +467,18 @@ async def update_task(
|
||||
if "priority" in update_data:
|
||||
TriggerService.evaluate_triggers(db, task, old_values, new_values, current_user)
|
||||
|
||||
# Handle custom values update
|
||||
if custom_values_data:
|
||||
try:
|
||||
from app.schemas.task import CustomValueInput
|
||||
custom_values = [CustomValueInput(**cv) for cv in custom_values_data]
|
||||
CustomValueService.save_custom_values(db, task, custom_values)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
|
||||
@@ -400,6 +502,7 @@ async def update_task(
|
||||
"assignee_id": str(task.assignee_id) if task.assignee_id else None,
|
||||
"assignee_name": task.assignee.name if task.assignee else None,
|
||||
"priority": task.priority,
|
||||
"start_date": str(task.start_date) if task.start_date else None,
|
||||
"due_date": str(task.due_date) if task.due_date else None,
|
||||
"time_estimate": task.original_estimate,
|
||||
"original_estimate": task.original_estimate,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from pydantic_settings import BaseSettings
|
||||
from pydantic import field_validator
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
import os
|
||||
|
||||
|
||||
@@ -52,6 +52,35 @@ class Settings(BaseSettings):
|
||||
)
|
||||
return v
|
||||
|
||||
# Encryption - Master key for encrypting file encryption keys
|
||||
# Must be a 32-byte (256-bit) key encoded as base64 for AES-256
|
||||
# Generate with: python -c "import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||
ENCRYPTION_MASTER_KEY: Optional[str] = None
|
||||
|
||||
@field_validator("ENCRYPTION_MASTER_KEY")
|
||||
@classmethod
|
||||
def validate_encryption_master_key(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""Validate that ENCRYPTION_MASTER_KEY is properly formatted if set."""
|
||||
if v is None or v.strip() == "":
|
||||
return None
|
||||
# Basic validation - should be base64 encoded 32 bytes
|
||||
import base64
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(v)
|
||||
if len(decoded) != 32:
|
||||
raise ValueError(
|
||||
"ENCRYPTION_MASTER_KEY must be a base64-encoded 32-byte key. "
|
||||
"Generate with: python -c \"import secrets, base64; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())\""
|
||||
)
|
||||
except Exception as e:
|
||||
if "must be a base64-encoded" in str(e):
|
||||
raise
|
||||
raise ValueError(
|
||||
"ENCRYPTION_MASTER_KEY must be a valid base64-encoded string. "
|
||||
f"Error: {e}"
|
||||
)
|
||||
return v
|
||||
|
||||
# External Auth API
|
||||
AUTH_API_URL: str = "https://pj-auth-api.vercel.app"
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ from app.api.attachments import router as attachments_router
|
||||
from app.api.triggers import router as triggers_router
|
||||
from app.api.reports import router as reports_router
|
||||
from app.api.health import router as health_router
|
||||
from app.api.custom_fields import router as custom_fields_router
|
||||
from app.api.task_dependencies import router as task_dependencies_router
|
||||
from app.api.admin import encryption_keys as admin_encryption_keys_router
|
||||
from app.core.config import settings
|
||||
|
||||
app = FastAPI(
|
||||
@@ -76,6 +79,9 @@ app.include_router(attachments_router)
|
||||
app.include_router(triggers_router)
|
||||
app.include_router(reports_router)
|
||||
app.include_router(health_router)
|
||||
app.include_router(custom_fields_router)
|
||||
app.include_router(task_dependencies_router)
|
||||
app.include_router(admin_encryption_keys_router.router)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.models.notification import Notification
|
||||
from app.models.blocker import Blocker
|
||||
from app.models.audit_log import AuditLog, AuditAction, SensitivityLevel, EVENT_SENSITIVITY, ALERT_EVENTS
|
||||
from app.models.audit_alert import AuditAlert
|
||||
from app.models.encryption_key import EncryptionKey
|
||||
from app.models.attachment import Attachment
|
||||
from app.models.attachment_version import AttachmentVersion
|
||||
from app.models.trigger import Trigger, TriggerType
|
||||
@@ -19,13 +20,18 @@ from app.models.trigger_log import TriggerLog, TriggerLogStatus
|
||||
from app.models.scheduled_report import ScheduledReport, ReportType
|
||||
from app.models.report_history import ReportHistory, ReportHistoryStatus
|
||||
from app.models.project_health import ProjectHealth, RiskLevel, ScheduleStatus, ResourceStatus
|
||||
from app.models.custom_field import CustomField, FieldType
|
||||
from app.models.task_custom_value import TaskCustomValue
|
||||
from app.models.task_dependency import TaskDependency, DependencyType
|
||||
|
||||
__all__ = [
|
||||
"User", "Role", "Department", "Space", "Project", "TaskStatus", "Task", "WorkloadSnapshot",
|
||||
"Comment", "Mention", "Notification", "Blocker",
|
||||
"AuditLog", "AuditAlert", "AuditAction", "SensitivityLevel", "EVENT_SENSITIVITY", "ALERT_EVENTS",
|
||||
"Attachment", "AttachmentVersion",
|
||||
"EncryptionKey", "Attachment", "AttachmentVersion",
|
||||
"Trigger", "TriggerType", "TriggerLog", "TriggerLogStatus",
|
||||
"ScheduledReport", "ReportType", "ReportHistory", "ReportHistoryStatus",
|
||||
"ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus"
|
||||
"ProjectHealth", "RiskLevel", "ScheduleStatus", "ResourceStatus",
|
||||
"CustomField", "FieldType", "TaskCustomValue",
|
||||
"TaskDependency", "DependencyType"
|
||||
]
|
||||
|
||||
@@ -16,6 +16,11 @@ class Attachment(Base):
|
||||
file_size = Column(BigInteger, nullable=False)
|
||||
current_version = Column(Integer, default=1, nullable=False)
|
||||
is_encrypted = Column(Boolean, default=False, nullable=False)
|
||||
encryption_key_id = Column(
|
||||
String(36),
|
||||
ForeignKey("pjctrl_encryption_keys.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
uploaded_by = Column(String(36), ForeignKey("pjctrl_users.id", ondelete="SET NULL"), nullable=True)
|
||||
is_deleted = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
@@ -24,6 +29,7 @@ class Attachment(Base):
|
||||
# Relationships
|
||||
task = relationship("Task", back_populates="attachments")
|
||||
uploader = relationship("User", foreign_keys=[uploaded_by])
|
||||
encryption_key = relationship("EncryptionKey", foreign_keys=[encryption_key_id])
|
||||
versions = relationship("AttachmentVersion", back_populates="attachment", cascade="all, delete-orphan")
|
||||
|
||||
__table_args__ = (
|
||||
|
||||
37
backend/app/models/custom_field.py
Normal file
37
backend/app/models/custom_field.py
Normal 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")
|
||||
22
backend/app/models/encryption_key.py
Normal file
22
backend/app/models/encryption_key.py
Normal 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
|
||||
@@ -40,3 +40,4 @@ class Project(Base):
|
||||
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
|
||||
triggers = relationship("Trigger", back_populates="project", cascade="all, delete-orphan")
|
||||
health = relationship("ProjectHealth", back_populates="project", uselist=False, cascade="all, delete-orphan")
|
||||
custom_fields = relationship("CustomField", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
@@ -30,6 +30,7 @@ class Task(Base):
|
||||
original_estimate = Column(Numeric(8, 2), nullable=True)
|
||||
time_spent = Column(Numeric(8, 2), default=0, nullable=False)
|
||||
blocker_flag = Column(Boolean, default=False, nullable=False)
|
||||
start_date = Column(DateTime, nullable=True)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
position = Column(Integer, default=0, nullable=False)
|
||||
created_by = Column(String(36), ForeignKey("pjctrl_users.id"), nullable=False)
|
||||
@@ -55,3 +56,18 @@ class Task(Base):
|
||||
blockers = relationship("Blocker", back_populates="task", cascade="all, delete-orphan")
|
||||
attachments = relationship("Attachment", back_populates="task", cascade="all, delete-orphan")
|
||||
trigger_logs = relationship("TriggerLog", back_populates="task")
|
||||
custom_values = relationship("TaskCustomValue", back_populates="task", cascade="all, delete-orphan")
|
||||
|
||||
# Dependency relationships (for Gantt view)
|
||||
predecessors = relationship(
|
||||
"TaskDependency",
|
||||
foreign_keys="TaskDependency.successor_id",
|
||||
back_populates="successor",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
successors = relationship(
|
||||
"TaskDependency",
|
||||
foreign_keys="TaskDependency.predecessor_id",
|
||||
back_populates="predecessor",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
24
backend/app/models/task_custom_value.py
Normal file
24
backend/app/models/task_custom_value.py
Normal 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")
|
||||
68
backend/app/models/task_dependency.py
Normal file
68
backend/app/models/task_dependency.py
Normal 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"
|
||||
)
|
||||
88
backend/app/schemas/custom_field.py
Normal file
88
backend/app/schemas/custom_field.py
Normal 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]
|
||||
46
backend/app/schemas/encryption_key.py
Normal file
46
backend/app/schemas/encryption_key.py
Normal 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
|
||||
@@ -1,5 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Any, Dict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from enum import Enum
|
||||
@@ -12,11 +12,27 @@ class Priority(str, Enum):
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class CustomValueInput(BaseModel):
|
||||
"""Input for setting a custom field value."""
|
||||
field_id: str
|
||||
value: Optional[Any] = None # Can be string, number, date string, or user id
|
||||
|
||||
|
||||
class CustomValueResponse(BaseModel):
|
||||
"""Response for a custom field value."""
|
||||
field_id: str
|
||||
field_name: str
|
||||
field_type: str
|
||||
value: Optional[Any] = None
|
||||
display_value: Optional[str] = None # Formatted for display
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
priority: Priority = Priority.MEDIUM
|
||||
original_estimate: Optional[Decimal] = None
|
||||
start_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
|
||||
@@ -24,6 +40,7 @@ class TaskCreate(TaskBase):
|
||||
parent_task_id: Optional[str] = None
|
||||
assignee_id: Optional[str] = None
|
||||
status_id: Optional[str] = None
|
||||
custom_values: Optional[List[CustomValueInput]] = None
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
@@ -32,8 +49,10 @@ class TaskUpdate(BaseModel):
|
||||
priority: Optional[Priority] = None
|
||||
original_estimate: Optional[Decimal] = None
|
||||
time_spent: Optional[Decimal] = None
|
||||
start_date: Optional[datetime] = None
|
||||
due_date: Optional[datetime] = None
|
||||
position: Optional[int] = None
|
||||
custom_values: Optional[List[CustomValueInput]] = None
|
||||
|
||||
|
||||
class TaskStatusUpdate(BaseModel):
|
||||
@@ -67,6 +86,7 @@ class TaskWithDetails(TaskResponse):
|
||||
status_color: Optional[str] = None
|
||||
creator_name: Optional[str] = None
|
||||
subtask_count: int = 0
|
||||
custom_values: Optional[List[CustomValueResponse]] = None
|
||||
|
||||
|
||||
class TaskListResponse(BaseModel):
|
||||
|
||||
78
backend/app/schemas/task_dependency.py
Normal file
78
backend/app/schemas/task_dependency.py
Normal 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
|
||||
278
backend/app/services/custom_value_service.py
Normal file
278
backend/app/services/custom_value_service.py
Normal 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
|
||||
424
backend/app/services/dependency_service.py
Normal file
424
backend/app/services/dependency_service.py
Normal 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
|
||||
300
backend/app/services/encryption_service.py
Normal file
300
backend/app/services/encryption_service.py
Normal 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()
|
||||
420
backend/app/services/formula_service.py
Normal file
420
backend/app/services/formula_service.py
Normal 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
|
||||
70
backend/migrations/versions/011_custom_fields_tables.py
Normal file
70
backend/migrations/versions/011_custom_fields_tables.py
Normal 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)
|
||||
48
backend/migrations/versions/012_encryption_keys_table.py
Normal file
48
backend/migrations/versions/012_encryption_keys_table.py
Normal 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')
|
||||
92
backend/migrations/versions/013_task_dependencies_table.py
Normal file
92
backend/migrations/versions/013_task_dependencies_table.py
Normal 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')
|
||||
440
backend/tests/test_custom_fields.py
Normal file
440
backend/tests/test_custom_fields.py
Normal 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
|
||||
275
backend/tests/test_encryption.py
Normal file
275
backend/tests/test_encryption.py
Normal 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
|
||||
1433
backend/tests/test_task_dependencies.py
Normal file
1433
backend/tests/test_task_dependencies.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -114,3 +114,383 @@ class TestSubtaskDepth:
|
||||
"""Test that MAX_SUBTASK_DEPTH is defined."""
|
||||
from app.api.tasks.router import MAX_SUBTASK_DEPTH
|
||||
assert MAX_SUBTASK_DEPTH == 2
|
||||
|
||||
|
||||
class TestDateRangeFilter:
|
||||
"""Test date range filter for calendar view."""
|
||||
|
||||
def test_due_after_filter(self, client, db, admin_token):
|
||||
"""Test filtering tasks with due_date >= due_after."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Space, Project, Task, TaskStatus
|
||||
|
||||
# Create test data
|
||||
space = Space(
|
||||
id="test-space-id",
|
||||
name="Test Space",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="test-project-id",
|
||||
space_id="test-space-id",
|
||||
title="Test Project",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id="test-status-id",
|
||||
project_id="test-project-id",
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
|
||||
# Create tasks with different due dates
|
||||
now = datetime.now()
|
||||
task1 = Task(
|
||||
id="task-1",
|
||||
project_id="test-project-id",
|
||||
title="Task Due Yesterday",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id",
|
||||
due_date=now - timedelta(days=1),
|
||||
)
|
||||
task2 = Task(
|
||||
id="task-2",
|
||||
project_id="test-project-id",
|
||||
title="Task Due Today",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id",
|
||||
due_date=now,
|
||||
)
|
||||
task3 = Task(
|
||||
id="task-3",
|
||||
project_id="test-project-id",
|
||||
title="Task Due Tomorrow",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id",
|
||||
due_date=now + timedelta(days=1),
|
||||
)
|
||||
task4 = Task(
|
||||
id="task-4",
|
||||
project_id="test-project-id",
|
||||
title="Task No Due Date",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id",
|
||||
due_date=None,
|
||||
)
|
||||
db.add_all([task1, task2, task3, task4])
|
||||
db.commit()
|
||||
|
||||
# Filter tasks due today or later
|
||||
due_after = now.isoformat()
|
||||
response = client.get(
|
||||
f"/api/projects/test-project-id/tasks?due_after={due_after}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should return task2 and task3 (due today and tomorrow)
|
||||
assert data["total"] == 2
|
||||
task_ids = [t["id"] for t in data["tasks"]]
|
||||
assert "task-2" in task_ids
|
||||
assert "task-3" in task_ids
|
||||
assert "task-1" not in task_ids
|
||||
assert "task-4" not in task_ids
|
||||
|
||||
def test_due_before_filter(self, client, db, admin_token):
|
||||
"""Test filtering tasks with due_date <= due_before."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Space, Project, Task, TaskStatus
|
||||
|
||||
# Create test data
|
||||
space = Space(
|
||||
id="test-space-id-2",
|
||||
name="Test Space 2",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="test-project-id-2",
|
||||
space_id="test-space-id-2",
|
||||
title="Test Project 2",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id="test-status-id-2",
|
||||
project_id="test-project-id-2",
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
|
||||
# Create tasks with different due dates
|
||||
now = datetime.now()
|
||||
task1 = Task(
|
||||
id="task-b-1",
|
||||
project_id="test-project-id-2",
|
||||
title="Task Due Yesterday",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-2",
|
||||
due_date=now - timedelta(days=1),
|
||||
)
|
||||
task2 = Task(
|
||||
id="task-b-2",
|
||||
project_id="test-project-id-2",
|
||||
title="Task Due Today",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-2",
|
||||
due_date=now,
|
||||
)
|
||||
task3 = Task(
|
||||
id="task-b-3",
|
||||
project_id="test-project-id-2",
|
||||
title="Task Due Tomorrow",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-2",
|
||||
due_date=now + timedelta(days=1),
|
||||
)
|
||||
db.add_all([task1, task2, task3])
|
||||
db.commit()
|
||||
|
||||
# Filter tasks due today or earlier
|
||||
due_before = now.isoformat()
|
||||
response = client.get(
|
||||
f"/api/projects/test-project-id-2/tasks?due_before={due_before}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should return task1 and task2 (due yesterday and today)
|
||||
assert data["total"] == 2
|
||||
task_ids = [t["id"] for t in data["tasks"]]
|
||||
assert "task-b-1" in task_ids
|
||||
assert "task-b-2" in task_ids
|
||||
assert "task-b-3" not in task_ids
|
||||
|
||||
def test_date_range_filter_combined(self, client, db, admin_token):
|
||||
"""Test filtering tasks within a date range (due_after AND due_before)."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Space, Project, Task, TaskStatus
|
||||
|
||||
# Create test data
|
||||
space = Space(
|
||||
id="test-space-id-3",
|
||||
name="Test Space 3",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="test-project-id-3",
|
||||
space_id="test-space-id-3",
|
||||
title="Test Project 3",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id="test-status-id-3",
|
||||
project_id="test-project-id-3",
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
|
||||
# Create tasks spanning a week
|
||||
now = datetime.now()
|
||||
start_of_week = now - timedelta(days=now.weekday()) # Monday
|
||||
end_of_week = start_of_week + timedelta(days=6) # Sunday
|
||||
|
||||
task_before = Task(
|
||||
id="task-c-before",
|
||||
project_id="test-project-id-3",
|
||||
title="Task Before Week",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-3",
|
||||
due_date=start_of_week - timedelta(days=1),
|
||||
)
|
||||
task_in_week = Task(
|
||||
id="task-c-in-week",
|
||||
project_id="test-project-id-3",
|
||||
title="Task In Week",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-3",
|
||||
due_date=start_of_week + timedelta(days=3),
|
||||
)
|
||||
task_after = Task(
|
||||
id="task-c-after",
|
||||
project_id="test-project-id-3",
|
||||
title="Task After Week",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-3",
|
||||
due_date=end_of_week + timedelta(days=1),
|
||||
)
|
||||
db.add_all([task_before, task_in_week, task_after])
|
||||
db.commit()
|
||||
|
||||
# Filter tasks within this week
|
||||
due_after = start_of_week.isoformat()
|
||||
due_before = end_of_week.isoformat()
|
||||
response = client.get(
|
||||
f"/api/projects/test-project-id-3/tasks?due_after={due_after}&due_before={due_before}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should only return the task in the week
|
||||
assert data["total"] == 1
|
||||
assert data["tasks"][0]["id"] == "task-c-in-week"
|
||||
|
||||
def test_date_filter_with_no_due_date(self, client, db, admin_token):
|
||||
"""Test that tasks without due_date are excluded from date range filters."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Space, Project, Task, TaskStatus
|
||||
|
||||
# Create test data
|
||||
space = Space(
|
||||
id="test-space-id-4",
|
||||
name="Test Space 4",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="test-project-id-4",
|
||||
space_id="test-space-id-4",
|
||||
title="Test Project 4",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id="test-status-id-4",
|
||||
project_id="test-project-id-4",
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
|
||||
# Create tasks - some with due_date, some without
|
||||
now = datetime.now()
|
||||
task_with_date = Task(
|
||||
id="task-d-with-date",
|
||||
project_id="test-project-id-4",
|
||||
title="Task With Due Date",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-4",
|
||||
due_date=now,
|
||||
)
|
||||
task_without_date = Task(
|
||||
id="task-d-without-date",
|
||||
project_id="test-project-id-4",
|
||||
title="Task Without Due Date",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-4",
|
||||
due_date=None,
|
||||
)
|
||||
db.add_all([task_with_date, task_without_date])
|
||||
db.commit()
|
||||
|
||||
# When using date filter, tasks without due_date should be excluded
|
||||
due_after = (now - timedelta(days=1)).isoformat()
|
||||
due_before = (now + timedelta(days=1)).isoformat()
|
||||
response = client.get(
|
||||
f"/api/projects/test-project-id-4/tasks?due_after={due_after}&due_before={due_before}",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should only return the task with due_date
|
||||
assert data["total"] == 1
|
||||
assert data["tasks"][0]["id"] == "task-d-with-date"
|
||||
|
||||
def test_date_filter_backward_compatibility(self, client, db, admin_token):
|
||||
"""Test that not providing date filters returns all tasks (backward compatibility)."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import Space, Project, Task, TaskStatus
|
||||
|
||||
# Create test data
|
||||
space = Space(
|
||||
id="test-space-id-5",
|
||||
name="Test Space 5",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
)
|
||||
db.add(space)
|
||||
|
||||
project = Project(
|
||||
id="test-project-id-5",
|
||||
space_id="test-space-id-5",
|
||||
title="Test Project 5",
|
||||
owner_id="00000000-0000-0000-0000-000000000001",
|
||||
security_level="public",
|
||||
)
|
||||
db.add(project)
|
||||
|
||||
status = TaskStatus(
|
||||
id="test-status-id-5",
|
||||
project_id="test-project-id-5",
|
||||
name="To Do",
|
||||
color="#808080",
|
||||
position=0,
|
||||
)
|
||||
db.add(status)
|
||||
|
||||
# Create tasks with and without due dates
|
||||
now = datetime.now()
|
||||
task1 = Task(
|
||||
id="task-e-1",
|
||||
project_id="test-project-id-5",
|
||||
title="Task 1",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-5",
|
||||
due_date=now,
|
||||
)
|
||||
task2 = Task(
|
||||
id="task-e-2",
|
||||
project_id="test-project-id-5",
|
||||
title="Task 2",
|
||||
priority="medium",
|
||||
created_by="00000000-0000-0000-0000-000000000001",
|
||||
status_id="test-status-id-5",
|
||||
due_date=None,
|
||||
)
|
||||
db.add_all([task1, task2])
|
||||
db.commit()
|
||||
|
||||
# Request without date filters - should return all tasks
|
||||
response = client.get(
|
||||
"/api/projects/test-project-id-5/tasks",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# Should return both tasks
|
||||
assert data["total"] == 2
|
||||
|
||||
73
frontend/package-lock.json
generated
73
frontend/package-lock.json
generated
@@ -8,7 +8,13 @@
|
||||
"name": "pjctrl-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"frappe-gantt": "^1.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0"
|
||||
@@ -695,6 +701,57 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/core": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
|
||||
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"preact": "~10.12.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/daygrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
|
||||
"integrity": "sha512-AO9vqhkLP77EesmJzuU+IGXgxNulsA8mgQHynclJ8U70vSwAVnbcLG9qftiTAFSlZjiY/NvhE7sflve6cJelyQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/interaction": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/interaction/-/interaction-6.1.20.tgz",
|
||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/react": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/react/-/react-6.1.20.tgz",
|
||||
"integrity": "sha512-1w0pZtceaUdfAnxMSCGHCQalhi+mR1jOe76sXzyAXpcPz/Lf0zHSdcGK/U2XpZlnQgQtBZW+d+QBnnzVQKCxAA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20",
|
||||
"react": "^16.7.0 || ^17 || ^18 || ^19",
|
||||
"react-dom": "^16.7.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/timegrid": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/timegrid/-/timegrid-6.1.20.tgz",
|
||||
"integrity": "sha512-4H+/MWbz3ntA50lrPif+7TsvMeX3R1GSYjiLULz0+zEJ7/Yfd9pupZmAwUs/PBpA6aAcFmeRr0laWfcz1a9V1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fullcalendar/daygrid": "~6.1.20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -1471,6 +1528,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frappe-gantt": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/frappe-gantt/-/frappe-gantt-1.0.4.tgz",
|
||||
"integrity": "sha512-N94OP9ZiapaG5nzgCeZdxsKP8HD5aLVlH5sEHxSNZQnNKQ4BOn2l46HUD+KIE0LpYIterP7gIrFfkLNRuK0npQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1746,6 +1809,16 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.12.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
|
||||
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
|
||||
@@ -9,10 +9,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/react": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
"axios": "^1.6.0",
|
||||
"frappe-gantt": "^1.0.4",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"axios": "^1.6.0"
|
||||
"react-router-dom": "^6.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
|
||||
@@ -5,6 +5,7 @@ import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
import Projects from './pages/Projects'
|
||||
import Tasks from './pages/Tasks'
|
||||
import ProjectSettings from './pages/ProjectSettings'
|
||||
import AuditPage from './pages/AuditPage'
|
||||
import WorkloadPage from './pages/WorkloadPage'
|
||||
import ProjectHealthPage from './pages/ProjectHealthPage'
|
||||
@@ -64,6 +65,16 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ProjectSettings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
|
||||
497
frontend/src/components/CalendarView.tsx
Normal file
497
frontend/src/components/CalendarView.tsx
Normal 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
|
||||
520
frontend/src/components/CustomFieldEditor.tsx
Normal file
520
frontend/src/components/CustomFieldEditor.tsx
Normal 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
|
||||
257
frontend/src/components/CustomFieldInput.tsx
Normal file
257
frontend/src/components/CustomFieldInput.tsx
Normal 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
|
||||
408
frontend/src/components/CustomFieldList.tsx
Normal file
408
frontend/src/components/CustomFieldList.tsx
Normal 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
|
||||
983
frontend/src/components/GanttChart.tsx
Normal file
983
frontend/src/components/GanttChart.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react'
|
||||
import { CustomValueResponse } from '../services/customFields'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
@@ -11,8 +13,10 @@ interface Task {
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
due_date: string | null
|
||||
start_date: string | null
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
custom_values?: CustomValueResponse[]
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
@@ -133,6 +137,12 @@ export function KanbanBoard({
|
||||
{task.subtask_count > 0 && (
|
||||
<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>
|
||||
)
|
||||
@@ -280,6 +290,17 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
subtaskBadge: {
|
||||
color: '#999',
|
||||
},
|
||||
customValueBadge: {
|
||||
backgroundColor: '#f3e5f5',
|
||||
color: '#7b1fa2',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '10px',
|
||||
maxWidth: '100px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
emptyColumn: {
|
||||
textAlign: 'center',
|
||||
padding: '24px',
|
||||
|
||||
@@ -4,9 +4,12 @@ import { Comments } from './Comments'
|
||||
import { TaskAttachments } from './TaskAttachments'
|
||||
import { UserSelect } from './UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from './CustomFieldInput'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
@@ -18,6 +21,7 @@ interface Task {
|
||||
due_date: string | null
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
custom_values?: CustomValueResponse[]
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
@@ -59,6 +63,44 @@ export function TaskDetailModal({
|
||||
: null
|
||||
)
|
||||
|
||||
// Custom fields state
|
||||
const [customFields, setCustomFields] = useState<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
|
||||
useEffect(() => {
|
||||
setEditForm({
|
||||
@@ -108,6 +150,21 @@ export function TaskDetailModal({
|
||||
payload.time_estimate = null
|
||||
}
|
||||
|
||||
// Include custom field values (only non-formula fields)
|
||||
const customValuesPayload = Object.entries(editCustomValues)
|
||||
.filter(([fieldId]) => {
|
||||
const field = customFields.find((f) => f.id === fieldId)
|
||||
return field && field.field_type !== 'formula'
|
||||
})
|
||||
.map(([fieldId, value]) => ({
|
||||
field_id: fieldId,
|
||||
value: value,
|
||||
}))
|
||||
|
||||
if (customValuesPayload.length > 0) {
|
||||
payload.custom_values = customValuesPayload
|
||||
}
|
||||
|
||||
await api.patch(`/tasks/${task.id}`, payload)
|
||||
setIsEditing(false)
|
||||
onUpdate()
|
||||
@@ -118,6 +175,13 @@ export function TaskDetailModal({
|
||||
}
|
||||
}
|
||||
|
||||
const handleCustomFieldChange = (fieldId: string, value: string | number | null) => {
|
||||
setEditCustomValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleAssigneeChange = (userId: string | null, user: UserSearchResult | null) => {
|
||||
setEditForm({ ...editForm, assignee_id: userId || '' })
|
||||
setSelectedAssignee(user)
|
||||
@@ -349,6 +413,50 @@ export function TaskDetailModal({
|
||||
<div style={styles.subtaskInfo}>{task.subtask_count} subtask(s)</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>
|
||||
@@ -571,6 +679,23 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
customFieldsDivider: {
|
||||
height: '1px',
|
||||
backgroundColor: '#ddd',
|
||||
margin: '20px 0',
|
||||
},
|
||||
customFieldsHeader: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
color: '#666',
|
||||
marginBottom: '16px',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: '13px',
|
||||
color: '#888',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
}
|
||||
|
||||
export default TaskDetailModal
|
||||
|
||||
@@ -16,3 +16,122 @@ body {
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* FullCalendar custom styles */
|
||||
.fc {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
text-transform: capitalize;
|
||||
padding: 0.5rem 1rem;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.fc .fc-button:hover {
|
||||
background-color: #e8e8e8;
|
||||
border-color: #ccc;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:not(:disabled).fc-button-active,
|
||||
.fc .fc-button-primary:not(:disabled):active {
|
||||
background-color: #0066cc;
|
||||
border-color: #0066cc;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fc .fc-button-primary:focus {
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 204, 0.25);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day-number {
|
||||
padding: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.fc .fc-col-header-cell-cushion {
|
||||
padding: 10px;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.fc .fc-event {
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
.fc .fc-event:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-event-dot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-today {
|
||||
background-color: rgba(0, 102, 204, 0.05);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day.fc-day-today .fc-daygrid-day-number {
|
||||
color: #0066cc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc .fc-more-link {
|
||||
color: #0066cc;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fc .fc-popover {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.fc .fc-popover-header {
|
||||
background-color: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc-theme-standard .fc-scrollgrid {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fc-theme-standard td,
|
||||
.fc-theme-standard th {
|
||||
border-color: #eee;
|
||||
}
|
||||
|
||||
/* Time grid styles */
|
||||
.fc .fc-timegrid-slot-label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.fc .fc-timegrid-axis {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
/* Now indicator */
|
||||
.fc .fc-timegrid-now-indicator-line {
|
||||
border-color: #f44336;
|
||||
}
|
||||
|
||||
.fc .fc-timegrid-now-indicator-arrow {
|
||||
border-top-color: #f44336;
|
||||
}
|
||||
|
||||
262
frontend/src/pages/ProjectSettings.tsx
Normal file
262
frontend/src/pages/ProjectSettings.tsx
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import api from '../services/api'
|
||||
import { KanbanBoard } from '../components/KanbanBoard'
|
||||
import { CalendarView } from '../components/CalendarView'
|
||||
import { GanttChart } from '../components/GanttChart'
|
||||
import { TaskDetailModal } from '../components/TaskDetailModal'
|
||||
import { UserSelect } from '../components/UserSelect'
|
||||
import { UserSearchResult } from '../services/collaboration'
|
||||
import { useProjectSync, TaskEvent } from '../contexts/ProjectSyncContext'
|
||||
import { customFieldsApi, CustomField, CustomValueResponse } from '../services/customFields'
|
||||
import { CustomFieldInput } from '../components/CustomFieldInput'
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
priority: string
|
||||
@@ -18,8 +23,10 @@ interface Task {
|
||||
assignee_id: string | null
|
||||
assignee_name: string | null
|
||||
due_date: string | null
|
||||
start_date: string | null
|
||||
time_estimate: number | null
|
||||
subtask_count: number
|
||||
custom_values?: CustomValueResponse[]
|
||||
}
|
||||
|
||||
interface TaskStatus {
|
||||
@@ -35,9 +42,25 @@ interface Project {
|
||||
space_id: string
|
||||
}
|
||||
|
||||
type ViewMode = 'list' | 'kanban'
|
||||
type ViewMode = 'list' | 'kanban' | 'calendar' | 'gantt'
|
||||
|
||||
const VIEW_MODE_STORAGE_KEY = 'tasks-view-mode'
|
||||
const COLUMN_VISIBILITY_STORAGE_KEY = 'tasks-column-visibility'
|
||||
|
||||
// Get column visibility settings from localStorage
|
||||
const getColumnVisibility = (projectId: string): Record<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() {
|
||||
const { projectId } = useParams()
|
||||
@@ -50,7 +73,7 @@ export default function Tasks() {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
||||
const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY)
|
||||
return (saved === 'kanban' || saved === 'list') ? saved : 'list'
|
||||
return (saved === 'kanban' || saved === 'list' || saved === 'calendar' || saved === 'gantt') ? saved : 'list'
|
||||
})
|
||||
const [newTask, setNewTask] = useState({
|
||||
title: '',
|
||||
@@ -65,10 +88,37 @@ export default function Tasks() {
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null)
|
||||
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(() => {
|
||||
loadData()
|
||||
}, [projectId])
|
||||
|
||||
// Load custom fields when project changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
loadCustomFields()
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
const loadCustomFields = async () => {
|
||||
try {
|
||||
const response = await customFieldsApi.getCustomFields(projectId!)
|
||||
setCustomFields(response.fields)
|
||||
} catch (err) {
|
||||
console.error('Failed to load custom fields:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to project WebSocket when project changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
@@ -91,6 +141,7 @@ export default function Tasks() {
|
||||
}
|
||||
const newTask: Task = {
|
||||
id: event.data.task_id,
|
||||
project_id: projectId!,
|
||||
title: event.data.title || '',
|
||||
description: event.data.description ?? null,
|
||||
priority: event.data.priority || 'medium',
|
||||
@@ -100,6 +151,7 @@ export default function Tasks() {
|
||||
assignee_id: event.data.assignee_id ?? null,
|
||||
assignee_name: event.data.assignee_name ?? null,
|
||||
due_date: event.data.due_date ?? null,
|
||||
start_date: (event.data.start_date as string) ?? null,
|
||||
time_estimate: event.data.time_estimate ?? event.data.original_estimate ?? null,
|
||||
subtask_count: event.data.subtask_count ?? 0,
|
||||
}
|
||||
@@ -131,6 +183,7 @@ export default function Tasks() {
|
||||
...(event.data.new_assignee_id !== undefined && { assignee_id: event.data.new_assignee_id ?? null }),
|
||||
...(event.data.new_assignee_name !== undefined && { assignee_name: event.data.new_assignee_name ?? null }),
|
||||
...(event.data.due_date !== undefined && { due_date: event.data.due_date ?? null }),
|
||||
...(event.data.start_date !== undefined && { start_date: (event.data.start_date as string) ?? null }),
|
||||
...(event.data.time_estimate !== undefined && { time_estimate: event.data.time_estimate ?? null }),
|
||||
...(event.data.original_estimate !== undefined && event.data.time_estimate === undefined && { time_estimate: event.data.original_estimate ?? null }),
|
||||
...(event.data.subtask_count !== undefined && { subtask_count: event.data.subtask_count }),
|
||||
@@ -156,6 +209,47 @@ export default function Tasks() {
|
||||
localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode)
|
||||
}, [viewMode])
|
||||
|
||||
// Load column visibility when projectId changes
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
setColumnVisibility(getColumnVisibility(projectId))
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// Check if a custom field column is visible (default to true if not set)
|
||||
const isColumnVisible = (fieldId: string): boolean => {
|
||||
return columnVisibility[fieldId] !== false
|
||||
}
|
||||
|
||||
// Toggle column visibility
|
||||
const toggleColumnVisibility = (fieldId: string) => {
|
||||
const newVisibility = {
|
||||
...columnVisibility,
|
||||
[fieldId]: !isColumnVisible(fieldId),
|
||||
}
|
||||
setColumnVisibility(newVisibility)
|
||||
if (projectId) {
|
||||
saveColumnVisibility(projectId, newVisibility)
|
||||
}
|
||||
}
|
||||
|
||||
// Close column menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (columnMenuRef.current && !columnMenuRef.current.contains(event.target as Node)) {
|
||||
setShowColumnMenu(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showColumnMenu) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [showColumnMenu])
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [projectRes, tasksRes, statusesRes] = await Promise.all([
|
||||
@@ -194,6 +288,21 @@ export default function Tasks() {
|
||||
payload.time_estimate = Number(newTask.time_estimate)
|
||||
}
|
||||
|
||||
// Include custom field values (only non-formula fields)
|
||||
const customValuesPayload = Object.entries(newTaskCustomValues)
|
||||
.filter(([fieldId, value]) => {
|
||||
const field = customFields.find((f) => f.id === fieldId)
|
||||
return field && field.field_type !== 'formula' && value !== null
|
||||
})
|
||||
.map(([fieldId, value]) => ({
|
||||
field_id: fieldId,
|
||||
value: value,
|
||||
}))
|
||||
|
||||
if (customValuesPayload.length > 0) {
|
||||
payload.custom_values = customValuesPayload
|
||||
}
|
||||
|
||||
await api.post(`/projects/${projectId}/tasks`, payload)
|
||||
setShowCreateModal(false)
|
||||
setNewTask({
|
||||
@@ -204,6 +313,7 @@ export default function Tasks() {
|
||||
due_date: '',
|
||||
time_estimate: '',
|
||||
})
|
||||
setNewTaskCustomValues({})
|
||||
setSelectedAssignee(null)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
@@ -213,6 +323,13 @@ export default function Tasks() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleNewTaskCustomFieldChange = (fieldId: string, value: string | number | null) => {
|
||||
setNewTaskCustomValues((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleStatusChange = async (taskId: string, statusId: string) => {
|
||||
// Save original state for rollback
|
||||
const originalTasks = [...tasks]
|
||||
@@ -246,7 +363,12 @@ export default function Tasks() {
|
||||
}
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task)
|
||||
// Ensure task has project_id for custom fields loading
|
||||
const taskWithProject = {
|
||||
...task,
|
||||
project_id: projectId!,
|
||||
}
|
||||
setSelectedTask(taskWithProject)
|
||||
setShowDetailModal(true)
|
||||
}
|
||||
|
||||
@@ -335,7 +457,65 @@ export default function Tasks() {
|
||||
>
|
||||
Kanban
|
||||
</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>
|
||||
{/* 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}>
|
||||
+ New Task
|
||||
</button>
|
||||
@@ -343,7 +523,7 @@ export default function Tasks() {
|
||||
</div>
|
||||
|
||||
{/* Conditional rendering based on view mode */}
|
||||
{viewMode === 'list' ? (
|
||||
{viewMode === 'list' && (
|
||||
<div style={styles.taskList}>
|
||||
{tasks.map((task) => (
|
||||
<div
|
||||
@@ -373,6 +553,15 @@ export default function Tasks() {
|
||||
{task.subtask_count} subtasks
|
||||
</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>
|
||||
<select
|
||||
@@ -402,7 +591,9 @@ export default function Tasks() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{viewMode === 'kanban' && (
|
||||
<KanbanBoard
|
||||
tasks={tasks}
|
||||
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 */}
|
||||
{showCreateModal && (
|
||||
<div style={styles.modalOverlay}>
|
||||
@@ -469,6 +679,37 @@ export default function Tasks() {
|
||||
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}>
|
||||
<button onClick={() => setShowCreateModal(false)} style={styles.cancelButton}>
|
||||
Cancel
|
||||
@@ -580,6 +821,15 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
backgroundColor: '#0066cc',
|
||||
color: 'white',
|
||||
},
|
||||
settingsButton: {
|
||||
padding: '10px 16px',
|
||||
backgroundColor: '#f5f5f5',
|
||||
color: '#333',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
},
|
||||
createButton: {
|
||||
padding: '10px 20px',
|
||||
backgroundColor: '#0066cc',
|
||||
@@ -733,4 +983,90 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
119
frontend/src/services/customFields.ts
Normal file
119
frontend/src/services/customFields.ts
Normal 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
|
||||
75
frontend/src/services/dependencies.ts
Normal file
75
frontend/src/services/dependencies.ts
Normal 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
46
frontend/src/types/frappe-gantt.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
31
issues.md
31
issues.md
@@ -1,9 +1,9 @@
|
||||
# PROJECT CONTROL - Issue Tracking
|
||||
|
||||
> 審核日期: 2026-01-04
|
||||
> 更新日期: 2026-01-04
|
||||
> 整體完成度: 約 98%
|
||||
> 已修復問題: 23 (CRIT-001~003, HIGH-001~008, MED-001~012)
|
||||
> 更新日期: 2026-01-05
|
||||
> 整體完成度: 約 99%
|
||||
> 已修復問題: 25 (CRIT-001~003, HIGH-001~008, MED-001~012, NEW-001~002)
|
||||
|
||||
---
|
||||
|
||||
@@ -500,16 +500,16 @@
|
||||
|
||||
| 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-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | 缺 | 中 | 待開發 |
|
||||
| FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | 缺 | 中 | 待開發 |
|
||||
| FEAT-003 | Task Management | 甘特圖視角 (Gantt View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
|
||||
| FEAT-004 | Task Management | 行事曆視角 (Calendar View) | 有 | 有 | 中 | ✅ 已完成 (2026-01-05) |
|
||||
| FEAT-005 | Task Management | 子任務建立 UI | 有 | 缺 | 中 | 待開發 |
|
||||
| FEAT-006 | Task Management | 拖拉變更狀態 | 有 | 有 | 中 | ✅ 已完成 (KanbanBoard drag-drop) |
|
||||
| FEAT-007 | Resource Management | 負載熱圖 UI | 有 | 有 | 高 | ✅ 已完成 (WorkloadPage.tsx) |
|
||||
| FEAT-008 | Resource Management | 專案健康看板 | 有 | 有 | 中 | ✅ 已完成 (ProjectHealthPage.tsx) |
|
||||
| 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-012 | Document Management | 版本還原 UI | 有 | 缺 | 低 | 待開發 |
|
||||
| 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-schedule-triggers | automation |
|
||||
| 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 支援任務變更事件廣播,前端訂閱並即時更新看板
|
||||
- **狀態**: [ ] 待開發
|
||||
- **狀態**: [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*
|
||||
*更新於 2026-01-04*
|
||||
*更新於 2026-01-05*
|
||||
|
||||
@@ -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 | 現代設計 | 社群較小 |
|
||||
|
||||
**建議**: 使用 FullCalendar(MIT 授權核心功能足夠)
|
||||
|
||||
### 視圖模式
|
||||
| 視圖 | 說明 |
|
||||
|------|------|
|
||||
| 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 篩選支援
|
||||
@@ -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** 篩選條件在視圖切換時保留
|
||||
@@ -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 為循序開發
|
||||
@@ -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 支援
|
||||
@@ -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** 系統拒絕新增並顯示數量已達上限的訊息
|
||||
@@ -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)
|
||||
@@ -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 環境變數配置
|
||||
@@ -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 錯誤
|
||||
@@ -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 可平行進行
|
||||
111
openspec/changes/archive/2026-01-05-add-gantt-view/proposal.md
Normal file
111
openspec/changes/archive/2026-01-05-add-gantt-view/proposal.md
Normal 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 Gantt(MIT 授權),足夠基本需求
|
||||
|
||||
### 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 欄位(✓ 已存在)
|
||||
@@ -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
|
||||
```
|
||||
92
openspec/changes/archive/2026-01-05-add-gantt-view/tasks.md
Normal file
92
openspec/changes/archive/2026-01-05-add-gantt-view/tasks.md
Normal 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)
|
||||
@@ -49,6 +49,12 @@
|
||||
### 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** 使用者上傳檔案
|
||||
@@ -61,11 +67,35 @@
|
||||
- **THEN** 系統解密檔案後提供下載
|
||||
- **AND** 解密過程透明,使用者無感
|
||||
|
||||
#### Scenario: 加密金鑰輪換
|
||||
#### Scenario: 串流處理大檔案
|
||||
- **GIVEN** 使用者上傳或下載大型加密檔案
|
||||
- **WHEN** 系統處理加密或解密
|
||||
- **THEN** 使用串流方式處理避免記憶體溢出
|
||||
- **AND** 效能損耗在可接受範圍內
|
||||
|
||||
#### Scenario: 金鑰輪換
|
||||
- **GIVEN** 安全政策要求金鑰輪換
|
||||
- **WHEN** 管理員執行金鑰輪換
|
||||
- **THEN** 系統使用新金鑰重新加密所有檔案
|
||||
- **AND** 舊金鑰安全銷毀
|
||||
- **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 錯誤
|
||||
|
||||
### Requirement: Dynamic Watermarking
|
||||
系統 SHALL 在下載時自動為檔案加上使用者浮水印。
|
||||
|
||||
@@ -33,42 +33,104 @@
|
||||
- **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** 系統拒絕新增並顯示數量已達上限的訊息
|
||||
|
||||
### Requirement: Multiple Views
|
||||
系統 SHALL 支援多維視角:看板 (Kanban)、甘特圖 (Gantt)、列表 (List)、行事曆 (Calendar)。
|
||||
|
||||
#### Scenario: 看板視角
|
||||
- **GIVEN** 使用者選擇看板視角
|
||||
- **WHEN** 系統載入專案任務
|
||||
- **THEN** 任務依狀態分組顯示為卡片
|
||||
- **AND** 支援拖拉變更狀態
|
||||
|
||||
#### Scenario: 甘特圖視角
|
||||
- **GIVEN** 使用者選擇甘特圖視角
|
||||
- **WHEN** 系統載入專案任務
|
||||
- **THEN** 任務依時間軸顯示
|
||||
- **THEN** 任務依時間軸顯示為水平條狀
|
||||
- **AND** 顯示任務相依關係與里程碑
|
||||
|
||||
#### Scenario: 列表視角
|
||||
- **GIVEN** 使用者選擇列表視角
|
||||
- **WHEN** 系統載入專案任務
|
||||
- **THEN** 任務以表格形式顯示
|
||||
- **AND** 支援欄位排序與篩選
|
||||
#### Scenario: 甘特圖時間軸縮放
|
||||
- **GIVEN** 使用者正在查看甘特圖
|
||||
- **WHEN** 使用者切換縮放層級(日、週、月)
|
||||
- **THEN** 時間軸相應調整顯示密度
|
||||
- **AND** 任務條保持正確的相對位置
|
||||
|
||||
#### Scenario: 行事曆視角
|
||||
- **GIVEN** 使用者選擇行事曆視角
|
||||
- **WHEN** 系統載入專案任務
|
||||
- **THEN** 任務依截止日期顯示在行事曆上
|
||||
#### 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): 前置開始後完成
|
||||
|
||||
### Requirement: Task Status Management
|
||||
系統 SHALL 管理任務狀態,包含標準狀態與自定義狀態。
|
||||
|
||||
Reference in New Issue
Block a user