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

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

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

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

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

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

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

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

View File

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

View File

@@ -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__ = (

View File

@@ -0,0 +1,37 @@
import uuid
import enum
from sqlalchemy import Column, String, Text, Boolean, DateTime, ForeignKey, Enum, JSON, Integer
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class FieldType(str, enum.Enum):
TEXT = "text"
NUMBER = "number"
DROPDOWN = "dropdown"
DATE = "date"
PERSON = "person"
FORMULA = "formula"
class CustomField(Base):
__tablename__ = "pjctrl_custom_fields"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
project_id = Column(String(36), ForeignKey("pjctrl_projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(100), nullable=False)
field_type = Column(
Enum("text", "number", "dropdown", "date", "person", "formula", name="field_type_enum"),
nullable=False
)
options = Column(JSON, nullable=True) # For dropdown: list of options
formula = Column(Text, nullable=True) # For formula: formula expression
is_required = Column(Boolean, default=False, nullable=False)
position = Column(Integer, default=0, nullable=False) # For ordering fields
created_at = Column(DateTime, server_default=func.now(), nullable=False)
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
project = relationship("Project", back_populates="custom_fields")
values = relationship("TaskCustomValue", back_populates="field", cascade="all, delete-orphan")

View File

@@ -0,0 +1,22 @@
"""EncryptionKey model for AES-256 file encryption key management."""
import uuid
from sqlalchemy import Column, String, Text, Boolean, DateTime
from sqlalchemy.sql import func
from app.core.database import Base
class EncryptionKey(Base):
"""
Encryption key storage for file encryption.
Keys are encrypted with the Master Key before storage.
Only system admin can manage encryption keys.
"""
__tablename__ = "pjctrl_encryption_keys"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
key_data = Column(Text, nullable=False) # Encrypted key using Master Key
algorithm = Column(String(20), default="AES-256-GCM", nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
rotated_at = Column(DateTime, nullable=True) # When this key was superseded

View File

@@ -40,3 +40,4 @@ class Project(Base):
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
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")

View File

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

View File

@@ -0,0 +1,24 @@
import uuid
from sqlalchemy import Column, String, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.core.database import Base
class TaskCustomValue(Base):
__tablename__ = "pjctrl_task_custom_values"
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
task_id = Column(String(36), ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"), nullable=False)
field_id = Column(String(36), ForeignKey("pjctrl_custom_fields.id", ondelete="CASCADE"), nullable=False)
value = Column(Text, nullable=True) # Stored as text, parsed based on field_type
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
# Unique constraint: one value per task-field combination
__table_args__ = (
UniqueConstraint('task_id', 'field_id', name='uq_task_field'),
)
# Relationships
task = relationship("Task", back_populates="custom_values")
field = relationship("CustomField", back_populates="values")

View File

@@ -0,0 +1,68 @@
from sqlalchemy import Column, String, Integer, Enum, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.core.database import Base
import enum
class DependencyType(str, enum.Enum):
"""
Task dependency types for Gantt chart.
FS (Finish-to-Start): Predecessor must finish before successor starts (most common)
SS (Start-to-Start): Predecessor must start before successor starts
FF (Finish-to-Finish): Predecessor must finish before successor finishes
SF (Start-to-Finish): Predecessor must start before successor finishes (rare)
"""
FS = "FS" # Finish-to-Start
SS = "SS" # Start-to-Start
FF = "FF" # Finish-to-Finish
SF = "SF" # Start-to-Finish
class TaskDependency(Base):
"""
Represents a dependency relationship between two tasks.
The predecessor task affects when the successor task can be scheduled,
based on the dependency_type. This is used for Gantt chart visualization
and date validation.
"""
__tablename__ = "pjctrl_task_dependencies"
__table_args__ = (
UniqueConstraint('predecessor_id', 'successor_id', name='uq_predecessor_successor'),
)
id = Column(String(36), primary_key=True)
predecessor_id = Column(
String(36),
ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
successor_id = Column(
String(36),
ForeignKey("pjctrl_tasks.id", ondelete="CASCADE"),
nullable=False,
index=True
)
dependency_type = Column(
Enum("FS", "SS", "FF", "SF", name="dependency_type_enum"),
default="FS",
nullable=False
)
lag_days = Column(Integer, default=0, nullable=False)
created_at = Column(DateTime, server_default=func.now(), nullable=False)
# Relationships
predecessor = relationship(
"Task",
foreign_keys=[predecessor_id],
back_populates="successors"
)
successor = relationship(
"Task",
foreign_keys=[successor_id],
back_populates="predecessors"
)