Add project Schedule page with interactive Gantt chart
Adds a Schedule tab (Kitsu-style production schedule) under each project: tasks grouped by type with drag-to-reschedule bars, Day/Week/ Month zoom, manual date-range control, weekend shading, a frozen task column, and a two-tier month/date axis header. Requires a new start_date field on Task (start_date was previously missing; only deadline existed) and shadcn DatePicker inputs replace native date inputs on the Schedule toolbar and TaskDetailPanel's Start Date/ Deadline fields. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-off script to generate example start_date/deadline data on shot tasks,
|
||||
for testing/demoing the Project Schedule (Gantt) page.
|
||||
|
||||
Reuses the same shot-sampling approach as generate_note_example_data.py: the
|
||||
dev database is heavily populated with disposable load-test fixtures, so this
|
||||
picks a small, bounded sample of shots (from Dragon Quest) that have the
|
||||
normal 4-task set, rather than scheduling all ~1400 shot tasks.
|
||||
|
||||
For each sampled shot, tasks are scheduled in pipeline order (layout ->
|
||||
animation -> simulation -> lighting -> compositing, any other task types
|
||||
appended after) with realistic overlapping durations, staggered around
|
||||
today's date so the chart's "Today" marker falls inside the visible range.
|
||||
|
||||
Idempotent: skips any task that already has a start_date set, so it's safe
|
||||
to re-run without clobbering manually-edited dates.
|
||||
"""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import DATABASE_URL, Base
|
||||
from models.shot import Shot
|
||||
from models.task import Task
|
||||
from models.project import Project
|
||||
import logging
|
||||
import random
|
||||
|
||||
import models # noqa: F401 - ensures every model is registered on Base.metadata
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_NAME = "Dragon Quest"
|
||||
SAMPLE_SIZE = 15
|
||||
RNG_SEED = 42
|
||||
|
||||
# Canonical pipeline order used to sequence a shot's tasks; anything not
|
||||
# listed here keeps its natural (query) order, appended at the end.
|
||||
PIPELINE_ORDER = ["layout", "animation", "simulation", "lighting", "compositing", "previz"]
|
||||
|
||||
|
||||
def pipeline_index(task_type: str) -> int:
|
||||
return PIPELINE_ORDER.index(task_type) if task_type in PIPELINE_ORDER else len(PIPELINE_ORDER)
|
||||
|
||||
|
||||
def generate_schedule_example_data():
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
rng = random.Random(RNG_SEED)
|
||||
today = date.today()
|
||||
|
||||
try:
|
||||
project = db.query(Project).filter(Project.name == PROJECT_NAME).first()
|
||||
if not project:
|
||||
logger.error(f"Project '{PROJECT_NAME}' not found")
|
||||
return
|
||||
logger.info(f"Project '{PROJECT_NAME}' (id={project.id})")
|
||||
|
||||
# Shots with the normal 4-task set (excludes shots with zero tasks
|
||||
# and the handful of outliers with extra bulk-test tasks piled on).
|
||||
shots = db.query(Shot).filter(
|
||||
Shot.project_id == project.id, Shot.deleted_at.is_(None)
|
||||
).order_by(Shot.id).all()
|
||||
candidates = [
|
||||
s for s in shots
|
||||
if db.query(Task).filter(Task.shot_id == s.id, Task.deleted_at.is_(None)).count() == 4
|
||||
]
|
||||
sample_shots = candidates[:SAMPLE_SIZE]
|
||||
logger.info(f"{len(candidates)} candidate shots with a 4-task set, sampling {len(sample_shots)}")
|
||||
|
||||
tasks_scheduled = 0
|
||||
tasks_skipped = 0
|
||||
|
||||
for shot in sample_shots:
|
||||
tasks = db.query(Task).filter(
|
||||
Task.shot_id == shot.id, Task.deleted_at.is_(None)
|
||||
).all()
|
||||
tasks.sort(key=lambda t: pipeline_index(t.task_type))
|
||||
|
||||
# Stagger each shot's pipeline start across a window that straddles
|
||||
# today, so the demo chart shows a mix of past/current/future work.
|
||||
shot_start = today + timedelta(days=rng.randint(-20, 20))
|
||||
cursor = shot_start
|
||||
|
||||
for task in tasks:
|
||||
if task.start_date is not None:
|
||||
tasks_skipped += 1
|
||||
# Still advance the cursor so later tasks in this shot
|
||||
# don't bunch up if an earlier one was already scheduled.
|
||||
cursor = task.deadline or cursor
|
||||
continue
|
||||
|
||||
duration = rng.randint(4, 12)
|
||||
task.start_date = cursor
|
||||
task.deadline = cursor + timedelta(days=duration)
|
||||
tasks_scheduled += 1
|
||||
|
||||
# Next task starts a little before this one ends (realistic
|
||||
# pipeline overlap) rather than strictly back-to-back.
|
||||
cursor = task.deadline - timedelta(days=rng.randint(0, 4))
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Tasks scheduled: {tasks_scheduled}, skipped (already scheduled): {tasks_skipped}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Generation failed: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Generating schedule example data...")
|
||||
generate_schedule_example_data()
|
||||
logger.info("Done!")
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add the start_date column to the tasks table.
|
||||
|
||||
Usage:
|
||||
python migrate_task_start_date.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path, trying multiple possible locations."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db", # Primary database
|
||||
"database.db",
|
||||
"../vfx_project_management.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
|
||||
def check_column_exists(cursor, table_name, column_name):
|
||||
"""Check if a column exists in a table."""
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add start_date column to the tasks table."""
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tasks'")
|
||||
if not cursor.fetchone():
|
||||
print("Tasks table not found. Creating new database schema...")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if check_column_exists(cursor, "tasks", "start_date"):
|
||||
print("Column start_date already exists, skipping...")
|
||||
else:
|
||||
print("Adding column: start_date")
|
||||
cursor.execute("ALTER TABLE tasks ADD COLUMN start_date DATE")
|
||||
|
||||
conn.commit()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks")
|
||||
task_count = cursor.fetchone()[0]
|
||||
print(f"Migration completed successfully! {task_count} tasks unaffected (column defaults to NULL).")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Task Start Date Migration")
|
||||
print("=" * 60)
|
||||
|
||||
migrate_database()
|
||||
|
||||
print("\nMigration completed successfully!")
|
||||
@@ -56,6 +56,7 @@ class Task(Base):
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(Text)
|
||||
status = Column(String, nullable=False, default="not_started") # Changed from Enum to String to support custom statuses
|
||||
start_date = Column(Date)
|
||||
deadline = Column(Date)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
@@ -248,6 +248,7 @@ async def get_tasks(
|
||||
"name": task.name,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"project_name": task.project.name if task.project else None,
|
||||
@@ -329,6 +330,7 @@ async def get_my_tasks(
|
||||
name=task.name,
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
start_date=task.start_date,
|
||||
deadline=task.deadline,
|
||||
project_id=task.project_id,
|
||||
project_name=task.project.name if task.project else "Unknown",
|
||||
@@ -718,6 +720,7 @@ async def get_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -843,6 +846,7 @@ async def update_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -934,6 +938,7 @@ async def update_task_status(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
@@ -1039,6 +1044,7 @@ async def assign_task(
|
||||
"description": task.description,
|
||||
"task_type": task.task_type,
|
||||
"status": task.status,
|
||||
"start_date": task.start_date,
|
||||
"deadline": task.deadline,
|
||||
"project_id": task.project_id,
|
||||
"episode_id": task.episode_id,
|
||||
|
||||
@@ -11,6 +11,7 @@ class TaskBase(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||
start_date: Optional[date] = None
|
||||
deadline: Optional[date] = None
|
||||
status: str = "not_started" # Changed from TaskStatus enum to str to support custom statuses
|
||||
|
||||
@@ -27,6 +28,7 @@ class TaskUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = None
|
||||
task_type: Optional[str] = None # Changed from TaskType enum to str to support custom task types
|
||||
start_date: Optional[date] = None
|
||||
deadline: Optional[date] = None
|
||||
status: Optional[str] = None # Changed from TaskStatus enum to str to support custom statuses
|
||||
assigned_user_id: Optional[int] = None
|
||||
@@ -68,6 +70,7 @@ class TaskListResponse(BaseModel):
|
||||
name: str
|
||||
task_type: str # Changed from TaskType enum to str to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
start_date: Optional[date] = None
|
||||
deadline: Optional[date] = None
|
||||
project_id: int
|
||||
project_name: str
|
||||
|
||||
Reference in New Issue
Block a user