diff --git a/backend/generate_schedule_example_data.py b/backend/generate_schedule_example_data.py new file mode 100644 index 0000000..92c9faa --- /dev/null +++ b/backend/generate_schedule_example_data.py @@ -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!") diff --git a/backend/migrate_task_start_date.py b/backend/migrate_task_start_date.py new file mode 100644 index 0000000..3080d90 --- /dev/null +++ b/backend/migrate_task_start_date.py @@ -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!") diff --git a/backend/models/task.py b/backend/models/task.py index 05d0b82..4903bff 100644 --- a/backend/models/task.py +++ b/backend/models/task.py @@ -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()) diff --git a/backend/routers/tasks.py b/backend/routers/tasks.py index 7d33057..84e94cc 100644 --- a/backend/routers/tasks.py +++ b/backend/routers/tasks.py @@ -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, diff --git a/backend/schemas/task.py b/backend/schemas/task.py index 8ced5bc..b823e8e 100644 --- a/backend/schemas/task.py +++ b/backend/schemas/task.py @@ -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 diff --git a/frontend/src/components/layout/AppSidebar.vue b/frontend/src/components/layout/AppSidebar.vue index db269ef..a6fda50 100644 --- a/frontend/src/components/layout/AppSidebar.vue +++ b/frontend/src/components/layout/AppSidebar.vue @@ -182,6 +182,7 @@ import { Package, ListTodo, ShieldCheck, + GanttChartSquare, } from 'lucide-vue-next' import { useAuthStore } from '@/stores/auth' @@ -221,6 +222,7 @@ const projectTabs = computed(() => { { id: 'shots', label: 'Shots', icon: Camera, route: `/projects/${id}/shots` }, { id: 'assets', label: 'Assets', icon: Package, route: `/projects/${id}/assets` }, { id: 'tasks', label: 'Tasks', icon: ListTodo, route: `/projects/${id}/tasks` }, + { id: 'schedule', label: 'Schedule', icon: GanttChartSquare, route: `/projects/${id}/schedule` }, { id: 'settings', label: 'Settings', icon: Settings, route: `/projects/${id}/settings` }, ] }) @@ -233,6 +235,7 @@ const activeProjectTab = computed(() => { if (path.startsWith(`/projects/${id}/shots`)) return 'shots' if (path.startsWith(`/projects/${id}/assets`)) return 'assets' if (path.startsWith(`/projects/${id}/tasks`)) return 'tasks' + if (path.startsWith(`/projects/${id}/schedule`)) return 'schedule' if (path.startsWith(`/projects/${id}/settings`)) return 'settings' return null }) diff --git a/frontend/src/components/schedule/ScheduleGantt.vue b/frontend/src/components/schedule/ScheduleGantt.vue new file mode 100644 index 0000000..68c98f7 --- /dev/null +++ b/frontend/src/components/schedule/ScheduleGantt.vue @@ -0,0 +1,639 @@ + + + diff --git a/frontend/src/components/task/TaskDetailPanel.vue b/frontend/src/components/task/TaskDetailPanel.vue index ef2b758..aa6ea1b 100644 --- a/frontend/src/components/task/TaskDetailPanel.vue +++ b/frontend/src/components/task/TaskDetailPanel.vue @@ -123,12 +123,26 @@ {{ formatTaskType(task.task_type) }}

+
+
+ +
+ +
+
-

- - {{ task.deadline ? formatDate(task.deadline) : 'No deadline' }} -

+
+ +
@@ -284,10 +298,11 @@