Init Repo
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Database configuration
|
||||
DATABASE_URL=sqlite:///./vfx_project_management.db
|
||||
|
||||
# JWT configuration
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# File upload configuration
|
||||
UPLOAD_DIR=./uploads
|
||||
MAX_FILE_SIZE=100000000 # 100MB in bytes
|
||||
@@ -0,0 +1,99 @@
|
||||
# Project Schema Update Summary
|
||||
|
||||
## New Fields Added to Project Model
|
||||
|
||||
The project database schema has been updated to include three new required fields:
|
||||
|
||||
### 1. Code Name (`code_name`)
|
||||
- **Type**: String (VARCHAR)
|
||||
- **Constraints**: NOT NULL, UNIQUE, Indexed
|
||||
- **Purpose**: Unique project identifier/code (e.g., "PROJ_001", "MARVEL_THOR_2024")
|
||||
- **Validation**: 1-50 characters, must be unique across all projects
|
||||
|
||||
### 2. Client Name (`client_name`)
|
||||
- **Type**: String (VARCHAR)
|
||||
- **Constraints**: NOT NULL, Indexed
|
||||
- **Purpose**: Name of the client or studio commissioning the project
|
||||
- **Validation**: 1-255 characters
|
||||
|
||||
### 3. Project Type (`project_type`)
|
||||
- **Type**: Enum
|
||||
- **Values**:
|
||||
- `tv` - TV Series/Shows
|
||||
- `cinema` - Cinema/Film projects
|
||||
- `game` - Game development projects
|
||||
- **Constraints**: NOT NULL
|
||||
- **Purpose**: Categorize projects by production type for better organization
|
||||
|
||||
## Database Migration
|
||||
|
||||
A migration script (`migrate_project_fields.py`) was created and executed to:
|
||||
- Add the new columns to existing `projects` table
|
||||
- Set default values for existing projects:
|
||||
- `code_name`: Generated from project name + ID (e.g., "PROJECT_NAME_001")
|
||||
- `client_name`: Set to "Default Client"
|
||||
- `project_type`: Set to "tv"
|
||||
- Create unique index on `code_name` field
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### Models (`models/project.py`)
|
||||
- Added `ProjectType` enum with TV, Cinema, Game values
|
||||
- Updated `Project` model with new fields
|
||||
- Added proper column constraints and indexing
|
||||
|
||||
### Schemas (`schemas/project.py`)
|
||||
- Updated `ProjectBase`, `ProjectCreate`, `ProjectUpdate` schemas
|
||||
- Added field validation and descriptions
|
||||
- Updated response schemas to include new fields
|
||||
|
||||
### API Endpoints (`routers/projects.py`)
|
||||
- Added validation for unique `code_name` constraint
|
||||
- Enhanced error handling for duplicate code names
|
||||
- Updated create/update endpoints to handle new fields
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### Services (`services/project.ts`)
|
||||
- Updated `Project`, `ProjectCreate`, `ProjectUpdate` interfaces
|
||||
- Added new fields with proper TypeScript types
|
||||
|
||||
### Stores (`stores/projects.ts`)
|
||||
- Enhanced icon assignment logic to use `project_type` field
|
||||
- Updated project filtering and organization
|
||||
|
||||
### UI (`views/ProjectsView.vue`)
|
||||
- Added form fields for code name, client name, and project type
|
||||
- Enhanced project cards to display new information
|
||||
- Updated search functionality to include new fields
|
||||
- Added project type formatting helper
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Better Organization**: Projects can now be categorized by type (TV, Cinema, Game)
|
||||
2. **Unique Identification**: Code names provide consistent project references
|
||||
3. **Client Tracking**: Clear client/studio association for each project
|
||||
4. **Enhanced Search**: Users can search by code name, client name, or project type
|
||||
5. **Visual Indicators**: Project cards show type badges and client information
|
||||
6. **Industry Standards**: Aligns with common VFX production workflows
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating a New Project
|
||||
```json
|
||||
{
|
||||
"name": "Marvel Thor: Love and Thunder",
|
||||
"code_name": "MARVEL_THOR_2024",
|
||||
"client_name": "Marvel Studios",
|
||||
"project_type": "cinema",
|
||||
"description": "VFX work for Thor sequel",
|
||||
"status": "planning"
|
||||
}
|
||||
```
|
||||
|
||||
### Project Types
|
||||
- **TV**: Series, shows, streaming content
|
||||
- **Cinema**: Feature films, movies
|
||||
- **Game**: Video game cinematics, in-game VFX
|
||||
|
||||
The schema update maintains backward compatibility while adding essential production management features commonly used in the VFX industry.
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to analyze current database indexes and identify optimization opportunities.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def analyze_database_indexes():
|
||||
"""Analyze current database indexes and suggest optimizations."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("=== Current Database Schema Analysis ===\n")
|
||||
|
||||
# Get all tables
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
for table in tables:
|
||||
print(f"Table: {table}")
|
||||
|
||||
# Get table info
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
print(" Columns:")
|
||||
for col in columns:
|
||||
col_name, col_type, not_null, default, pk = col[1], col[2], col[3], col[4], col[5]
|
||||
pk_str = " (PRIMARY KEY)" if pk else ""
|
||||
print(f" - {col_name}: {col_type}{pk_str}")
|
||||
|
||||
# Get existing indexes
|
||||
cursor.execute(f"PRAGMA index_list({table})")
|
||||
indexes = cursor.fetchall()
|
||||
|
||||
if indexes:
|
||||
print(" Existing Indexes:")
|
||||
for idx in indexes:
|
||||
idx_name, unique, origin = idx[1], idx[2], idx[3]
|
||||
unique_str = " (UNIQUE)" if unique else ""
|
||||
print(f" - {idx_name}{unique_str}")
|
||||
|
||||
# Get index details
|
||||
cursor.execute(f"PRAGMA index_info({idx_name})")
|
||||
idx_info = cursor.fetchall()
|
||||
if idx_info:
|
||||
cols = [info[2] for info in idx_info]
|
||||
print(f" Columns: {', '.join(cols)}")
|
||||
else:
|
||||
print(" No indexes found")
|
||||
|
||||
print()
|
||||
|
||||
# Analyze foreign key relationships for index optimization
|
||||
print("=== Foreign Key Analysis ===\n")
|
||||
|
||||
for table in tables:
|
||||
cursor.execute(f"PRAGMA foreign_key_list({table})")
|
||||
fks = cursor.fetchall()
|
||||
|
||||
if fks:
|
||||
print(f"Table: {table}")
|
||||
for fk in fks:
|
||||
from_col, to_table, to_col = fk[3], fk[2], fk[4]
|
||||
print(f" FK: {from_col} -> {to_table}.{to_col}")
|
||||
print()
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def suggest_index_optimizations():
|
||||
"""Suggest additional indexes for performance optimization."""
|
||||
|
||||
print("=== Suggested Index Optimizations ===\n")
|
||||
|
||||
suggestions = [
|
||||
{
|
||||
"table": "tasks",
|
||||
"index": "idx_tasks_assigned_user",
|
||||
"columns": ["assigned_user_id"],
|
||||
"reason": "Optimize queries filtering tasks by assigned user"
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"index": "idx_tasks_status",
|
||||
"columns": ["status"],
|
||||
"reason": "Optimize queries filtering tasks by status"
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"index": "idx_tasks_type",
|
||||
"columns": ["task_type"],
|
||||
"reason": "Optimize queries filtering tasks by type"
|
||||
},
|
||||
{
|
||||
"table": "submissions",
|
||||
"index": "idx_submissions_created_at",
|
||||
"columns": ["created_at"],
|
||||
"reason": "Optimize queries ordering submissions by creation date"
|
||||
},
|
||||
{
|
||||
"table": "activities",
|
||||
"index": "idx_activities_entity",
|
||||
"columns": ["entity_type", "entity_id"],
|
||||
"reason": "Optimize activity queries by entity"
|
||||
},
|
||||
{
|
||||
"table": "activities",
|
||||
"index": "idx_activities_created_at",
|
||||
"columns": ["created_at"],
|
||||
"reason": "Optimize activity feed queries by date"
|
||||
},
|
||||
{
|
||||
"table": "shots",
|
||||
"index": "idx_shots_episode",
|
||||
"columns": ["episode_id"],
|
||||
"reason": "Optimize queries filtering shots by episode"
|
||||
},
|
||||
{
|
||||
"table": "assets",
|
||||
"index": "idx_assets_project",
|
||||
"columns": ["project_id"],
|
||||
"reason": "Optimize queries filtering assets by project"
|
||||
},
|
||||
{
|
||||
"table": "tasks",
|
||||
"index": "idx_tasks_composite",
|
||||
"columns": ["shot_id", "asset_id", "status"],
|
||||
"reason": "Optimize complex queries filtering by parent and status"
|
||||
}
|
||||
]
|
||||
|
||||
for suggestion in suggestions:
|
||||
print(f"Table: {suggestion['table']}")
|
||||
print(f" Suggested Index: {suggestion['index']}")
|
||||
print(f" Columns: {', '.join(suggestion['columns'])}")
|
||||
print(f" Reason: {suggestion['reason']}")
|
||||
print()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Database Index Analysis Tool")
|
||||
print("=" * 50)
|
||||
|
||||
if analyze_database_indexes():
|
||||
suggest_index_optimizations()
|
||||
else:
|
||||
print("Failed to analyze database")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Asset Router Optimization Summary
|
||||
|
||||
## Task Completed: Backend Asset Router Optimization
|
||||
|
||||
### Requirements Addressed
|
||||
|
||||
✅ **Requirement 2.1**: Replace N+1 query pattern in `list_assets()` endpoint with single JOIN query
|
||||
- Implemented single query with `outerjoin(Task, ...)` to fetch assets and tasks together
|
||||
- Eliminated the previous N+1 pattern where each asset required a separate task query
|
||||
- Added pre-fetching of project data to avoid repeated project queries
|
||||
|
||||
✅ **Requirement 2.3**: Modify asset query to include task status aggregation using SQLAlchemy joins
|
||||
- Implemented task status aggregation in the single query using `add_columns()`
|
||||
- Added task data grouping and aggregation logic to build `task_status` and `task_details`
|
||||
- Pre-fetch all task types for all projects to eliminate repeated queries
|
||||
|
||||
✅ **Requirement 3.1**: Update `get_asset()` endpoint to fetch task data in single query
|
||||
- Replaced separate task count query with single optimized query using `selectinload(Asset.tasks)`
|
||||
- Used `joinedload(Asset.project)` for eager loading of project data
|
||||
- Count tasks from already loaded relationship to avoid separate COUNT query
|
||||
|
||||
✅ **Backward Compatibility**: Ensure backward compatibility with existing response format
|
||||
- Maintained all existing response fields and structure
|
||||
- No changes to API endpoints or response schemas
|
||||
- All existing functionality preserved
|
||||
|
||||
### Optimization Techniques Implemented
|
||||
|
||||
1. **Single Query Operations**
|
||||
- `list_assets()`: Uses `outerjoin(Task, ...)` to fetch assets and tasks in one query
|
||||
- `get_asset()`: Uses `selectinload(Asset.tasks)` for efficient task loading
|
||||
|
||||
2. **Eager Loading**
|
||||
- `joinedload(Asset.project)` for project data
|
||||
- `selectinload(Asset.tasks).options(selectinload(Task.assigned_user))` for task data
|
||||
- Eliminates N+1 query problems
|
||||
|
||||
3. **Pre-fetching Patterns**
|
||||
- Pre-fetch all project data and custom task types in single query
|
||||
- Cache project information to avoid repeated database calls
|
||||
- Use pre-fetched data for task status sorting
|
||||
|
||||
4. **Enhanced Data Tracking**
|
||||
- Added `task_updated_at` tracking for better task status monitoring
|
||||
- Improved task details with comprehensive information
|
||||
|
||||
5. **Efficient Aggregation**
|
||||
- Group results by asset and aggregate task data efficiently
|
||||
- Build task status maps and task details in application layer using pre-fetched data
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- **Before**: N+1 queries (1 for assets + 1 per asset for tasks + 1 per project for task types)
|
||||
- **After**: Single optimized query with joins and pre-fetching
|
||||
- **Expected**: Significant reduction in database round trips for asset listing operations
|
||||
|
||||
### Code Quality
|
||||
|
||||
- ✅ Follows same optimization pattern as shot router
|
||||
- ✅ Comprehensive optimization comments explaining changes
|
||||
- ✅ Maintains existing function signatures and response formats
|
||||
- ✅ Proper error handling and access control preserved
|
||||
- ✅ No syntax errors or import issues
|
||||
|
||||
### Testing
|
||||
|
||||
- ✅ Code imports successfully without errors
|
||||
- ✅ Function signatures are correct
|
||||
- ✅ Optimization patterns are properly implemented
|
||||
- ✅ Follows established patterns from shot router optimization
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### list_assets() Optimization
|
||||
|
||||
```python
|
||||
# OPTIMIZATION: Use single query with optimized JOIN to fetch assets and their tasks
|
||||
assets_with_tasks = (
|
||||
base_query
|
||||
.outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None)))
|
||||
.options(
|
||||
joinedload(Asset.project), # Eager load project
|
||||
selectinload(Asset.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users
|
||||
)
|
||||
)
|
||||
.add_columns(
|
||||
Task.id.label('task_id'),
|
||||
Task.task_type,
|
||||
Task.status.label('task_status'),
|
||||
Task.assigned_user_id,
|
||||
Task.updated_at.label('task_updated_at') # Include task update time for better tracking
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
```
|
||||
|
||||
### get_asset() Optimization
|
||||
|
||||
```python
|
||||
# OPTIMIZATION: Use single query with optimized JOINs to fetch asset and all related data
|
||||
asset_query = (
|
||||
db.query(Asset)
|
||||
.options(
|
||||
joinedload(Asset.project), # Eager load project
|
||||
selectinload(Asset.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users if needed
|
||||
)
|
||||
)
|
||||
.filter(Asset.id == asset_id, Asset.deleted_at.is_(None))
|
||||
)
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
The asset router optimization has been successfully implemented following the same patterns as the shot router optimization. The implementation:
|
||||
|
||||
1. ✅ Eliminates N+1 query patterns
|
||||
2. ✅ Uses single database operations for data fetching
|
||||
3. ✅ Maintains full backward compatibility
|
||||
4. ✅ Follows established optimization patterns
|
||||
5. ✅ Includes comprehensive error handling and access control
|
||||
|
||||
The optimization is ready for production use and should provide significant performance improvements for asset data operations.
|
||||
@@ -0,0 +1,10 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('database.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email, is_admin, is_approved FROM users WHERE is_admin = 1')
|
||||
rows = cursor.fetchall()
|
||||
print("Admin users:")
|
||||
for row in rows:
|
||||
print(f" Email: {row[0]}, is_admin: {row[1]}, is_approved: {row[2]}")
|
||||
conn.close()
|
||||
@@ -0,0 +1,26 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check all users
|
||||
cursor.execute('SELECT id, email, role, is_admin FROM users')
|
||||
users = cursor.fetchall()
|
||||
print("All users:")
|
||||
for user in users:
|
||||
print(f" ID: {user[0]}, Email: {user[1]}, Role: {user[2]}, Is Admin: {user[3]}")
|
||||
|
||||
# Check admin user specifically
|
||||
cursor.execute('SELECT id, email, role, is_admin FROM users WHERE email = "admin@vfx.com"')
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
print(f"\nAdmin user: Email: {result[1]}, Role: {result[2]}, Is Admin: {result[3]}")
|
||||
|
||||
# Check project membership
|
||||
cursor.execute('SELECT project_id FROM project_members WHERE user_id = ?', (result[0],))
|
||||
projects = cursor.fetchall()
|
||||
print(f"Project memberships: {[p[0] for p in projects]}")
|
||||
else:
|
||||
print("Admin user not found")
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,11 @@
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
conn = sqlite3.connect('backend/vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT id, custom_asset_task_types, custom_shot_task_types FROM projects')
|
||||
for row in cursor.fetchall():
|
||||
asset_types = json.loads(row[1]) if row[1] else []
|
||||
shot_types = json.loads(row[2]) if row[2] else []
|
||||
print(f'Project {row[0]}: Asset={asset_types}, Shot={shot_types}')
|
||||
conn.close()
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check database schema for enum constraints.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
|
||||
def check_db_schema():
|
||||
"""Check database schema for enum constraints."""
|
||||
|
||||
print("Checking Database Schema")
|
||||
print("=" * 30)
|
||||
|
||||
db_path = "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get the CREATE TABLE statement for projects
|
||||
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
create_sql = cursor.fetchone()
|
||||
|
||||
if create_sql:
|
||||
print("Projects table CREATE statement:")
|
||||
print(create_sql[0])
|
||||
else:
|
||||
print("Projects table not found")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_db_schema()
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to check current database indexes
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
def check_current_indexes():
|
||||
conn = sqlite3.connect('database.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all non-system indexes
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%' ORDER BY name;")
|
||||
indexes = cursor.fetchall()
|
||||
|
||||
print('Current indexes:')
|
||||
for idx in indexes:
|
||||
print(f' {idx[0]}')
|
||||
|
||||
# Get index details
|
||||
cursor.execute(f"PRAGMA index_info('{idx[0]}')")
|
||||
info = cursor.fetchall()
|
||||
if info:
|
||||
columns = [col[2] for col in info]
|
||||
print(f' Columns: {", ".join(columns)}')
|
||||
|
||||
# Get table info for tasks table
|
||||
print('\nTasks table structure:')
|
||||
cursor.execute("PRAGMA table_info(tasks)")
|
||||
columns = cursor.fetchall()
|
||||
for col in columns:
|
||||
print(f' {col[1]} ({col[2]})')
|
||||
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_current_indexes()
|
||||
@@ -0,0 +1,14 @@
|
||||
from database import SessionLocal
|
||||
from models.project import Project
|
||||
|
||||
db = SessionLocal()
|
||||
p = db.query(Project).filter(Project.id == 1).first()
|
||||
if p and p.custom_task_statuses:
|
||||
statuses = p.custom_task_statuses
|
||||
print(f"Total custom statuses: {len(statuses)}")
|
||||
print("\nLast 5 statuses:")
|
||||
for s in statuses[-5:]:
|
||||
print(f" • {s['name']} ({s['id']}) - {s['color']}")
|
||||
else:
|
||||
print("No custom statuses found")
|
||||
db.close()
|
||||
@@ -0,0 +1,14 @@
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('backend/vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT email, role, is_admin FROM users WHERE email = "admin@vfx.com"')
|
||||
result = cursor.fetchone()
|
||||
print(f"Email: {result[0]}, Role: {result[1]}, Is Admin: {result[2]}")
|
||||
|
||||
# Check project membership
|
||||
cursor.execute('SELECT project_id FROM project_members WHERE user_id = (SELECT id FROM users WHERE email = "admin@vfx.com")')
|
||||
projects = cursor.fetchall()
|
||||
print(f"Project memberships: {[p[0] for p in projects]}")
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create an admin user for testing the VFX Project Management System.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from database import SessionLocal, engine
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_password_hash
|
||||
|
||||
|
||||
def create_admin_user():
|
||||
"""Create an admin user for testing."""
|
||||
|
||||
# Create database session
|
||||
db: Session = SessionLocal()
|
||||
|
||||
try:
|
||||
# Check if admin user already exists
|
||||
existing_admin = db.query(User).filter(
|
||||
User.email == "admin@vfx.com"
|
||||
).first()
|
||||
|
||||
if existing_admin:
|
||||
print("Admin user already exists!")
|
||||
print(f"Email: {existing_admin.email}")
|
||||
print(f"Role: {existing_admin.role}")
|
||||
print(f"Approved: {existing_admin.is_approved}")
|
||||
return existing_admin
|
||||
|
||||
# Create admin user
|
||||
password = "admin123"
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
|
||||
admin_user = User(
|
||||
email="admin@vfx.com",
|
||||
password_hash=get_password_hash(password),
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role=UserRole.COORDINATOR, # Default functional role
|
||||
is_admin=True, # Grant admin permission
|
||||
is_approved=True # Admin is automatically approved
|
||||
)
|
||||
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
db.refresh(admin_user)
|
||||
|
||||
print("✅ Admin user created successfully!")
|
||||
print(f"Email: {admin_user.email}")
|
||||
print(f"Password: admin123")
|
||||
print(f"Role: {admin_user.role}")
|
||||
print(f"ID: {admin_user.id}")
|
||||
|
||||
return admin_user
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating admin user: {e}")
|
||||
db.rollback()
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def create_test_users():
|
||||
"""Create additional test users for different roles."""
|
||||
|
||||
db: Session = SessionLocal()
|
||||
|
||||
test_users = [
|
||||
{
|
||||
"email": "director@vfx.com",
|
||||
"password": "director123",
|
||||
"first_name": "John",
|
||||
"last_name": "Director",
|
||||
"role": UserRole.DIRECTOR,
|
||||
"is_approved": True
|
||||
},
|
||||
{
|
||||
"email": "coordinator@vfx.com",
|
||||
"password": "coord123",
|
||||
"first_name": "Jane",
|
||||
"last_name": "Coordinator",
|
||||
"role": UserRole.COORDINATOR,
|
||||
"is_approved": True
|
||||
},
|
||||
{
|
||||
"email": "artist@vfx.com",
|
||||
"password": "artist123",
|
||||
"first_name": "Bob",
|
||||
"last_name": "Artist",
|
||||
"role": UserRole.ARTIST,
|
||||
"is_approved": True
|
||||
}
|
||||
]
|
||||
|
||||
try:
|
||||
created_users = []
|
||||
|
||||
for user_data in test_users:
|
||||
# Check if user already exists
|
||||
existing_user = db.query(User).filter(
|
||||
User.email == user_data["email"]
|
||||
).first()
|
||||
|
||||
if existing_user:
|
||||
print(f"User {user_data['email']} already exists, skipping...")
|
||||
continue
|
||||
|
||||
# Create user
|
||||
password = user_data["password"]
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
|
||||
user = User(
|
||||
email=user_data["email"],
|
||||
password_hash=get_password_hash(password),
|
||||
first_name=user_data["first_name"],
|
||||
last_name=user_data["last_name"],
|
||||
role=user_data["role"],
|
||||
is_approved=user_data["is_approved"]
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
created_users.append(user_data)
|
||||
|
||||
db.commit()
|
||||
|
||||
if created_users:
|
||||
print(f"\n✅ Created {len(created_users)} test users:")
|
||||
for user_data in created_users:
|
||||
print(f" - {user_data['email']} (password: {user_data['password']}) - {user_data['role']}")
|
||||
else:
|
||||
print("\n📝 All test users already exist")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error creating test users: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Creating admin user for VFX Project Management System...")
|
||||
|
||||
# Create admin user
|
||||
admin = create_admin_user()
|
||||
|
||||
if admin:
|
||||
print("\n" + "="*50)
|
||||
print("ADMIN LOGIN CREDENTIALS")
|
||||
print("="*50)
|
||||
print("Email: admin@vfx.com")
|
||||
print("Password: admin123")
|
||||
print("="*50)
|
||||
|
||||
# Ask if user wants to create additional test users
|
||||
create_more = input("\nCreate additional test users? (y/n): ").lower().strip()
|
||||
if create_more in ['y', 'yes']:
|
||||
create_test_users()
|
||||
|
||||
print("\n🚀 You can now login to the system!")
|
||||
print("📖 API Documentation: http://127.0.0.1:8000/docs")
|
||||
print("🔍 Health Check: http://127.0.0.1:8000/health")
|
||||
@@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create comprehensive example data for the VFX project management system.
|
||||
This includes episodes, assets, and project members for the Dragon Quest project.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db",
|
||||
"database.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
def create_example_episodes(cursor, project_id):
|
||||
"""Create example episodes for the project."""
|
||||
|
||||
episodes_data = [
|
||||
{
|
||||
"name": "The Dragon's Awakening",
|
||||
"episode_number": 1,
|
||||
"description": "Opening sequence where the ancient dragon awakens from its thousand-year slumber. Features extensive particle effects, environmental destruction, and creature animation.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "The Quest Begins",
|
||||
"episode_number": 2,
|
||||
"description": "Heroes embark on their journey through magical forests and mystical landscapes. Requires complex environment work and magical effect sequences.",
|
||||
"status": "planning"
|
||||
},
|
||||
{
|
||||
"name": "Battle of the Crystal Caves",
|
||||
"episode_number": 3,
|
||||
"description": "Epic battle sequence in underground crystal caves with magical creatures. Heavy focus on lighting effects, crystal simulations, and creature interactions.",
|
||||
"status": "planning"
|
||||
},
|
||||
{
|
||||
"name": "The Final Confrontation",
|
||||
"episode_number": 4,
|
||||
"description": "Climactic battle between heroes and the dragon. Most VFX-intensive episode featuring fire effects, destruction, magical spells, and complex creature animation.",
|
||||
"status": "planning"
|
||||
}
|
||||
]
|
||||
|
||||
episode_ids = []
|
||||
current_time = datetime.now().isoformat()
|
||||
|
||||
for episode_data in episodes_data:
|
||||
# Check if episode already exists
|
||||
cursor.execute("""
|
||||
SELECT id FROM episodes
|
||||
WHERE project_id = ? AND episode_number = ?
|
||||
""", (project_id, episode_data["episode_number"]))
|
||||
|
||||
existing_episode = cursor.fetchone()
|
||||
if existing_episode:
|
||||
episode_ids.append(existing_episode[0])
|
||||
continue
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO episodes (
|
||||
project_id, name, episode_number, description, status,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_query, (
|
||||
project_id,
|
||||
episode_data["name"],
|
||||
episode_data["episode_number"],
|
||||
episode_data["description"],
|
||||
episode_data["status"],
|
||||
current_time,
|
||||
current_time
|
||||
))
|
||||
|
||||
episode_ids.append(cursor.lastrowid)
|
||||
|
||||
return episode_ids
|
||||
|
||||
def create_example_assets(cursor, project_id):
|
||||
"""Create example assets for the project."""
|
||||
|
||||
assets_data = [
|
||||
{
|
||||
"name": "Ancient Dragon",
|
||||
"category": "characters",
|
||||
"description": "Main antagonist dragon character with detailed scales, wings, and fire-breathing capabilities. Requires complex rigging and animation systems.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "Hero Character - Warrior",
|
||||
"category": "characters",
|
||||
"description": "Main protagonist warrior character with armor, weapons, and facial animation capabilities.",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"name": "Hero Character - Mage",
|
||||
"category": "characters",
|
||||
"description": "Magical character with spell-casting animations and mystical effects integration.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "Crystal Cave Environment",
|
||||
"category": "sets",
|
||||
"description": "Underground cave system with glowing crystals, stalactites, and magical lighting effects.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "Enchanted Forest",
|
||||
"category": "sets",
|
||||
"description": "Magical forest environment with animated trees, floating particles, and dynamic lighting.",
|
||||
"status": "not_started"
|
||||
},
|
||||
{
|
||||
"name": "Dragon's Lair",
|
||||
"category": "sets",
|
||||
"description": "Massive cave environment where the dragon resides, featuring treasure piles and ancient architecture.",
|
||||
"status": "not_started"
|
||||
},
|
||||
{
|
||||
"name": "Excalibur Sword",
|
||||
"category": "props",
|
||||
"description": "Legendary sword with magical glow effects and particle systems.",
|
||||
"status": "approved"
|
||||
},
|
||||
{
|
||||
"name": "Magic Staff",
|
||||
"category": "props",
|
||||
"description": "Mage's staff with crystal orb and magical energy effects.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "Dragon Armor Set",
|
||||
"category": "props",
|
||||
"description": "Protective armor made from dragon scales with metallic and organic textures.",
|
||||
"status": "not_started"
|
||||
},
|
||||
{
|
||||
"name": "Dragon Wings",
|
||||
"category": "props",
|
||||
"description": "Detailed dragon wing assets for close-up shots and animation reference.",
|
||||
"status": "in_progress"
|
||||
},
|
||||
{
|
||||
"name": "Flying Carpet",
|
||||
"category": "vehicles",
|
||||
"description": "Magical flying carpet for transportation sequences with cloth simulation.",
|
||||
"status": "completed"
|
||||
},
|
||||
{
|
||||
"name": "War Chariot",
|
||||
"category": "vehicles",
|
||||
"description": "Battle chariot for epic combat sequences with destruction capabilities.",
|
||||
"status": "not_started"
|
||||
}
|
||||
]
|
||||
|
||||
asset_ids = []
|
||||
current_time = datetime.now().isoformat()
|
||||
|
||||
for asset_data in assets_data:
|
||||
# Check if asset already exists
|
||||
cursor.execute("""
|
||||
SELECT id FROM assets
|
||||
WHERE project_id = ? AND name = ?
|
||||
""", (project_id, asset_data["name"]))
|
||||
|
||||
existing_asset = cursor.fetchone()
|
||||
if existing_asset:
|
||||
asset_ids.append(existing_asset[0])
|
||||
continue
|
||||
|
||||
insert_query = """
|
||||
INSERT INTO assets (
|
||||
project_id, name, category, description, status,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_query, (
|
||||
project_id,
|
||||
asset_data["name"],
|
||||
asset_data["category"],
|
||||
asset_data["description"],
|
||||
asset_data["status"],
|
||||
current_time,
|
||||
current_time
|
||||
))
|
||||
|
||||
asset_ids.append(cursor.lastrowid)
|
||||
|
||||
return asset_ids
|
||||
|
||||
def create_example_data():
|
||||
"""Create comprehensive example data for the VFX project."""
|
||||
|
||||
print("Creating Example VFX Project Data")
|
||||
print("=" * 40)
|
||||
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get the Dragon Quest project ID
|
||||
cursor.execute("SELECT id FROM projects WHERE code_name = 'DRAGON_QUEST_2024'")
|
||||
project_result = cursor.fetchone()
|
||||
|
||||
if not project_result:
|
||||
print("❌ Dragon Quest project not found. Please run create_example_project.py first.")
|
||||
return False
|
||||
|
||||
project_id = project_result[0]
|
||||
print(f"✅ Found Dragon Quest project (ID: {project_id})")
|
||||
|
||||
# Create episodes
|
||||
print("\n📺 Creating example episodes...")
|
||||
episode_ids = create_example_episodes(cursor, project_id)
|
||||
print(f"✅ Created/found {len(episode_ids)} episodes")
|
||||
|
||||
# Create assets
|
||||
print("\n🎨 Creating example assets...")
|
||||
asset_ids = create_example_assets(cursor, project_id)
|
||||
print(f"✅ Created/found {len(asset_ids)} assets")
|
||||
|
||||
# Commit all changes
|
||||
conn.commit()
|
||||
|
||||
# Show summary
|
||||
print(f"\n📊 Summary:")
|
||||
print(f" Project: Dragon Quest: The Awakening")
|
||||
print(f" Episodes: {len(episode_ids)}")
|
||||
print(f" Assets: {len(asset_ids)}")
|
||||
|
||||
# Show episode breakdown
|
||||
cursor.execute("""
|
||||
SELECT name, episode_number, status
|
||||
FROM episodes
|
||||
WHERE project_id = ?
|
||||
ORDER BY episode_number
|
||||
""", (project_id,))
|
||||
|
||||
episodes = cursor.fetchall()
|
||||
print(f"\n📺 Episodes:")
|
||||
for episode in episodes:
|
||||
status_emoji = {"not_started": "⏳", "planning": "📋", "in_progress": "🎬", "completed": "✅"}
|
||||
print(f" {status_emoji.get(episode[2], '❓')} Episode {episode[1]}: {episode[0]} ({episode[2].replace('_', ' ').title()})")
|
||||
|
||||
# Show asset breakdown by category
|
||||
cursor.execute("""
|
||||
SELECT category, COUNT(*) as count
|
||||
FROM assets
|
||||
WHERE project_id = ?
|
||||
GROUP BY category
|
||||
ORDER BY count DESC
|
||||
""", (project_id,))
|
||||
|
||||
asset_categories = cursor.fetchall()
|
||||
print(f"\n🎨 Assets by Category:")
|
||||
category_emojis = {"characters": "👤", "sets": "🌍", "props": "⚔️", "vehicles": "🚗"}
|
||||
for category, count in asset_categories:
|
||||
emoji = category_emojis.get(category, "📦")
|
||||
print(f" {emoji} {category.title()}: {count}")
|
||||
|
||||
# Show asset status breakdown
|
||||
cursor.execute("""
|
||||
SELECT status, COUNT(*) as count
|
||||
FROM assets
|
||||
WHERE project_id = ?
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
""", (project_id,))
|
||||
|
||||
asset_statuses = cursor.fetchall()
|
||||
print(f"\n📈 Asset Status:")
|
||||
status_emojis = {"not_started": "⏳", "planning": "📋", "in_progress": "🎬", "completed": "✅"}
|
||||
for status, count in asset_statuses:
|
||||
emoji = status_emojis.get(status, "❓")
|
||||
print(f" {emoji} {status.replace('_', ' ').title()}: {count}")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Example Data Creator")
|
||||
print("=" * 50)
|
||||
|
||||
success = create_example_data()
|
||||
|
||||
if success:
|
||||
print("\n🎬 Example data created successfully!")
|
||||
print("The Dragon Quest project now has realistic episodes and assets for testing.")
|
||||
else:
|
||||
print("\n❌ Failed to create example data.")
|
||||
@@ -0,0 +1,245 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create an example VFX project in the database with realistic data.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db",
|
||||
"database.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
def create_example_project():
|
||||
"""Create an example VFX project with realistic data."""
|
||||
|
||||
print("Creating Example VFX Project")
|
||||
print("=" * 40)
|
||||
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if projects table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
if not cursor.fetchone():
|
||||
print("❌ Projects table not found. Please run the application first to create the database schema.")
|
||||
return False
|
||||
|
||||
# Check if example project already exists
|
||||
cursor.execute("SELECT id FROM projects WHERE code_name = 'DRAGON_QUEST_2024'")
|
||||
existing_project = cursor.fetchone()
|
||||
|
||||
if existing_project:
|
||||
print(f"✅ Example project already exists (ID: {existing_project[0]})")
|
||||
return True
|
||||
|
||||
# Create example project data
|
||||
project_data = {
|
||||
"name": "Dragon Quest: The Awakening",
|
||||
"code_name": "DRAGON_QUEST_2024",
|
||||
"client_name": "Epic Fantasy Studios",
|
||||
"project_type": "cinema",
|
||||
"description": "A high-budget fantasy film featuring dragons, magic, and epic battles. Requires extensive VFX work including creature animation, environmental effects, and magical elements.",
|
||||
"status": "in_progress",
|
||||
"start_date": "2024-01-15",
|
||||
"end_date": "2024-12-20",
|
||||
"frame_rate": 24.0,
|
||||
"data_drive_path": "/projects/dragon_quest_2024/data",
|
||||
"publish_storage_path": "/projects/dragon_quest_2024/publish",
|
||||
"delivery_image_resolution": "4096x2160",
|
||||
"delivery_movie_specs_by_department": {
|
||||
"layout": {
|
||||
"resolution": "1920x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "medium"
|
||||
},
|
||||
"animation": {
|
||||
"resolution": "2048x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "high"
|
||||
},
|
||||
"lighting": {
|
||||
"resolution": "4096x2160",
|
||||
"format": "exr",
|
||||
"codec": None,
|
||||
"quality": "high"
|
||||
},
|
||||
"composite": {
|
||||
"resolution": "4096x2160",
|
||||
"format": "mov",
|
||||
"codec": "prores",
|
||||
"quality": "high"
|
||||
},
|
||||
"modeling": {
|
||||
"resolution": "1920x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "medium"
|
||||
},
|
||||
"rigging": {
|
||||
"resolution": "1920x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "medium"
|
||||
},
|
||||
"surfacing": {
|
||||
"resolution": "2048x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "high"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Convert delivery specs to JSON string
|
||||
delivery_specs_json = json.dumps(project_data["delivery_movie_specs_by_department"])
|
||||
|
||||
# Insert the project
|
||||
insert_query = """
|
||||
INSERT INTO projects (
|
||||
name, code_name, client_name, project_type, description, status,
|
||||
start_date, end_date, frame_rate, data_drive_path, publish_storage_path,
|
||||
delivery_image_resolution, delivery_movie_specs_by_department,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
current_time = datetime.now().isoformat()
|
||||
|
||||
cursor.execute(insert_query, (
|
||||
project_data["name"],
|
||||
project_data["code_name"],
|
||||
project_data["client_name"],
|
||||
project_data["project_type"],
|
||||
project_data["description"],
|
||||
project_data["status"],
|
||||
project_data["start_date"],
|
||||
project_data["end_date"],
|
||||
project_data["frame_rate"],
|
||||
project_data["data_drive_path"],
|
||||
project_data["publish_storage_path"],
|
||||
project_data["delivery_image_resolution"],
|
||||
delivery_specs_json,
|
||||
current_time,
|
||||
current_time
|
||||
))
|
||||
|
||||
project_id = cursor.lastrowid
|
||||
|
||||
# Commit the changes
|
||||
conn.commit()
|
||||
|
||||
print(f"✅ Example project created successfully!")
|
||||
print(f" Project ID: {project_id}")
|
||||
print(f" Name: {project_data['name']}")
|
||||
print(f" Code: {project_data['code_name']}")
|
||||
print(f" Client: {project_data['client_name']}")
|
||||
print(f" Type: {project_data['project_type'].upper()}")
|
||||
print(f" Status: {project_data['status'].replace('_', ' ').title()}")
|
||||
print(f" Frame Rate: {project_data['frame_rate']} fps")
|
||||
print(f" Resolution: {project_data['delivery_image_resolution']}")
|
||||
print(f" Departments: {', '.join(project_data['delivery_movie_specs_by_department'].keys())}")
|
||||
|
||||
# Verify the project was created correctly
|
||||
cursor.execute("""
|
||||
SELECT name, code_name, frame_rate, delivery_image_resolution
|
||||
FROM projects WHERE id = ?
|
||||
""", (project_id,))
|
||||
|
||||
verification = cursor.fetchone()
|
||||
if verification:
|
||||
print(f"\n✅ Verification successful:")
|
||||
print(f" Database Name: {verification[0]}")
|
||||
print(f" Database Code: {verification[1]}")
|
||||
print(f" Database Frame Rate: {verification[2]} fps")
|
||||
print(f" Database Resolution: {verification[3]}")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def show_all_projects():
|
||||
"""Display all projects in the database."""
|
||||
|
||||
print("\nAll Projects in Database:")
|
||||
print("-" * 40)
|
||||
|
||||
db_path = get_database_path()
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
SELECT id, name, code_name, client_name, project_type, status,
|
||||
frame_rate, delivery_image_resolution
|
||||
FROM projects
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
|
||||
projects = cursor.fetchall()
|
||||
|
||||
if not projects:
|
||||
print("No projects found in database.")
|
||||
return
|
||||
|
||||
for project in projects:
|
||||
print(f"ID: {project[0]}")
|
||||
print(f" Name: {project[1]}")
|
||||
print(f" Code: {project[2]}")
|
||||
print(f" Client: {project[3]}")
|
||||
print(f" Type: {project[4].upper()}")
|
||||
print(f" Status: {project[5].replace('_', ' ').title()}")
|
||||
print(f" Frame Rate: {project[6]} fps")
|
||||
print(f" Resolution: {project[7]}")
|
||||
print()
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Database error: {e}")
|
||||
except Exception as e:
|
||||
print(f"❌ Unexpected error: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Example Project Creator")
|
||||
print("=" * 50)
|
||||
|
||||
success = create_example_project()
|
||||
|
||||
if success:
|
||||
show_all_projects()
|
||||
print("\n🎬 Example project created successfully!")
|
||||
print("You can now test the VFX project management system with realistic data.")
|
||||
else:
|
||||
print("\n❌ Failed to create example project.")
|
||||
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create a fresh database with proper schema and test data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
def create_fresh_database():
|
||||
"""Create a fresh database with proper schema."""
|
||||
|
||||
print("Creating Fresh Database")
|
||||
print("=" * 30)
|
||||
|
||||
# Use a new database name
|
||||
new_db_name = "vfx_project_fresh.db"
|
||||
|
||||
# Remove if exists
|
||||
if os.path.exists(new_db_name):
|
||||
os.remove(new_db_name)
|
||||
print(f"Removed existing {new_db_name}")
|
||||
|
||||
# Update database configuration temporarily
|
||||
os.environ['DATABASE_URL'] = f"sqlite:///./{new_db_name}"
|
||||
|
||||
try:
|
||||
# Import after setting environment variable
|
||||
from database import engine, Base
|
||||
import models # This imports all models
|
||||
|
||||
# Create all tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("✅ Created database schema")
|
||||
|
||||
# Create admin user
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_password_hash
|
||||
|
||||
db = next(get_db())
|
||||
|
||||
# Check if admin exists
|
||||
admin_user = db.query(User).filter(User.email == "admin@vfx.com").first()
|
||||
if not admin_user:
|
||||
admin_user = User(
|
||||
email="admin@vfx.com",
|
||||
password_hash=get_password_hash("admin123"),
|
||||
first_name="Admin",
|
||||
last_name="User",
|
||||
role=UserRole.COORDINATOR,
|
||||
is_admin=True,
|
||||
is_approved=True
|
||||
)
|
||||
db.add(admin_user)
|
||||
db.commit()
|
||||
print("✅ Created admin user")
|
||||
else:
|
||||
print("✅ Admin user already exists")
|
||||
|
||||
# Create test projects
|
||||
from models.project import Project, ProjectStatus, ProjectType
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Project 1
|
||||
project1 = Project(
|
||||
name="Test VFX Project",
|
||||
code_name="TEST_VFX_001",
|
||||
client_name="Test Studio",
|
||||
project_type=ProjectType.CINEMA,
|
||||
description="Test project for VFX management system",
|
||||
status=ProjectStatus.PLANNING,
|
||||
frame_rate=24.0,
|
||||
data_drive_path="/projects/test_vfx/data",
|
||||
publish_storage_path="/projects/test_vfx/publish",
|
||||
delivery_image_resolution="1920x1080",
|
||||
delivery_movie_specs_by_department=json.dumps({
|
||||
"layout": {"resolution": "1920x1080", "format": "mov", "codec": "h264", "quality": "medium"},
|
||||
"animation": {"resolution": "1920x1080", "format": "mov", "codec": "h264", "quality": "high"}
|
||||
})
|
||||
)
|
||||
|
||||
# Project 2
|
||||
project2 = Project(
|
||||
name="Dragon Quest: The Awakening",
|
||||
code_name="DRAGON_QUEST_2024",
|
||||
client_name="Epic Fantasy Studios",
|
||||
project_type=ProjectType.CINEMA,
|
||||
description="A high-budget fantasy film featuring dragons and magic",
|
||||
status=ProjectStatus.IN_PROGRESS,
|
||||
frame_rate=24.0,
|
||||
data_drive_path="/projects/dragon_quest_2024/data",
|
||||
publish_storage_path="/projects/dragon_quest_2024/publish",
|
||||
delivery_image_resolution="4096x2160",
|
||||
delivery_movie_specs_by_department=json.dumps({
|
||||
"layout": {"resolution": "1920x1080", "format": "mov", "codec": "h264", "quality": "medium"},
|
||||
"animation": {"resolution": "2048x1080", "format": "mov", "codec": "h264", "quality": "high"},
|
||||
"lighting": {"resolution": "4096x2160", "format": "exr", "codec": None, "quality": "high"},
|
||||
"composite": {"resolution": "4096x2160", "format": "mov", "codec": "prores", "quality": "high"}
|
||||
})
|
||||
)
|
||||
|
||||
db.add(project1)
|
||||
db.add(project2)
|
||||
db.commit()
|
||||
print("✅ Created test projects")
|
||||
|
||||
# Rename to replace old database
|
||||
db.close()
|
||||
|
||||
# Now replace the old database
|
||||
old_db = "vfx_project_management.db"
|
||||
if os.path.exists(old_db):
|
||||
os.remove(old_db)
|
||||
|
||||
os.rename(new_db_name, old_db)
|
||||
print(f"✅ Replaced old database with fresh one")
|
||||
|
||||
print("\n🎉 Fresh database created successfully!")
|
||||
print("Admin credentials: admin@vfx.com / admin123")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
create_fresh_database()
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create database indexes to optimize task status queries for shots and assets.
|
||||
This script implements the database schema optimization from the shot-asset-task-status-optimization spec.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def create_task_status_indexes():
|
||||
"""Create optimized indexes for task status queries."""
|
||||
|
||||
db_path = Path("database.db")
|
||||
if not db_path.exists():
|
||||
print("Error: database.db not found. Please run from the backend directory.")
|
||||
return False
|
||||
|
||||
conn = sqlite3.connect('database.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("Creating optimized indexes for task status queries...")
|
||||
|
||||
# Index 1: Optimize task lookups by shot_id (active tasks only)
|
||||
print("Creating idx_tasks_shot_id_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_shot_id_active
|
||||
ON tasks(shot_id)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 2: Optimize task lookups by asset_id (active tasks only)
|
||||
print("Creating idx_tasks_asset_id_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_asset_id_active
|
||||
ON tasks(asset_id)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 3: Optimize task status filtering (active tasks only)
|
||||
print("Creating idx_tasks_status_type_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status_type_active
|
||||
ON tasks(status, task_type)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 4: Composite index for shot + status + type queries (most common pattern)
|
||||
print("Creating idx_tasks_shot_status_type_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_shot_status_type_active
|
||||
ON tasks(shot_id, status, task_type)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 5: Composite index for asset + status + type queries (most common pattern)
|
||||
print("Creating idx_tasks_asset_status_type_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_asset_status_type_active
|
||||
ON tasks(asset_id, status, task_type)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 6: Optimize queries that need task details (id, type, status, assignee, updated_at)
|
||||
print("Creating idx_tasks_details_shot...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_details_shot
|
||||
ON tasks(shot_id, id, task_type, status, assigned_user_id, updated_at)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 7: Optimize queries that need task details for assets
|
||||
print("Creating idx_tasks_details_asset...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_details_asset
|
||||
ON tasks(asset_id, id, task_type, status, assigned_user_id, updated_at)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Index 8: Optimize project-wide task queries with status filtering
|
||||
print("Creating idx_tasks_project_status_active...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project_status_active
|
||||
ON tasks(project_id, status, task_type)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
print("✅ All indexes created successfully!")
|
||||
|
||||
# Verify indexes were created
|
||||
print("\nVerifying created indexes...")
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type='index'
|
||||
AND name LIKE 'idx_tasks_%_active'
|
||||
ORDER BY name
|
||||
""")
|
||||
|
||||
new_indexes = cursor.fetchall()
|
||||
for idx in new_indexes:
|
||||
print(f" ✓ {idx[0]}")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Error creating indexes: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_index_performance():
|
||||
"""Test the performance of the new indexes with sample queries."""
|
||||
|
||||
conn = sqlite3.connect('database.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("\n" + "="*50)
|
||||
print("TESTING INDEX PERFORMANCE")
|
||||
print("="*50)
|
||||
|
||||
# Test 1: Shot task status aggregation query
|
||||
print("\nTest 1: Shot task status aggregation")
|
||||
cursor.execute("EXPLAIN QUERY PLAN SELECT shot_id, task_type, status FROM tasks WHERE shot_id = 1 AND deleted_at IS NULL")
|
||||
plan = cursor.fetchall()
|
||||
for row in plan:
|
||||
print(f" {row}")
|
||||
|
||||
# Test 2: Asset task status aggregation query
|
||||
print("\nTest 2: Asset task status aggregation")
|
||||
cursor.execute("EXPLAIN QUERY PLAN SELECT asset_id, task_type, status FROM tasks WHERE asset_id = 1 AND deleted_at IS NULL")
|
||||
plan = cursor.fetchall()
|
||||
for row in plan:
|
||||
print(f" {row}")
|
||||
|
||||
# Test 3: Project-wide status filtering
|
||||
print("\nTest 3: Project-wide status filtering")
|
||||
cursor.execute("EXPLAIN QUERY PLAN SELECT * FROM tasks WHERE project_id = 1 AND status = 'in_progress' AND deleted_at IS NULL")
|
||||
plan = cursor.fetchall()
|
||||
for row in plan:
|
||||
print(f" {row}")
|
||||
|
||||
# Test 4: Complex join query (shots with task status)
|
||||
print("\nTest 4: Shots with task status join")
|
||||
cursor.execute("""
|
||||
EXPLAIN QUERY PLAN
|
||||
SELECT s.id, s.name, t.task_type, t.status
|
||||
FROM shots s
|
||||
LEFT JOIN tasks t ON s.id = t.shot_id AND t.deleted_at IS NULL
|
||||
WHERE s.deleted_at IS NULL
|
||||
LIMIT 10
|
||||
""")
|
||||
plan = cursor.fetchall()
|
||||
for row in plan:
|
||||
print(f" {row}")
|
||||
|
||||
print("\n✅ Index performance tests completed!")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Error testing performance: {e}")
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_sample_data_stats():
|
||||
"""Get statistics about the current data to validate index effectiveness."""
|
||||
|
||||
conn = sqlite3.connect('database.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("\n" + "="*50)
|
||||
print("SAMPLE DATA STATISTICS")
|
||||
print("="*50)
|
||||
|
||||
# Count total tasks
|
||||
cursor.execute("SELECT COUNT(*) FROM tasks WHERE deleted_at IS NULL")
|
||||
total_tasks = cursor.fetchone()[0]
|
||||
print(f"Total active tasks: {total_tasks}")
|
||||
|
||||
# Count tasks by type
|
||||
cursor.execute("SELECT task_type, COUNT(*) FROM tasks WHERE deleted_at IS NULL GROUP BY task_type")
|
||||
task_types = cursor.fetchall()
|
||||
print("\nTasks by type:")
|
||||
for task_type, count in task_types:
|
||||
print(f" {task_type}: {count}")
|
||||
|
||||
# Count tasks by status
|
||||
cursor.execute("SELECT status, COUNT(*) FROM tasks WHERE deleted_at IS NULL GROUP BY status")
|
||||
task_statuses = cursor.fetchall()
|
||||
print("\nTasks by status:")
|
||||
for status, count in task_statuses:
|
||||
print(f" {status}: {count}")
|
||||
|
||||
# Count shots with tasks
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT s.id)
|
||||
FROM shots s
|
||||
INNER JOIN tasks t ON s.id = t.shot_id
|
||||
WHERE s.deleted_at IS NULL AND t.deleted_at IS NULL
|
||||
""")
|
||||
shots_with_tasks = cursor.fetchone()[0]
|
||||
print(f"\nShots with tasks: {shots_with_tasks}")
|
||||
|
||||
# Count assets with tasks
|
||||
cursor.execute("""
|
||||
SELECT COUNT(DISTINCT a.id)
|
||||
FROM assets a
|
||||
INNER JOIN tasks t ON a.id = t.asset_id
|
||||
WHERE a.deleted_at IS NULL AND t.deleted_at IS NULL
|
||||
""")
|
||||
assets_with_tasks = cursor.fetchone()[0]
|
||||
print(f"Assets with tasks: {assets_with_tasks}")
|
||||
|
||||
return {
|
||||
'total_tasks': total_tasks,
|
||||
'shots_with_tasks': shots_with_tasks,
|
||||
'assets_with_tasks': assets_with_tasks
|
||||
}
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"❌ Error getting statistics: {e}")
|
||||
return None
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Shot-Asset Task Status Optimization: Database Index Creation")
|
||||
print("=" * 60)
|
||||
|
||||
# Get current data statistics
|
||||
stats = get_sample_data_stats()
|
||||
|
||||
# Create the indexes
|
||||
success = create_task_status_indexes()
|
||||
|
||||
if success:
|
||||
# Test index performance
|
||||
test_index_performance()
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("INDEX CREATION COMPLETED SUCCESSFULLY!")
|
||||
print("="*60)
|
||||
print("\nNext steps:")
|
||||
print("1. Run backend optimization tests")
|
||||
print("2. Implement optimized query patterns in routers")
|
||||
print("3. Test with larger datasets")
|
||||
|
||||
else:
|
||||
print("\n❌ Index creation failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Database configuration
|
||||
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./database.db")
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
# Dependency to get database session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""
|
||||
Debug script to check episode access issue
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('backend/vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if episode 2 exists
|
||||
print("=== Checking Episode 2 ===")
|
||||
cursor.execute('SELECT id, name, project_id FROM episodes WHERE id = 2')
|
||||
episode = cursor.fetchone()
|
||||
if episode:
|
||||
print(f"Episode found: ID={episode[0]}, Name={episode[1]}, Project ID={episode[2]}")
|
||||
project_id = episode[2]
|
||||
|
||||
# Check if admin user is a member of this project
|
||||
print(f"\n=== Checking Project {project_id} Membership ===")
|
||||
cursor.execute('''
|
||||
SELECT u.id, u.email, u.role, pm.project_id
|
||||
FROM users u
|
||||
LEFT JOIN project_members pm ON u.id = pm.user_id AND pm.project_id = ?
|
||||
WHERE u.email = "admin@vfx.com"
|
||||
''', (project_id,))
|
||||
user_info = cursor.fetchone()
|
||||
print(f"User ID: {user_info[0]}, Email: {user_info[1]}, Role: {user_info[2]}, Project Member: {user_info[3]}")
|
||||
|
||||
if user_info[3] is None:
|
||||
print(f"\n⚠️ User is NOT a member of project {project_id}")
|
||||
print("This is why the 403 error occurs!")
|
||||
|
||||
# Add user to project
|
||||
print(f"\nAdding user to project {project_id}...")
|
||||
cursor.execute('''
|
||||
INSERT INTO project_members (user_id, project_id, department_role)
|
||||
VALUES (?, ?, NULL)
|
||||
''', (user_info[0], project_id))
|
||||
conn.commit()
|
||||
print("✓ User added to project")
|
||||
else:
|
||||
print(f"\n✓ User is already a member of project {project_id}")
|
||||
else:
|
||||
print("Episode 2 not found!")
|
||||
|
||||
# List all episodes
|
||||
print("\n=== All Episodes ===")
|
||||
cursor.execute('SELECT id, name, project_id FROM episodes')
|
||||
for ep in cursor.fetchall():
|
||||
print(f" Episode {ep[0]}: {ep[1]} (Project {ep[2]})")
|
||||
|
||||
conn.close()
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Debug script to check projects table and data.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def debug_projects():
|
||||
"""Debug projects table structure and data."""
|
||||
|
||||
print("Debugging Projects Table")
|
||||
print("=" * 30)
|
||||
|
||||
db_path = "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check table structure
|
||||
print("1. Projects table structure:")
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = cursor.fetchall()
|
||||
for col in columns:
|
||||
print(f" {col[1]} ({col[2]}) - NOT NULL: {bool(col[3])}")
|
||||
|
||||
# Check data
|
||||
print("\n2. Projects data:")
|
||||
cursor.execute("SELECT * FROM projects")
|
||||
projects = cursor.fetchall()
|
||||
|
||||
if not projects:
|
||||
print(" No projects found")
|
||||
else:
|
||||
for i, project in enumerate(projects):
|
||||
print(f" Project {i+1}: {project}")
|
||||
|
||||
# Check specific fields that might be causing issues
|
||||
print("\n3. Checking for NULL values in required fields:")
|
||||
cursor.execute("""
|
||||
SELECT id, name, code_name, client_name, project_type, status, created_at, updated_at
|
||||
FROM projects
|
||||
""")
|
||||
projects = cursor.fetchall()
|
||||
|
||||
for project in projects:
|
||||
print(f" ID: {project[0]}")
|
||||
print(f" Name: {project[1]}")
|
||||
print(f" Code: {project[2]}")
|
||||
print(f" Client: {project[3]}")
|
||||
print(f" Type: {project[4]}")
|
||||
print(f" Status: {project[5]}")
|
||||
print(f" Created: {project[6]}")
|
||||
print(f" Updated: {project[7]}")
|
||||
print()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
debug_projects()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Debug script to check why admin is getting 403
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
|
||||
# Try to login and access a shot
|
||||
print("=" * 60)
|
||||
print("Debugging Shot 403 Error")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Login
|
||||
print("\n1. Attempting login...")
|
||||
login_response = requests.post(f"{BASE_URL}/auth/login", json={
|
||||
"email": "admin@vfx.com",
|
||||
"password": "admin123"
|
||||
})
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"✗ Login failed: {login_response.status_code}")
|
||||
print(f"Response: {login_response.text}")
|
||||
exit(1)
|
||||
|
||||
print("✓ Login successful")
|
||||
token = login_response.json()["access_token"]
|
||||
user_data = login_response.json().get("user", {})
|
||||
print(f" User: {user_data.get('email')}")
|
||||
print(f" Role: {user_data.get('role')}")
|
||||
print(f" Is Admin: {user_data.get('is_admin')}")
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
# Step 2: Get shots list
|
||||
print("\n2. Getting shots list...")
|
||||
shots_response = requests.get(f"{BASE_URL}/shots/", headers=headers)
|
||||
|
||||
if shots_response.status_code != 200:
|
||||
print(f"✗ Failed to get shots: {shots_response.status_code}")
|
||||
print(f"Response: {shots_response.text}")
|
||||
exit(1)
|
||||
|
||||
shots = shots_response.json()
|
||||
print(f"✓ Got {len(shots)} shots")
|
||||
|
||||
if not shots:
|
||||
print("No shots available to test")
|
||||
exit(0)
|
||||
|
||||
# Step 3: Try to get first shot detail
|
||||
shot = shots[0]
|
||||
shot_id = shot["id"]
|
||||
print(f"\n3. Getting shot detail for shot ID: {shot_id}")
|
||||
print(f" Shot name: {shot.get('name')}")
|
||||
print(f" Episode ID: {shot.get('episode_id')}")
|
||||
|
||||
shot_detail_response = requests.get(f"{BASE_URL}/shots/{shot_id}", headers=headers)
|
||||
|
||||
print(f"\n4. Response:")
|
||||
print(f" Status Code: {shot_detail_response.status_code}")
|
||||
|
||||
if shot_detail_response.status_code == 200:
|
||||
print("✓ SUCCESS: Shot detail retrieved")
|
||||
detail = shot_detail_response.json()
|
||||
print(f" Shot: {detail.get('name')}")
|
||||
print(f" Frame range: {detail.get('frame_start')}-{detail.get('frame_end')}")
|
||||
elif shot_detail_response.status_code == 403:
|
||||
print("✗ FAILED: 403 Forbidden")
|
||||
print(f" Response: {shot_detail_response.text}")
|
||||
print("\n This means the backend check_episode_access function is still blocking access")
|
||||
print(" Possible causes:")
|
||||
print(" - Backend not restarted after code change")
|
||||
print(" - User is_admin field is False in database")
|
||||
print(" - Different endpoint being called")
|
||||
else:
|
||||
print(f"✗ FAILED: {shot_detail_response.status_code}")
|
||||
print(f" Response: {shot_detail_response.text}")
|
||||
|
||||
# Step 5: Check user in database
|
||||
print("\n5. Checking user in database...")
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from database import SessionLocal
|
||||
from models.user import User
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
db_user = db.query(User).filter(User.email == user_data.get('email')).first()
|
||||
if db_user:
|
||||
print(f"✓ User found in database")
|
||||
print(f" Email: {db_user.email}")
|
||||
print(f" Role: {db_user.role}")
|
||||
print(f" is_admin: {db_user.is_admin}")
|
||||
|
||||
if not db_user.is_admin:
|
||||
print("\n⚠ WARNING: User is_admin is False in database!")
|
||||
print(" This is why you're getting 403")
|
||||
print(" Run: python backend/migrate_admin_users.py")
|
||||
else:
|
||||
print("✗ User not found in database")
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Debug script to test tasks endpoint with admin/coordinator user
|
||||
"""
|
||||
import requests
|
||||
|
||||
BASE_URL = "http://localhost:8000"
|
||||
SHOT_ID = 1
|
||||
|
||||
# Login
|
||||
login_response = requests.post(
|
||||
f"{BASE_URL}/auth/login",
|
||||
json={"email": "admin@vfx.com", "password": "admin123"}
|
||||
)
|
||||
|
||||
if login_response.status_code == 200:
|
||||
token = login_response.json()["access_token"]
|
||||
print(f"✓ Logged in successfully")
|
||||
|
||||
# Get user info
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
me_response = requests.get(f"{BASE_URL}/users/me", headers=headers)
|
||||
if me_response.status_code == 200:
|
||||
user = me_response.json()
|
||||
print(f"User: {user['email']}")
|
||||
print(f"Role: {user['role']}")
|
||||
print(f"Is Admin: {user['is_admin']}")
|
||||
|
||||
# Try without trailing slash
|
||||
print(f"\n--- Testing GET /tasks?shot_id={SHOT_ID} (no trailing slash) ---")
|
||||
response1 = requests.get(
|
||||
f"{BASE_URL}/tasks",
|
||||
params={"shot_id": SHOT_ID},
|
||||
headers=headers,
|
||||
allow_redirects=False # Don't follow redirects
|
||||
)
|
||||
print(f"Status: {response1.status_code}")
|
||||
print(f"Headers: {dict(response1.headers)}")
|
||||
if response1.status_code in [301, 302, 307, 308]:
|
||||
print(f"Redirect to: {response1.headers.get('location')}")
|
||||
|
||||
# Try with trailing slash
|
||||
print(f"\n--- Testing GET /tasks/?shot_id={SHOT_ID} (with trailing slash) ---")
|
||||
response2 = requests.get(
|
||||
f"{BASE_URL}/tasks/",
|
||||
params={"shot_id": SHOT_ID},
|
||||
headers=headers
|
||||
)
|
||||
print(f"Status: {response2.status_code}")
|
||||
if response2.status_code == 200:
|
||||
tasks = response2.json()
|
||||
print(f"✓ Found {len(tasks)} tasks")
|
||||
else:
|
||||
print(f"Response: {response2.text[:500]}")
|
||||
else:
|
||||
print(f"✗ Login failed: {login_response.status_code}")
|
||||
print(login_response.text)
|
||||
@@ -0,0 +1,190 @@
|
||||
# Admin API Key Management
|
||||
|
||||
This document describes the enhanced API key management functionality that allows administrators to create and manage API keys for any user in the system.
|
||||
|
||||
## Overview
|
||||
|
||||
The VFX Project Management System now supports comprehensive API key management with different permission levels:
|
||||
|
||||
- **Developers**: Can create and manage their own API keys
|
||||
- **Admins**: Can create and manage API keys for any user in the system
|
||||
|
||||
## Admin Capabilities
|
||||
|
||||
### 1. Create API Keys for Any User
|
||||
|
||||
Admins can create API keys for any approved user in the system using two methods:
|
||||
|
||||
#### Method 1: General Endpoint with user_id Parameter
|
||||
```http
|
||||
POST /auth/api-keys
|
||||
Authorization: Bearer <admin_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Integration Key for John Doe",
|
||||
"scopes": ["read:projects", "read:tasks"],
|
||||
"user_id": 123,
|
||||
"expires_at": "2024-12-31T23:59:59"
|
||||
}
|
||||
```
|
||||
|
||||
#### Method 2: Admin-Specific Endpoint
|
||||
```http
|
||||
POST /auth/admin/users/123/api-keys
|
||||
Authorization: Bearer <admin_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "Integration Key for John Doe",
|
||||
"scopes": ["read:projects", "read:tasks"],
|
||||
"expires_at": "2024-12-31T23:59:59"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. View All API Keys
|
||||
|
||||
Admins can view all API keys in the system:
|
||||
|
||||
```http
|
||||
GET /auth/api-keys
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
Response includes user email for each API key:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": 123,
|
||||
"user_email": "john.doe@example.com",
|
||||
"name": "Integration Key",
|
||||
"scopes": ["read:projects", "read:tasks"],
|
||||
"is_active": true,
|
||||
"expires_at": "2024-12-31T23:59:59Z",
|
||||
"last_used_at": "2024-01-15T10:30:00Z",
|
||||
"created_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 3. View API Keys for Specific User
|
||||
|
||||
```http
|
||||
GET /auth/admin/users/123/api-keys
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
### 4. Manage Any API Key
|
||||
|
||||
Admins can update or delete any API key in the system:
|
||||
|
||||
```http
|
||||
PUT /auth/api-keys/456
|
||||
DELETE /auth/api-keys/456
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
### 5. View Usage Logs for Any API Key
|
||||
|
||||
```http
|
||||
GET /auth/api-keys/456/usage
|
||||
Authorization: Bearer <admin_token>
|
||||
```
|
||||
|
||||
## API Key Scopes
|
||||
|
||||
Available scopes for API keys:
|
||||
|
||||
- `read:projects` - Read access to all projects
|
||||
- `read:tasks` - Read access to all tasks
|
||||
- `read:submissions` - Read access to all submissions
|
||||
- `read:users` - Read access to user information
|
||||
- `write:tasks` - Write access to tasks
|
||||
- `write:submissions` - Write access to submissions
|
||||
- `admin:users` - Administrative access to user management
|
||||
- `full:access` - Full system access
|
||||
|
||||
## Security Features
|
||||
|
||||
1. **API Key Hashing**: All API keys are hashed before storage using SHA-256
|
||||
2. **Usage Logging**: Every API request is logged with timestamp, endpoint, method, IP address, and user agent
|
||||
3. **Expiration**: API keys can have expiration dates
|
||||
4. **Revocation**: API keys can be deactivated or deleted at any time
|
||||
5. **Scope-based Access**: Fine-grained permissions control what each API key can access
|
||||
|
||||
## Developer vs Admin Permissions
|
||||
|
||||
| Action | Developer | Admin |
|
||||
|--------|-----------|-------|
|
||||
| Create own API keys | ✅ | ✅ |
|
||||
| Create API keys for others | ❌ | ✅ |
|
||||
| View own API keys | ✅ | ✅ |
|
||||
| View all API keys | ❌ | ✅ |
|
||||
| Update own API keys | ✅ | ✅ |
|
||||
| Update any API key | ❌ | ✅ |
|
||||
| Delete own API keys | ✅ | ✅ |
|
||||
| Delete any API key | ❌ | ✅ |
|
||||
| View own usage logs | ✅ | ✅ |
|
||||
| View any usage logs | ❌ | ✅ |
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Creating an API Key for a Developer
|
||||
|
||||
As an admin, you can create API keys for developers who need to integrate external tools:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Admin login
|
||||
admin_token = login_as_admin()
|
||||
|
||||
# Create API key for developer
|
||||
response = requests.post("http://localhost:8000/auth/api-keys",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
json={
|
||||
"name": "CI/CD Pipeline Integration",
|
||||
"scopes": ["read:projects", "read:tasks", "write:submissions"],
|
||||
"user_id": 456, # Developer's user ID
|
||||
"expires_at": "2024-12-31T23:59:59"
|
||||
}
|
||||
)
|
||||
|
||||
api_key_data = response.json()
|
||||
print(f"API Key: {api_key_data['token']}")
|
||||
```
|
||||
|
||||
### Monitoring API Usage
|
||||
|
||||
Admins can monitor how API keys are being used:
|
||||
|
||||
```python
|
||||
# Get usage logs for an API key
|
||||
response = requests.get(f"http://localhost:8000/auth/api-keys/123/usage",
|
||||
headers={"Authorization": f"Bearer {admin_token}"}
|
||||
)
|
||||
|
||||
usage_logs = response.json()
|
||||
for log in usage_logs:
|
||||
print(f"{log['timestamp']}: {log['method']} {log['endpoint']} from {log['ip_address']}")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Principle of Least Privilege**: Only grant the minimum scopes necessary
|
||||
2. **Regular Rotation**: Set expiration dates and rotate API keys regularly
|
||||
3. **Monitor Usage**: Regularly review API key usage logs
|
||||
4. **Revoke Unused Keys**: Delete or deactivate API keys that are no longer needed
|
||||
5. **Secure Distribution**: Share API keys securely and never commit them to version control
|
||||
|
||||
## Testing
|
||||
|
||||
Use the provided test script to verify admin functionality:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python test_admin_api_keys.py
|
||||
```
|
||||
|
||||
This script demonstrates all admin API key management capabilities.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Bulk Actions Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of bulk action endpoints for the task management system. These endpoints allow coordinators and admins to perform batch operations on multiple tasks simultaneously.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Bulk Status Update
|
||||
|
||||
**Endpoint:** `PUT /tasks/bulk/status`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"task_ids": [1, 2, 3],
|
||||
"status": "in_progress"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success_count": 3,
|
||||
"failed_count": 0,
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
- Coordinators and admins can update any tasks
|
||||
- Artists can only update their own assigned tasks
|
||||
|
||||
**Features:**
|
||||
- Atomic transaction handling - either all tasks update or none
|
||||
- Permission validation for all tasks before making changes
|
||||
- Detailed error reporting for failed tasks
|
||||
|
||||
### 2. Bulk Assignment
|
||||
|
||||
**Endpoint:** `PUT /tasks/bulk/assign`
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"task_ids": [1, 2, 3],
|
||||
"assigned_user_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success_count": 3,
|
||||
"failed_count": 0,
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
- Only coordinators and admins can perform bulk assignments
|
||||
|
||||
**Features:**
|
||||
- Atomic transaction handling
|
||||
- Validates user is a member of all task projects
|
||||
- Sends notifications to assigned user for each task
|
||||
- Detailed error reporting for failed tasks
|
||||
|
||||
## Schemas
|
||||
|
||||
### BulkStatusUpdate
|
||||
```python
|
||||
class BulkStatusUpdate(BaseModel):
|
||||
task_ids: List[int] = Field(..., min_length=1)
|
||||
status: TaskStatus
|
||||
```
|
||||
|
||||
### BulkAssignment
|
||||
```python
|
||||
class BulkAssignment(BaseModel):
|
||||
task_ids: List[int] = Field(..., min_length=1)
|
||||
assigned_user_id: int
|
||||
```
|
||||
|
||||
### BulkActionResult
|
||||
```python
|
||||
class BulkActionResult(BaseModel):
|
||||
success_count: int
|
||||
failed_count: int
|
||||
errors: Optional[List[dict]] = None
|
||||
```
|
||||
|
||||
## Atomicity
|
||||
|
||||
Both endpoints implement atomic transactions:
|
||||
|
||||
1. All tasks are fetched in a single query
|
||||
2. All validations (permissions, project membership) are performed before any changes
|
||||
3. If any validation fails, the transaction is rolled back and no changes are made
|
||||
4. Only if all validations pass are the changes committed
|
||||
|
||||
This ensures that partial updates never occur - either all tasks are updated successfully or none are.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors are returned in a structured format:
|
||||
|
||||
```json
|
||||
{
|
||||
"success_count": 1,
|
||||
"failed_count": 2,
|
||||
"errors": [
|
||||
{
|
||||
"task_id": 99,
|
||||
"error": "Task not found"
|
||||
},
|
||||
{
|
||||
"task_id": 100,
|
||||
"error": "Not authorized to update this task"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Common error scenarios:
|
||||
- Task not found
|
||||
- Insufficient permissions
|
||||
- User not a project member (for assignments)
|
||||
|
||||
## Testing
|
||||
|
||||
A comprehensive test script is available at `backend/test_bulk_actions.py` that tests:
|
||||
|
||||
1. Successful bulk status updates
|
||||
2. Successful bulk assignments
|
||||
3. Error handling with invalid task IDs
|
||||
4. Atomicity with mixed valid/invalid IDs
|
||||
5. Permission validation
|
||||
|
||||
Run tests with:
|
||||
```bash
|
||||
python test_bulk_actions.py
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
### Route Ordering
|
||||
|
||||
The bulk action endpoints are placed BEFORE the `/{task_id}` routes in the router to prevent FastAPI from trying to match "bulk" as a task_id parameter.
|
||||
|
||||
### Transaction Management
|
||||
|
||||
SQLAlchemy's session management is used for transactions:
|
||||
- `db.commit()` commits all changes
|
||||
- `db.rollback()` reverts all changes if errors occur
|
||||
|
||||
### Notifications
|
||||
|
||||
The bulk assignment endpoint sends individual notifications for each assigned task using the existing `notification_service.notify_task_assigned()` function.
|
||||
|
||||
## Requirements Validated
|
||||
|
||||
This implementation satisfies the following requirements from the spec:
|
||||
|
||||
- **4.2**: Bulk status update with atomic transaction handling
|
||||
- **4.4**: Error handling and rollback on failure
|
||||
- **5.3**: Bulk assignment with atomic transaction handling
|
||||
- **5.5**: Error handling and rollback on failure
|
||||
@@ -0,0 +1,172 @@
|
||||
# Custom Task Status Creation Endpoint Implementation
|
||||
|
||||
## Overview
|
||||
Implemented the POST endpoint for creating custom task statuses in projects. This allows coordinators and admins to define project-specific task statuses beyond the built-in system statuses.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Endpoint
|
||||
- **Route**: `POST /projects/{project_id}/task-statuses`
|
||||
- **Status Code**: 201 Created
|
||||
- **Authorization**: Requires coordinator or admin role
|
||||
|
||||
### Features Implemented
|
||||
|
||||
#### 1. Status Name Uniqueness Validation
|
||||
- Validates that the status name is unique within the project
|
||||
- Case-insensitive comparison to prevent duplicates like "In Review" and "in review"
|
||||
- Checks against both existing custom statuses and system statuses
|
||||
- Returns 409 Conflict if duplicate found
|
||||
|
||||
#### 2. Auto-Assign Color from Palette
|
||||
- Defines a default color palette with 10 distinct colors:
|
||||
- Purple (#8B5CF6)
|
||||
- Pink (#EC4899)
|
||||
- Teal (#14B8A6)
|
||||
- Orange (#F97316)
|
||||
- Cyan (#06B6D4)
|
||||
- Lime (#84CC16)
|
||||
- Violet (#A855F7)
|
||||
- Rose (#F43F5E)
|
||||
- Sky (#22D3EE)
|
||||
- Yellow (#FACC15)
|
||||
- Automatically assigns the first unused color from the palette
|
||||
- If all colors are used, cycles back through the palette
|
||||
- Users can override by providing a custom color in hex format
|
||||
|
||||
#### 3. Unique Status ID Generation
|
||||
- Generates unique IDs using UUID4 with format: `custom_{8-char-hex}`
|
||||
- Example: `custom_0c7ba931`
|
||||
- Ensures no ID collisions
|
||||
|
||||
#### 4. JSON Column Updates
|
||||
- Uses SQLAlchemy's `flag_modified()` to properly track changes to JSON columns
|
||||
- Ensures database updates are persisted correctly
|
||||
|
||||
#### 5. Status Ordering
|
||||
- Automatically assigns order based on existing statuses
|
||||
- New statuses are appended to the end (max_order + 1)
|
||||
- Maintains consistent ordering for UI display
|
||||
|
||||
### Request Schema
|
||||
```json
|
||||
{
|
||||
"name": "Ready for Review",
|
||||
"color": "#8B5CF6" // Optional - auto-assigned if not provided
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
```json
|
||||
{
|
||||
"message": "Custom task status 'Ready for Review' created successfully",
|
||||
"status": {
|
||||
"id": "custom_0c7ba931",
|
||||
"name": "Ready for Review",
|
||||
"color": "#EC4899",
|
||||
"order": 3,
|
||||
"is_default": false
|
||||
},
|
||||
"all_statuses": {
|
||||
"statuses": [...], // All custom statuses
|
||||
"system_statuses": [...], // Built-in system statuses
|
||||
"default_status_id": "not_started"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Rules
|
||||
|
||||
#### Name Validation (via Pydantic schema)
|
||||
- Minimum length: 1 character
|
||||
- Maximum length: 50 characters
|
||||
- Whitespace is trimmed
|
||||
- Cannot be empty after trimming
|
||||
|
||||
#### Color Validation (via Pydantic schema)
|
||||
- Must be valid hex color code format: `#RRGGBB`
|
||||
- Example: `#FF5733`
|
||||
- Normalized to uppercase
|
||||
- Optional field
|
||||
|
||||
### Error Responses
|
||||
|
||||
#### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"detail": "Project not found"
|
||||
}
|
||||
```
|
||||
|
||||
#### 409 Conflict - Duplicate Name
|
||||
```json
|
||||
{
|
||||
"detail": "Status with name 'Ready for Review' already exists in this project"
|
||||
}
|
||||
```
|
||||
|
||||
#### 409 Conflict - System Status Name
|
||||
```json
|
||||
{
|
||||
"detail": "Status name 'In Progress' conflicts with a system status"
|
||||
}
|
||||
```
|
||||
|
||||
#### 422 Unprocessable Entity - Validation Error
|
||||
```json
|
||||
{
|
||||
"detail": "1 validation error for CustomTaskStatusCreate\nname\n String should have at least 1 character..."
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Results
|
||||
All validation and functionality tests passed:
|
||||
|
||||
1. ✅ Create status without color (auto-assigned from palette)
|
||||
2. ✅ Create status with custom color
|
||||
3. ✅ Reject duplicate status names (409 Conflict)
|
||||
4. ✅ Reject empty status names (422 Validation Error)
|
||||
5. ✅ Reject invalid color formats (422 Validation Error)
|
||||
6. ✅ Reject system status name conflicts (409 Conflict)
|
||||
7. ✅ Proper ordering of statuses
|
||||
8. ✅ Unique ID generation
|
||||
9. ✅ JSON column updates with flag_modified
|
||||
|
||||
### Test Files
|
||||
- `backend/test_create_custom_task_status.py` - Comprehensive test suite
|
||||
- `backend/test_custom_status_validation.py` - Validation-focused tests
|
||||
|
||||
## Database Schema
|
||||
The custom task statuses are stored in the `projects.custom_task_statuses` JSON column:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "custom_review",
|
||||
"name": "In Review",
|
||||
"color": "#8B5CF6",
|
||||
"order": 0,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "custom_blocked",
|
||||
"name": "Blocked",
|
||||
"color": "#DC2626",
|
||||
"order": 1,
|
||||
"is_default": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Requirements Satisfied
|
||||
- ✅ Requirement 1.2: Create new custom task status
|
||||
- ✅ Requirement 1.3: Validate status name uniqueness within project
|
||||
- ✅ Requirement 1.4: Auto-assign color from palette if not provided
|
||||
|
||||
## Next Steps
|
||||
The following endpoints still need to be implemented:
|
||||
- PUT endpoint for updating custom status (Task 5)
|
||||
- DELETE endpoint for deleting custom status (Task 6)
|
||||
- PATCH endpoint for reordering statuses (Task 7)
|
||||
@@ -0,0 +1,137 @@
|
||||
# Custom Task Status DELETE Endpoint Implementation
|
||||
|
||||
## Overview
|
||||
Implemented the DELETE endpoint for removing custom task statuses from projects with comprehensive validation and task reassignment support.
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### DELETE `/projects/{project_id}/task-statuses/{status_id}`
|
||||
|
||||
**Authentication Required:** Coordinator or Admin
|
||||
|
||||
**Query Parameters:**
|
||||
- `reassign_to_status_id` (optional): Status ID to reassign tasks to if the status being deleted is in use
|
||||
|
||||
**Response:** 200 OK with CustomTaskStatusResponse containing:
|
||||
- Success message
|
||||
- All remaining statuses (system + custom)
|
||||
- Updated default status ID
|
||||
|
||||
## Implementation Features
|
||||
|
||||
### 1. Status In-Use Check
|
||||
- Queries all tasks in the project to check if any use the status being deleted
|
||||
- Returns detailed error (422) if status is in use and no reassignment provided:
|
||||
```json
|
||||
{
|
||||
"error": "Cannot delete status 'X' because it is currently in use by N task(s)",
|
||||
"status_id": "custom_abc123",
|
||||
"status_name": "In Review",
|
||||
"task_count": 5,
|
||||
"task_ids": [1, 2, 3, 4, 5]
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Task Reassignment
|
||||
- Supports optional `reassign_to_status_id` query parameter
|
||||
- Validates reassignment target exists (can be system or custom status)
|
||||
- Prevents reassigning to the same status being deleted
|
||||
- Automatically updates all affected tasks to the new status
|
||||
- Includes reassignment count in success message
|
||||
|
||||
### 3. Default Status Management
|
||||
- Detects if the status being deleted is the default status
|
||||
- Automatically assigns the first remaining custom status as the new default
|
||||
- Ensures there's always a default status after deletion
|
||||
|
||||
### 4. Last Status Protection
|
||||
- Prevents deletion of the last custom status
|
||||
- Returns 422 error with clear message
|
||||
- Ensures at least one custom status always remains
|
||||
|
||||
### 5. Error Handling
|
||||
- 404: Status not found
|
||||
- 404: Project not found
|
||||
- 400: Invalid reassignment status ID
|
||||
- 422: Status in use without reassignment
|
||||
- 422: Attempting to delete last status
|
||||
- 500: Database operation failure
|
||||
|
||||
## Code Structure
|
||||
|
||||
```python
|
||||
@router.delete("/{project_id}/task-statuses/{status_id}")
|
||||
async def delete_custom_task_status(
|
||||
project_id: int,
|
||||
status_id: str,
|
||||
reassign_to_status_id: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
# 1. Verify project exists
|
||||
# 2. Load custom statuses from JSON
|
||||
# 3. Find status to delete
|
||||
# 4. Prevent deletion of last status
|
||||
# 5. Check if status is in use
|
||||
# 6. Handle reassignment if needed
|
||||
# 7. Auto-assign new default if needed
|
||||
# 8. Update database with flag_modified
|
||||
# 9. Return success response with all statuses
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
Comprehensive test suite in `test_delete_custom_task_status.py`:
|
||||
|
||||
1. ✅ Delete unused custom status
|
||||
2. ✅ Delete status in use without reassignment (error)
|
||||
3. ✅ Delete status in use with reassignment
|
||||
4. ✅ Delete default status (auto-assign new default)
|
||||
5. ✅ Prevent deletion of last status
|
||||
6. ✅ Delete non-existent status (error)
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
✅ **3.1**: Check if status is in use by any tasks
|
||||
✅ **3.2**: Return error with task count and IDs if in use
|
||||
✅ **3.3**: Support optional reassignment of tasks to another status
|
||||
✅ **3.4**: Auto-assign new default if deleting default status
|
||||
✅ **3.5**: Prevent deletion of last status
|
||||
|
||||
## Database Considerations
|
||||
|
||||
- Uses `flag_modified()` for JSON column updates (required for SQLAlchemy to detect changes)
|
||||
- Transactional: All changes (status deletion + task reassignments) happen in one transaction
|
||||
- Rollback on any error to maintain data consistency
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Delete unused status
|
||||
```bash
|
||||
DELETE /projects/1/task-statuses/custom_abc123
|
||||
```
|
||||
|
||||
### Delete status with reassignment
|
||||
```bash
|
||||
DELETE /projects/1/task-statuses/custom_abc123?reassign_to_status_id=not_started
|
||||
```
|
||||
|
||||
### Delete status with reassignment to another custom status
|
||||
```bash
|
||||
DELETE /projects/1/task-statuses/custom_abc123?reassign_to_status_id=custom_xyz789
|
||||
```
|
||||
|
||||
## Integration Notes
|
||||
|
||||
- Works seamlessly with existing custom status endpoints (GET, POST, PUT)
|
||||
- Maintains consistency with system statuses (cannot delete system statuses)
|
||||
- Properly updates the AllTaskStatusesResponse to reflect changes
|
||||
- Frontend can use the returned `all_statuses` to update UI immediately
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future iterations:
|
||||
- Bulk delete with single reassignment target
|
||||
- Soft delete with archive functionality
|
||||
- Status usage analytics before deletion
|
||||
- Undo/restore deleted statuses
|
||||
@@ -0,0 +1,194 @@
|
||||
# Custom Task Status GET Endpoint Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
Implemented the GET endpoint for retrieving all task statuses (system + custom) for a project as part of the custom task status management feature.
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### GET /projects/{project_id}/task-statuses
|
||||
|
||||
**Description**: Retrieves all task statuses (both system and custom) for a specific project.
|
||||
|
||||
**Authentication**: Required (JWT Bearer token)
|
||||
|
||||
**Authorization**:
|
||||
- Artists: Can only access projects they are members of
|
||||
- Coordinators/Directors/Developers/Admins: Can access all projects
|
||||
|
||||
**Path Parameters**:
|
||||
- `project_id` (int): The ID of the project
|
||||
|
||||
**Response Schema**: `AllTaskStatusesResponse`
|
||||
```json
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"id": "custom_review",
|
||||
"name": "In Review",
|
||||
"color": "#8B5CF6",
|
||||
"order": 0,
|
||||
"is_default": false
|
||||
}
|
||||
],
|
||||
"system_statuses": [
|
||||
{
|
||||
"id": "not_started",
|
||||
"name": "Not Started",
|
||||
"color": "#6B7280",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "in_progress",
|
||||
"name": "In Progress",
|
||||
"color": "#3B82F6",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "submitted",
|
||||
"name": "Submitted",
|
||||
"color": "#F59E0B",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "approved",
|
||||
"name": "Approved",
|
||||
"color": "#10B981",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "retake",
|
||||
"name": "Retake",
|
||||
"color": "#EF4444",
|
||||
"is_system": true
|
||||
}
|
||||
],
|
||||
"default_status_id": "not_started"
|
||||
}
|
||||
```
|
||||
|
||||
**Status Codes**:
|
||||
- `200 OK`: Successfully retrieved task statuses
|
||||
- `403 Forbidden`: User does not have access to the project
|
||||
- `404 Not Found`: Project does not exist
|
||||
|
||||
## System Task Statuses
|
||||
|
||||
The following system statuses are always available:
|
||||
|
||||
| ID | Name | Color | Description |
|
||||
|----|------|-------|-------------|
|
||||
| not_started | Not Started | #6B7280 | Task has not been started |
|
||||
| in_progress | In Progress | #3B82F6 | Task is currently being worked on |
|
||||
| submitted | Submitted | #F59E0B | Work has been submitted for review |
|
||||
| approved | Approved | #10B981 | Work has been approved |
|
||||
| retake | Retake | #EF4444 | Work needs to be redone |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Location
|
||||
- File: `backend/routers/projects.py`
|
||||
- Function: `get_all_task_statuses()`
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Project Validation**: Verifies the project exists before returning statuses
|
||||
2. **Access Control**: Enforces role-based access control (artists can only access projects they're members of)
|
||||
3. **System Statuses**: Always returns the 5 built-in system statuses
|
||||
4. **Custom Statuses**: Returns project-specific custom statuses if defined
|
||||
5. **Default Status**: Identifies which status is the default for new tasks
|
||||
6. **JSON Handling**: Properly handles both JSON string and dict formats for custom_task_statuses field
|
||||
|
||||
### Database Schema
|
||||
|
||||
Custom statuses are stored in the `projects` table:
|
||||
- Column: `custom_task_statuses` (JSON)
|
||||
- Format: Array of status objects with id, name, color, order, and is_default fields
|
||||
|
||||
### Access Control Logic
|
||||
|
||||
```python
|
||||
# Artists can only access projects they're members of
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Files Created
|
||||
|
||||
1. **test_task_statuses.py**: Basic functionality test
|
||||
- Tests retrieval of system statuses
|
||||
- Validates response structure
|
||||
- Verifies all system statuses are present
|
||||
|
||||
2. **test_task_statuses_with_custom.py**: Custom status test
|
||||
- Creates custom statuses in database
|
||||
- Tests retrieval of both system and custom statuses
|
||||
- Validates default status identification
|
||||
|
||||
3. **test_task_statuses_access.py**: Access control test
|
||||
- Tests artist access control (member vs non-member)
|
||||
- Tests coordinator access to all projects
|
||||
|
||||
4. **test_task_statuses_errors.py**: Error handling test
|
||||
- Tests 404 for non-existent projects
|
||||
- Tests 401/403 for unauthorized access
|
||||
|
||||
### Test Results
|
||||
|
||||
All tests passed successfully:
|
||||
- ✅ System statuses correctly returned
|
||||
- ✅ Custom statuses correctly returned
|
||||
- ✅ Default status correctly identified
|
||||
- ✅ Access control working for artists
|
||||
- ✅ Coordinators can access all projects
|
||||
- ✅ 404 returned for non-existent projects
|
||||
- ✅ Unauthorized access properly blocked
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
This implementation satisfies the following requirements:
|
||||
|
||||
- **Requirement 1.1**: ✅ Displays task status information for project settings
|
||||
- **Requirement 9.2**: ✅ Only shows statuses from the task's project (project-specific)
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Login
|
||||
response = requests.post(
|
||||
"http://localhost:8000/auth/login",
|
||||
json={"email": "user@example.com", "password": "password"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Get task statuses for project
|
||||
response = requests.get(
|
||||
"http://localhost:8000/projects/1/task-statuses",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
statuses = response.json()
|
||||
print(f"System statuses: {len(statuses['system_statuses'])}")
|
||||
print(f"Custom statuses: {len(statuses['statuses'])}")
|
||||
print(f"Default status: {statuses['default_status_id']}")
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
The following endpoints still need to be implemented:
|
||||
- POST /projects/{project_id}/task-statuses - Create custom status
|
||||
- PUT /projects/{project_id}/task-statuses/{status_id} - Update custom status
|
||||
- DELETE /projects/{project_id}/task-statuses/{status_id} - Delete custom status
|
||||
- PATCH /projects/{project_id}/task-statuses/reorder - Reorder statuses
|
||||
@@ -0,0 +1,115 @@
|
||||
# Custom Task Status Migration - Task 1 Implementation Summary
|
||||
|
||||
## Overview
|
||||
Successfully implemented database schema changes and migration to support custom task statuses in the VFX Project Management System.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Database Schema Updates
|
||||
|
||||
#### Project Model (`backend/models/project.py`)
|
||||
- ✅ Added `custom_task_statuses` JSON column to store project-specific custom statuses
|
||||
- Column initialized with empty array `[]` for all existing projects
|
||||
|
||||
#### Task Model (`backend/models/task.py`)
|
||||
- ✅ Changed `status` field from `Enum(TaskStatus)` to `String`
|
||||
- Updated default value from `TaskStatus.NOT_STARTED` to `"not_started"`
|
||||
- Maintains backward compatibility with existing system statuses
|
||||
|
||||
### 2. Schema Updates (`backend/schemas/task.py`)
|
||||
- ✅ Updated `TaskBase.status` from `TaskStatus` enum to `str` type
|
||||
- ✅ Updated `TaskUpdate.status` from `Optional[TaskStatus]` to `Optional[str]`
|
||||
- ✅ Updated `TaskStatusUpdate.status` from `TaskStatus` to `str`
|
||||
- Changed default value to `"not_started"` (lowercase string)
|
||||
|
||||
### 3. Router Updates
|
||||
Updated all routers to use string values instead of TaskStatus enum:
|
||||
|
||||
#### Assets Router (`backend/routers/assets.py`)
|
||||
- ✅ Changed task creation to use `status="not_started"`
|
||||
- ✅ Updated status initialization in task status aggregation
|
||||
- ✅ Updated status filtering to use string comparison
|
||||
- ✅ Updated status sorting to use string-based status order
|
||||
|
||||
#### Reviews Router (`backend/routers/reviews.py`)
|
||||
- ✅ Updated submission status checks to use `"submitted"` string
|
||||
- ✅ Changed approval status update to `"approved"` string
|
||||
- ✅ Changed retake status update to `"retake"` string
|
||||
|
||||
#### Shots Router (`backend/routers/shots.py`)
|
||||
- ✅ Changed task creation to use `status="not_started"`
|
||||
- ✅ Updated status initialization in task status aggregation
|
||||
- ✅ Updated status filtering to use string comparison
|
||||
- ✅ Updated status sorting to use string-based status order
|
||||
|
||||
### 4. Migration Script (`backend/migrate_custom_task_statuses.py`)
|
||||
Created comprehensive migration script that:
|
||||
- ✅ Adds `custom_task_statuses` column to projects table
|
||||
- ✅ Initializes column with empty array `[]` for existing projects
|
||||
- ✅ Converts existing uppercase enum values to lowercase strings:
|
||||
- `NOT_STARTED` → `not_started`
|
||||
- `IN_PROGRESS` → `in_progress`
|
||||
- `SUBMITTED` → `submitted`
|
||||
- `APPROVED` → `approved`
|
||||
- `RETAKE` → `retake`
|
||||
- ✅ Provides detailed logging of conversion process
|
||||
- ✅ Includes verification steps
|
||||
|
||||
### 5. Test Script (`backend/test_custom_task_status_migration.py`)
|
||||
Created verification test that confirms:
|
||||
- ✅ `custom_task_statuses` column exists in projects table
|
||||
- ✅ Task status column supports string values
|
||||
- ✅ All existing task statuses are valid lowercase strings
|
||||
- ✅ `custom_task_statuses` is initialized as empty array
|
||||
- ✅ Displays task status distribution
|
||||
|
||||
## Migration Results
|
||||
|
||||
### Database Changes
|
||||
```
|
||||
Projects Table:
|
||||
- Added column: custom_task_statuses (TEXT/JSON)
|
||||
|
||||
Tasks Table:
|
||||
- Status column: VARCHAR(11) (already TEXT, no change needed)
|
||||
```
|
||||
|
||||
### Data Conversion
|
||||
Successfully converted 105 tasks:
|
||||
- 91 tasks: `NOT_STARTED` → `not_started`
|
||||
- 9 tasks: `IN_PROGRESS` → `in_progress`
|
||||
- 4 tasks: `RETAKE` → `retake`
|
||||
- 1 task: `APPROVED` → `approved`
|
||||
|
||||
## System Status Values
|
||||
The following system statuses remain available:
|
||||
- `not_started` - Task has not been started
|
||||
- `in_progress` - Task is currently being worked on
|
||||
- `submitted` - Task work has been submitted for review
|
||||
- `approved` - Task has been approved
|
||||
- `retake` - Task requires revisions
|
||||
|
||||
## Backward Compatibility
|
||||
- ✅ All existing tasks continue to work with lowercase string statuses
|
||||
- ✅ System statuses are always available across all projects
|
||||
- ✅ No breaking changes to existing API endpoints
|
||||
- ✅ Frontend can continue using existing status values
|
||||
|
||||
## Next Steps
|
||||
With the database schema and migration complete, the system is ready for:
|
||||
1. Backend API endpoints for custom status CRUD operations (Task 2-8)
|
||||
2. Frontend components for custom status management (Task 10-21)
|
||||
3. Integration with task creation and status update workflows
|
||||
|
||||
## Testing
|
||||
All tests passed successfully:
|
||||
- ✅ Backend imports without errors
|
||||
- ✅ Database schema verification
|
||||
- ✅ Data conversion verification
|
||||
- ✅ Status value validation
|
||||
|
||||
## Requirements Validated
|
||||
- ✅ Requirement 6.1: System statuses remain available
|
||||
- ✅ Requirement 6.2: Backward compatibility maintained
|
||||
- ✅ Requirement 6.3: Existing tasks continue to work
|
||||
- ✅ Requirement 9.1: Database schema supports custom statuses
|
||||
@@ -0,0 +1,381 @@
|
||||
# Custom Task Status Reorder Endpoint
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the PATCH endpoint for reordering custom task statuses within a project. The endpoint allows coordinators and administrators to change the display order of custom task statuses.
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
PATCH /projects/{project_id}/task-statuses/reorder
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
Requires JWT authentication with coordinator or admin role.
|
||||
|
||||
## Request
|
||||
|
||||
### Path Parameters
|
||||
|
||||
- `project_id` (integer, required): The ID of the project
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"status_ids": ["custom_abc123", "custom_def456", "custom_ghi789"]
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `status_ids` (array of strings, required): Ordered list of status IDs in the desired sequence
|
||||
- Must contain all existing custom status IDs for the project
|
||||
- Cannot contain duplicates
|
||||
- Cannot be empty
|
||||
|
||||
## Response
|
||||
|
||||
### Success Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Custom task statuses reordered successfully",
|
||||
"status": null,
|
||||
"all_statuses": {
|
||||
"statuses": [
|
||||
{
|
||||
"id": "custom_abc123",
|
||||
"name": "Review",
|
||||
"color": "#9333EA",
|
||||
"order": 0,
|
||||
"is_default": false
|
||||
},
|
||||
{
|
||||
"id": "custom_def456",
|
||||
"name": "Blocked",
|
||||
"color": "#DC2626",
|
||||
"order": 1,
|
||||
"is_default": true
|
||||
},
|
||||
{
|
||||
"id": "custom_ghi789",
|
||||
"name": "Ready for Delivery",
|
||||
"color": "#059669",
|
||||
"order": 2,
|
||||
"is_default": false
|
||||
}
|
||||
],
|
||||
"system_statuses": [
|
||||
{
|
||||
"id": "not_started",
|
||||
"name": "Not Started",
|
||||
"color": "#6B7280",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "in_progress",
|
||||
"name": "In Progress",
|
||||
"color": "#3B82F6",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "submitted",
|
||||
"name": "Submitted",
|
||||
"color": "#F59E0B",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "approved",
|
||||
"name": "Approved",
|
||||
"color": "#10B981",
|
||||
"is_system": true
|
||||
},
|
||||
{
|
||||
"id": "retake",
|
||||
"name": "Retake",
|
||||
"color": "#EF4444",
|
||||
"is_system": true
|
||||
}
|
||||
],
|
||||
"default_status_id": "custom_def456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Responses
|
||||
|
||||
#### 400 Bad Request - Missing Status IDs
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Missing status IDs in reorder request: custom_xyz999"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the request doesn't include all existing custom status IDs.
|
||||
|
||||
#### 400 Bad Request - Invalid Status IDs
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Status IDs not found: invalid_id_12345"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the request includes status IDs that don't exist in the project.
|
||||
|
||||
#### 403 Forbidden
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Insufficient permissions"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the user doesn't have coordinator or admin role.
|
||||
|
||||
#### 404 Not Found
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Project not found"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the specified project doesn't exist.
|
||||
|
||||
#### 422 Unprocessable Entity - Duplicate IDs
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "1 validation error for CustomTaskStatusReorder\nstatus_ids\n Value error, Status IDs list contains duplicates"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the request contains duplicate status IDs.
|
||||
|
||||
#### 422 Unprocessable Entity - Empty List
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "1 validation error for CustomTaskStatusReorder\nstatus_ids\n Value error, Status IDs list cannot be empty"
|
||||
}
|
||||
```
|
||||
|
||||
Occurs when the request contains an empty status_ids array.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Validation
|
||||
|
||||
1. **Project Existence**: Verifies the project exists
|
||||
2. **Permission Check**: Ensures user has coordinator or admin role
|
||||
3. **Complete List**: Validates that all existing custom status IDs are included
|
||||
4. **No Missing IDs**: Ensures no status IDs are omitted
|
||||
5. **No Invalid IDs**: Ensures all provided IDs exist in the project
|
||||
6. **No Duplicates**: Validates the list contains no duplicate IDs (handled by Pydantic schema)
|
||||
|
||||
### Order Update Process
|
||||
|
||||
1. Parse and validate the reorder request
|
||||
2. Retrieve existing custom statuses from the project
|
||||
3. Create a mapping of status_id to status data
|
||||
4. Reorder statuses according to the provided list
|
||||
5. Update the `order` field for each status (0-indexed)
|
||||
6. Save the reordered list to the database
|
||||
7. Use `flag_modified()` to ensure JSON column changes are persisted
|
||||
|
||||
### Database Changes
|
||||
|
||||
- Updates the `custom_task_statuses` JSON column in the `projects` table
|
||||
- Each status object's `order` field is updated to match its position in the new list
|
||||
- Uses SQLAlchemy's `flag_modified()` to ensure JSON column changes are detected
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Python (requests)
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Login and get token
|
||||
response = requests.post(
|
||||
"http://localhost:8000/auth/login",
|
||||
json={"email": "admin@vfx.com", "password": "admin123"}
|
||||
)
|
||||
token = response.json()["access_token"]
|
||||
|
||||
# Reorder statuses
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
data = {
|
||||
"status_ids": [
|
||||
"custom_abc123",
|
||||
"custom_def456",
|
||||
"custom_ghi789"
|
||||
]
|
||||
}
|
||||
|
||||
response = requests.patch(
|
||||
"http://localhost:8000/projects/1/task-statuses/reorder",
|
||||
headers=headers,
|
||||
json=data
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
print(f"✅ {result['message']}")
|
||||
print(f"Reordered {len(result['all_statuses']['statuses'])} statuses")
|
||||
else:
|
||||
print(f"❌ Error: {response.json()['detail']}")
|
||||
```
|
||||
|
||||
### JavaScript (fetch)
|
||||
|
||||
```javascript
|
||||
// Assuming you have a token from login
|
||||
const token = "your_jwt_token_here";
|
||||
|
||||
const reorderStatuses = async (projectId, statusIds) => {
|
||||
const response = await fetch(
|
||||
`http://localhost:8000/projects/${projectId}/task-statuses/reorder`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status_ids: statusIds
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('✅', result.message);
|
||||
return result.all_statuses;
|
||||
} else {
|
||||
const error = await response.json();
|
||||
console.error('❌ Error:', error.detail);
|
||||
throw new Error(error.detail);
|
||||
}
|
||||
};
|
||||
|
||||
// Usage
|
||||
const statusIds = [
|
||||
'custom_abc123',
|
||||
'custom_def456',
|
||||
'custom_ghi789'
|
||||
];
|
||||
|
||||
reorderStatuses(1, statusIds)
|
||||
.then(allStatuses => {
|
||||
console.log('New order:', allStatuses.statuses);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to reorder:', error);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
### Drag-and-Drop Implementation
|
||||
|
||||
The frontend should implement drag-and-drop functionality using a library like `vue-draggable-next`:
|
||||
|
||||
1. Display statuses in their current order
|
||||
2. Allow users to drag statuses to reorder them
|
||||
3. On drop, collect the new order of status IDs
|
||||
4. Call the reorder endpoint with the new order
|
||||
5. Update the UI optimistically or wait for the response
|
||||
6. Handle errors by reverting to the previous order
|
||||
|
||||
### Example Vue Component
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<draggable
|
||||
v-model="statuses"
|
||||
@end="handleReorder"
|
||||
item-key="id"
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div class="status-item">
|
||||
<span class="drag-handle">⋮⋮</span>
|
||||
<span :style="{ color: element.color }">
|
||||
{{ element.name }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</draggable>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import { reorderTaskStatuses } from '@/services/customTaskStatus';
|
||||
|
||||
const props = defineProps({
|
||||
projectId: Number,
|
||||
initialStatuses: Array
|
||||
});
|
||||
|
||||
const statuses = ref([...props.initialStatuses]);
|
||||
|
||||
const handleReorder = async () => {
|
||||
const statusIds = statuses.value.map(s => s.id);
|
||||
|
||||
try {
|
||||
const result = await reorderTaskStatuses(props.projectId, statusIds);
|
||||
// Update with server response
|
||||
statuses.value = result.all_statuses.statuses;
|
||||
} catch (error) {
|
||||
// Revert to original order on error
|
||||
statuses.value = [...props.initialStatuses];
|
||||
console.error('Failed to reorder:', error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
A comprehensive test script is available at `backend/test_reorder_custom_task_status.py` that tests:
|
||||
|
||||
1. ✅ Successful reordering (reversing order)
|
||||
2. ✅ Order field updates correctly
|
||||
3. ✅ Rejection of incomplete status lists
|
||||
4. ✅ Rejection of invalid status IDs
|
||||
5. ✅ Rejection of duplicate status IDs
|
||||
|
||||
Run the test with:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python test_reorder_custom_task_status.py
|
||||
```
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
This endpoint satisfies the following requirements from the custom task status specification:
|
||||
|
||||
- **Requirement 4.1**: ✅ Displays statuses in their defined order
|
||||
- **Requirement 4.2**: ✅ Updates the order when user reorders statuses
|
||||
- **Requirement 4.3**: ✅ Updates display order in all dropdowns and filters
|
||||
- **Requirement 4.4**: ✅ Validates all status IDs are present
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- `GET /projects/{project_id}/task-statuses` - Get all task statuses
|
||||
- `POST /projects/{project_id}/task-statuses` - Create a custom status
|
||||
- `PUT /projects/{project_id}/task-statuses/{status_id}` - Update a custom status
|
||||
- `DELETE /projects/{project_id}/task-statuses/{status_id}` - Delete a custom status
|
||||
|
||||
## Notes
|
||||
|
||||
- System statuses (not_started, in_progress, submitted, approved, retake) cannot be reordered
|
||||
- Only custom statuses can be reordered
|
||||
- The order field is 0-indexed
|
||||
- Reordering does not affect the default status designation
|
||||
- The endpoint uses `flag_modified()` to ensure JSON column changes are persisted to the database
|
||||
@@ -0,0 +1,260 @@
|
||||
# Custom Task Status Update Endpoint
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of the PUT endpoint for updating custom task statuses in a project.
|
||||
|
||||
**Endpoint:** `PUT /projects/{project_id}/task-statuses/{status_id}`
|
||||
|
||||
**Requirements Implemented:**
|
||||
- 2.1: Support updating name
|
||||
- 2.2: Support updating color
|
||||
- 2.3: Support updating is_default flag
|
||||
- 5.2: If setting as default, unset other default statuses
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Endpoint Signature
|
||||
|
||||
```python
|
||||
@router.put("/{project_id}/task-statuses/{status_id}")
|
||||
async def update_custom_task_status(
|
||||
project_id: int,
|
||||
status_id: str,
|
||||
status_update: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
```
|
||||
|
||||
### Request Body Schema
|
||||
|
||||
Uses `CustomTaskStatusUpdate` schema:
|
||||
|
||||
```python
|
||||
{
|
||||
"name": "string (optional)", # New status name (1-50 chars)
|
||||
"color": "string (optional)", # Hex color code (e.g., #FF5733)
|
||||
"is_default": "boolean (optional)" # Set as default status
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
|
||||
Returns `CustomTaskStatusResponse`:
|
||||
|
||||
```python
|
||||
{
|
||||
"message": "string",
|
||||
"status": {
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"color": "string",
|
||||
"order": "integer",
|
||||
"is_default": "boolean"
|
||||
},
|
||||
"all_statuses": {
|
||||
"statuses": [...], # All custom statuses
|
||||
"system_statuses": [...], # System statuses
|
||||
"default_status_id": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Name Update (Requirement 2.1)
|
||||
|
||||
- Validates name uniqueness within project
|
||||
- Checks against other custom statuses
|
||||
- Checks against system status names
|
||||
- Returns 409 Conflict if name already exists
|
||||
|
||||
```python
|
||||
# Validate name uniqueness if name is being changed
|
||||
if status_update.name is not None and status_update.name != status_to_update.get('name'):
|
||||
# Check against other custom statuses
|
||||
existing_names = [
|
||||
s.get('name', '').lower()
|
||||
for i, s in enumerate(custom_statuses_data)
|
||||
if isinstance(s, dict) and i != status_index
|
||||
]
|
||||
|
||||
# Check against system statuses
|
||||
system_names = [s['name'].lower() for s in SYSTEM_TASK_STATUSES]
|
||||
|
||||
if status_update.name.lower() in existing_names:
|
||||
raise HTTPException(status_code=409, detail="Name already exists")
|
||||
|
||||
if status_update.name.lower() in system_names:
|
||||
raise HTTPException(status_code=409, detail="Conflicts with system status")
|
||||
```
|
||||
|
||||
### 2. Color Update (Requirement 2.2)
|
||||
|
||||
- Accepts hex color codes (e.g., #FF5733)
|
||||
- Validates color format via schema
|
||||
- Updates color independently of other fields
|
||||
|
||||
```python
|
||||
# Update color if provided
|
||||
if status_update.color is not None:
|
||||
status_to_update['color'] = status_update.color
|
||||
```
|
||||
|
||||
### 3. Default Status Management (Requirement 2.3, 5.2)
|
||||
|
||||
- When setting a status as default, automatically unsets all other defaults
|
||||
- Ensures only one default status exists at a time
|
||||
- Allows unsetting default status
|
||||
|
||||
```python
|
||||
# Handle is_default flag
|
||||
if status_update.is_default is not None:
|
||||
if status_update.is_default:
|
||||
# If setting as default, unset other default statuses
|
||||
for status_data in custom_statuses_data:
|
||||
if isinstance(status_data, dict):
|
||||
status_data['is_default'] = False
|
||||
|
||||
# Set this status as default
|
||||
status_to_update['is_default'] = True
|
||||
else:
|
||||
# Just unset this status as default
|
||||
status_to_update['is_default'] = False
|
||||
```
|
||||
|
||||
### 4. JSON Column Updates
|
||||
|
||||
Uses `flag_modified` to ensure SQLAlchemy detects changes to JSON columns:
|
||||
|
||||
```python
|
||||
# Update the status in the list
|
||||
custom_statuses_data[status_index] = status_to_update
|
||||
db_project.custom_task_statuses = custom_statuses_data
|
||||
|
||||
# Use flag_modified for JSON column updates
|
||||
flag_modified(db_project, 'custom_task_statuses')
|
||||
|
||||
db.commit()
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### 404 Not Found
|
||||
- Project doesn't exist
|
||||
- Status ID not found in project
|
||||
|
||||
### 409 Conflict
|
||||
- Status name already exists in project
|
||||
- Status name conflicts with system status
|
||||
|
||||
### 403 Forbidden
|
||||
- User is not coordinator or admin
|
||||
|
||||
### 422 Unprocessable Entity
|
||||
- Invalid request body format
|
||||
- Invalid color format
|
||||
- Invalid name length
|
||||
|
||||
## Example Usage
|
||||
|
||||
### Update Status Name
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "New Status Name"}'
|
||||
```
|
||||
|
||||
### Update Status Color
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"color": "#00FF00"}'
|
||||
```
|
||||
|
||||
### Update Both Name and Color
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Updated Status", "color": "#0000FF"}'
|
||||
```
|
||||
|
||||
### Set as Default Status
|
||||
|
||||
```bash
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_default": true}'
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
To test this endpoint:
|
||||
|
||||
1. Start the backend server:
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
2. Create a custom status first (if needed):
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/projects/1/task-statuses" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Test Status", "color": "#FF5733"}'
|
||||
```
|
||||
|
||||
3. Update the status using the examples above
|
||||
|
||||
4. Verify changes by getting all statuses:
|
||||
```bash
|
||||
curl -X GET "http://localhost:8000/projects/1/task-statuses" \
|
||||
-H "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
## Integration with Frontend
|
||||
|
||||
The frontend can use this endpoint to:
|
||||
|
||||
1. Update status names when users edit them
|
||||
2. Change status colors via color picker
|
||||
3. Set/unset default status via toggle or button
|
||||
4. Update multiple fields at once
|
||||
|
||||
The response includes the updated status and all statuses, allowing the frontend to update its state in a single request.
|
||||
|
||||
## Database Schema
|
||||
|
||||
The custom task statuses are stored in the `projects` table as a JSON column:
|
||||
|
||||
```sql
|
||||
custom_task_statuses JSON -- Array of status objects
|
||||
```
|
||||
|
||||
Each status object has the structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "custom_abc123",
|
||||
"name": "Status Name",
|
||||
"color": "#FF5733",
|
||||
"order": 0,
|
||||
"is_default": false
|
||||
}
|
||||
```
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- `GET /projects/{project_id}/task-statuses` - Get all statuses
|
||||
- `POST /projects/{project_id}/task-statuses` - Create new status
|
||||
- `DELETE /projects/{project_id}/task-statuses/{status_id}` - Delete status (to be implemented)
|
||||
- `PATCH /projects/{project_id}/task-statuses/reorder` - Reorder statuses (to be implemented)
|
||||
@@ -0,0 +1,231 @@
|
||||
# Data Consistency and Real-time Updates Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of data consistency checks and real-time update propagation for the shot-asset-task-status-optimization feature. The implementation ensures that individual task updates remain consistent with aggregated views and provides real-time update propagation mechanisms.
|
||||
|
||||
## Requirements Addressed
|
||||
|
||||
This implementation addresses the following requirements from the specification:
|
||||
|
||||
- **Requirement 3.3**: Data consistency between individual task updates and aggregated views
|
||||
- **Requirement 4.5**: Real-time update propagation to aggregated data
|
||||
- **Task 14**: Data Consistency and Real-time Updates
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **DataConsistencyService** (`backend/services/data_consistency.py`)
|
||||
- Main service for validating consistency between individual tasks and aggregated data
|
||||
- Provides bulk validation and reporting capabilities
|
||||
- Handles real-time update propagation
|
||||
|
||||
2. **Data Consistency API** (`backend/routers/data_consistency.py`)
|
||||
- REST API endpoints for consistency validation and monitoring
|
||||
- Health check and reporting endpoints
|
||||
- Administrative tools for consistency management
|
||||
|
||||
3. **Task Update Hooks** (integrated into `backend/routers/tasks.py`)
|
||||
- Automatic consistency validation on task status updates
|
||||
- Propagation logging and error handling
|
||||
- Integration with existing task update workflows
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Data Consistency Validation
|
||||
|
||||
The system validates consistency by:
|
||||
|
||||
1. **Fetching Individual Task Records**: Queries all active tasks for a shot or asset
|
||||
2. **Building Expected Aggregated Data**: Constructs the expected task_status and task_details from individual tasks
|
||||
3. **Fetching Actual Aggregated Data**: Uses the optimized queries to get current aggregated data
|
||||
4. **Comparing Results**: Identifies inconsistencies between expected and actual data
|
||||
|
||||
#### Validation Process
|
||||
|
||||
```python
|
||||
def validate_task_aggregation_consistency(self, entity_id: int, entity_type: str) -> Dict[str, Any]:
|
||||
# Get individual task records
|
||||
tasks = self.db.query(Task).filter(conditions).all()
|
||||
|
||||
# Build expected aggregated data
|
||||
expected_task_status = {}
|
||||
expected_task_details = []
|
||||
|
||||
# Get actual aggregated data using optimized queries
|
||||
aggregated_data = self._get_shot_aggregated_data(entity_id) # or asset
|
||||
|
||||
# Compare and identify inconsistencies
|
||||
inconsistencies = []
|
||||
# ... comparison logic
|
||||
|
||||
return {
|
||||
'valid': len(inconsistencies) == 0,
|
||||
'inconsistencies': inconsistencies,
|
||||
# ... additional metadata
|
||||
}
|
||||
```
|
||||
|
||||
### Real-time Update Propagation
|
||||
|
||||
The system ensures real-time consistency through:
|
||||
|
||||
1. **Task Update Hooks**: Automatically triggered on task status changes
|
||||
2. **Consistency Validation**: Validates aggregated data after each update
|
||||
3. **Propagation Logging**: Records all update propagations for monitoring
|
||||
4. **Error Handling**: Logs inconsistencies without failing user operations
|
||||
|
||||
#### Update Propagation Flow
|
||||
|
||||
```python
|
||||
def propagate_task_update(self, task_id: int, old_status: str, new_status: str) -> Dict[str, Any]:
|
||||
# Get task and determine parent entity
|
||||
task = self.db.query(Task).filter(Task.id == task_id).first()
|
||||
|
||||
# Validate consistency after update
|
||||
validation_result = self.validate_task_aggregation_consistency(entity_id, entity_type)
|
||||
|
||||
# Log propagation results
|
||||
propagation_log = {
|
||||
'task_id': task_id,
|
||||
'entity_type': entity_type,
|
||||
'entity_id': entity_id,
|
||||
'old_status': old_status,
|
||||
'new_status': new_status,
|
||||
'consistency_valid': validation_result['valid'],
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
|
||||
return propagation_log
|
||||
```
|
||||
|
||||
### Integration with Task Updates
|
||||
|
||||
The consistency system is integrated into existing task update endpoints:
|
||||
|
||||
1. **Individual Task Updates** (`PUT /tasks/{task_id}`)
|
||||
2. **Task Status Updates** (`PUT /tasks/{task_id}/status`)
|
||||
3. **Bulk Status Updates** (`PUT /tasks/bulk/status`)
|
||||
|
||||
Each endpoint now includes:
|
||||
- Pre-update status capture
|
||||
- Post-update consistency validation
|
||||
- Propagation logging
|
||||
- Error handling that doesn't disrupt user operations
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Data Consistency Endpoints
|
||||
|
||||
All endpoints are prefixed with `/data-consistency` and require admin or coordinator permissions.
|
||||
|
||||
#### Validation Endpoints
|
||||
|
||||
- `GET /data-consistency/validate/{entity_type}/{entity_id}`
|
||||
- Validate consistency for a specific shot or asset
|
||||
- Returns detailed validation results and any inconsistencies found
|
||||
|
||||
- `POST /data-consistency/validate/bulk`
|
||||
- Validate consistency for multiple entities at once
|
||||
- Supports up to 100 entities per request
|
||||
|
||||
#### Reporting Endpoints
|
||||
|
||||
- `GET /data-consistency/report?project_id={id}`
|
||||
- Generate comprehensive consistency report
|
||||
- Optional project filtering
|
||||
- Returns summary statistics and detailed results
|
||||
|
||||
- `GET /data-consistency/health?project_id={id}`
|
||||
- Quick health check for data consistency
|
||||
- Returns overall system health status
|
||||
- Useful for monitoring and alerting
|
||||
|
||||
#### Management Endpoints
|
||||
|
||||
- `POST /data-consistency/propagate/{task_id}`
|
||||
- Manually trigger update propagation for a task
|
||||
- Useful for debugging and maintenance
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
The implementation includes comprehensive unit tests:
|
||||
|
||||
- **test_data_consistency.py**: Core functionality testing
|
||||
- Data consistency validation
|
||||
- Real-time update propagation
|
||||
- Consistency reporting
|
||||
- Bulk validation operations
|
||||
|
||||
### API Integration Tests
|
||||
|
||||
- **test_data_consistency_api.py**: API endpoint testing
|
||||
- Authentication and authorization
|
||||
- Endpoint functionality
|
||||
- Error handling
|
||||
- Response format validation
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run core functionality tests
|
||||
cd backend
|
||||
python test_data_consistency.py
|
||||
|
||||
# Run API integration tests (requires running server)
|
||||
python test_data_consistency_api.py
|
||||
```
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Consistency Health Monitoring
|
||||
|
||||
The system provides several monitoring capabilities:
|
||||
|
||||
1. **Health Check Endpoint**: Quick status overview
|
||||
2. **Detailed Reports**: Comprehensive consistency analysis
|
||||
3. **Propagation Logging**: Audit trail of all updates
|
||||
4. **Error Logging**: Automatic logging of consistency issues
|
||||
|
||||
### Maintenance Operations
|
||||
|
||||
1. **Bulk Validation**: Validate consistency across multiple entities
|
||||
2. **Manual Propagation**: Force update propagation for specific tasks
|
||||
3. **Consistency Reports**: Generate detailed analysis reports
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Consistency validation uses the same optimized queries as the main system
|
||||
- Bulk operations are limited to prevent performance impact
|
||||
- Validation is performed asynchronously to avoid blocking user operations
|
||||
- Logging is designed to be lightweight and non-intrusive
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system is designed to be resilient:
|
||||
|
||||
1. **Non-blocking Operations**: Consistency issues don't prevent task updates
|
||||
2. **Graceful Degradation**: System continues to function even with consistency problems
|
||||
3. **Comprehensive Logging**: All issues are logged for investigation
|
||||
4. **Recovery Mechanisms**: Manual tools available for fixing inconsistencies
|
||||
|
||||
## Configuration
|
||||
|
||||
The data consistency system requires no additional configuration and integrates seamlessly with the existing system. All settings use the same database connection and authentication mechanisms as the main application.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Automated Repair**: Automatic fixing of detected inconsistencies
|
||||
2. **Real-time Notifications**: Alert administrators of consistency issues
|
||||
3. **Performance Metrics**: Detailed performance monitoring and optimization
|
||||
4. **Batch Processing**: Scheduled consistency validation jobs
|
||||
5. **Custom Validation Rules**: Project-specific consistency requirements
|
||||
|
||||
## Conclusion
|
||||
|
||||
The data consistency implementation provides robust validation and monitoring capabilities while maintaining system performance and reliability. It ensures that the optimized query system continues to provide accurate data while offering tools for monitoring and maintaining data integrity over time.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Default Asset Task Creation Implementation
|
||||
|
||||
## Overview
|
||||
This document describes the implementation of automatic default task creation for assets based on their category (Task 5.3).
|
||||
|
||||
## Requirements Implemented
|
||||
- **17.1**: Automatic task generation when assets are created based on asset category
|
||||
- **17.2**: Default task templates for each asset category (modeling, surfacing, rigging)
|
||||
- **17.3**: Customizable task creation options for coordinators
|
||||
- **17.4**: Default task naming conventions
|
||||
- **17.5**: API endpoint for retrieving default tasks by asset category
|
||||
- **17.6**: Proper task naming
|
||||
- **17.7**: Unassigned task creation
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Default Task Templates
|
||||
Located in `backend/routers/assets.py`:
|
||||
|
||||
```python
|
||||
DEFAULT_ASSET_TASKS = {
|
||||
AssetCategory.CHARACTERS: ["modeling", "surfacing", "rigging"],
|
||||
AssetCategory.PROPS: ["modeling", "surfacing"],
|
||||
AssetCategory.SETS: ["modeling", "surfacing"],
|
||||
AssetCategory.VEHICLES: ["modeling", "surfacing", "rigging"]
|
||||
}
|
||||
```
|
||||
|
||||
### Key Functions
|
||||
|
||||
#### `get_default_asset_task_types(category: AssetCategory) -> List[str]`
|
||||
Returns the default task types for a given asset category.
|
||||
|
||||
#### `get_all_asset_task_types(project_id: int, db: Session) -> List[str]`
|
||||
Returns all task types (standard + custom) for assets in a project.
|
||||
|
||||
#### `create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]`
|
||||
Creates default tasks for an asset with proper naming conventions:
|
||||
- Task name format: `{asset_name} - {task_type.title()}`
|
||||
- Tasks are created with status `NOT_STARTED`
|
||||
- Tasks are left unassigned (assigned_user_id = None)
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### `GET /assets/default-tasks/{category}`
|
||||
Returns default task types for an asset category.
|
||||
|
||||
**Query Parameters:**
|
||||
- `project_id` (optional): Include custom task types for the project
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
["modeling", "surfacing", "rigging"]
|
||||
```
|
||||
|
||||
#### `POST /assets/?project_id={project_id}`
|
||||
Creates a new asset with optional default tasks.
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "Hero Character",
|
||||
"category": "characters",
|
||||
"description": "Main character asset",
|
||||
"status": "not_started",
|
||||
"create_default_tasks": true,
|
||||
"selected_task_types": ["modeling", "surfacing", "rigging"]
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `create_default_tasks` (boolean): Whether to create default tasks (default: true)
|
||||
- `selected_task_types` (array, optional): Specific task types to create. If not provided, uses category defaults.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Hero Character",
|
||||
"category": "characters",
|
||||
"status": "not_started",
|
||||
"project_id": 1,
|
||||
"task_count": 3,
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### AssetForm Component
|
||||
Located in `frontend/src/components/asset/AssetForm.vue`
|
||||
|
||||
**Features:**
|
||||
1. **Default Tasks Toggle**: Checkbox to enable/disable default task creation
|
||||
2. **Task Preview**: Shows which tasks will be created based on category
|
||||
3. **Custom Task Selection**: Allows coordinators to select specific tasks to create
|
||||
4. **Confirmation Dialog**: Shows preview of tasks before creation
|
||||
|
||||
**Key Sections:**
|
||||
```vue
|
||||
<!-- Default Tasks Section -->
|
||||
<div v-if="!isEdit" class="space-y-4 border-t pt-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="create-default-tasks"
|
||||
v-model:checked="formData.create_default_tasks"
|
||||
/>
|
||||
<Label>Create default tasks for this asset</Label>
|
||||
</div>
|
||||
|
||||
<!-- Task Selection -->
|
||||
<div v-if="formData.create_default_tasks && formData.category">
|
||||
<div v-for="taskType in defaultTasks" :key="taskType">
|
||||
<Checkbox
|
||||
:checked="selectedTaskTypes.includes(taskType)"
|
||||
@update:checked="toggleTaskType(taskType)"
|
||||
/>
|
||||
<Label>{{ formatTaskType(taskType) }}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Asset Service
|
||||
Located in `frontend/src/services/asset.ts`
|
||||
|
||||
**Methods:**
|
||||
```typescript
|
||||
async getDefaultTasksForCategory(
|
||||
category: AssetCategory,
|
||||
projectId?: number
|
||||
): Promise<string[]>
|
||||
|
||||
async createAsset(
|
||||
projectId: number,
|
||||
data: AssetCreate
|
||||
): Promise<Asset>
|
||||
```
|
||||
|
||||
## Task Naming Convention
|
||||
Tasks are automatically named using the format:
|
||||
```
|
||||
{Asset Name} - {Task Type}
|
||||
```
|
||||
|
||||
Examples:
|
||||
- "Hero Character - Modeling"
|
||||
- "Hero Character - Surfacing"
|
||||
- "Hero Character - Rigging"
|
||||
- "Sword Prop - Modeling"
|
||||
- "Sword Prop - Surfacing"
|
||||
|
||||
## Task Assignment
|
||||
All default tasks are created **unassigned** (assigned_user_id = null). Coordinators must manually assign tasks to artists after creation.
|
||||
|
||||
## Category-Specific Defaults
|
||||
|
||||
| Category | Default Tasks |
|
||||
|------------|-----------------------------------|
|
||||
| Characters | Modeling, Surfacing, Rigging |
|
||||
| Props | Modeling, Surfacing |
|
||||
| Sets | Modeling, Surfacing |
|
||||
| Vehicles | Modeling, Surfacing, Rigging |
|
||||
|
||||
## Custom Task Types
|
||||
The system supports custom task types per project. When a project has custom asset task types defined, they are included in the available task types for selection during asset creation.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Test
|
||||
Run `backend/test_default_asset_tasks.py` to verify:
|
||||
- Default task templates for each category
|
||||
- Asset creation with default tasks
|
||||
- Task naming conventions
|
||||
- Custom task selection
|
||||
- Unassigned task creation
|
||||
|
||||
### Frontend Test
|
||||
Open `frontend/test-default-asset-tasks.html` to test:
|
||||
- Default task retrieval
|
||||
- Asset creation with task selection
|
||||
- Task verification
|
||||
|
||||
## Usage Example
|
||||
|
||||
### Creating an Asset with Default Tasks
|
||||
1. Navigate to Assets section in a project
|
||||
2. Click "Create Asset"
|
||||
3. Fill in asset details (name, category, description)
|
||||
4. Ensure "Create default tasks" is checked
|
||||
5. Review the task preview
|
||||
6. Optionally customize which tasks to create
|
||||
7. Click "Create Asset"
|
||||
8. Confirm task creation in the dialog
|
||||
|
||||
### Creating an Asset without Default Tasks
|
||||
1. Follow steps 1-3 above
|
||||
2. Uncheck "Create default tasks"
|
||||
3. Click "Create Asset"
|
||||
4. Asset is created with no tasks
|
||||
|
||||
### Custom Task Selection
|
||||
1. Follow steps 1-4 above
|
||||
2. Uncheck specific tasks you don't want to create
|
||||
3. Click "Create Asset"
|
||||
4. Only selected tasks will be created
|
||||
|
||||
## Integration with Custom Task Types
|
||||
When custom task types are defined for a project (via Task 19), they are automatically included in the available task types for asset creation. The default task templates remain the same, but coordinators can select custom task types during asset creation.
|
||||
|
||||
## Error Handling
|
||||
- Validates that selected task types are valid (standard or custom)
|
||||
- Prevents duplicate asset names within a project
|
||||
- Returns appropriate error messages for invalid requests
|
||||
- Handles missing project or category gracefully
|
||||
|
||||
## Performance Considerations
|
||||
- Tasks are created in a single database transaction
|
||||
- Bulk task creation is efficient using SQLAlchemy's bulk operations
|
||||
- Task count is returned immediately after creation without additional queries
|
||||
@@ -0,0 +1,155 @@
|
||||
# FastAPI Trailing Slash Issue - 307 Redirect & 403 Forbidden
|
||||
|
||||
## Problem Description
|
||||
|
||||
When making authenticated API calls to FastAPI endpoints, a mismatch in trailing slashes between the frontend request and backend route definition causes a **307 Temporary Redirect** that **loses the authentication header**, resulting in a **403 Forbidden** error.
|
||||
|
||||
## Root Cause
|
||||
|
||||
FastAPI automatically redirects requests to add or remove trailing slashes to match the route definition:
|
||||
- If route is defined as `@router.get("/tasks/")` (with slash) and you call `/tasks` (without slash) → 307 redirect to `/tasks/`
|
||||
- If route is defined as `@router.get("/tasks")` (without slash) and you call `/tasks/` (with slash) → 307 redirect to `/tasks`
|
||||
|
||||
**The problem:** HTTP redirects (307) do NOT preserve the `Authorization` header by default, so the redirected request arrives without authentication, causing a 403 Forbidden error.
|
||||
|
||||
## Symptoms
|
||||
|
||||
### Backend Logs
|
||||
```
|
||||
INFO: 127.0.0.1:59653 - "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: 127.0.0.1:59615 - "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden
|
||||
```
|
||||
|
||||
### Frontend Console
|
||||
```
|
||||
GET http://localhost:8000/tasks/?shot_id=12 403 (Forbidden)
|
||||
AxiosError {message: 'Request failed with status code 403', ...}
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
**Always ensure trailing slashes match between frontend API calls and backend route definitions.**
|
||||
|
||||
### Option 1: Add Trailing Slash to Frontend (Recommended)
|
||||
|
||||
**Frontend Service:**
|
||||
```typescript
|
||||
// ❌ WRONG - No trailing slash
|
||||
const response = await apiClient.get(`/tasks?${params}`)
|
||||
|
||||
// ✅ CORRECT - With trailing slash
|
||||
const response = await apiClient.get(`/tasks/?${params}`)
|
||||
```
|
||||
|
||||
**Backend Route:**
|
||||
```python
|
||||
# Route defined WITH trailing slash
|
||||
@router.get("/tasks/")
|
||||
async def get_tasks(...):
|
||||
...
|
||||
```
|
||||
|
||||
### Option 2: Remove Trailing Slash from Backend
|
||||
|
||||
**Backend Route:**
|
||||
```python
|
||||
# Route defined WITHOUT trailing slash
|
||||
@router.get("/tasks")
|
||||
async def get_tasks(...):
|
||||
...
|
||||
```
|
||||
|
||||
**Frontend Service:**
|
||||
```typescript
|
||||
// Call WITHOUT trailing slash
|
||||
const response = await apiClient.get(`/tasks?${params}`)
|
||||
```
|
||||
|
||||
## Prevention Checklist
|
||||
|
||||
When adding or modifying routes, **always check**:
|
||||
|
||||
1. **Backend Route Definition** - Does it have a trailing slash?
|
||||
```python
|
||||
@router.get("/endpoint/") # Has trailing slash
|
||||
@router.get("/endpoint") # No trailing slash
|
||||
```
|
||||
|
||||
2. **Frontend API Call** - Does it match the backend?
|
||||
```typescript
|
||||
apiClient.get(`/endpoint/`) // Has trailing slash
|
||||
apiClient.get(`/endpoint`) // No trailing slash
|
||||
```
|
||||
|
||||
3. **Query Parameters** - Trailing slash goes BEFORE the `?`
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
apiClient.get(`/tasks/?shot_id=12`)
|
||||
|
||||
// ❌ WRONG
|
||||
apiClient.get(`/tasks?shot_id=12/`)
|
||||
```
|
||||
|
||||
4. **Path Parameters** - Usually no trailing slash
|
||||
```typescript
|
||||
// ✅ CORRECT
|
||||
apiClient.get(`/tasks/${taskId}`)
|
||||
|
||||
// ❌ WRONG (usually)
|
||||
apiClient.get(`/tasks/${taskId}/`)
|
||||
```
|
||||
|
||||
## Historical Issues Fixed
|
||||
|
||||
### Issue 1: Shots Endpoint (Fixed)
|
||||
- **Problem:** Frontend called `/shots/1/` but backend defined `/shots/{shot_id}`
|
||||
- **Solution:** Changed frontend to call `/shots/1` (no trailing slash)
|
||||
- **Files:** `frontend/src/services/shot.ts`
|
||||
|
||||
### Issue 2: Tasks Endpoint (Fixed)
|
||||
- **Problem:** Frontend called `/tasks?shot_id=12` but backend defined `/tasks/`
|
||||
- **Solution:** Changed frontend to call `/tasks/?shot_id=12` (with trailing slash)
|
||||
- **Files:** `frontend/src/services/task.ts`
|
||||
|
||||
## Testing
|
||||
|
||||
To verify there's no redirect issue:
|
||||
|
||||
1. **Check backend logs** - Should see only ONE request, not two:
|
||||
```
|
||||
✅ GOOD:
|
||||
INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 200 OK
|
||||
|
||||
❌ BAD (redirect happening):
|
||||
INFO: "GET /tasks?shot_id=12 HTTP/1.1" 307 Temporary Redirect
|
||||
INFO: "GET /tasks/?shot_id=12 HTTP/1.1" 403 Forbidden
|
||||
```
|
||||
|
||||
2. **Check frontend network tab** - Should see 200 OK, not 307 or 403
|
||||
|
||||
3. **Test with authentication** - Ensure authenticated endpoints work correctly
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Patterns
|
||||
|
||||
| Endpoint Type | Backend Route | Frontend Call |
|
||||
|--------------|---------------|---------------|
|
||||
| List with query params | `@router.get("/items/")` | `get("/items/?param=value")` |
|
||||
| Get by ID | `@router.get("/items/{id}")` | `get("/items/123")` |
|
||||
| Create | `@router.post("/items/")` | `post("/items/", data)` |
|
||||
| Update by ID | `@router.put("/items/{id}")` | `put("/items/123", data)` |
|
||||
| Delete by ID | `@router.delete("/items/{id}")` | `delete("/items/123")` |
|
||||
|
||||
## Related Files
|
||||
|
||||
- Backend routes: `backend/routers/*.py`
|
||||
- Frontend services: `frontend/src/services/*.ts`
|
||||
- API client: `frontend/src/services/api.ts`
|
||||
|
||||
## Additional Notes
|
||||
|
||||
- This issue only affects authenticated endpoints because the `Authorization` header is lost during redirect
|
||||
- Public endpoints might not show this issue as clearly
|
||||
- Always test with actual authentication tokens, not just in development mode
|
||||
- Consider adding a linter rule or pre-commit hook to check for trailing slash consistency
|
||||
@@ -0,0 +1,341 @@
|
||||
# File Path Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the database migration scripts created to convert absolute file paths to relative paths in the VFX Project Management System. This migration addresses the issue where absolute paths stored in the database become invalid when deploying to different environments, particularly Linux.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The system previously stored absolute file paths like:
|
||||
- `D:\Repo\LinkDesk\backend\uploads\submissions\98\v001_render.jpg`
|
||||
- `/home/user/vfx-system/backend/uploads/attachments/123/reference.pdf`
|
||||
|
||||
These paths become invalid when:
|
||||
- Deploying to different operating systems
|
||||
- Moving the backend directory
|
||||
- Running in containerized environments
|
||||
|
||||
## Solution
|
||||
|
||||
Convert all absolute paths to relative paths:
|
||||
- `uploads/submissions/98/v001_render.jpg`
|
||||
- `uploads/attachments/123/reference.pdf`
|
||||
- `uploads/project_thumbnails/project_1_thumbnail.jpg`
|
||||
|
||||
## Migration Scripts
|
||||
|
||||
### 1. `migrate_file_paths_to_relative.py`
|
||||
|
||||
**Purpose**: Basic migration script for development use.
|
||||
|
||||
**Features**:
|
||||
- Converts absolute paths to relative paths in all three tables
|
||||
- Basic validation and error handling
|
||||
- Interactive confirmation prompt
|
||||
- Detailed logging
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
cd backend
|
||||
python migrate_file_paths_to_relative.py
|
||||
```
|
||||
|
||||
### 2. `migrate_file_paths_production.py`
|
||||
|
||||
**Purpose**: Production-ready migration script with comprehensive validation.
|
||||
|
||||
**Features**:
|
||||
- Automatic database backup creation
|
||||
- Pre-migration analysis of problematic paths
|
||||
- Enhanced error handling and recovery
|
||||
- Comprehensive validation of results
|
||||
- Detailed logging with timestamps
|
||||
- Support for multiple absolute path patterns
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
cd backend
|
||||
python migrate_file_paths_production.py
|
||||
```
|
||||
|
||||
### 3. `validate_migration_results.py`
|
||||
|
||||
**Purpose**: Validation script to verify migration success.
|
||||
|
||||
**Features**:
|
||||
- Validates all paths are now relative
|
||||
- Checks file accessibility
|
||||
- Verifies path format consistency
|
||||
- Comprehensive reporting
|
||||
|
||||
**Usage**:
|
||||
```bash
|
||||
cd backend
|
||||
python validate_migration_results.py
|
||||
```
|
||||
|
||||
## Tables Affected
|
||||
|
||||
### 1. `submissions` table
|
||||
- **Column**: `file_path`
|
||||
- **Purpose**: Stores paths to user-submitted work files
|
||||
- **Example conversion**:
|
||||
- Before: `D:\Repo\LinkDesk\backend\uploads\submissions\98\v001_render.jpg`
|
||||
- After: `uploads/submissions/98/v001_render.jpg`
|
||||
|
||||
### 2. `task_attachments` table
|
||||
- **Column**: `file_path`
|
||||
- **Purpose**: Stores paths to task attachment files
|
||||
- **Example conversion**:
|
||||
- Before: `D:\Repo\LinkDesk\backend\uploads\attachments\98\reference.pdf`
|
||||
- After: `uploads/attachments/98/reference.pdf`
|
||||
|
||||
### 3. `projects` table
|
||||
- **Column**: `thumbnail_path`
|
||||
- **Purpose**: Stores paths to project thumbnail images
|
||||
- **Example conversion**:
|
||||
- Before: `D:\Repo\LinkDesk\backend\uploads\project_thumbnails\project_1_thumb.jpg`
|
||||
- After: `uploads/project_thumbnails/project_1_thumb.jpg`
|
||||
|
||||
## Migration Process
|
||||
|
||||
### Pre-Migration Steps
|
||||
|
||||
1. **Backup Database**:
|
||||
```bash
|
||||
cp database.db database_backup_$(date +%Y%m%d_%H%M%S).db
|
||||
```
|
||||
|
||||
2. **Verify Current State**:
|
||||
```bash
|
||||
python validate_migration_results.py
|
||||
```
|
||||
|
||||
### Running Migration
|
||||
|
||||
1. **Development Environment**:
|
||||
```bash
|
||||
python migrate_file_paths_to_relative.py
|
||||
```
|
||||
|
||||
2. **Production Environment**:
|
||||
```bash
|
||||
python migrate_file_paths_production.py
|
||||
```
|
||||
|
||||
### Post-Migration Validation
|
||||
|
||||
1. **Validate Results**:
|
||||
```bash
|
||||
python validate_migration_results.py
|
||||
```
|
||||
|
||||
2. **Test File Serving**:
|
||||
- Start the backend server
|
||||
- Test thumbnail URLs in the frontend
|
||||
- Verify file downloads work correctly
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
1. **File Not Found**:
|
||||
- **Issue**: Original file doesn't exist at expected location
|
||||
- **Solution**: Migration logs the issue but continues processing
|
||||
- **Action**: Review log files and manually fix missing files
|
||||
|
||||
2. **Path Conversion Failure**:
|
||||
- **Issue**: Cannot determine relative path from absolute path
|
||||
- **Solution**: Migration attempts multiple conversion strategies
|
||||
- **Action**: Review problematic paths in log files
|
||||
|
||||
3. **Database Transaction Failure**:
|
||||
- **Issue**: Database error during migration
|
||||
- **Solution**: Automatic rollback preserves data integrity
|
||||
- **Action**: Fix underlying issue and re-run migration
|
||||
|
||||
### Recovery Procedures
|
||||
|
||||
1. **Restore from Backup**:
|
||||
```bash
|
||||
cp database_backup_YYYYMMDD_HHMMSS.db database.db
|
||||
```
|
||||
|
||||
2. **Partial Migration Recovery**:
|
||||
- Migration is transactional per table
|
||||
- Individual table failures don't affect other tables
|
||||
- Re-run migration to complete partial migrations
|
||||
|
||||
## Validation Criteria
|
||||
|
||||
### Success Criteria
|
||||
|
||||
1. **All paths are relative**: No absolute paths remain in database
|
||||
2. **Files are accessible**: All relative paths resolve to existing files
|
||||
3. **Format consistency**: All paths follow expected format patterns
|
||||
4. **No data loss**: All original file references are preserved
|
||||
|
||||
### Validation Checks
|
||||
|
||||
1. **Path Format Validation**:
|
||||
- Submissions: `uploads/submissions/[task_id]/[filename]`
|
||||
- Attachments: `uploads/attachments/[task_id]/[filename]`
|
||||
- Projects: `uploads/project_thumbnails/[filename]`
|
||||
|
||||
2. **File Accessibility**:
|
||||
- All relative paths resolve to existing files
|
||||
- File permissions allow read access
|
||||
|
||||
3. **Database Integrity**:
|
||||
- No NULL values introduced
|
||||
- All foreign key relationships preserved
|
||||
- No duplicate entries created
|
||||
|
||||
## Logging and Monitoring
|
||||
|
||||
### Log Files
|
||||
|
||||
- **Development**: `migration_file_paths.log`
|
||||
- **Production**: `migration_file_paths_YYYYMMDD_HHMMSS.log`
|
||||
|
||||
### Log Levels
|
||||
|
||||
- **INFO**: Normal migration progress
|
||||
- **WARNING**: Non-critical issues (missing files, etc.)
|
||||
- **ERROR**: Critical issues that prevent migration
|
||||
|
||||
### Monitoring Points
|
||||
|
||||
1. **Conversion Statistics**:
|
||||
- Total files processed
|
||||
- Files successfully converted
|
||||
- Files skipped (already relative)
|
||||
- Errors encountered
|
||||
|
||||
2. **Validation Results**:
|
||||
- Path format compliance
|
||||
- File accessibility
|
||||
- Database integrity
|
||||
|
||||
## Integration with FileHandler
|
||||
|
||||
### Updated FileHandler Behavior
|
||||
|
||||
After migration, the FileHandler class should be updated to:
|
||||
|
||||
1. **Store only relative paths** in database
|
||||
2. **Resolve relative paths** to absolute paths for file operations
|
||||
3. **Handle both formats** during transition period
|
||||
|
||||
### Code Changes Required
|
||||
|
||||
```python
|
||||
# Before migration
|
||||
file_path = "/absolute/path/to/file.jpg"
|
||||
|
||||
# After migration
|
||||
file_path = "uploads/submissions/123/file.jpg"
|
||||
absolute_path = file_handler.resolve_absolute_path(file_path)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Scenarios
|
||||
|
||||
1. **File Upload**: Verify new uploads store relative paths
|
||||
2. **File Serving**: Verify existing files serve correctly
|
||||
3. **Thumbnail Generation**: Verify thumbnails work with relative paths
|
||||
4. **Cross-Platform**: Test on different operating systems
|
||||
|
||||
### Test Commands
|
||||
|
||||
```bash
|
||||
# Test file serving endpoints
|
||||
curl http://localhost:8000/files/submissions/98/v001_render.jpg
|
||||
|
||||
# Test thumbnail URLs
|
||||
curl http://localhost:8000/files/thumbnails/v001_render_thumb.jpg
|
||||
|
||||
# Test project thumbnails
|
||||
curl http://localhost:8000/files/project_thumbnails/project_1_thumb.jpg
|
||||
```
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
### Pre-Deployment
|
||||
|
||||
- [ ] Database backup created
|
||||
- [ ] Migration scripts tested in staging
|
||||
- [ ] Validation scripts ready
|
||||
- [ ] Rollback procedure documented
|
||||
|
||||
### During Deployment
|
||||
|
||||
- [ ] Stop application services
|
||||
- [ ] Run migration script
|
||||
- [ ] Validate migration results
|
||||
- [ ] Update FileHandler code
|
||||
- [ ] Start application services
|
||||
|
||||
### Post-Deployment
|
||||
|
||||
- [ ] Test file serving endpoints
|
||||
- [ ] Verify thumbnail URLs work
|
||||
- [ ] Monitor application logs
|
||||
- [ ] Confirm no absolute paths in new uploads
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Problems
|
||||
|
||||
1. **Migration appears to do nothing**:
|
||||
- Check if paths are already relative
|
||||
- Review log files for details
|
||||
|
||||
2. **Files not accessible after migration**:
|
||||
- Verify file permissions
|
||||
- Check if files were moved or deleted
|
||||
- Validate relative path resolution
|
||||
|
||||
3. **Thumbnails not displaying**:
|
||||
- Check thumbnail file existence
|
||||
- Verify thumbnail path format
|
||||
- Test thumbnail serving endpoints
|
||||
|
||||
### Debug Commands
|
||||
|
||||
```bash
|
||||
# Check current path formats
|
||||
python -c "
|
||||
from models.task import Submission
|
||||
from database import SessionLocal
|
||||
db = SessionLocal()
|
||||
for s in db.query(Submission).limit(5):
|
||||
print(f'ID: {s.id}, Path: {s.file_path}')
|
||||
"
|
||||
|
||||
# Test path resolution
|
||||
python -c "
|
||||
from utils.file_handler import file_handler
|
||||
from pathlib import Path
|
||||
backend_dir = Path(__file__).parent
|
||||
test_path = 'uploads/submissions/98/test.jpg'
|
||||
resolved = backend_dir / test_path
|
||||
print(f'Resolved: {resolved}')
|
||||
print(f'Exists: {resolved.exists()}')
|
||||
"
|
||||
```
|
||||
|
||||
## Requirements Addressed
|
||||
|
||||
This migration addresses the following requirements from the specification:
|
||||
|
||||
- **1.1**: Convert absolute paths to relative paths in submissions table
|
||||
- **1.2**: Convert absolute paths to relative paths in task_attachments table
|
||||
- **1.3**: Convert absolute paths to relative paths in projects table (thumbnail_path)
|
||||
- **1.4**: Add validation and error handling for problematic paths
|
||||
- **1.5**: Ensure all file paths in database are relative to backend directory
|
||||
|
||||
## Conclusion
|
||||
|
||||
The file path migration scripts provide a comprehensive solution for converting absolute file paths to relative paths, ensuring cross-platform compatibility and system portability. The migration includes robust error handling, validation, and recovery procedures suitable for both development and production environments.
|
||||
@@ -0,0 +1,246 @@
|
||||
# File Handling System Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The VFX Project Management System includes a comprehensive file handling system that provides secure file upload, storage, serving, and access control for task attachments and work submissions.
|
||||
|
||||
## Features
|
||||
|
||||
### File Upload System
|
||||
- **Secure file validation** with format and size restrictions
|
||||
- **Organized directory structure** for efficient file storage
|
||||
- **Unique filename generation** to prevent conflicts
|
||||
- **Automatic thumbnail generation** for image files
|
||||
- **Version control** for submissions
|
||||
|
||||
### File Serving and Access Control
|
||||
- **Authenticated file serving** with role-based permissions
|
||||
- **Thumbnail serving** for quick previews
|
||||
- **Video streaming** for media playback
|
||||
- **File information API** for metadata access
|
||||
|
||||
## Supported File Formats
|
||||
|
||||
### Video Formats
|
||||
- `.mov` - QuickTime Movie
|
||||
- `.mp4` - MPEG-4 Video
|
||||
- `.avi` - Audio Video Interleave
|
||||
- `.mkv` - Matroska Video
|
||||
- `.webm` - WebM Video
|
||||
|
||||
### Image Formats
|
||||
- `.exr` - OpenEXR (High Dynamic Range)
|
||||
- `.jpg`, `.jpeg` - JPEG Image
|
||||
- `.png` - Portable Network Graphics
|
||||
- `.tiff`, `.tif` - Tagged Image File Format
|
||||
- `.dpx` - Digital Picture Exchange
|
||||
- `.hdr` - High Dynamic Range Image
|
||||
|
||||
### Document Formats
|
||||
- `.pdf` - Portable Document Format
|
||||
- `.txt` - Plain Text
|
||||
- `.doc`, `.docx` - Microsoft Word Document
|
||||
|
||||
### Archive Formats
|
||||
- `.zip` - ZIP Archive
|
||||
- `.rar` - RAR Archive
|
||||
- `.7z` - 7-Zip Archive
|
||||
|
||||
## File Size Limits
|
||||
|
||||
- **Task Attachments**: 10MB maximum
|
||||
- **Work Submissions**: 500MB maximum
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### File Upload Endpoints (via Tasks Router)
|
||||
|
||||
#### Upload Task Attachment
|
||||
```
|
||||
POST /tasks/{task_id}/attachments
|
||||
```
|
||||
- Uploads a file attachment to a task
|
||||
- Creates thumbnail for image files
|
||||
- Returns attachment metadata with serving URLs
|
||||
|
||||
#### Submit Work
|
||||
```
|
||||
POST /tasks/{task_id}/submit
|
||||
```
|
||||
- Submits work file for review
|
||||
- Automatically versions submissions
|
||||
- Updates task status to "submitted"
|
||||
|
||||
### File Serving Endpoints
|
||||
|
||||
#### Serve Attachment
|
||||
```
|
||||
GET /files/attachments/{attachment_id}
|
||||
GET /files/attachments/{attachment_id}?thumbnail=true
|
||||
```
|
||||
- Serves attachment files with access control
|
||||
- Optional thumbnail parameter for image previews
|
||||
|
||||
#### Serve Submission
|
||||
```
|
||||
GET /files/submissions/{submission_id}
|
||||
GET /files/submissions/{submission_id}?thumbnail=true
|
||||
```
|
||||
- Serves submission files with access control
|
||||
- Optional thumbnail parameter for image previews
|
||||
|
||||
#### Stream Submission
|
||||
```
|
||||
GET /files/submissions/{submission_id}/stream
|
||||
```
|
||||
- Streams video files for playback
|
||||
- Supports range requests for efficient streaming
|
||||
|
||||
#### File Information
|
||||
```
|
||||
GET /files/info/attachment/{attachment_id}
|
||||
GET /files/info/submission/{submission_id}
|
||||
```
|
||||
- Returns file metadata and information
|
||||
- Includes file type detection and existence status
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
backend/uploads/
|
||||
├── attachments/
|
||||
│ └── {task_id}/
|
||||
│ └── {unique_filename}
|
||||
├── submissions/
|
||||
│ └── {task_id}/
|
||||
│ └── {versioned_filename}
|
||||
└── thumbnails/
|
||||
└── {filename}_thumb.jpg
|
||||
```
|
||||
|
||||
## Access Control
|
||||
|
||||
### Permission Matrix
|
||||
|
||||
| User Role | Own Tasks | Other Tasks | Review Access |
|
||||
|-------------|-----------|-------------|---------------|
|
||||
| Artist | ✓ | ✗ | ✗ |
|
||||
| Coordinator | ✓ | ✓ | ✓ |
|
||||
| Director | ✓ | ✓ | ✓ |
|
||||
| Admin | ✓ | ✓ | ✓ |
|
||||
|
||||
### Security Features
|
||||
|
||||
- **Authentication required** for all file operations
|
||||
- **Role-based access control** for file serving
|
||||
- **File type validation** to prevent malicious uploads
|
||||
- **File size limits** to prevent abuse
|
||||
- **Unique filename generation** to prevent conflicts
|
||||
- **Path traversal protection** through controlled directory structure
|
||||
|
||||
## File Handler Utility
|
||||
|
||||
The `FileHandler` class provides core functionality:
|
||||
|
||||
### Key Methods
|
||||
|
||||
- `validate_file()` - Validates file format and size
|
||||
- `save_file()` - Saves uploaded file with unique naming
|
||||
- `delete_file()` - Removes file and associated thumbnail
|
||||
- `create_thumbnail()` - Generates image thumbnails
|
||||
- `get_file_info()` - Returns file metadata
|
||||
- `is_image_file()` / `is_video_file()` - File type detection
|
||||
|
||||
### Configuration
|
||||
|
||||
```python
|
||||
# File size limits
|
||||
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10MB
|
||||
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB
|
||||
|
||||
# Thumbnail settings
|
||||
THUMBNAIL_SIZE = (200, 200)
|
||||
THUMBNAIL_QUALITY = 85
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Frontend Integration
|
||||
|
||||
```javascript
|
||||
// Upload attachment
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('attachment_type', 'reference');
|
||||
formData.append('description', 'Reference image');
|
||||
|
||||
const response = await fetch(`/tasks/${taskId}/attachments`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const attachment = await response.json();
|
||||
console.log('Download URL:', attachment.download_url);
|
||||
console.log('Thumbnail URL:', attachment.thumbnail_url);
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Submit work
|
||||
const formData = new FormData();
|
||||
formData.append('file', workFile);
|
||||
formData.append('notes', 'Final version ready for review');
|
||||
|
||||
const response = await fetch(`/tasks/${taskId}/submit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
const submission = await response.json();
|
||||
console.log('Stream URL:', submission.stream_url);
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Error Responses
|
||||
|
||||
- `400 Bad Request` - Invalid file format or missing filename
|
||||
- `403 Forbidden` - Insufficient permissions to access file
|
||||
- `404 Not Found` - File or associated task not found
|
||||
- `413 Payload Too Large` - File exceeds size limits
|
||||
|
||||
### Error Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "File too large. Maximum size is 10MB"
|
||||
}
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Chunked streaming** for large video files
|
||||
- **On-demand thumbnail generation** with caching
|
||||
- **Efficient file serving** with proper MIME types
|
||||
- **Range request support** for video streaming
|
||||
|
||||
## Maintenance
|
||||
|
||||
### File Cleanup
|
||||
|
||||
The system does not automatically clean up orphaned files. Consider implementing:
|
||||
|
||||
- Periodic cleanup of files without database references
|
||||
- Archive old submissions after project completion
|
||||
- Monitor disk usage and implement retention policies
|
||||
|
||||
### Backup Considerations
|
||||
|
||||
- Include upload directories in backup procedures
|
||||
- Consider separate backup strategy for large media files
|
||||
- Implement file integrity checks for critical submissions
|
||||
@@ -0,0 +1,338 @@
|
||||
# Project Settings Implementation Summary
|
||||
|
||||
## Overview
|
||||
Task 5.5 implements project-specific settings for upload location and default task templates, allowing coordinators to customize workflows and file organization for each project's unique requirements.
|
||||
|
||||
## Backend Implementation
|
||||
|
||||
### Database Schema
|
||||
The Project model already includes the necessary fields:
|
||||
- `upload_data_location` (String): Custom upload storage path for project files
|
||||
- `asset_task_templates` (JSON): Default tasks per asset category
|
||||
- `shot_task_templates` (JSON): Default tasks for shots
|
||||
- `enabled_asset_tasks` (JSON): Enabled/disabled asset tasks per category
|
||||
- `enabled_shot_tasks` (JSON): Enabled/disabled shot tasks
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### GET /projects/{project_id}/settings
|
||||
- **Description**: Retrieve project-specific settings
|
||||
- **Authorization**: Any project member
|
||||
- **Response**: ProjectSettings schema with all settings fields
|
||||
- **Default Values**: Returns default templates if not configured
|
||||
|
||||
#### PUT /projects/{project_id}/settings
|
||||
- **Description**: Update project-specific settings
|
||||
- **Authorization**: Coordinator or Admin only
|
||||
- **Request Body**: ProjectSettingsUpdate schema (all fields optional)
|
||||
- **Response**: Updated ProjectSettings
|
||||
- **Features**:
|
||||
- Partial updates supported
|
||||
- Validates asset categories and task templates
|
||||
- Persists changes to database
|
||||
|
||||
### Schemas (backend/schemas/project.py)
|
||||
|
||||
```python
|
||||
class ProjectSettings(BaseModel):
|
||||
"""Project-specific settings for upload location and task templates"""
|
||||
upload_data_location: Optional[str]
|
||||
asset_task_templates: Optional[Dict[str, List[str]]]
|
||||
shot_task_templates: Optional[List[str]]
|
||||
enabled_asset_tasks: Optional[Dict[str, List[str]]]
|
||||
enabled_shot_tasks: Optional[List[str]]
|
||||
|
||||
class ProjectSettingsUpdate(BaseModel):
|
||||
"""Update project settings (all fields optional)"""
|
||||
upload_data_location: Optional[str]
|
||||
asset_task_templates: Optional[Dict[str, List[str]]]
|
||||
shot_task_templates: Optional[List[str]]
|
||||
enabled_asset_tasks: Optional[Dict[str, List[str]]]
|
||||
enabled_shot_tasks: Optional[List[str]]
|
||||
```
|
||||
|
||||
### Default Values
|
||||
|
||||
```python
|
||||
DEFAULT_ASSET_TASKS = {
|
||||
"characters": ["modeling", "surfacing", "rigging"],
|
||||
"props": ["modeling", "surfacing"],
|
||||
"sets": ["modeling", "surfacing"],
|
||||
"vehicles": ["modeling", "surfacing", "rigging"]
|
||||
}
|
||||
|
||||
DEFAULT_SHOT_TASKS = ["layout", "animation", "simulation", "lighting", "compositing"]
|
||||
```
|
||||
|
||||
### Validation
|
||||
- Asset categories must be one of: characters, props, sets, vehicles
|
||||
- Task templates must be lists of strings
|
||||
- Enabled tasks must match the template structure
|
||||
- Invalid categories or formats return 422 validation errors
|
||||
|
||||
## Frontend Implementation
|
||||
|
||||
### Components
|
||||
|
||||
#### 1. UploadLocationConfig.vue
|
||||
**Location**: `frontend/src/components/settings/UploadLocationConfig.vue`
|
||||
|
||||
**Features**:
|
||||
- Input field for upload data location path
|
||||
- Clear button to reset location
|
||||
- Example paths for Windows, Linux/Mac, and Network drives
|
||||
- Info box with usage guidelines
|
||||
- Save/Cancel actions
|
||||
|
||||
**Props**:
|
||||
- `initialLocation`: Current upload location
|
||||
- `isSaving`: Loading state during save
|
||||
|
||||
**Events**:
|
||||
- `save`: Emits new location string
|
||||
- `cancel`: Emits cancel action
|
||||
|
||||
#### 2. DefaultTaskTemplatesEditor.vue
|
||||
**Location**: `frontend/src/components/settings/DefaultTaskTemplatesEditor.vue`
|
||||
|
||||
**Features**:
|
||||
- Asset task templates table with checkboxes per category
|
||||
- Shot task templates table with enable/disable checkboxes
|
||||
- Dynamic loading of custom task types from API
|
||||
- Edit/Delete buttons for custom task types
|
||||
- Template preview showing what tasks will be created
|
||||
- Reset to defaults button
|
||||
- Integration with CustomTaskTypeManager
|
||||
|
||||
**Props**:
|
||||
- `projectId`: Project ID for loading custom task types
|
||||
- `initialAssetTemplates`: Current asset templates
|
||||
- `initialShotTemplates`: Current shot templates
|
||||
- `isSaving`: Loading state during save
|
||||
|
||||
**Events**:
|
||||
- `save`: Emits { assetTemplates, shotTemplates }
|
||||
- `cancel`: Emits cancel action
|
||||
- `editCustomTaskType`: Emits (taskType, category) for editing
|
||||
- `deleteCustomTaskType`: Emits (taskType, category) for deletion
|
||||
|
||||
**Methods**:
|
||||
- `refreshTaskTypes()`: Exposed method to reload task types after custom type changes
|
||||
|
||||
#### 3. ProjectSettingsView.vue
|
||||
**Location**: `frontend/src/views/ProjectSettingsView.vue`
|
||||
|
||||
**Features**:
|
||||
- Tabbed interface with 6 tabs:
|
||||
- General: Project basic information
|
||||
- Episodes: Episode management
|
||||
- Team: Project members
|
||||
- Technical: Technical specifications
|
||||
- Tasks: Custom task types and templates
|
||||
- Storage: Upload location configuration
|
||||
- Loads project settings on mount
|
||||
- Handles save operations for both components
|
||||
- Integrates with CustomTaskTypeManager
|
||||
- Provides navigation between tabs for edit/delete operations
|
||||
|
||||
**Tab Structure**:
|
||||
```
|
||||
General | Episodes | Team | Technical | Tasks | Storage
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
#### projectService (frontend/src/services/project.ts)
|
||||
|
||||
```typescript
|
||||
export interface ProjectSettings {
|
||||
upload_data_location?: string
|
||||
asset_task_templates?: AssetTaskTemplates
|
||||
shot_task_templates?: string[]
|
||||
enabled_asset_tasks?: Record<string, string[]>
|
||||
enabled_shot_tasks?: string[]
|
||||
}
|
||||
|
||||
async getProjectSettings(projectId: number): Promise<ProjectSettings>
|
||||
async updateProjectSettings(projectId: number, settings: ProjectSettings): Promise<ProjectSettings>
|
||||
```
|
||||
|
||||
### User Flow
|
||||
|
||||
1. **Navigate to Project Settings**:
|
||||
- User clicks on project settings from project page
|
||||
- ProjectSettingsView loads with tabbed interface
|
||||
|
||||
2. **Configure Upload Location** (Storage Tab):
|
||||
- User enters custom upload path
|
||||
- Clicks "Save Configuration"
|
||||
- System validates and saves to backend
|
||||
- Toast notification confirms success
|
||||
|
||||
3. **Configure Task Templates** (Tasks Tab):
|
||||
- User sees two sections:
|
||||
- Custom Task Type Manager (top)
|
||||
- Default Task Templates Editor (bottom)
|
||||
- User can add/edit/delete custom task types
|
||||
- User toggles checkboxes for each task type per category
|
||||
- Preview shows what tasks will be created
|
||||
- Clicks "Save Templates"
|
||||
- System validates and saves to backend
|
||||
- Toast notification confirms success
|
||||
|
||||
4. **Integration with Asset/Shot Creation**:
|
||||
- When creating assets, system uses project-specific templates
|
||||
- When creating shots, system uses project-specific templates
|
||||
- Custom task types appear in templates automatically
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend Tests
|
||||
**File**: `backend/test_project_settings_api.py`
|
||||
|
||||
**Test Cases**:
|
||||
1. ✅ GET project settings returns defaults
|
||||
2. ✅ PUT updates all settings fields
|
||||
3. ✅ Settings persist across requests
|
||||
4. ✅ Partial updates work correctly
|
||||
5. ✅ Invalid project ID returns 404
|
||||
|
||||
**Run Tests**:
|
||||
```bash
|
||||
cd backend
|
||||
python test_project_settings_api.py
|
||||
```
|
||||
|
||||
### Manual Testing Checklist
|
||||
|
||||
#### Backend:
|
||||
- [x] GET /projects/{id}/settings returns default values
|
||||
- [x] PUT /projects/{id}/settings updates all fields
|
||||
- [x] PUT /projects/{id}/settings supports partial updates
|
||||
- [x] Settings persist in database
|
||||
- [x] Invalid project ID returns 404
|
||||
- [x] Non-coordinator users cannot update settings
|
||||
- [x] Validation errors for invalid categories
|
||||
|
||||
#### Frontend:
|
||||
- [ ] Upload location input accepts and saves paths
|
||||
- [ ] Task template checkboxes toggle correctly
|
||||
- [ ] Preview updates when templates change
|
||||
- [ ] Reset to defaults button works
|
||||
- [ ] Save button shows loading state
|
||||
- [ ] Toast notifications appear on success/error
|
||||
- [ ] Settings persist after page refresh
|
||||
- [ ] Custom task types appear in templates
|
||||
- [ ] Edit/Delete custom task type navigation works
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
This implementation satisfies the following requirements:
|
||||
|
||||
### Requirement 19: Project Settings for Upload Location and Default Tasks
|
||||
|
||||
**19.1** ✅ Configure upload data storage locations per project
|
||||
- Upload location field in Project model
|
||||
- API endpoint to get/update upload location
|
||||
- Frontend component with path input
|
||||
|
||||
**19.2** ✅ Define custom default task templates for asset creation per project
|
||||
- Asset task templates stored as JSON in Project model
|
||||
- API endpoints to manage templates
|
||||
- Frontend editor with category-specific checkboxes
|
||||
|
||||
**19.3** ✅ Define custom default task templates for shot creation per project
|
||||
- Shot task templates stored as JSON in Project model
|
||||
- API endpoints to manage templates
|
||||
- Frontend editor with task type checkboxes
|
||||
|
||||
**19.4** ✅ Support different task templates for different asset categories
|
||||
- Templates organized by category (characters, props, sets, vehicles)
|
||||
- Each category can have different task lists
|
||||
- Frontend table shows all categories
|
||||
|
||||
**19.5** ✅ Support different task templates for different shot types
|
||||
- Shot templates stored as list
|
||||
- Can be customized per project
|
||||
- Frontend table shows all shot task types
|
||||
|
||||
**19.6** ✅ Enable or disable specific default tasks per project
|
||||
- Enabled tasks tracked separately from templates
|
||||
- Can disable tasks without removing from templates
|
||||
- Frontend checkboxes control enabled state
|
||||
|
||||
**19.7** ✅ Apply project-specific upload locations to all file uploads
|
||||
- Upload location stored in project settings
|
||||
- Available for file upload handlers to use
|
||||
- Displayed to users during upload
|
||||
|
||||
**19.8** ✅ Use project-specific default task templates when creating assets and shots
|
||||
- Templates loaded from project settings
|
||||
- Applied during asset/shot creation
|
||||
- Custom task types included automatically
|
||||
|
||||
**19.9** ✅ Provide project settings interface for coordinators
|
||||
- Dedicated settings view with tabs
|
||||
- Coordinator-only access for updates
|
||||
- All project members can view settings
|
||||
|
||||
## Database Migration
|
||||
|
||||
The migration script `backend/migrate_project_settings.py` adds the necessary columns:
|
||||
- Checks if columns already exist
|
||||
- Adds columns if missing
|
||||
- Sets default values for existing projects
|
||||
- Safe to run multiple times
|
||||
|
||||
**Run Migration**:
|
||||
```bash
|
||||
cd backend
|
||||
python migrate_project_settings.py
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Asset Creation
|
||||
- Asset creation reads `asset_task_templates` from project settings
|
||||
- Creates tasks based on asset category
|
||||
- Respects `enabled_asset_tasks` to skip disabled tasks
|
||||
- Includes custom task types from project
|
||||
|
||||
### With Shot Creation
|
||||
- Shot creation reads `shot_task_templates` from project settings
|
||||
- Creates tasks for all enabled shot task types
|
||||
- Respects `enabled_shot_tasks` to skip disabled tasks
|
||||
- Includes custom task types from project
|
||||
|
||||
### With File Uploads
|
||||
- File upload handlers can read `upload_data_location`
|
||||
- Use project-specific path if configured
|
||||
- Fall back to default location if not set
|
||||
|
||||
### With Custom Task Types
|
||||
- Custom task types automatically appear in templates
|
||||
- Edit/Delete operations navigate to CustomTaskTypeManager
|
||||
- Template editor refreshes after custom type changes
|
||||
- Seamless integration between components
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Template Presets**: Save and share template configurations across projects
|
||||
2. **Path Validation**: Verify upload paths exist and are writable
|
||||
3. **Template History**: Track changes to templates over time
|
||||
4. **Bulk Template Update**: Apply template changes to existing assets/shots
|
||||
5. **Template Import/Export**: Share templates between projects or installations
|
||||
6. **Advanced Path Configuration**: Separate paths for different file types
|
||||
7. **Template Inheritance**: Base templates on other projects
|
||||
8. **Conditional Templates**: Different templates based on project type or status
|
||||
|
||||
## Notes
|
||||
|
||||
- All settings are optional and have sensible defaults
|
||||
- Settings can be updated independently (partial updates)
|
||||
- Changes take effect immediately for new assets/shots
|
||||
- Existing assets/shots are not affected by template changes
|
||||
- Coordinators and admins can manage settings
|
||||
- All project members can view settings
|
||||
- Settings are project-specific and don't affect other projects
|
||||
- Custom task types integrate seamlessly with templates
|
||||
@@ -0,0 +1,208 @@
|
||||
# Project Thumbnail Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the implementation of project thumbnail upload functionality for the VFX Project Management System.
|
||||
|
||||
## Requirements
|
||||
|
||||
Based on Requirement 2.1, the system allows coordinators and administrators to:
|
||||
- Upload thumbnail images for projects
|
||||
- Replace existing thumbnails
|
||||
- Delete thumbnails
|
||||
- View thumbnails on project cards
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Database Changes
|
||||
|
||||
**Migration Script**: `backend/migrate_project_thumbnail.py`
|
||||
|
||||
Added `thumbnail_path` column to the `projects` table:
|
||||
```sql
|
||||
ALTER TABLE projects ADD COLUMN thumbnail_path VARCHAR
|
||||
```
|
||||
|
||||
**Model Update**: `backend/models/project.py`
|
||||
```python
|
||||
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
||||
```
|
||||
|
||||
### 2. API Endpoints
|
||||
|
||||
#### Upload Thumbnail
|
||||
**Endpoint**: `POST /api/projects/{project_id}/thumbnail`
|
||||
**Access**: Coordinators and Admins only
|
||||
**Request**: Multipart form data with image file
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"message": "Thumbnail uploaded successfully",
|
||||
"thumbnail_url": "/api/files/projects/{project_id}/thumbnail"
|
||||
}
|
||||
```
|
||||
|
||||
**Features**:
|
||||
- Validates file format (jpg, jpeg, png, gif, webp)
|
||||
- Validates file size (max 10MB)
|
||||
- Processes and resizes images to max 800x600 while maintaining aspect ratio
|
||||
- Converts images with transparency to RGB with white background
|
||||
- Generates unique filenames with timestamp and hash
|
||||
- Automatically deletes old thumbnail when uploading new one
|
||||
- Stores processed images in `uploads/project_thumbnails/`
|
||||
|
||||
#### Delete Thumbnail
|
||||
**Endpoint**: `DELETE /api/projects/{project_id}/thumbnail`
|
||||
**Access**: Coordinators and Admins only
|
||||
**Response**: 204 No Content
|
||||
|
||||
**Features**:
|
||||
- Removes thumbnail file from filesystem
|
||||
- Clears `thumbnail_path` in database
|
||||
- Returns 404 if project has no thumbnail
|
||||
|
||||
#### Serve Thumbnail
|
||||
**Endpoint**: `GET /api/files/projects/{project_id}/thumbnail`
|
||||
**Access**: All authenticated users (with project access)
|
||||
**Response**: Image file with appropriate content-type
|
||||
|
||||
**Features**:
|
||||
- Checks user has access to the project
|
||||
- Artists can only access thumbnails for projects they're members of
|
||||
- Returns 404 if thumbnail doesn't exist
|
||||
- Serves image with proper MIME type
|
||||
|
||||
### 3. Schema Updates
|
||||
|
||||
**File**: `backend/schemas/project.py`
|
||||
|
||||
Added `thumbnail_url` field to response schemas:
|
||||
|
||||
```python
|
||||
class ProjectResponse(ProjectBase):
|
||||
# ... existing fields ...
|
||||
thumbnail_url: Optional[str] = None
|
||||
|
||||
class ProjectListResponse(BaseModel):
|
||||
# ... existing fields ...
|
||||
thumbnail_url: Optional[str] = None
|
||||
```
|
||||
|
||||
### 4. Router Updates
|
||||
|
||||
**File**: `backend/routers/projects.py`
|
||||
|
||||
Updated project endpoints to include thumbnail URL:
|
||||
|
||||
```python
|
||||
# In list_projects
|
||||
if project.thumbnail_path:
|
||||
project_data.thumbnail_url = f"/api/files/projects/{project.id}/thumbnail"
|
||||
|
||||
# In get_project
|
||||
if project.thumbnail_path:
|
||||
project_data.thumbnail_url = f"/api/files/projects/{project.id}/thumbnail"
|
||||
|
||||
# In update_project
|
||||
'thumbnail_url': f"/api/files/projects/{db_project.id}/thumbnail" if db_project.thumbnail_path else None
|
||||
```
|
||||
|
||||
## File Storage Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
└── uploads/
|
||||
└── project_thumbnails/
|
||||
├── project_1_20241119_123456_a1b2c3d4.jpg
|
||||
├── project_2_20241119_123457_e5f6g7h8.jpg
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Image Processing
|
||||
|
||||
The system processes uploaded thumbnails as follows:
|
||||
|
||||
1. **Format Validation**: Only accepts jpg, jpeg, png, gif, webp
|
||||
2. **Size Validation**: Maximum 10MB file size
|
||||
3. **Color Conversion**: Converts RGBA/LA/P modes to RGB with white background
|
||||
4. **Resizing**: Maintains aspect ratio while fitting within 800x600 pixels
|
||||
5. **Optimization**: Saves as JPEG with 90% quality and optimization enabled
|
||||
|
||||
## Access Control
|
||||
|
||||
| Role | Upload | Delete | View |
|
||||
|------|--------|--------|------|
|
||||
| Admin | ✓ | ✓ | ✓ |
|
||||
| Coordinator | ✓ | ✓ | ✓ |
|
||||
| Director | ✗ | ✗ | ✓ |
|
||||
| Artist | ✗ | ✗ | ✓ (own projects only) |
|
||||
| Developer | ✗ | ✗ | ✓ |
|
||||
|
||||
## Testing
|
||||
|
||||
A test script is provided at `backend/test_project_thumbnail.py` that verifies:
|
||||
1. Thumbnail upload
|
||||
2. Thumbnail URL in project responses
|
||||
3. Thumbnail download
|
||||
4. Thumbnail deletion
|
||||
5. Thumbnail removal verification
|
||||
|
||||
To run the test:
|
||||
```bash
|
||||
cd backend
|
||||
python test_project_thumbnail.py
|
||||
```
|
||||
|
||||
**Note**: The backend server must be running on `http://localhost:8000`
|
||||
|
||||
## Error Handling
|
||||
|
||||
| Error | Status Code | Description |
|
||||
|-------|-------------|-------------|
|
||||
| Invalid file format | 400 | File extension not in allowed list |
|
||||
| File too large | 413 | File exceeds 10MB limit |
|
||||
| Image processing failed | 400 | PIL failed to process image |
|
||||
| Project not found | 404 | Invalid project_id |
|
||||
| No thumbnail | 404 | Project has no thumbnail |
|
||||
| Access denied | 403 | User lacks permission |
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
The frontend can now:
|
||||
1. Display thumbnails on project cards using `project.thumbnail_url`
|
||||
2. Upload thumbnails in project settings
|
||||
3. Replace existing thumbnails
|
||||
4. Remove thumbnails
|
||||
5. Show placeholder when no thumbnail exists
|
||||
|
||||
Example usage:
|
||||
```typescript
|
||||
// Display thumbnail
|
||||
<img :src="project.thumbnail_url || '/default-project-thumbnail.png'" />
|
||||
|
||||
// Upload thumbnail
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
await apiClient.post(`/projects/${projectId}/thumbnail`, formData)
|
||||
|
||||
// Delete thumbnail
|
||||
await apiClient.delete(`/projects/${projectId}/thumbnail`)
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
The following frontend tasks remain to be implemented:
|
||||
- Task 22: Implement frontend project thumbnail upload component
|
||||
- Task 22.1: Add thumbnail preview and management
|
||||
- Task 22.2: Integrate thumbnail upload in project settings
|
||||
- Task 22.3: Update project card to display thumbnails
|
||||
- Task 22.4: Update project type definitions
|
||||
|
||||
## Related Files
|
||||
|
||||
- `backend/models/project.py` - Project model with thumbnail_path
|
||||
- `backend/routers/projects.py` - Thumbnail upload/delete endpoints
|
||||
- `backend/routers/files.py` - Thumbnail serving endpoint
|
||||
- `backend/schemas/project.py` - Response schemas with thumbnail_url
|
||||
- `backend/migrate_project_thumbnail.py` - Database migration script
|
||||
- `backend/test_project_thumbnail.py` - Test script
|
||||
@@ -0,0 +1,110 @@
|
||||
# Shot Access 403 Error Fix
|
||||
|
||||
## Problem
|
||||
Admin users were getting 403 Forbidden errors when trying to access shot details, even though they should have access to all shots.
|
||||
|
||||
## Root Cause
|
||||
The `check_episode_access()` function in `backend/routers/shots.py` was using implicit logic:
|
||||
- It only checked if the user was an **artist**
|
||||
- If not an artist, it assumed access was granted
|
||||
- However, this logic failed because it didn't explicitly handle the case where a user has `is_admin=True`
|
||||
|
||||
The original code:
|
||||
```python
|
||||
# Check project access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == episode.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
return episode
|
||||
```
|
||||
|
||||
## Solution
|
||||
Added explicit checks for admins and coordinators at the beginning of the function:
|
||||
|
||||
```python
|
||||
# Admins and coordinators have access to all episodes
|
||||
if current_user.is_admin or current_user.role == UserRole.COORDINATOR:
|
||||
return episode
|
||||
|
||||
# Check project access for artists and other roles
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == episode.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
return episode
|
||||
```
|
||||
|
||||
## Changes Made
|
||||
|
||||
**File**: `backend/routers/shots.py`
|
||||
**Function**: `check_episode_access()` (lines 44-72)
|
||||
|
||||
### Before
|
||||
- Implicit access for non-artists
|
||||
- No explicit admin check
|
||||
|
||||
### After
|
||||
- Explicit early return for admins (`is_admin=True`)
|
||||
- Explicit early return for coordinators (`role=COORDINATOR`)
|
||||
- Clear access control logic
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Script
|
||||
Created `backend/test_admin_shot_access.py` to verify:
|
||||
- ✓ Admins can access any episode
|
||||
- ✓ Coordinators can access any episode
|
||||
- ✓ Artists can only access episodes in their projects
|
||||
|
||||
### Manual Testing
|
||||
1. Login as admin
|
||||
2. Navigate to any project
|
||||
3. Click on any shot
|
||||
4. Shot detail panel should open without 403 error
|
||||
|
||||
## Impact
|
||||
|
||||
### Fixed
|
||||
- Admins can now access all shot details
|
||||
- Coordinators can access all shot details
|
||||
- No more 403 errors for privileged users
|
||||
|
||||
### Unchanged
|
||||
- Artists still require project membership
|
||||
- Security model remains intact
|
||||
- No breaking changes to API
|
||||
|
||||
## Related Files
|
||||
- `backend/routers/shots.py` - Main fix
|
||||
- `backend/test_admin_shot_access.py` - Test script
|
||||
- `frontend/src/components/shot/ShotDetailPanel.vue` - Frontend component (no changes needed)
|
||||
|
||||
## Deployment Notes
|
||||
- No database migration required
|
||||
- No frontend changes required
|
||||
- Backend restart required to apply fix
|
||||
- Backward compatible
|
||||
|
||||
## Prevention
|
||||
This issue occurred because the access control logic relied on implicit behavior. Future improvements:
|
||||
1. Always use explicit checks for privileged roles
|
||||
2. Add comprehensive access control tests
|
||||
3. Document access control logic clearly
|
||||
|
||||
## Date
|
||||
November 17, 2025
|
||||
@@ -0,0 +1,96 @@
|
||||
# Shot Project ID Soft Deletion Service Update
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the updates made to the soft deletion services to ensure `project_id` is properly preserved during soft deletion and recovery operations for shots.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Shot Soft Deletion Service (`backend/services/shot_soft_deletion.py`)
|
||||
|
||||
#### DeletionInfo Class Updates
|
||||
- **Added `project_id` field**: The `DeletionInfo` class now includes a `project_id` field to store the project ID of the shot being deleted.
|
||||
- **Updated initialization**: The `get_deletion_info` method now populates `project_id` directly from `shot.project_id` instead of deriving it from the episode relationship.
|
||||
|
||||
#### Activity Logging Updates
|
||||
- **Direct project_id usage**: The `_log_shot_deletion` method now uses `shot.project_id` directly for the activity's `project_id` field.
|
||||
- **Enhanced metadata**: Added `project_id` and `project_name` to the activity metadata for better tracking.
|
||||
|
||||
### 2. Recovery Service (`backend/services/recovery_service.py`)
|
||||
|
||||
#### DeletedShot Class Updates
|
||||
- **Added `project_id` field**: The `DeletedShot` class now includes a `project_id` field for consistency.
|
||||
|
||||
#### RecoveryInfo Class Updates
|
||||
- **Added `project_id` field**: The `RecoveryInfo` class now includes a `project_id` field to track project information during recovery operations.
|
||||
|
||||
#### Service Method Updates
|
||||
- **Updated `get_deleted_shots`**: Now populates `project_id` directly from `shot.project_id`.
|
||||
- **Updated `preview_shot_recovery`**: Now includes `project_id` in the recovery information.
|
||||
- **Updated `_log_shot_recovery`**: Uses `shot.project_id` directly and includes project information in activity metadata.
|
||||
|
||||
### 3. Admin Router (`backend/routers/admin.py`)
|
||||
|
||||
#### API Response Updates
|
||||
- **Enhanced deleted shots endpoint**: The `/admin/deleted-shots/` endpoint now includes `project_id` in the response.
|
||||
- **Enhanced recovery preview endpoint**: The `/admin/shots/{shot_id}/recovery-preview` endpoint now includes `project_id` in the response.
|
||||
|
||||
## Key Benefits
|
||||
|
||||
### 1. Data Integrity
|
||||
- **Direct relationship**: Using `shot.project_id` directly ensures data integrity and eliminates dependency on episode relationships for project identification.
|
||||
- **Consistent tracking**: Project information is consistently preserved throughout the soft deletion and recovery lifecycle.
|
||||
|
||||
### 2. Performance Improvement
|
||||
- **Reduced joins**: Direct access to `project_id` reduces the need for complex joins through episode relationships.
|
||||
- **Faster queries**: Project filtering can now use direct `project_id` comparisons.
|
||||
|
||||
### 3. API Enhancement
|
||||
- **Complete information**: API responses now include both `project_id` and `project_name` for better frontend integration.
|
||||
- **Filtering support**: The existing project filtering functionality now works more efficiently with direct `project_id` access.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
All changes maintain backward compatibility:
|
||||
- Existing API endpoints continue to work as before
|
||||
- Additional fields are added without removing existing functionality
|
||||
- Episode relationships are still maintained and used where appropriate
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
- **Unit tests**: Existing soft deletion service tests continue to pass
|
||||
- **Integration tests**: New tests verify `project_id` preservation throughout the deletion/recovery cycle
|
||||
- **API tests**: Tests verify that API endpoints return `project_id` information correctly
|
||||
|
||||
### Test Files Created
|
||||
1. `test_project_id_preservation_simple.py` - Tests core `project_id` preservation functionality
|
||||
2. `test_admin_api_project_id.py` - Tests API endpoint responses (requires running server)
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
This implementation satisfies the following requirements from the specification:
|
||||
|
||||
### Requirement 4.5
|
||||
> "WHEN soft deleting shots, THE VFX_System SHALL preserve project_id information for recovery operations"
|
||||
|
||||
**✅ Satisfied**:
|
||||
- `project_id` is preserved in the shot record during soft deletion
|
||||
- Recovery operations maintain `project_id` consistency
|
||||
- All related data maintains project relationships
|
||||
|
||||
### Additional Benefits
|
||||
- **Enhanced logging**: Activity logs now include complete project information
|
||||
- **Improved debugging**: Project context is available in all deletion/recovery operations
|
||||
- **Better admin tools**: Admin interfaces have access to project information for better management
|
||||
|
||||
## Migration Notes
|
||||
|
||||
No database migration is required for these changes as they work with the existing `project_id` column that was added in the previous migration. The changes are purely at the service and API layer to ensure proper utilization of the existing `project_id` field.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements could include:
|
||||
- Adding project-specific deletion policies
|
||||
- Enhanced project-based recovery filtering
|
||||
- Project-scoped deletion statistics and reporting
|
||||
@@ -0,0 +1,222 @@
|
||||
# Shot Task Creation Endpoint
|
||||
|
||||
## Overview
|
||||
|
||||
Added a new backend endpoint to create tasks for shots, enabling AJAX-based task status editing in the frontend shot table.
|
||||
|
||||
## Endpoint Details
|
||||
|
||||
### POST /shots/{shot_id}/tasks
|
||||
|
||||
**Purpose**: Create a new task for a specific shot
|
||||
|
||||
**Location**: `backend/routers/shots.py` (after `get_shot` endpoint)
|
||||
|
||||
**Authentication**: Requires coordinator or admin role
|
||||
|
||||
**Parameters**:
|
||||
- `shot_id` (path): The ID of the shot
|
||||
- `task_type` (query): The type of task to create (e.g., "layout", "animation")
|
||||
|
||||
**Request Example**:
|
||||
```
|
||||
POST /shots/1/tasks?task_type=layout
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
**Response** (201 Created):
|
||||
```json
|
||||
{
|
||||
"task_type": "layout",
|
||||
"status": "not_started",
|
||||
"task_id": 123,
|
||||
"assigned_user_id": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200 OK - if task already exists):
|
||||
```json
|
||||
{
|
||||
"task_type": "layout",
|
||||
"status": "in_progress",
|
||||
"task_id": 123,
|
||||
"assigned_user_id": 5
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
```python
|
||||
@router.post("/{shot_id}/tasks", response_model=TaskStatusInfo, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shot_task(
|
||||
shot_id: int,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new task for a shot"""
|
||||
shot = db.query(Shot).filter(Shot.id == shot_id).first()
|
||||
|
||||
if not shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
episode = check_episode_access(shot.episode_id, current_user, db)
|
||||
|
||||
# Check if task already exists
|
||||
existing_task = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.task_type == task_type
|
||||
).first()
|
||||
|
||||
if existing_task:
|
||||
# Return existing task info instead of error (idempotent)
|
||||
return TaskStatusInfo(
|
||||
task_type=existing_task.task_type,
|
||||
status=existing_task.status,
|
||||
task_id=existing_task.id,
|
||||
assigned_user_id=existing_task.assigned_user_id
|
||||
)
|
||||
|
||||
# Create the task
|
||||
task_name = f"{shot.name} - {task_type.title()}"
|
||||
db_task = Task(
|
||||
project_id=episode.project_id,
|
||||
shot_id=shot.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"{task_type.title()} task for {shot.name}",
|
||||
status=TaskStatus.NOT_STARTED
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
return TaskStatusInfo(
|
||||
task_type=db_task.task_type,
|
||||
status=db_task.status,
|
||||
task_id=db_task.id,
|
||||
assigned_user_id=db_task.assigned_user_id
|
||||
)
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 1. Idempotent Operation
|
||||
- If task already exists, returns existing task info
|
||||
- No error thrown for duplicate creation attempts
|
||||
- Simplifies frontend logic (no need to check existence first)
|
||||
|
||||
### 2. Permission Validation
|
||||
- Requires coordinator or admin role
|
||||
- Uses `require_coordinator_or_admin` dependency
|
||||
- Artists cannot create tasks directly
|
||||
|
||||
### 3. Access Control
|
||||
- Validates shot exists
|
||||
- Checks episode access via `check_episode_access`
|
||||
- Ensures user has permission to access the project
|
||||
|
||||
### 4. Automatic Task Naming
|
||||
- Format: `{shot_name} - {task_type}`
|
||||
- Example: "SH010 - Layout"
|
||||
- Consistent with asset task naming
|
||||
|
||||
### 5. Default Values
|
||||
- Status: `NOT_STARTED`
|
||||
- Description: Auto-generated
|
||||
- Project ID: Inherited from episode
|
||||
- No assignee initially
|
||||
|
||||
## Error Responses
|
||||
|
||||
### 404 Not Found
|
||||
```json
|
||||
{
|
||||
"detail": "Shot not found"
|
||||
}
|
||||
```
|
||||
|
||||
### 403 Forbidden
|
||||
```json
|
||||
{
|
||||
"detail": "Insufficient permissions"
|
||||
}
|
||||
```
|
||||
|
||||
Or:
|
||||
```json
|
||||
{
|
||||
"detail": "Access denied to this project"
|
||||
}
|
||||
```
|
||||
|
||||
## Comparison with Asset Endpoint
|
||||
|
||||
The shot task creation endpoint mirrors the asset version:
|
||||
|
||||
| Feature | Asset Endpoint | Shot Endpoint |
|
||||
|---------|---------------|---------------|
|
||||
| Path | `/assets/{asset_id}/tasks` | `/shots/{shot_id}/tasks` |
|
||||
| Permission | Coordinator/Admin | Coordinator/Admin |
|
||||
| Idempotent | ✅ Yes | ✅ Yes |
|
||||
| Access Check | `check_project_access` | `check_episode_access` |
|
||||
| Task Naming | `{asset.name} - {type}` | `{shot.name} - {type}` |
|
||||
| Project ID | From asset | From episode |
|
||||
|
||||
## Frontend Integration
|
||||
|
||||
This endpoint is called by:
|
||||
- `frontend/src/services/task.ts` - `createShotTask()` method
|
||||
- `frontend/src/components/shot/EditableTaskStatus.vue` - When changing status for non-existent task
|
||||
|
||||
**Usage Flow**:
|
||||
1. User changes task status in shot table
|
||||
2. Frontend checks if task exists (via `taskId` prop)
|
||||
3. If no task exists, calls `createShotTask(shotId, taskType)`
|
||||
4. Backend creates task and returns task ID
|
||||
5. Frontend then calls `updateTaskStatus(taskId, newStatus)`
|
||||
6. Status is updated and table refreshes
|
||||
|
||||
## Testing
|
||||
|
||||
Test file created: `backend/test_shot_task_creation.py`
|
||||
|
||||
**Manual Testing**:
|
||||
1. Navigate to project shots tab
|
||||
2. Switch to table view
|
||||
3. Click on a task status cell for a shot without that task
|
||||
4. Select a status
|
||||
5. Verify:
|
||||
- Task is created
|
||||
- Status is set
|
||||
- No errors in console
|
||||
- Table updates correctly
|
||||
|
||||
## Related Files
|
||||
|
||||
- `backend/routers/shots.py` - Endpoint implementation
|
||||
- `backend/routers/assets.py` - Reference implementation for assets
|
||||
- `backend/schemas/shot.py` - TaskStatusInfo schema
|
||||
- `backend/models/task.py` - Task model
|
||||
- `frontend/src/services/task.ts` - Frontend service calling this endpoint
|
||||
- `frontend/src/components/shot/EditableTaskStatus.vue` - Component using this endpoint
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements:
|
||||
|
||||
1. **Bulk Task Creation**: Create multiple tasks at once
|
||||
2. **Custom Defaults**: Allow project-specific default task settings
|
||||
3. **Template Support**: Use task templates for consistent setup
|
||||
4. **Validation**: Validate task_type against allowed types
|
||||
5. **Webhooks**: Trigger notifications when tasks are created
|
||||
6. **Audit Log**: Track who created which tasks and when
|
||||
|
||||
## Conclusion
|
||||
|
||||
The shot task creation endpoint successfully enables AJAX-based task status editing in the shot table, providing a seamless user experience without page refreshes. The implementation follows the same pattern as the asset endpoint, ensuring consistency across the application.
|
||||
@@ -0,0 +1,99 @@
|
||||
# Soft Deletion Commit Fix
|
||||
|
||||
## Issue Description
|
||||
|
||||
The soft deletion functionality for shots and assets was not properly setting the `deleted_by` and `deleted_at` fields in the database. This was happening because the soft deletion services were using `db.flush()` to send SQL statements to the database but were not calling `db.commit()` to actually commit the transaction.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue was in the following services and their corresponding router endpoints:
|
||||
|
||||
1. **ShotSoftDeletionService** (`backend/services/shot_soft_deletion.py`)
|
||||
- Method: `soft_delete_shot_cascade()`
|
||||
- Used `db.flush()` but no `db.commit()`
|
||||
|
||||
2. **AssetSoftDeletionService** (`backend/services/asset_soft_deletion.py`)
|
||||
- Method: `soft_delete_asset_cascade()`
|
||||
- Used `db.flush()` but no `db.commit()`
|
||||
|
||||
3. **RecoveryService** (`backend/services/recovery_service.py`)
|
||||
- Methods: `recover_shot()`, `recover_asset()`, `bulk_recover_shots()`, `bulk_recover_assets()`
|
||||
- Used `db.flush()` but no `db.commit()`
|
||||
|
||||
4. **BatchOperationsService** (`backend/services/batch_operations.py`)
|
||||
- Methods: `batch_delete_shots()`, `batch_delete_assets()`, `batch_recover_shots()`, `batch_recover_assets()`
|
||||
- Used `db.flush()` but no `db.commit()`
|
||||
|
||||
## The Difference Between flush() and commit()
|
||||
|
||||
- `db.flush()`: Sends pending SQL statements to the database within the current transaction, but doesn't commit the transaction
|
||||
- `db.commit()`: Actually commits the transaction, making the changes permanent in the database
|
||||
|
||||
Without `db.commit()`, the changes were only visible within the current transaction and would be lost when the transaction ended.
|
||||
|
||||
## Files Fixed
|
||||
|
||||
### Router Endpoints (Added db.commit() calls):
|
||||
|
||||
1. **backend/routers/shots.py**
|
||||
- `delete_shot()` endpoint - Added commit after successful soft deletion
|
||||
|
||||
2. **backend/routers/assets.py**
|
||||
- `delete_asset()` endpoint - Added commit after successful soft deletion
|
||||
|
||||
3. **backend/routers/admin.py**
|
||||
- `recover_shot()` endpoint - Added commit after successful recovery
|
||||
- `recover_asset()` endpoint - Added commit after successful recovery
|
||||
- `bulk_recover_shots()` endpoint - Added commit after successful bulk recovery
|
||||
- `bulk_recover_assets()` endpoint - Added commit after successful bulk recovery
|
||||
- `batch_delete_shots()` endpoint - Added commit after successful batch deletion
|
||||
- `batch_delete_assets()` endpoint - Added commit after successful batch deletion
|
||||
- `batch_recover_shots_enhanced()` endpoint - Added commit after successful batch recovery
|
||||
- `batch_recover_assets_enhanced()` endpoint - Added commit after successful batch recovery
|
||||
|
||||
### Pattern Applied:
|
||||
|
||||
```python
|
||||
# Before (incorrect):
|
||||
result = service.soft_delete_shot_cascade(shot_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(...)
|
||||
|
||||
return response_data
|
||||
|
||||
# After (correct):
|
||||
result = service.soft_delete_shot_cascade(shot_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback() # Also added rollback on failure
|
||||
raise HTTPException(...)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return response_data
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Created `backend/test_shot_deletion_fix.py` to verify the fix works correctly. The test confirms that both `deleted_at` and `deleted_by` fields are now properly set when performing soft deletion operations.
|
||||
|
||||
## Impact
|
||||
|
||||
This fix ensures that:
|
||||
|
||||
1. Soft deletion operations properly set `deleted_by` and `deleted_at` fields
|
||||
2. Recovery operations properly clear these fields
|
||||
3. Batch operations work correctly
|
||||
4. Database transactions are properly committed
|
||||
5. Failed operations are properly rolled back
|
||||
|
||||
## Prevention
|
||||
|
||||
To prevent similar issues in the future:
|
||||
|
||||
1. Always pair `db.flush()` with `db.commit()` in router endpoints
|
||||
2. Add `db.rollback()` calls in error handling
|
||||
3. Consider adding database transaction tests to verify commits work correctly
|
||||
4. Review all service methods that modify database state to ensure proper transaction handling
|
||||
@@ -0,0 +1,60 @@
|
||||
# Task 18: Schema Fix for Custom Status Support
|
||||
|
||||
## Issue
|
||||
The backend was throwing a validation error when trying to return custom task statuses:
|
||||
```
|
||||
pydantic_core._pydantic_core.ValidationError: 1 validation error for TaskStatusInfo
|
||||
status
|
||||
Input should be 'not_started', 'in_progress', 'submitted', 'approved' or 'retake' [type=enum, input_value='custom_ready', input_type=str]
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
The `TaskStatusInfo` schema in both `backend/schemas/asset.py` and `backend/schemas/shot.py` was still using the `TaskStatus` enum for the `status` field, which only accepts the five system statuses.
|
||||
|
||||
## Fix Applied
|
||||
|
||||
### backend/schemas/asset.py
|
||||
Changed the `status` field from `TaskStatus` enum to `str`:
|
||||
```python
|
||||
class TaskStatusInfo(BaseModel):
|
||||
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
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
```
|
||||
|
||||
### backend/schemas/shot.py
|
||||
Changed the `status` field from `TaskStatus` enum to `str`:
|
||||
```python
|
||||
class TaskStatusInfo(BaseModel):
|
||||
"""Task status information for table display"""
|
||||
task_type: str # String to support custom task types
|
||||
status: str # Changed from TaskStatus enum to str to support custom statuses
|
||||
task_id: Optional[int] = None
|
||||
assigned_user_id: Optional[int] = None
|
||||
```
|
||||
|
||||
### backend/schemas/task.py
|
||||
Changed the `status` field from `TaskStatus` enum to `str` in `TaskListResponse`:
|
||||
```python
|
||||
class TaskListResponse(BaseModel):
|
||||
id: int
|
||||
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
|
||||
deadline: Optional[date] = None
|
||||
# ... other fields
|
||||
```
|
||||
|
||||
## Impact
|
||||
- Asset list endpoint (`GET /assets/`) now returns custom statuses correctly
|
||||
- Shot list endpoint (`GET /shots/`) now returns custom statuses correctly
|
||||
- Task list endpoint (`GET /tasks/`) now returns custom statuses correctly
|
||||
- All endpoints can now include custom status IDs in their responses
|
||||
|
||||
## Testing
|
||||
After this fix, the following should work:
|
||||
1. Create a custom status in a project
|
||||
2. Assign the custom status to a task
|
||||
3. View the asset or shot list - the custom status should appear without validation errors
|
||||
4. The frontend EditableTaskStatus components should display and update custom statuses correctly
|
||||
@@ -0,0 +1,188 @@
|
||||
# Task 5 Implementation Summary: Update Custom Task Status Endpoint
|
||||
|
||||
## Task Description
|
||||
|
||||
Implement PUT endpoint for updating custom task statuses in a project.
|
||||
|
||||
**Requirements Implemented:**
|
||||
- 2.1: Support updating name
|
||||
- 2.2: Support updating color
|
||||
- 2.3: Support updating is_default flag
|
||||
- 5.2: If setting as default, unset other default statuses
|
||||
|
||||
## Implementation
|
||||
|
||||
### Endpoint Added
|
||||
|
||||
**File:** `backend/routers/projects.py`
|
||||
|
||||
**Endpoint:** `PUT /projects/{project_id}/task-statuses/{status_id}`
|
||||
|
||||
**Authorization:** Requires coordinator or admin role
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Name Update (Requirement 2.1)**
|
||||
- Validates name uniqueness within project
|
||||
- Checks against other custom statuses
|
||||
- Checks against system status names
|
||||
- Returns 409 Conflict if name already exists
|
||||
|
||||
2. **Color Update (Requirement 2.2)**
|
||||
- Accepts hex color codes (e.g., #FF5733)
|
||||
- Validates color format via schema
|
||||
- Updates color independently of other fields
|
||||
|
||||
3. **Default Status Management (Requirement 2.3, 5.2)**
|
||||
- When setting a status as default, automatically unsets all other defaults
|
||||
- Ensures only one default status exists at a time
|
||||
- Allows unsetting default status
|
||||
|
||||
4. **JSON Column Updates**
|
||||
- Uses `flag_modified` to ensure SQLAlchemy detects changes to JSON columns
|
||||
- Properly persists changes to database
|
||||
|
||||
### Request/Response
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (optional)", // New status name (1-50 chars)
|
||||
"color": "string (optional)", // Hex color code (e.g., #FF5733)
|
||||
"is_default": "boolean (optional)" // Set as default status
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Custom task status updated successfully",
|
||||
"status": {
|
||||
"id": "custom_abc123",
|
||||
"name": "Updated Name",
|
||||
"color": "#00FF00",
|
||||
"order": 0,
|
||||
"is_default": true
|
||||
},
|
||||
"all_statuses": {
|
||||
"statuses": [...], // All custom statuses
|
||||
"system_statuses": [...], // System statuses
|
||||
"default_status_id": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
- **404 Not Found:** Project or status doesn't exist
|
||||
- **409 Conflict:** Name already exists or conflicts with system status
|
||||
- **403 Forbidden:** User is not coordinator or admin
|
||||
- **422 Unprocessable Entity:** Invalid request body format
|
||||
|
||||
### Code Structure
|
||||
|
||||
The implementation follows this flow:
|
||||
|
||||
1. Validate input using `CustomTaskStatusUpdate` schema
|
||||
2. Verify project exists
|
||||
3. Load custom statuses from JSON column
|
||||
4. Find status to update by ID
|
||||
5. Validate name uniqueness if name is being changed
|
||||
6. Update name, color, and/or is_default flag
|
||||
7. If setting as default, unset all other defaults
|
||||
8. Update status in the list
|
||||
9. Use `flag_modified` to mark JSON column as changed
|
||||
10. Commit to database
|
||||
11. Return updated status and all statuses
|
||||
|
||||
### Testing
|
||||
|
||||
Created test files:
|
||||
- `backend/test_update_status_manual.py` - Manual test script (requires running server)
|
||||
- `backend/docs/custom-task-status-update-endpoint.md` - Complete documentation
|
||||
|
||||
### Validation
|
||||
|
||||
The implementation includes comprehensive validation:
|
||||
|
||||
✅ Name uniqueness within project
|
||||
✅ System status name conflict detection
|
||||
✅ Color format validation (hex codes)
|
||||
✅ Only one default status at a time
|
||||
✅ Proper JSON column updates with flag_modified
|
||||
✅ Non-existent status handling
|
||||
✅ Authorization checks
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **backend/routers/projects.py**
|
||||
- Added `update_custom_task_status` endpoint (lines ~1506-1640)
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **backend/docs/custom-task-status-update-endpoint.md**
|
||||
- Complete endpoint documentation
|
||||
- Usage examples
|
||||
- Error handling details
|
||||
|
||||
2. **backend/test_update_status_manual.py**
|
||||
- Manual test script for verification
|
||||
- Tests all requirements
|
||||
|
||||
3. **backend/docs/task-5-implementation-summary.md**
|
||||
- This summary document
|
||||
|
||||
## Next Steps
|
||||
|
||||
The next task in the implementation plan is:
|
||||
|
||||
**Task 6:** Backend: Implement DELETE endpoint for deleting custom status
|
||||
- Check if status is in use by any tasks
|
||||
- Support optional reassignment of tasks to another status
|
||||
- If deleting default status, auto-assign new default
|
||||
- Prevent deletion of last status
|
||||
|
||||
## Verification
|
||||
|
||||
To verify the implementation:
|
||||
|
||||
1. Start the backend server:
|
||||
```bash
|
||||
cd backend
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
2. Run the manual test script:
|
||||
```bash
|
||||
python test_update_status_manual.py
|
||||
```
|
||||
|
||||
3. Or test manually using curl:
|
||||
```bash
|
||||
# Update name
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "New Name"}'
|
||||
|
||||
# Update color
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"color": "#00FF00"}'
|
||||
|
||||
# Set as default
|
||||
curl -X PUT "http://localhost:8000/projects/1/task-statuses/custom_abc123" \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"is_default": true}'
|
||||
```
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
✅ **Requirement 2.1:** Support updating name - Implemented with uniqueness validation
|
||||
✅ **Requirement 2.2:** Support updating color - Implemented with format validation
|
||||
✅ **Requirement 2.3:** Support updating is_default flag - Implemented
|
||||
✅ **Requirement 5.2:** If setting as default, unset other default statuses - Implemented
|
||||
|
||||
All requirements for Task 5 have been successfully implemented and verified.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Task 5 Implementation Verification
|
||||
|
||||
## Task: Backend - Implement PUT endpoint for updating custom status
|
||||
|
||||
**Status:** ✅ COMPLETED
|
||||
|
||||
## Requirements Verification
|
||||
|
||||
### Requirement 2.1: Support updating name
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` lines 1565-1593
|
||||
- The endpoint accepts an optional `name` field in the request body
|
||||
- Updates the status name when provided
|
||||
- Validates name uniqueness before updating
|
||||
|
||||
### Requirement 2.2: Support updating color
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` lines 1595-1597
|
||||
- The endpoint accepts an optional `color` field in the request body
|
||||
- Updates the status color when provided
|
||||
- Color format validation is handled by the `CustomTaskStatusUpdate` schema
|
||||
|
||||
### Requirement 2.3: Support updating is_default flag
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` lines 1599-1611
|
||||
- The endpoint accepts an optional `is_default` field in the request body
|
||||
- Updates the default status flag when provided
|
||||
- Handles both setting and unsetting the default flag
|
||||
|
||||
### Requirement 5.2: If setting as default, unset other default statuses
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` lines 1601-1607
|
||||
- When `is_default` is set to `True`, the endpoint automatically unsets all other default statuses
|
||||
- Ensures only one default status exists at a time
|
||||
- This is done by iterating through all custom statuses and setting their `is_default` to `False` before setting the current status as default
|
||||
|
||||
### Additional Requirement: Validate name uniqueness if name is changed
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` lines 1565-1593
|
||||
- Validates that the new name doesn't conflict with:
|
||||
- Other custom statuses in the same project
|
||||
- System status names
|
||||
- Returns 409 Conflict error if name already exists
|
||||
|
||||
### Additional Requirement: Use flag_modified for JSON column updates
|
||||
✅ **IMPLEMENTED**
|
||||
- Location: `backend/routers/projects.py` line 1617
|
||||
- Uses `flag_modified(db_project, 'custom_task_statuses')` to ensure SQLAlchemy detects changes to the JSON column
|
||||
- This is critical for proper database persistence of JSON column updates
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Endpoint Signature
|
||||
```python
|
||||
@router.put("/{project_id}/task-statuses/{status_id}")
|
||||
async def update_custom_task_status(
|
||||
project_id: int,
|
||||
status_id: str,
|
||||
status_update: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
)
|
||||
```
|
||||
|
||||
### Request Body Schema
|
||||
Uses `CustomTaskStatusUpdate` from `backend/schemas/custom_task_status.py`:
|
||||
```python
|
||||
{
|
||||
"name": "string (optional)", # 1-50 characters
|
||||
"color": "string (optional)", # Hex color code (e.g., #FF5733)
|
||||
"is_default": "boolean (optional)" # Set as default status
|
||||
}
|
||||
```
|
||||
|
||||
### Response Schema
|
||||
Returns `CustomTaskStatusResponse`:
|
||||
```python
|
||||
{
|
||||
"message": "string",
|
||||
"status": {
|
||||
"id": "string",
|
||||
"name": "string",
|
||||
"color": "string",
|
||||
"order": "integer",
|
||||
"is_default": "boolean"
|
||||
},
|
||||
"all_statuses": {
|
||||
"statuses": [...], # All custom statuses
|
||||
"system_statuses": [...], # System statuses
|
||||
"default_status_id": "string"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Partial Updates**: All fields are optional, allowing partial updates
|
||||
2. **Name Uniqueness Validation**: Prevents duplicate names within a project
|
||||
3. **System Status Protection**: Prevents using system status names
|
||||
4. **Default Status Management**: Automatically manages default status uniqueness
|
||||
5. **JSON Column Handling**: Properly uses `flag_modified` for database persistence
|
||||
6. **Authorization**: Requires coordinator or admin role
|
||||
7. **Comprehensive Error Handling**: Returns appropriate HTTP status codes
|
||||
|
||||
## Error Responses
|
||||
|
||||
- **404 Not Found**: Project or status doesn't exist
|
||||
- **409 Conflict**: Name already exists or conflicts with system status
|
||||
- **403 Forbidden**: User lacks coordinator/admin permissions
|
||||
- **422 Unprocessable Entity**: Invalid request body format
|
||||
|
||||
## Testing
|
||||
|
||||
Comprehensive test suite exists at `backend/test_update_custom_task_status.py` covering:
|
||||
- ✅ Status name update
|
||||
- ✅ Status color update
|
||||
- ✅ Both name and color update
|
||||
- ✅ Name uniqueness validation
|
||||
- ✅ System status name conflict detection
|
||||
- ✅ Setting status as default
|
||||
- ✅ Changing default status (unsets previous default)
|
||||
- ✅ Unsetting default status
|
||||
- ✅ Non-existent status handling
|
||||
- ✅ JSON column persistence verification
|
||||
|
||||
## Documentation
|
||||
|
||||
Complete documentation available at:
|
||||
- `backend/docs/custom-task-status-update-endpoint.md`
|
||||
|
||||
## Conclusion
|
||||
|
||||
Task 5 is **FULLY IMPLEMENTED** and meets all specified requirements:
|
||||
- ✅ PUT endpoint added to `backend/routers/projects.py`
|
||||
- ✅ Supports updating name, color, and is_default flag
|
||||
- ✅ Validates name uniqueness when name is changed
|
||||
- ✅ Unsets other default statuses when setting a new default
|
||||
- ✅ Uses `flag_modified` for JSON column updates
|
||||
- ✅ Comprehensive test coverage
|
||||
- ✅ Complete documentation
|
||||
|
||||
The implementation is production-ready and follows best practices for FastAPI development.
|
||||
@@ -0,0 +1,240 @@
|
||||
# Task 5 Requirements Checklist
|
||||
|
||||
## Task: Backend: Implement PUT endpoint for updating custom status
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
#### ✅ Requirement 2.1: Support updating name
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Validate name uniqueness if name is being changed
|
||||
if status_update.name is not None and status_update.name != status_to_update.get('name'):
|
||||
# Check against other custom statuses
|
||||
existing_names = [
|
||||
s.get('name', '').lower()
|
||||
for i, s in enumerate(custom_statuses_data)
|
||||
if isinstance(s, dict) and i != status_index
|
||||
]
|
||||
|
||||
# Check against system statuses
|
||||
system_names = [s['name'].lower() for s in SYSTEM_TASK_STATUSES]
|
||||
|
||||
if status_update.name.lower() in existing_names:
|
||||
raise HTTPException(status_code=409, ...)
|
||||
|
||||
if status_update.name.lower() in system_names:
|
||||
raise HTTPException(status_code=409, ...)
|
||||
|
||||
# Update name
|
||||
status_to_update['name'] = status_update.name
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ Name can be updated
|
||||
- ✅ Name validation works
|
||||
- ✅ Uniqueness check implemented
|
||||
- ✅ Returns updated status
|
||||
|
||||
---
|
||||
|
||||
#### ✅ Requirement 2.2: Support updating color
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Update color if provided
|
||||
if status_update.color is not None:
|
||||
status_to_update['color'] = status_update.color
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ Color can be updated
|
||||
- ✅ Color format validated by schema (hex codes)
|
||||
- ✅ Color updates independently of other fields
|
||||
- ✅ Returns updated status
|
||||
|
||||
---
|
||||
|
||||
#### ✅ Requirement 2.3: Support updating is_default flag
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Handle is_default flag
|
||||
if status_update.is_default is not None:
|
||||
if status_update.is_default:
|
||||
# If setting as default, unset other default statuses
|
||||
for status_data in custom_statuses_data:
|
||||
if isinstance(status_data, dict):
|
||||
status_data['is_default'] = False
|
||||
|
||||
# Set this status as default
|
||||
status_to_update['is_default'] = True
|
||||
else:
|
||||
# Just unset this status as default
|
||||
status_to_update['is_default'] = False
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ is_default flag can be updated
|
||||
- ✅ Can set status as default
|
||||
- ✅ Can unset status as default
|
||||
- ✅ Returns updated status
|
||||
|
||||
---
|
||||
|
||||
#### ✅ Requirement 5.2: If setting as default, unset other default statuses
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
if status_update.is_default:
|
||||
# If setting as default, unset other default statuses
|
||||
for status_data in custom_statuses_data:
|
||||
if isinstance(status_data, dict):
|
||||
status_data['is_default'] = False
|
||||
|
||||
# Set this status as default
|
||||
status_to_update['is_default'] = True
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ When setting a status as default, all other defaults are unset
|
||||
- ✅ Only one default status exists at a time
|
||||
- ✅ Default status ID is correctly returned in response
|
||||
- ✅ Previous default is properly unset
|
||||
|
||||
---
|
||||
|
||||
#### ✅ Additional Requirement: Validate name uniqueness if name is changed
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Validate name uniqueness if name is being changed
|
||||
if status_update.name is not None and status_update.name != status_to_update.get('name'):
|
||||
# Check against other custom statuses
|
||||
existing_names = [...]
|
||||
|
||||
# Check against system statuses
|
||||
system_names = [...]
|
||||
|
||||
if status_update.name.lower() in existing_names:
|
||||
raise HTTPException(status_code=409, ...)
|
||||
|
||||
if status_update.name.lower() in system_names:
|
||||
raise HTTPException(status_code=409, ...)
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ Name uniqueness validated within project
|
||||
- ✅ Checks against other custom statuses
|
||||
- ✅ Checks against system status names
|
||||
- ✅ Returns 409 Conflict on duplicate
|
||||
|
||||
---
|
||||
|
||||
#### ✅ Additional Requirement: Use flag_modified for JSON column updates
|
||||
|
||||
**Implementation:**
|
||||
```python
|
||||
# Update the status in the list
|
||||
custom_statuses_data[status_index] = status_to_update
|
||||
db_project.custom_task_statuses = custom_statuses_data
|
||||
|
||||
# Use flag_modified for JSON column updates
|
||||
flag_modified(db_project, 'custom_task_statuses')
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(...)
|
||||
```
|
||||
|
||||
**Verified:**
|
||||
- ✅ flag_modified is used
|
||||
- ✅ Changes persist to database
|
||||
- ✅ JSON column updates work correctly
|
||||
- ✅ Rollback on error
|
||||
|
||||
---
|
||||
|
||||
### Endpoint Details
|
||||
|
||||
**Route:** `PUT /projects/{project_id}/task-statuses/{status_id}`
|
||||
|
||||
**Authorization:** Requires coordinator or admin role
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"name": "string (optional)",
|
||||
"color": "string (optional)",
|
||||
"is_default": "boolean (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:** `CustomTaskStatusResponse`
|
||||
```json
|
||||
{
|
||||
"message": "string",
|
||||
"status": { ... },
|
||||
"all_statuses": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**Error Codes:**
|
||||
- 404: Project or status not found
|
||||
- 409: Name conflict (duplicate or system status)
|
||||
- 403: Insufficient permissions
|
||||
- 422: Invalid request body
|
||||
|
||||
---
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**Manual Tests Created:**
|
||||
- ✅ `test_update_status_manual.py` - Comprehensive manual test script
|
||||
- ✅ Tests all requirements
|
||||
- ✅ Tests error conditions
|
||||
- ✅ Tests edge cases
|
||||
|
||||
**Documentation Created:**
|
||||
- ✅ `custom-task-status-update-endpoint.md` - Complete API documentation
|
||||
- ✅ `task-5-implementation-summary.md` - Implementation summary
|
||||
- ✅ `task-5-requirements-checklist.md` - This checklist
|
||||
|
||||
---
|
||||
|
||||
### Code Quality
|
||||
|
||||
**Validation:**
|
||||
- ✅ No syntax errors
|
||||
- ✅ No linting errors
|
||||
- ✅ Follows existing code patterns
|
||||
- ✅ Proper error handling
|
||||
- ✅ Comprehensive validation
|
||||
- ✅ Clear error messages
|
||||
|
||||
**Best Practices:**
|
||||
- ✅ Uses dependency injection
|
||||
- ✅ Proper authorization checks
|
||||
- ✅ Transaction management (commit/rollback)
|
||||
- ✅ Input validation via Pydantic schemas
|
||||
- ✅ Consistent response format
|
||||
- ✅ Proper HTTP status codes
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
✅ **All requirements implemented and verified**
|
||||
|
||||
The PUT endpoint for updating custom task statuses has been successfully implemented with:
|
||||
- Full support for updating name, color, and is_default flag
|
||||
- Comprehensive validation (uniqueness, format, conflicts)
|
||||
- Proper default status management (only one default at a time)
|
||||
- Correct JSON column updates using flag_modified
|
||||
- Complete error handling and authorization
|
||||
- Comprehensive documentation and test scripts
|
||||
|
||||
**Task Status:** ✅ COMPLETED
|
||||
@@ -0,0 +1,277 @@
|
||||
# Task 8: Backend Task Endpoints Custom Status Support
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds support for custom task statuses to all task-related endpoints in the backend. The system now validates statuses against both system statuses and project-specific custom statuses, ensuring proper status resolution across project boundaries.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Added Helper Functions (`backend/routers/tasks.py`)
|
||||
|
||||
#### `get_project_default_status(db: Session, project_id: int) -> str`
|
||||
- Retrieves the default status for a project
|
||||
- Checks for custom statuses with `is_default=True` flag
|
||||
- Falls back to system default "not_started" if no custom default is set
|
||||
- Handles JSON parsing for custom_task_statuses field
|
||||
|
||||
#### `validate_task_status(db: Session, project_id: int, status_value: str) -> bool`
|
||||
- Validates that a status exists for a specific project
|
||||
- Checks system statuses first (not_started, in_progress, submitted, approved, retake)
|
||||
- Then checks project-specific custom statuses
|
||||
- Returns True if valid, False otherwise
|
||||
- Ensures status isolation between projects
|
||||
|
||||
### 2. Updated Task Creation Endpoint
|
||||
|
||||
**Endpoint**: `POST /tasks/`
|
||||
|
||||
**Changes**:
|
||||
- Uses `get_project_default_status()` when no status is specified
|
||||
- Validates provided status using `validate_task_status()`
|
||||
- Returns 400 error if invalid status is provided
|
||||
- Maintains backward compatibility with system statuses
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# Use default status if not specified
|
||||
task_data = task.model_dump()
|
||||
if not task_data.get('status') or task_data['status'] == 'not_started':
|
||||
task_data['status'] = get_project_default_status(db, task.project_id)
|
||||
else:
|
||||
# Validate the provided status
|
||||
if not validate_task_status(db, task.project_id, task_data['status']):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status '{task_data['status']}' for this project"
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Updated Task Status Update Endpoint
|
||||
|
||||
**Endpoint**: `PUT /tasks/{task_id}/status`
|
||||
|
||||
**Changes**:
|
||||
- Validates new status against project's available statuses
|
||||
- Returns 400 error if status is invalid for the task's project
|
||||
- Supports both system and custom statuses
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# Validate the status for the task's project
|
||||
if not validate_task_status(db, task.project_id, status_update.status):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid status '{status_update.status}' for this project"
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Updated Task Update Endpoint
|
||||
|
||||
**Endpoint**: `PUT /tasks/{task_id}`
|
||||
|
||||
**Changes**:
|
||||
- Validates status field if it's being updated
|
||||
- Checks status validity before applying update
|
||||
- Maintains existing permission checks
|
||||
|
||||
### 5. Updated Bulk Status Update Endpoint
|
||||
|
||||
**Endpoint**: `PUT /tasks/bulk/status`
|
||||
|
||||
**Changes**:
|
||||
- Validates status for each task's project before updating
|
||||
- Ensures atomic updates - all tasks succeed or all fail
|
||||
- Provides detailed error messages for invalid statuses
|
||||
- Handles tasks from different projects correctly
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
# Validate status for the task's project
|
||||
if not validate_task_status(db, task.project_id, bulk_update.status):
|
||||
errors.append({
|
||||
"task_id": task_id,
|
||||
"error": f"Invalid status '{bulk_update.status}' for task's project"
|
||||
})
|
||||
failed_count += 1
|
||||
continue
|
||||
```
|
||||
|
||||
## Requirements Addressed
|
||||
|
||||
### Requirement 5.3: Default Status for New Tasks
|
||||
✅ Task creation uses project's default status when not specified
|
||||
✅ Falls back to system default "not_started" for projects without custom statuses
|
||||
|
||||
### Requirement 6.4: Backward Compatibility
|
||||
✅ System statuses (not_started, in_progress, submitted, approved, retake) remain valid for all projects
|
||||
✅ Existing tasks with system statuses continue to work
|
||||
|
||||
### Requirement 6.5: Status Resolution
|
||||
✅ Status validation checks both system and custom statuses
|
||||
✅ Custom statuses are project-specific and don't leak between projects
|
||||
|
||||
### Requirement 10.1: Bulk Status Update Validation
|
||||
✅ Bulk updates validate status for each task's project
|
||||
✅ Atomic updates ensure consistency
|
||||
|
||||
### Requirement 10.2: Cross-Project Handling
|
||||
✅ Tasks from different projects can be updated in bulk
|
||||
✅ Each task's status is validated against its own project's statuses
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
|
||||
Created comprehensive test suite (`backend/test_task_custom_status_support.py`) covering:
|
||||
|
||||
1. **Task Creation with Default Status**
|
||||
- ✅ Default status correctly identified from custom statuses
|
||||
- ✅ Tasks created without explicit status use project default
|
||||
- ✅ Tasks can be created with explicit custom status
|
||||
|
||||
2. **Status Validation**
|
||||
- ✅ All system statuses validated successfully
|
||||
- ✅ Custom statuses validated for their project
|
||||
- ✅ Invalid statuses correctly rejected
|
||||
|
||||
3. **Status Resolution Across Projects**
|
||||
- ✅ Project 1 custom statuses rejected for Project 2
|
||||
- ✅ Project 2 custom statuses rejected for Project 1
|
||||
- ✅ System statuses valid for all projects
|
||||
- ✅ Each project has its own default status
|
||||
|
||||
4. **Bulk Status Update Validation**
|
||||
- ✅ Status validated for each task's project
|
||||
- ✅ System statuses valid for all tasks
|
||||
- ✅ Custom statuses only valid for their project
|
||||
|
||||
5. **Projects Without Custom Statuses**
|
||||
- ✅ Use system default "not_started"
|
||||
- ✅ System statuses remain valid
|
||||
- ✅ Custom statuses from other projects rejected
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
============================================================
|
||||
Testing Task Endpoints Custom Status Support
|
||||
============================================================
|
||||
|
||||
=== Test 1: Task Creation with Default Status ===
|
||||
✓ Default status correctly identified: custom_todo
|
||||
✓ Task created with default status: custom_todo
|
||||
✓ Task created with explicit custom status: custom_doing
|
||||
✓ Test 1 PASSED
|
||||
|
||||
=== Test 2: Status Validation ===
|
||||
✓ All system statuses validated successfully
|
||||
✓ All custom statuses validated successfully
|
||||
✓ Invalid statuses correctly rejected
|
||||
✓ Test 2 PASSED
|
||||
|
||||
=== Test 3: Status Resolution Across Projects ===
|
||||
✓ Project 1 custom status correctly rejected for Project 2
|
||||
✓ Project 2 custom status correctly rejected for Project 1
|
||||
✓ System statuses valid for both projects
|
||||
✓ Project 1 default: custom_todo, Project 2 default: project2_status
|
||||
✓ Test 3 PASSED
|
||||
|
||||
=== Test 4: Bulk Status Update Validation ===
|
||||
✓ Validated 2 tasks from Project 1
|
||||
✓ Validated 0 tasks from Project 2
|
||||
✓ System statuses valid for all 2 tasks
|
||||
✓ Test 4 PASSED
|
||||
|
||||
=== Test 5: Project Without Custom Statuses ===
|
||||
✓ Project without custom statuses uses system default: not_started
|
||||
✓ System statuses valid for project without custom statuses
|
||||
✓ Custom statuses from other projects correctly rejected
|
||||
✓ Test 5 PASSED
|
||||
|
||||
============================================================
|
||||
✓ ALL TESTS PASSED
|
||||
============================================================
|
||||
```
|
||||
|
||||
## API Behavior
|
||||
|
||||
### Task Creation
|
||||
```json
|
||||
POST /tasks/
|
||||
{
|
||||
"project_id": 1,
|
||||
"name": "New Task",
|
||||
"task_type": "modeling",
|
||||
// status not specified - will use project default
|
||||
}
|
||||
```
|
||||
|
||||
### Task Status Update
|
||||
```json
|
||||
PUT /tasks/123/status
|
||||
{
|
||||
"status": "custom_todo" // Must be valid for task's project
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Status Update
|
||||
```json
|
||||
PUT /tasks/bulk/status
|
||||
{
|
||||
"task_ids": [1, 2, 3],
|
||||
"status": "custom_doing" // Validated for each task's project
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Invalid Status Error
|
||||
```json
|
||||
{
|
||||
"detail": "Invalid status 'custom_nonexistent' for this project"
|
||||
}
|
||||
```
|
||||
|
||||
### Bulk Update Errors
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"failed_count": 1,
|
||||
"errors": [
|
||||
{
|
||||
"task_id": 3,
|
||||
"error": "Invalid status 'custom_todo' for task's project"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
No database changes required. The implementation uses existing fields:
|
||||
- `projects.custom_task_statuses` (JSON) - stores custom statuses
|
||||
- `tasks.status` (String) - stores status value
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
✅ **Fully backward compatible**
|
||||
- Existing tasks with system statuses continue to work
|
||||
- Projects without custom statuses use system defaults
|
||||
- System statuses remain valid for all projects
|
||||
- No migration required
|
||||
|
||||
## Next Steps
|
||||
|
||||
The frontend components should be updated to:
|
||||
1. Fetch custom statuses from `/projects/{id}/task-statuses` endpoint
|
||||
2. Display custom status colors in UI
|
||||
3. Use custom statuses in status dropdowns and filters
|
||||
4. Handle validation errors from backend
|
||||
|
||||
## Related Files
|
||||
|
||||
- `backend/routers/tasks.py` - Main implementation
|
||||
- `backend/routers/projects.py` - Custom status management endpoints
|
||||
- `backend/models/task.py` - Task model (status field is String)
|
||||
- `backend/models/project.py` - Project model (custom_task_statuses field)
|
||||
- `backend/test_task_custom_status_support.py` - Test suite
|
||||
@@ -0,0 +1,101 @@
|
||||
# Task Status Index Optimization Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the database schema and index optimization implemented for the shot-asset-task-status-optimization feature. The optimization addresses the N+1 query problem identified in the current shot and asset data fetching patterns.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The current implementation suffers from N+1 query patterns:
|
||||
- **Main Query**: Fetches shots/assets first
|
||||
- **Per-Entity Query**: For each shot/asset, runs separate query for tasks
|
||||
- **Application-Level Aggregation**: Task status building happens in Python loops
|
||||
|
||||
For 100 shots, this results in 101 database queries (1 for shots + 100 for tasks).
|
||||
|
||||
## Solution Implemented
|
||||
|
||||
### New Database Indexes Created
|
||||
|
||||
The following indexes were created to optimize task status queries:
|
||||
|
||||
1. **`idx_tasks_shot_id_active`**
|
||||
- Optimizes task lookups by shot_id (active tasks only)
|
||||
- Includes WHERE clause: `deleted_at IS NULL`
|
||||
|
||||
2. **`idx_tasks_asset_id_active`**
|
||||
- Optimizes task lookups by asset_id (active tasks only)
|
||||
- Includes WHERE clause: `deleted_at IS NULL`
|
||||
|
||||
3. **`idx_tasks_status_type_active`**
|
||||
- Optimizes task status and type filtering
|
||||
- Covers: `(status, task_type)` with `deleted_at IS NULL`
|
||||
|
||||
4. **`idx_tasks_shot_status_type_active`**
|
||||
- Composite index for shot + status + type queries
|
||||
- Covers: `(shot_id, status, task_type)` with `deleted_at IS NULL`
|
||||
|
||||
5. **`idx_tasks_asset_status_type_active`**
|
||||
- Composite index for asset + status + type queries
|
||||
- Covers: `(asset_id, status, task_type)` with `deleted_at IS NULL`
|
||||
|
||||
6. **`idx_tasks_details_shot`**
|
||||
- Optimizes queries needing full task details for shots
|
||||
- Covers: `(shot_id, id, task_type, status, assigned_user_id, updated_at)`
|
||||
|
||||
7. **`idx_tasks_details_asset`**
|
||||
- Optimizes queries needing full task details for assets
|
||||
- Covers: `(asset_id, id, task_type, status, assigned_user_id, updated_at)`
|
||||
|
||||
8. **`idx_tasks_project_status_active`**
|
||||
- Optimizes project-wide task queries with status filtering
|
||||
- Covers: `(project_id, status, task_type)` with `deleted_at IS NULL`
|
||||
|
||||
### Performance Results
|
||||
|
||||
Testing with the current dataset (1,444 tasks, 441 shots, 15 assets):
|
||||
|
||||
| Query Type | Execution Time | Performance |
|
||||
|------------|----------------|-------------|
|
||||
| Shot list with task aggregation (441 shots) | 6ms | ✅ Excellent |
|
||||
| Asset list with task aggregation (15 assets) | 1ms | ✅ Excellent |
|
||||
| Project dashboard (1,444 tasks) | 1ms | ✅ Excellent |
|
||||
| Task browser with filtering | 1ms | ✅ Excellent |
|
||||
| Complex aggregation statistics | 4ms | ✅ Excellent |
|
||||
|
||||
**All queries perform well under the 500ms requirement**, with most completing in under 10ms.
|
||||
|
||||
### Index Usage Verification
|
||||
|
||||
Query plan analysis confirms that all new indexes are being used correctly:
|
||||
|
||||
- ✅ `idx_tasks_shot_id_active` used for shot task lookups
|
||||
- ✅ `idx_tasks_asset_id_active` used for asset task lookups
|
||||
- ✅ `idx_tasks_status_type_active` used for status filtering
|
||||
- ✅ `idx_tasks_shot_status_type_active` used for shot+status combinations
|
||||
- ✅ `idx_tasks_asset_status_type_active` used for asset+status combinations
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **`create_task_status_indexes.py`** - Main index creation script
|
||||
2. **`test_index_performance.py`** - Performance testing with realistic queries
|
||||
3. **`test_index_scalability.py`** - Scalability testing with current dataset
|
||||
4. **`check_indexes.py`** - Utility to inspect current database indexes
|
||||
|
||||
## Next Steps
|
||||
|
||||
The database optimization is complete and ready for the next phase:
|
||||
|
||||
1. **Backend Router Optimization** - Implement optimized query patterns in shot/asset routers
|
||||
2. **Frontend Component Updates** - Remove redundant API calls in components
|
||||
3. **Integration Testing** - Test end-to-end performance improvements
|
||||
|
||||
## Requirements Validation
|
||||
|
||||
This implementation satisfies the following requirements:
|
||||
|
||||
- ✅ **Requirement 3.1**: Uses optimized SQL joins for single database round trips
|
||||
- ✅ **Requirement 3.2**: Maintains query performance through proper indexing strategies
|
||||
- ✅ **Requirement 1.5 & 2.5**: Completes data fetching in under 500ms for 100+ entities
|
||||
|
||||
The database schema optimization provides the foundation for eliminating N+1 query patterns and achieving significant performance improvements in shot and asset data table rendering.
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to fix the activity_metadata column name in the activities table.
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from database import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Fix the activity_metadata column name in the activities table."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
# Check if the column exists with the wrong name
|
||||
result = conn.execute(text("PRAGMA table_info(activities)")).fetchall()
|
||||
columns = [row[1] for row in result]
|
||||
|
||||
if 'metadata' in columns and 'activity_metadata' not in columns:
|
||||
logger.info("Renaming 'metadata' column to 'activity_metadata'...")
|
||||
|
||||
# SQLite doesn't support ALTER COLUMN RENAME directly, so we need to recreate the table
|
||||
# First, create a backup of the data
|
||||
conn.execute(text("""
|
||||
CREATE TABLE activities_backup AS
|
||||
SELECT * FROM activities
|
||||
"""))
|
||||
|
||||
# Drop the old table
|
||||
conn.execute(text("DROP TABLE activities"))
|
||||
|
||||
# Create the new table with correct column name
|
||||
conn.execute(text("""
|
||||
CREATE TABLE activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
task_id INTEGER,
|
||||
asset_id INTEGER,
|
||||
shot_id INTEGER,
|
||||
submission_id INTEGER,
|
||||
description TEXT NOT NULL,
|
||||
activity_metadata TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (shot_id) REFERENCES shots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (submission_id) REFERENCES submissions(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
# Copy data back with correct column mapping
|
||||
conn.execute(text("""
|
||||
INSERT INTO activities (
|
||||
id, type, user_id, project_id, task_id, asset_id, shot_id,
|
||||
submission_id, description, activity_metadata, created_at
|
||||
)
|
||||
SELECT
|
||||
id, type, user_id, project_id, task_id, asset_id, shot_id,
|
||||
submission_id, description, metadata, created_at
|
||||
FROM activities_backup
|
||||
"""))
|
||||
|
||||
# Drop the backup table
|
||||
conn.execute(text("DROP TABLE activities_backup"))
|
||||
|
||||
# Recreate indexes
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_type
|
||||
ON activities(type)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_user_id
|
||||
ON activities(user_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_project_id
|
||||
ON activities(project_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||
ON activities(created_at)
|
||||
"""))
|
||||
|
||||
logger.info("✅ Column renamed successfully!")
|
||||
|
||||
elif 'activity_metadata' in columns:
|
||||
logger.info("✅ Column 'activity_metadata' already exists - no migration needed")
|
||||
else:
|
||||
logger.info("Adding 'activity_metadata' column...")
|
||||
conn.execute(text("""
|
||||
ALTER TABLE activities
|
||||
ADD COLUMN activity_metadata TEXT
|
||||
"""))
|
||||
logger.info("✅ Column added successfully!")
|
||||
|
||||
conn.commit()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {str(e)}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Fix project enum values in the database to match the model definitions.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def fix_project_enums():
|
||||
"""Fix project enum values to match model definitions."""
|
||||
|
||||
print("Fixing Project Enum Values")
|
||||
print("=" * 30)
|
||||
|
||||
db_path = "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Fix project_type values
|
||||
print("1. Fixing project_type values...")
|
||||
cursor.execute("UPDATE projects SET project_type = 'cinema' WHERE project_type = 'CINEMA'")
|
||||
cursor.execute("UPDATE projects SET project_type = 'tv' WHERE project_type = 'TV'")
|
||||
cursor.execute("UPDATE projects SET project_type = 'game' WHERE project_type = 'GAME'")
|
||||
|
||||
# Fix status values
|
||||
print("2. Fixing status values...")
|
||||
cursor.execute("UPDATE projects SET status = 'planning' WHERE status = 'PLANNING'")
|
||||
cursor.execute("UPDATE projects SET status = 'in_progress' WHERE status = 'IN_PROGRESS'")
|
||||
cursor.execute("UPDATE projects SET status = 'on_hold' WHERE status = 'ON_HOLD'")
|
||||
cursor.execute("UPDATE projects SET status = 'completed' WHERE status = 'COMPLETED'")
|
||||
cursor.execute("UPDATE projects SET status = 'cancelled' WHERE status = 'CANCELLED'")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
|
||||
# Verify the changes
|
||||
print("\n3. Verifying changes...")
|
||||
cursor.execute("SELECT id, name, project_type, status FROM projects")
|
||||
projects = cursor.fetchall()
|
||||
|
||||
for project in projects:
|
||||
print(f" ID {project[0]}: {project[1]} - Type: {project[2]}, Status: {project[3]}")
|
||||
|
||||
print("\n✅ Project enum values fixed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
fix_project_enums()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Fix task types in database - convert uppercase to lowercase
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect('backend/vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("=== Fixing Task Type Case ===\n")
|
||||
|
||||
# Get all distinct task types
|
||||
cursor.execute('SELECT DISTINCT task_type FROM tasks')
|
||||
task_types = [row[0] for row in cursor.fetchall()]
|
||||
print(f"Current task types: {task_types}\n")
|
||||
|
||||
# Convert uppercase to lowercase
|
||||
uppercase_types = [tt for tt in task_types if tt.isupper()]
|
||||
if uppercase_types:
|
||||
print(f"Converting {len(uppercase_types)} uppercase task types to lowercase...")
|
||||
for task_type in uppercase_types:
|
||||
lowercase = task_type.lower()
|
||||
cursor.execute('UPDATE tasks SET task_type = ? WHERE task_type = ?', (lowercase, task_type))
|
||||
count = cursor.rowcount
|
||||
print(f" {task_type} -> {lowercase} ({count} tasks updated)")
|
||||
|
||||
conn.commit()
|
||||
print("\n✓ Task types converted successfully")
|
||||
else:
|
||||
print("✓ All task types are already lowercase")
|
||||
|
||||
# Show final state
|
||||
cursor.execute('SELECT DISTINCT task_type FROM tasks')
|
||||
final_types = [row[0] for row in cursor.fetchall()]
|
||||
print(f"\nFinal task types: {final_types}")
|
||||
|
||||
conn.close()
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
import time
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
|
||||
from database import engine, Base
|
||||
from routers import auth, users, projects, episodes, assets, shots, tasks, reviews, files, developer, settings, notifications, activities, admin, data_consistency
|
||||
# Import models to ensure they are registered with SQLAlchemy
|
||||
import models
|
||||
|
||||
# # Configure logging
|
||||
# logging.basicConfig(
|
||||
# level=logging.DEBUG,
|
||||
# format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
# )
|
||||
# logger = logging.getLogger("vfx_api")
|
||||
# # logger = logging.getLogger("uvicorn")
|
||||
# logger.setLevel(logging.DEBUG)
|
||||
|
||||
# # Also set the auth logger to debug
|
||||
# auth_logger = logging.getLogger("vfx_auth")
|
||||
# auth_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LinkDesk API",
|
||||
description="A comprehensive project management system for animation and VFX production",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:5173", "http://localhost:5174"], # Vue.js dev server
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Add logging middleware
|
||||
# @app.middleware("http")
|
||||
# async def log_requests(request: Request, call_next):
|
||||
# """Log all HTTP requests and responses."""
|
||||
|
||||
# start_time = time.time()
|
||||
# # Log request
|
||||
# client_ip = request.client.host if request.client else "unknown"
|
||||
# auth_header = request.headers.get("authorization", "none")
|
||||
|
||||
# # Mask the token for security
|
||||
# if auth_header and auth_header.startswith("Bearer "):
|
||||
# auth_display = f"Bearer {auth_header[7:15]}..."
|
||||
# else:
|
||||
# auth_display = auth_header
|
||||
|
||||
# log_message = f"🔵 {request.method} {request.url.path} from {client_ip} | Auth: {auth_display}"
|
||||
# logger.info(log_message)
|
||||
# print(log_message) # Also print to console for debugging
|
||||
|
||||
# # Process the request
|
||||
# try:
|
||||
# response = await call_next(request)
|
||||
# process_time = time.time() - start_time
|
||||
|
||||
# # Log response
|
||||
# status_emoji = "🟢" if response.status_code < 400 else "🔴" if response.status_code >= 500 else "🟡"
|
||||
# response_message = f"{status_emoji} {response.status_code} - {process_time:.3f}s"
|
||||
# logger.info(response_message)
|
||||
# print(response_message) # Also print to console for debugging
|
||||
|
||||
# return response
|
||||
|
||||
# except Exception as e:
|
||||
# process_time = time.time() - start_time
|
||||
# logger.error(f"🔴 ERROR: {str(e)} - {process_time:.3f}s")
|
||||
# raise
|
||||
|
||||
# Mount static files for uploads
|
||||
uploads_dir = os.path.join(os.path.dirname(__file__), "uploads")
|
||||
if not os.path.exists(uploads_dir):
|
||||
os.makedirs(uploads_dir)
|
||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||
|
||||
# Include routers
|
||||
app.include_router(auth.router, prefix="/auth", tags=["authentication"])
|
||||
app.include_router(users.router, prefix="/users", tags=["users"])
|
||||
app.include_router(projects.router, prefix="/projects", tags=["projects"])
|
||||
app.include_router(episodes.router, prefix="/episodes", tags=["episodes"])
|
||||
app.include_router(assets.router, prefix="/assets", tags=["assets"])
|
||||
app.include_router(shots.router, prefix="/shots", tags=["shots"])
|
||||
app.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
||||
app.include_router(reviews.router, prefix="/reviews", tags=["reviews"])
|
||||
app.include_router(files.router, prefix="/files", tags=["files"])
|
||||
app.include_router(developer.router, prefix="/developer", tags=["developer"])
|
||||
app.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||
app.include_router(notifications.router, tags=["notifications"])
|
||||
app.include_router(activities.router, tags=["activities"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
app.include_router(data_consistency.router, prefix="/data-consistency", tags=["data-consistency"])
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "VFX Project Management System API"}
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy"}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Migration script to add activities table to the database.
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from database import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Add activities table to the database."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
# Create activities table
|
||||
logger.info("Creating activities table...")
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
project_id INTEGER,
|
||||
task_id INTEGER,
|
||||
asset_id INTEGER,
|
||||
shot_id INTEGER,
|
||||
submission_id INTEGER,
|
||||
description TEXT NOT NULL,
|
||||
activity_metadata TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (shot_id) REFERENCES shots(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (submission_id) REFERENCES submissions(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes for activities
|
||||
logger.info("Creating indexes for activities table...")
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_type
|
||||
ON activities(type)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_user_id
|
||||
ON activities(user_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_project_id
|
||||
ON activities(project_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_created_at
|
||||
ON activities(created_at)
|
||||
"""))
|
||||
|
||||
conn.commit()
|
||||
logger.info("✅ Activities table created successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {str(e)}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to convert admin role users to admin permission users.
|
||||
This script should be run once to migrate existing admin users to the new admin permission system.
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from database import DATABASE_URL, Base
|
||||
from models.user import User, UserRole
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def migrate_admin_users():
|
||||
"""Migrate existing admin role users to admin permission system."""
|
||||
|
||||
# Create engine and session
|
||||
engine = create_engine(DATABASE_URL)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# Create tables if they don't exist
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
# Check if is_admin column exists, if not add it
|
||||
try:
|
||||
# Try to query is_admin column
|
||||
db.execute(text("SELECT is_admin FROM users LIMIT 1"))
|
||||
logger.info("is_admin column already exists")
|
||||
except Exception:
|
||||
# Add is_admin column if it doesn't exist
|
||||
logger.info("Adding is_admin column to users table")
|
||||
db.execute(text("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE"))
|
||||
db.commit()
|
||||
|
||||
# Find all users with admin role and convert them
|
||||
# Use raw SQL to avoid enum validation issues
|
||||
result = db.execute(text("SELECT * FROM users WHERE role = 'ADMIN'"))
|
||||
admin_users_data = result.fetchall()
|
||||
|
||||
# Also try lowercase 'admin' in case some are stored that way
|
||||
result2 = db.execute(text("SELECT * FROM users WHERE role = 'admin'"))
|
||||
admin_users_data2 = result2.fetchall()
|
||||
|
||||
all_admin_data = list(admin_users_data) + list(admin_users_data2)
|
||||
|
||||
if not all_admin_data:
|
||||
logger.info("No admin role users found to migrate")
|
||||
return
|
||||
|
||||
logger.info(f"Found {len(all_admin_data)} admin role users to migrate")
|
||||
|
||||
for user_data in all_admin_data:
|
||||
user_id = user_data[0] # Assuming id is the first column
|
||||
user_email = user_data[1] # Assuming email is the second column
|
||||
|
||||
logger.info(f"Migrating user {user_email} (ID: {user_id}) from admin role to admin permission")
|
||||
|
||||
# Update using raw SQL to avoid enum validation issues
|
||||
# Use uppercase enum values as they're stored in the database
|
||||
db.execute(text("""
|
||||
UPDATE users
|
||||
SET role = 'COORDINATOR', is_admin = TRUE
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
logger.info(f"User {user_email} migrated: role=COORDINATOR, is_admin=True")
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
logger.info("Migration completed successfully")
|
||||
|
||||
# Verify migration using raw SQL
|
||||
result = db.execute(text("SELECT email, role, is_admin FROM users WHERE is_admin = TRUE"))
|
||||
admin_permission_users = result.fetchall()
|
||||
logger.info(f"After migration: {len(admin_permission_users)} users have admin permission")
|
||||
|
||||
for user_data in admin_permission_users:
|
||||
email, role, is_admin = user_data
|
||||
logger.info(f" - {email}: role={role}, is_admin={is_admin}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting admin user migration...")
|
||||
migrate_admin_users()
|
||||
logger.info("Migration completed!")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""
|
||||
Migration script to add avatar_url field to users table
|
||||
"""
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
def migrate_avatar_field():
|
||||
"""Add avatar_url column to users table"""
|
||||
db_path = "vfx_project_management.db"
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
print(f"Database file {db_path} not found!")
|
||||
return
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if column already exists
|
||||
cursor.execute("PRAGMA table_info(users)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'avatar_url' in columns:
|
||||
print("✓ avatar_url column already exists in users table")
|
||||
else:
|
||||
# Add avatar_url column
|
||||
cursor.execute("ALTER TABLE users ADD COLUMN avatar_url TEXT")
|
||||
conn.commit()
|
||||
print("✓ Added avatar_url column to users table")
|
||||
|
||||
print("\nMigration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during migration: {e}")
|
||||
conn.rollback()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_avatar_field()
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Migration script to add custom_task_statuses column to projects table
|
||||
and convert task status from enum to string.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Database path
|
||||
DB_PATH = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add custom_task_statuses column and migrate task status to string"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("Starting migration for custom task statuses...")
|
||||
|
||||
# Check if column already exists in projects table
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
# Add custom_task_statuses column if it doesn't exist
|
||||
if 'custom_task_statuses' not in columns:
|
||||
print("Adding custom_task_statuses column to projects table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN custom_task_statuses TEXT
|
||||
""")
|
||||
# Initialize with empty arrays
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET custom_task_statuses = '[]'
|
||||
WHERE custom_task_statuses IS NULL
|
||||
""")
|
||||
print("✓ Added custom_task_statuses column")
|
||||
else:
|
||||
print("✓ custom_task_statuses column already exists")
|
||||
|
||||
# Note: SQLite doesn't support changing column types directly
|
||||
# The status column in tasks table will remain as TEXT in the database
|
||||
# The Enum constraint was only enforced at the application level
|
||||
print("✓ Task status column already supports string values")
|
||||
|
||||
# Verify existing task statuses and convert uppercase to lowercase
|
||||
cursor.execute("SELECT DISTINCT status FROM tasks")
|
||||
existing_statuses = [row[0] for row in cursor.fetchall()]
|
||||
print(f"✓ Existing task statuses in database: {existing_statuses}")
|
||||
|
||||
# Convert uppercase enum values to lowercase string values
|
||||
status_mapping = {
|
||||
'NOT_STARTED': 'not_started',
|
||||
'IN_PROGRESS': 'in_progress',
|
||||
'SUBMITTED': 'submitted',
|
||||
'APPROVED': 'approved',
|
||||
'RETAKE': 'retake'
|
||||
}
|
||||
|
||||
for old_status, new_status in status_mapping.items():
|
||||
cursor.execute(
|
||||
"UPDATE tasks SET status = ? WHERE status = ?",
|
||||
(new_status, old_status)
|
||||
)
|
||||
updated_count = cursor.rowcount
|
||||
if updated_count > 0:
|
||||
print(f"✓ Converted {updated_count} tasks from '{old_status}' to '{new_status}'")
|
||||
|
||||
# Verify conversion
|
||||
cursor.execute("SELECT DISTINCT status FROM tasks")
|
||||
updated_statuses = [row[0] for row in cursor.fetchall()]
|
||||
print(f"✓ Updated task statuses in database: {updated_statuses}")
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ Migration completed successfully!")
|
||||
print("\nNext steps:")
|
||||
print("1. System statuses remain available: not_started, in_progress, submitted, approved, retake")
|
||||
print("2. Projects can now define custom statuses via the custom_task_statuses JSON column")
|
||||
print("3. Tasks can use both system and custom status values")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"\n❌ Migration failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Migration script to add custom task type columns to projects table
|
||||
and convert task_type from enum to string.
|
||||
"""
|
||||
import sqlite3
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Database path
|
||||
DB_PATH = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add custom task type columns and migrate task_type to string"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
print("Starting migration for custom task types...")
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
# Add custom_asset_task_types column if it doesn't exist
|
||||
if 'custom_asset_task_types' not in columns:
|
||||
print("Adding custom_asset_task_types column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN custom_asset_task_types TEXT
|
||||
""")
|
||||
# Initialize with empty arrays
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET custom_asset_task_types = '[]'
|
||||
WHERE custom_asset_task_types IS NULL
|
||||
""")
|
||||
print("✓ Added custom_asset_task_types column")
|
||||
else:
|
||||
print("✓ custom_asset_task_types column already exists")
|
||||
|
||||
# Add custom_shot_task_types column if it doesn't exist
|
||||
if 'custom_shot_task_types' not in columns:
|
||||
print("Adding custom_shot_task_types column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN custom_shot_task_types TEXT
|
||||
""")
|
||||
# Initialize with empty arrays
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET custom_shot_task_types = '[]'
|
||||
WHERE custom_shot_task_types IS NULL
|
||||
""")
|
||||
print("✓ Added custom_shot_task_types column")
|
||||
else:
|
||||
print("✓ custom_shot_task_types column already exists")
|
||||
|
||||
# Note: SQLite doesn't support changing column types directly
|
||||
# The task_type column will remain as TEXT in the database
|
||||
# The Enum constraint was only enforced at the application level
|
||||
print("✓ Task type column already supports string values")
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"\n❌ Migration failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Production-ready database migration script to convert absolute file paths to relative paths.
|
||||
|
||||
This script is designed for production deployment and includes comprehensive
|
||||
validation, backup recommendations, and detailed logging.
|
||||
|
||||
Requirements addressed: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from datetime import datetime
|
||||
|
||||
# Add the backend directory to the path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
from models.task import Submission, TaskAttachment
|
||||
from models.project import Project
|
||||
|
||||
# Configure logging
|
||||
def setup_logging():
|
||||
"""Setup logging configuration."""
|
||||
log_filename = f"migration_file_paths_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler(log_filename),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProductionFilePathMigrator:
|
||||
"""Production-ready file path migrator with comprehensive validation and error handling."""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = setup_logging()
|
||||
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
self.backend_dir = Path(__file__).parent.resolve()
|
||||
self.errors = []
|
||||
self.warnings = []
|
||||
self.stats = {
|
||||
'submissions_processed': 0,
|
||||
'submissions_converted': 0,
|
||||
'submissions_skipped': 0,
|
||||
'attachments_processed': 0,
|
||||
'attachments_converted': 0,
|
||||
'attachments_skipped': 0,
|
||||
'projects_processed': 0,
|
||||
'projects_converted': 0,
|
||||
'projects_skipped': 0,
|
||||
'errors': 0,
|
||||
'warnings': 0
|
||||
}
|
||||
|
||||
def create_database_backup(self) -> Optional[str]:
|
||||
"""Create a backup of the database before migration."""
|
||||
try:
|
||||
db_path = self.backend_dir / "database.db"
|
||||
if not db_path.exists():
|
||||
self.logger.warning("Database file not found, skipping backup")
|
||||
return None
|
||||
|
||||
backup_filename = f"database_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
|
||||
backup_path = self.backend_dir / backup_filename
|
||||
|
||||
shutil.copy2(db_path, backup_path)
|
||||
self.logger.info(f"Database backup created: {backup_filename}")
|
||||
return str(backup_path)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create database backup: {e}")
|
||||
return None
|
||||
|
||||
def validate_database_connection(self) -> bool:
|
||||
"""Validate database connection and basic structure."""
|
||||
try:
|
||||
db = self.SessionLocal()
|
||||
|
||||
# Test basic queries
|
||||
submissions_count = db.query(Submission).count()
|
||||
attachments_count = db.query(TaskAttachment).count()
|
||||
projects_count = db.query(Project).count()
|
||||
|
||||
self.logger.info(f"Database validation successful:")
|
||||
self.logger.info(f" - Submissions: {submissions_count}")
|
||||
self.logger.info(f" - Attachments: {attachments_count}")
|
||||
self.logger.info(f" - Projects: {projects_count}")
|
||||
|
||||
db.close()
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Database validation failed: {e}")
|
||||
return False
|
||||
|
||||
def is_absolute_path(self, path: str) -> bool:
|
||||
"""Check if a path is absolute."""
|
||||
if not path:
|
||||
return False
|
||||
path_obj = Path(path)
|
||||
return path_obj.is_absolute()
|
||||
|
||||
def convert_to_relative_path(self, absolute_path: str) -> Optional[str]:
|
||||
"""
|
||||
Convert absolute path to relative path with enhanced error handling.
|
||||
|
||||
Args:
|
||||
absolute_path: The absolute file path to convert
|
||||
|
||||
Returns:
|
||||
Relative path string or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
abs_path = Path(absolute_path).resolve()
|
||||
|
||||
# Check if the path is within the backend directory
|
||||
try:
|
||||
relative_path = abs_path.relative_to(self.backend_dir)
|
||||
return str(relative_path).replace('\\', '/') # Use forward slashes for consistency
|
||||
except ValueError:
|
||||
# Path is not within backend directory - try to extract uploads part
|
||||
path_parts = abs_path.parts
|
||||
if 'uploads' in path_parts:
|
||||
uploads_index = path_parts.index('uploads')
|
||||
relative_parts = path_parts[uploads_index:]
|
||||
relative_path = '/'.join(relative_parts)
|
||||
self.logger.info(f"Extracted uploads path from external location: {relative_path}")
|
||||
return relative_path
|
||||
|
||||
# Try to find backend directory in path
|
||||
if 'backend' in path_parts:
|
||||
backend_index = path_parts.index('backend')
|
||||
if backend_index + 1 < len(path_parts):
|
||||
relative_parts = path_parts[backend_index + 1:]
|
||||
relative_path = '/'.join(relative_parts)
|
||||
self.logger.info(f"Extracted path relative to backend: {relative_path}")
|
||||
return relative_path
|
||||
|
||||
self.logger.warning(f"Cannot determine relative path for: {absolute_path}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to convert path {absolute_path}: {e}")
|
||||
return None
|
||||
|
||||
def validate_file_exists(self, relative_path: str) -> Tuple[bool, str]:
|
||||
"""
|
||||
Validate that the file exists at the relative path.
|
||||
|
||||
Args:
|
||||
relative_path: The relative path to validate
|
||||
|
||||
Returns:
|
||||
Tuple of (exists, full_path)
|
||||
"""
|
||||
try:
|
||||
full_path = self.backend_dir / relative_path
|
||||
exists = full_path.exists()
|
||||
return exists, str(full_path)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error validating file existence for {relative_path}: {e}")
|
||||
return False, ""
|
||||
|
||||
def analyze_problematic_paths(self) -> Dict[str, List[str]]:
|
||||
"""Analyze and categorize problematic paths before migration."""
|
||||
self.logger.info("Analyzing potentially problematic paths...")
|
||||
|
||||
problematic = {
|
||||
'submissions': [],
|
||||
'attachments': [],
|
||||
'projects': []
|
||||
}
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
# Check submissions
|
||||
submissions = db.query(Submission).all()
|
||||
for sub in submissions:
|
||||
if self.is_absolute_path(sub.file_path):
|
||||
relative = self.convert_to_relative_path(sub.file_path)
|
||||
if not relative:
|
||||
problematic['submissions'].append(f"ID {sub.id}: {sub.file_path}")
|
||||
else:
|
||||
exists, _ = self.validate_file_exists(relative)
|
||||
if not exists:
|
||||
problematic['submissions'].append(f"ID {sub.id}: File not found - {sub.file_path}")
|
||||
|
||||
# Check attachments
|
||||
attachments = db.query(TaskAttachment).all()
|
||||
for att in attachments:
|
||||
if self.is_absolute_path(att.file_path):
|
||||
relative = self.convert_to_relative_path(att.file_path)
|
||||
if not relative:
|
||||
problematic['attachments'].append(f"ID {att.id}: {att.file_path}")
|
||||
else:
|
||||
exists, _ = self.validate_file_exists(relative)
|
||||
if not exists:
|
||||
problematic['attachments'].append(f"ID {att.id}: File not found - {att.file_path}")
|
||||
|
||||
# Check projects
|
||||
projects = db.query(Project).filter(Project.thumbnail_path.isnot(None)).all()
|
||||
for proj in projects:
|
||||
if self.is_absolute_path(proj.thumbnail_path):
|
||||
relative = self.convert_to_relative_path(proj.thumbnail_path)
|
||||
if not relative:
|
||||
problematic['projects'].append(f"ID {proj.id}: {proj.thumbnail_path}")
|
||||
else:
|
||||
exists, _ = self.validate_file_exists(relative)
|
||||
if not exists:
|
||||
problematic['projects'].append(f"ID {proj.id}: File not found - {proj.thumbnail_path}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Report findings
|
||||
total_issues = sum(len(issues) for issues in problematic.values())
|
||||
if total_issues > 0:
|
||||
self.logger.warning(f"Found {total_issues} potentially problematic paths:")
|
||||
for category, issues in problematic.items():
|
||||
if issues:
|
||||
self.logger.warning(f" {category.upper()}:")
|
||||
for issue in issues:
|
||||
self.logger.warning(f" - {issue}")
|
||||
else:
|
||||
self.logger.info("No problematic paths detected")
|
||||
|
||||
return problematic
|
||||
|
||||
def migrate_submissions_table(self) -> None:
|
||||
"""Migrate file paths in submissions table with enhanced error handling."""
|
||||
self.logger.info("Starting migration of submissions table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
submissions = db.query(Submission).all()
|
||||
self.stats['submissions_processed'] = len(submissions)
|
||||
|
||||
for submission in submissions:
|
||||
try:
|
||||
if not self.is_absolute_path(submission.file_path):
|
||||
self.logger.debug(f"Submission {submission.id} already has relative path: {submission.file_path}")
|
||||
self.stats['submissions_skipped'] += 1
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(submission.file_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
exists, full_path = self.validate_file_exists(relative_path)
|
||||
if exists:
|
||||
old_path = submission.file_path
|
||||
submission.file_path = relative_path
|
||||
self.stats['submissions_converted'] += 1
|
||||
self.logger.info(f"Submission {submission.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Submission {submission.id}: File not found at {full_path}"
|
||||
self.logger.warning(error_msg)
|
||||
self.warnings.append(error_msg)
|
||||
self.stats['warnings'] += 1
|
||||
else:
|
||||
error_msg = f"Submission {submission.id}: Failed to convert path {submission.file_path}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Submission {submission.id}: Exception during migration: {e}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
self.logger.info(f"Submissions migration completed. Converted: {self.stats['submissions_converted']}, Skipped: {self.stats['submissions_skipped']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to migrate submissions table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def migrate_attachments_table(self) -> None:
|
||||
"""Migrate file paths in task_attachments table with enhanced error handling."""
|
||||
self.logger.info("Starting migration of task_attachments table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
attachments = db.query(TaskAttachment).all()
|
||||
self.stats['attachments_processed'] = len(attachments)
|
||||
|
||||
for attachment in attachments:
|
||||
try:
|
||||
if not self.is_absolute_path(attachment.file_path):
|
||||
self.logger.debug(f"Attachment {attachment.id} already has relative path: {attachment.file_path}")
|
||||
self.stats['attachments_skipped'] += 1
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(attachment.file_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
exists, full_path = self.validate_file_exists(relative_path)
|
||||
if exists:
|
||||
old_path = attachment.file_path
|
||||
attachment.file_path = relative_path
|
||||
self.stats['attachments_converted'] += 1
|
||||
self.logger.info(f"Attachment {attachment.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Attachment {attachment.id}: File not found at {full_path}"
|
||||
self.logger.warning(error_msg)
|
||||
self.warnings.append(error_msg)
|
||||
self.stats['warnings'] += 1
|
||||
else:
|
||||
error_msg = f"Attachment {attachment.id}: Failed to convert path {attachment.file_path}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Attachment {attachment.id}: Exception during migration: {e}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
self.logger.info(f"Attachments migration completed. Converted: {self.stats['attachments_converted']}, Skipped: {self.stats['attachments_skipped']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to migrate attachments table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def migrate_projects_table(self) -> None:
|
||||
"""Migrate thumbnail paths in projects table with enhanced error handling."""
|
||||
self.logger.info("Starting migration of projects table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
projects = db.query(Project).filter(Project.thumbnail_path.isnot(None)).all()
|
||||
self.stats['projects_processed'] = len(projects)
|
||||
|
||||
for project in projects:
|
||||
try:
|
||||
if not self.is_absolute_path(project.thumbnail_path):
|
||||
self.logger.debug(f"Project {project.id} already has relative thumbnail path: {project.thumbnail_path}")
|
||||
self.stats['projects_skipped'] += 1
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(project.thumbnail_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
exists, full_path = self.validate_file_exists(relative_path)
|
||||
if exists:
|
||||
old_path = project.thumbnail_path
|
||||
project.thumbnail_path = relative_path
|
||||
self.stats['projects_converted'] += 1
|
||||
self.logger.info(f"Project {project.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Project {project.id}: Thumbnail not found at {full_path}"
|
||||
self.logger.warning(error_msg)
|
||||
self.warnings.append(error_msg)
|
||||
self.stats['warnings'] += 1
|
||||
else:
|
||||
error_msg = f"Project {project.id}: Failed to convert thumbnail path {project.thumbnail_path}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Project {project.id}: Exception during migration: {e}"
|
||||
self.logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
self.logger.info(f"Projects migration completed. Converted: {self.stats['projects_converted']}, Skipped: {self.stats['projects_skipped']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
self.logger.error(f"Failed to migrate projects table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def validate_migration_results(self) -> bool:
|
||||
"""Comprehensive validation of migration results."""
|
||||
self.logger.info("Validating migration results...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
# Check for any remaining absolute paths using multiple patterns
|
||||
absolute_patterns = ['C:%', 'D:%', 'E:%', 'F:%', '/%', '/home/%', '/usr/%', '/var/%']
|
||||
|
||||
remaining_absolute_submissions = 0
|
||||
remaining_absolute_attachments = 0
|
||||
remaining_absolute_projects = 0
|
||||
|
||||
for pattern in absolute_patterns:
|
||||
remaining_absolute_submissions += db.query(Submission).filter(
|
||||
Submission.file_path.like(pattern)
|
||||
).count()
|
||||
|
||||
remaining_absolute_attachments += db.query(TaskAttachment).filter(
|
||||
TaskAttachment.file_path.like(pattern)
|
||||
).count()
|
||||
|
||||
remaining_absolute_projects += db.query(Project).filter(
|
||||
Project.thumbnail_path.like(pattern)
|
||||
).count()
|
||||
|
||||
total_remaining = remaining_absolute_submissions + remaining_absolute_attachments + remaining_absolute_projects
|
||||
|
||||
# Additional validation: check that all relative paths start with expected prefixes
|
||||
valid_prefixes = ['uploads/', './uploads/', 'backend/uploads/']
|
||||
|
||||
invalid_submissions = db.query(Submission).filter(
|
||||
~Submission.file_path.like('uploads/%')
|
||||
).count()
|
||||
|
||||
invalid_attachments = db.query(TaskAttachment).filter(
|
||||
~TaskAttachment.file_path.like('uploads/%')
|
||||
).count()
|
||||
|
||||
invalid_projects = db.query(Project).filter(
|
||||
Project.thumbnail_path.isnot(None),
|
||||
~Project.thumbnail_path.like('uploads/%')
|
||||
).count()
|
||||
|
||||
total_invalid = invalid_submissions + invalid_attachments + invalid_projects
|
||||
|
||||
if total_remaining > 0:
|
||||
self.logger.warning(f"Migration incomplete: {total_remaining} absolute paths remain")
|
||||
self.logger.warning(f" - Submissions: {remaining_absolute_submissions}")
|
||||
self.logger.warning(f" - Attachments: {remaining_absolute_attachments}")
|
||||
self.logger.warning(f" - Projects: {remaining_absolute_projects}")
|
||||
return False
|
||||
elif total_invalid > 0:
|
||||
self.logger.warning(f"Migration validation found {total_invalid} paths with unexpected format")
|
||||
self.logger.warning(f" - Submissions: {invalid_submissions}")
|
||||
self.logger.warning(f" - Attachments: {invalid_attachments}")
|
||||
self.logger.warning(f" - Projects: {invalid_projects}")
|
||||
return False
|
||||
else:
|
||||
self.logger.info("Migration validation successful: All paths are now relative and properly formatted")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to validate migration: {e}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def print_migration_summary(self) -> None:
|
||||
"""Print a comprehensive summary of the migration results."""
|
||||
self.logger.info("=" * 60)
|
||||
self.logger.info("MIGRATION SUMMARY")
|
||||
self.logger.info("=" * 60)
|
||||
self.logger.info(f"Submissions processed: {self.stats['submissions_processed']}")
|
||||
self.logger.info(f"Submissions converted: {self.stats['submissions_converted']}")
|
||||
self.logger.info(f"Submissions skipped: {self.stats['submissions_skipped']}")
|
||||
self.logger.info(f"Attachments processed: {self.stats['attachments_processed']}")
|
||||
self.logger.info(f"Attachments converted: {self.stats['attachments_converted']}")
|
||||
self.logger.info(f"Attachments skipped: {self.stats['attachments_skipped']}")
|
||||
self.logger.info(f"Projects processed: {self.stats['projects_processed']}")
|
||||
self.logger.info(f"Projects converted: {self.stats['projects_converted']}")
|
||||
self.logger.info(f"Projects skipped: {self.stats['projects_skipped']}")
|
||||
self.logger.info(f"Total errors: {self.stats['errors']}")
|
||||
self.logger.info(f"Total warnings: {self.stats['warnings']}")
|
||||
|
||||
if self.errors:
|
||||
self.logger.info("=" * 60)
|
||||
self.logger.info("ERRORS")
|
||||
self.logger.info("=" * 60)
|
||||
for error in self.errors:
|
||||
self.logger.error(f" - {error}")
|
||||
|
||||
if self.warnings:
|
||||
self.logger.info("=" * 60)
|
||||
self.logger.info("WARNINGS")
|
||||
self.logger.info("=" * 60)
|
||||
for warning in self.warnings:
|
||||
self.logger.warning(f" - {warning}")
|
||||
|
||||
def run_migration(self, create_backup: bool = True) -> bool:
|
||||
"""Run the complete migration process with comprehensive validation."""
|
||||
self.logger.info("Starting production file path migration to relative paths...")
|
||||
self.logger.info(f"Backend directory: {self.backend_dir}")
|
||||
|
||||
try:
|
||||
# Step 1: Validate database connection
|
||||
if not self.validate_database_connection():
|
||||
self.logger.error("Database validation failed. Aborting migration.")
|
||||
return False
|
||||
|
||||
# Step 2: Create backup if requested
|
||||
backup_path = None
|
||||
if create_backup:
|
||||
backup_path = self.create_database_backup()
|
||||
if backup_path:
|
||||
self.logger.info(f"Database backup created at: {backup_path}")
|
||||
else:
|
||||
self.logger.warning("Failed to create backup, but continuing with migration")
|
||||
|
||||
# Step 3: Analyze problematic paths
|
||||
problematic_paths = self.analyze_problematic_paths()
|
||||
|
||||
# Step 4: Migrate each table
|
||||
self.migrate_submissions_table()
|
||||
self.migrate_attachments_table()
|
||||
self.migrate_projects_table()
|
||||
|
||||
# Step 5: Validate results
|
||||
validation_success = self.validate_migration_results()
|
||||
|
||||
# Step 6: Print summary
|
||||
self.print_migration_summary()
|
||||
|
||||
# Step 7: Determine overall success
|
||||
if validation_success and self.stats['errors'] == 0:
|
||||
self.logger.info("Migration completed successfully!")
|
||||
if backup_path:
|
||||
self.logger.info(f"Backup is available at: {backup_path}")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Migration completed with warnings or errors. Please review the log.")
|
||||
if backup_path:
|
||||
self.logger.info(f"Database can be restored from backup: {backup_path}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Migration failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function for production migration."""
|
||||
print("Production File Path Migration Script")
|
||||
print("=" * 60)
|
||||
print("This script will convert absolute file paths to relative paths in the database.")
|
||||
print("It includes comprehensive validation and error handling for production use.")
|
||||
print()
|
||||
print("IMPORTANT: This script will create a database backup before migration.")
|
||||
print(" Review the log file for detailed results.")
|
||||
print()
|
||||
|
||||
# Ask for confirmation
|
||||
response = input("Do you want to proceed with the migration? (y/N): ").strip().lower()
|
||||
if response not in ['y', 'yes']:
|
||||
print("Migration cancelled.")
|
||||
return
|
||||
|
||||
# Ask about backup
|
||||
backup_response = input("Create database backup before migration? (Y/n): ").strip().lower()
|
||||
create_backup = backup_response not in ['n', 'no']
|
||||
|
||||
# Run migration
|
||||
migrator = ProductionFilePathMigrator()
|
||||
success = migrator.run_migration(create_backup=create_backup)
|
||||
|
||||
if success:
|
||||
print("\nMigration completed successfully!")
|
||||
print("Check the log file for detailed results.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nMigration completed with errors or warnings.")
|
||||
print("Check the log file for detailed results.")
|
||||
print("Database backup is available if restoration is needed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,381 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration script to convert absolute file paths to relative paths.
|
||||
|
||||
This script addresses the file path storage issue where absolute paths stored
|
||||
in the database become invalid when deploying to different environments,
|
||||
particularly Linux. The solution converts all absolute paths to relative paths
|
||||
that are resolved dynamically at runtime.
|
||||
|
||||
Requirements addressed: 1.1, 1.2, 1.3, 1.4, 1.5
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple, Optional
|
||||
|
||||
# Add the backend directory to the path
|
||||
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import text
|
||||
from database import engine
|
||||
from models.task import Submission, TaskAttachment
|
||||
from models.project import Project
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('migration_file_paths.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FilePathMigrator:
|
||||
"""Handles migration of absolute file paths to relative paths."""
|
||||
|
||||
def __init__(self):
|
||||
self.SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
self.backend_dir = Path(__file__).parent.resolve()
|
||||
self.errors = []
|
||||
self.stats = {
|
||||
'submissions_processed': 0,
|
||||
'submissions_converted': 0,
|
||||
'attachments_processed': 0,
|
||||
'attachments_converted': 0,
|
||||
'projects_processed': 0,
|
||||
'projects_converted': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
def is_absolute_path(self, path: str) -> bool:
|
||||
"""Check if a path is absolute."""
|
||||
if not path:
|
||||
return False
|
||||
path_obj = Path(path)
|
||||
return path_obj.is_absolute()
|
||||
|
||||
def convert_to_relative_path(self, absolute_path: str) -> Optional[str]:
|
||||
"""
|
||||
Convert absolute path to relative path.
|
||||
|
||||
Args:
|
||||
absolute_path: The absolute file path to convert
|
||||
|
||||
Returns:
|
||||
Relative path string or None if conversion fails
|
||||
"""
|
||||
try:
|
||||
abs_path = Path(absolute_path).resolve()
|
||||
|
||||
# Check if the path is within the backend directory
|
||||
try:
|
||||
relative_path = abs_path.relative_to(self.backend_dir)
|
||||
return str(relative_path).replace('\\', '/') # Use forward slashes for consistency
|
||||
except ValueError:
|
||||
# Path is not within backend directory
|
||||
logger.warning(f"Path is outside backend directory: {absolute_path}")
|
||||
|
||||
# Try to extract just the uploads part if it exists
|
||||
path_parts = abs_path.parts
|
||||
if 'uploads' in path_parts:
|
||||
uploads_index = path_parts.index('uploads')
|
||||
relative_parts = path_parts[uploads_index:]
|
||||
relative_path = '/'.join(relative_parts)
|
||||
logger.info(f"Extracted uploads path: {relative_path}")
|
||||
return relative_path
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to convert path {absolute_path}: {e}")
|
||||
return None
|
||||
|
||||
def validate_file_exists(self, relative_path: str) -> bool:
|
||||
"""
|
||||
Validate that the file exists at the relative path.
|
||||
|
||||
Args:
|
||||
relative_path: The relative path to validate
|
||||
|
||||
Returns:
|
||||
True if file exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
full_path = self.backend_dir / relative_path
|
||||
return full_path.exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def migrate_submissions_table(self) -> None:
|
||||
"""Migrate file paths in submissions table."""
|
||||
logger.info("Starting migration of submissions table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
submissions = db.query(Submission).all()
|
||||
self.stats['submissions_processed'] = len(submissions)
|
||||
|
||||
for submission in submissions:
|
||||
try:
|
||||
if not self.is_absolute_path(submission.file_path):
|
||||
logger.debug(f"Submission {submission.id} already has relative path: {submission.file_path}")
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(submission.file_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
if self.validate_file_exists(relative_path):
|
||||
old_path = submission.file_path
|
||||
submission.file_path = relative_path
|
||||
self.stats['submissions_converted'] += 1
|
||||
logger.info(f"Submission {submission.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Submission {submission.id}: File not found at relative path {relative_path}"
|
||||
logger.warning(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
else:
|
||||
error_msg = f"Submission {submission.id}: Failed to convert path {submission.file_path}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Submission {submission.id}: Exception during migration: {e}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Submissions migration completed. Converted: {self.stats['submissions_converted']}/{self.stats['submissions_processed']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to migrate submissions table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def migrate_attachments_table(self) -> None:
|
||||
"""Migrate file paths in task_attachments table."""
|
||||
logger.info("Starting migration of task_attachments table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
attachments = db.query(TaskAttachment).all()
|
||||
self.stats['attachments_processed'] = len(attachments)
|
||||
|
||||
for attachment in attachments:
|
||||
try:
|
||||
if not self.is_absolute_path(attachment.file_path):
|
||||
logger.debug(f"Attachment {attachment.id} already has relative path: {attachment.file_path}")
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(attachment.file_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
if self.validate_file_exists(relative_path):
|
||||
old_path = attachment.file_path
|
||||
attachment.file_path = relative_path
|
||||
self.stats['attachments_converted'] += 1
|
||||
logger.info(f"Attachment {attachment.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Attachment {attachment.id}: File not found at relative path {relative_path}"
|
||||
logger.warning(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
else:
|
||||
error_msg = f"Attachment {attachment.id}: Failed to convert path {attachment.file_path}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Attachment {attachment.id}: Exception during migration: {e}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Attachments migration completed. Converted: {self.stats['attachments_converted']}/{self.stats['attachments_processed']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to migrate attachments table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def migrate_projects_table(self) -> None:
|
||||
"""Migrate thumbnail paths in projects table."""
|
||||
logger.info("Starting migration of projects table...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
projects = db.query(Project).filter(Project.thumbnail_path.isnot(None)).all()
|
||||
self.stats['projects_processed'] = len(projects)
|
||||
|
||||
for project in projects:
|
||||
try:
|
||||
if not self.is_absolute_path(project.thumbnail_path):
|
||||
logger.debug(f"Project {project.id} already has relative thumbnail path: {project.thumbnail_path}")
|
||||
continue
|
||||
|
||||
relative_path = self.convert_to_relative_path(project.thumbnail_path)
|
||||
if relative_path:
|
||||
# Validate file exists
|
||||
if self.validate_file_exists(relative_path):
|
||||
old_path = project.thumbnail_path
|
||||
project.thumbnail_path = relative_path
|
||||
self.stats['projects_converted'] += 1
|
||||
logger.info(f"Project {project.id}: {old_path} -> {relative_path}")
|
||||
else:
|
||||
error_msg = f"Project {project.id}: Thumbnail not found at relative path {relative_path}"
|
||||
logger.warning(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
else:
|
||||
error_msg = f"Project {project.id}: Failed to convert thumbnail path {project.thumbnail_path}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Project {project.id}: Exception during migration: {e}"
|
||||
logger.error(error_msg)
|
||||
self.errors.append(error_msg)
|
||||
self.stats['errors'] += 1
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Projects migration completed. Converted: {self.stats['projects_converted']}/{self.stats['projects_processed']}")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to migrate projects table: {e}")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def validate_migration_results(self) -> bool:
|
||||
"""Validate that migration was successful."""
|
||||
logger.info("Validating migration results...")
|
||||
|
||||
db = self.SessionLocal()
|
||||
try:
|
||||
# Check for any remaining absolute paths
|
||||
remaining_absolute_submissions = db.query(Submission).filter(
|
||||
Submission.file_path.like('C:%') |
|
||||
Submission.file_path.like('D:%') |
|
||||
Submission.file_path.like('/%')
|
||||
).count()
|
||||
|
||||
remaining_absolute_attachments = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.file_path.like('C:%') |
|
||||
TaskAttachment.file_path.like('D:%') |
|
||||
TaskAttachment.file_path.like('/%')
|
||||
).count()
|
||||
|
||||
remaining_absolute_projects = db.query(Project).filter(
|
||||
Project.thumbnail_path.like('C:%') |
|
||||
Project.thumbnail_path.like('D:%') |
|
||||
Project.thumbnail_path.like('/%')
|
||||
).count()
|
||||
|
||||
total_remaining = remaining_absolute_submissions + remaining_absolute_attachments + remaining_absolute_projects
|
||||
|
||||
if total_remaining > 0:
|
||||
logger.warning(f"Migration incomplete: {total_remaining} absolute paths remain")
|
||||
logger.warning(f" - Submissions: {remaining_absolute_submissions}")
|
||||
logger.warning(f" - Attachments: {remaining_absolute_attachments}")
|
||||
logger.warning(f" - Projects: {remaining_absolute_projects}")
|
||||
return False
|
||||
else:
|
||||
logger.info("Migration validation successful: No absolute paths remain")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate migration: {e}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def print_migration_summary(self) -> None:
|
||||
"""Print a summary of the migration results."""
|
||||
logger.info("=== MIGRATION SUMMARY ===")
|
||||
logger.info(f"Submissions processed: {self.stats['submissions_processed']}")
|
||||
logger.info(f"Submissions converted: {self.stats['submissions_converted']}")
|
||||
logger.info(f"Attachments processed: {self.stats['attachments_processed']}")
|
||||
logger.info(f"Attachments converted: {self.stats['attachments_converted']}")
|
||||
logger.info(f"Projects processed: {self.stats['projects_processed']}")
|
||||
logger.info(f"Projects converted: {self.stats['projects_converted']}")
|
||||
logger.info(f"Total errors: {self.stats['errors']}")
|
||||
|
||||
if self.errors:
|
||||
logger.info("=== ERRORS ===")
|
||||
for error in self.errors:
|
||||
logger.info(f" - {error}")
|
||||
|
||||
def run_migration(self) -> bool:
|
||||
"""Run the complete migration process."""
|
||||
logger.info("Starting file path migration to relative paths...")
|
||||
logger.info(f"Backend directory: {self.backend_dir}")
|
||||
|
||||
try:
|
||||
# Migrate each table
|
||||
self.migrate_submissions_table()
|
||||
self.migrate_attachments_table()
|
||||
self.migrate_projects_table()
|
||||
|
||||
# Validate results
|
||||
validation_success = self.validate_migration_results()
|
||||
|
||||
# Print summary
|
||||
self.print_migration_summary()
|
||||
|
||||
if validation_success and self.stats['errors'] == 0:
|
||||
logger.info("Migration completed successfully!")
|
||||
return True
|
||||
else:
|
||||
logger.warning("Migration completed with warnings or errors. Please review the log.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run the migration."""
|
||||
print("File Path Migration Script")
|
||||
print("=" * 50)
|
||||
print("This script will convert absolute file paths to relative paths in the database.")
|
||||
print("A backup of the database is recommended before running this migration.")
|
||||
print()
|
||||
|
||||
# Ask for confirmation
|
||||
response = input("Do you want to proceed with the migration? (y/N): ").strip().lower()
|
||||
if response not in ['y', 'yes']:
|
||||
print("Migration cancelled.")
|
||||
return
|
||||
|
||||
# Run migration
|
||||
migrator = FilePathMigrator()
|
||||
success = migrator.run_migration()
|
||||
|
||||
if success:
|
||||
print("\nMigration completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nMigration completed with errors. Please check the log file.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Migration script to add notification tables to the database.
|
||||
"""
|
||||
from sqlalchemy import create_engine, text
|
||||
from database import DATABASE_URL
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Add notification tables to the database."""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
# Create notifications table
|
||||
logger.info("Creating notifications table...")
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
priority VARCHAR(20) DEFAULT 'normal',
|
||||
title VARCHAR(255) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
read BOOLEAN DEFAULT 0,
|
||||
project_id INTEGER,
|
||||
task_id INTEGER,
|
||||
submission_id INTEGER,
|
||||
email_sent BOOLEAN DEFAULT 0,
|
||||
email_sent_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
read_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (submission_id) REFERENCES submissions(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
# Create indexes for notifications
|
||||
logger.info("Creating indexes for notifications table...")
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_id
|
||||
ON notifications(user_id)
|
||||
"""))
|
||||
|
||||
conn.execute(text("""
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_read
|
||||
ON notifications(read)
|
||||
"""))
|
||||
|
||||
# Create user_notification_preferences table
|
||||
logger.info("Creating user_notification_preferences table...")
|
||||
conn.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS user_notification_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL UNIQUE,
|
||||
email_enabled BOOLEAN DEFAULT 1,
|
||||
email_task_assigned BOOLEAN DEFAULT 1,
|
||||
email_task_status_changed BOOLEAN DEFAULT 1,
|
||||
email_submission_reviewed BOOLEAN DEFAULT 1,
|
||||
email_work_submitted BOOLEAN DEFAULT 1,
|
||||
email_deadline_approaching BOOLEAN DEFAULT 1,
|
||||
email_project_update BOOLEAN DEFAULT 1,
|
||||
email_comment_added BOOLEAN DEFAULT 1,
|
||||
inapp_enabled BOOLEAN DEFAULT 1,
|
||||
inapp_task_assigned BOOLEAN DEFAULT 1,
|
||||
inapp_task_status_changed BOOLEAN DEFAULT 1,
|
||||
inapp_submission_reviewed BOOLEAN DEFAULT 1,
|
||||
inapp_work_submitted BOOLEAN DEFAULT 1,
|
||||
inapp_deadline_approaching BOOLEAN DEFAULT 1,
|
||||
inapp_project_update BOOLEAN DEFAULT 1,
|
||||
inapp_comment_added BOOLEAN DEFAULT 1,
|
||||
email_digest_enabled BOOLEAN DEFAULT 0,
|
||||
email_digest_frequency VARCHAR(50) DEFAULT 'daily',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
"""))
|
||||
|
||||
conn.commit()
|
||||
logger.info("✅ Notification tables created successfully!")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Migration failed: {str(e)}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add new fields to the projects table:
|
||||
- code_name: Unique project code identifier
|
||||
- client_name: Client or studio name
|
||||
- project_type: TV, Cinema, or Game
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def migrate_project_fields():
|
||||
"""Add new fields to the projects table"""
|
||||
|
||||
# Database path
|
||||
db_path = Path("vfx_project_management.db")
|
||||
|
||||
if not db_path.exists():
|
||||
print("Database file not found. Creating new database with updated schema.")
|
||||
return True
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if new columns already exist
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
new_columns_needed = []
|
||||
if 'code_name' not in columns:
|
||||
new_columns_needed.append('code_name')
|
||||
if 'client_name' not in columns:
|
||||
new_columns_needed.append('client_name')
|
||||
if 'project_type' not in columns:
|
||||
new_columns_needed.append('project_type')
|
||||
|
||||
if not new_columns_needed:
|
||||
print("All new columns already exist. No migration needed.")
|
||||
return True
|
||||
|
||||
print(f"Adding columns: {', '.join(new_columns_needed)}")
|
||||
|
||||
# Add new columns
|
||||
if 'code_name' in new_columns_needed:
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN code_name VARCHAR")
|
||||
print("Added code_name column")
|
||||
|
||||
if 'client_name' in new_columns_needed:
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN client_name VARCHAR")
|
||||
print("Added client_name column")
|
||||
|
||||
if 'project_type' in new_columns_needed:
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN project_type VARCHAR")
|
||||
print("Added project_type column")
|
||||
|
||||
# Update existing projects with default values
|
||||
cursor.execute("SELECT id, name FROM projects WHERE code_name IS NULL OR client_name IS NULL OR project_type IS NULL")
|
||||
existing_projects = cursor.fetchall()
|
||||
|
||||
for project_id, project_name in existing_projects:
|
||||
# Generate default code_name from project name
|
||||
code_name = project_name.upper().replace(' ', '_')[:10] + f"_{project_id:03d}"
|
||||
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET code_name = COALESCE(code_name, ?),
|
||||
client_name = COALESCE(client_name, 'Default Client'),
|
||||
project_type = COALESCE(project_type, 'tv')
|
||||
WHERE id = ?
|
||||
""", (code_name, project_id))
|
||||
|
||||
print(f"Updated {len(existing_projects)} existing projects with default values")
|
||||
|
||||
# Create unique index on code_name
|
||||
try:
|
||||
cursor.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_code_name ON projects(code_name)")
|
||||
print("Created unique index on code_name")
|
||||
except sqlite3.IntegrityError as e:
|
||||
print(f"Warning: Could not create unique index on code_name: {e}")
|
||||
print("You may need to manually ensure code_name values are unique")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print("Migration completed successfully!")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Migration failed: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = migrate_project_fields()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Migration script to add project settings fields to the projects table.
|
||||
This adds support for upload location configuration and default task templates.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
def migrate_project_settings():
|
||||
"""Add project settings fields to projects table"""
|
||||
conn = sqlite3.connect('vfx_project_management.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
# Add upload_data_location column if it doesn't exist
|
||||
if 'upload_data_location' not in columns:
|
||||
print("Adding upload_data_location column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN upload_data_location TEXT
|
||||
""")
|
||||
print("✓ Added upload_data_location column")
|
||||
else:
|
||||
print("✓ upload_data_location column already exists")
|
||||
|
||||
# Add asset_task_templates column if it doesn't exist
|
||||
if 'asset_task_templates' not in columns:
|
||||
print("Adding asset_task_templates column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN asset_task_templates TEXT
|
||||
""")
|
||||
|
||||
# Set default values for existing projects
|
||||
default_asset_templates = json.dumps({
|
||||
"characters": ["modeling", "surfacing", "rigging"],
|
||||
"props": ["modeling", "surfacing"],
|
||||
"sets": ["modeling", "surfacing"],
|
||||
"vehicles": ["modeling", "surfacing", "rigging"]
|
||||
})
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET asset_task_templates = ?
|
||||
WHERE asset_task_templates IS NULL
|
||||
""", (default_asset_templates,))
|
||||
print("✓ Added asset_task_templates column with default values")
|
||||
else:
|
||||
print("✓ asset_task_templates column already exists")
|
||||
|
||||
# Add shot_task_templates column if it doesn't exist
|
||||
if 'shot_task_templates' not in columns:
|
||||
print("Adding shot_task_templates column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN shot_task_templates TEXT
|
||||
""")
|
||||
|
||||
# Set default values for existing projects
|
||||
default_shot_templates = json.dumps([
|
||||
"layout", "animation", "simulation", "lighting", "compositing"
|
||||
])
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET shot_task_templates = ?
|
||||
WHERE shot_task_templates IS NULL
|
||||
""", (default_shot_templates,))
|
||||
print("✓ Added shot_task_templates column with default values")
|
||||
else:
|
||||
print("✓ shot_task_templates column already exists")
|
||||
|
||||
# Add enabled_asset_tasks column if it doesn't exist
|
||||
if 'enabled_asset_tasks' not in columns:
|
||||
print("Adding enabled_asset_tasks column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN enabled_asset_tasks TEXT
|
||||
""")
|
||||
print("✓ Added enabled_asset_tasks column")
|
||||
else:
|
||||
print("✓ enabled_asset_tasks column already exists")
|
||||
|
||||
# Add enabled_shot_tasks column if it doesn't exist
|
||||
if 'enabled_shot_tasks' not in columns:
|
||||
print("Adding enabled_shot_tasks column...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN enabled_shot_tasks TEXT
|
||||
""")
|
||||
print("✓ Added enabled_shot_tasks column")
|
||||
else:
|
||||
print("✓ enabled_shot_tasks column already exists")
|
||||
|
||||
conn.commit()
|
||||
print("\n✅ Migration completed successfully!")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"\n❌ Migration failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting project settings migration...")
|
||||
print("=" * 50)
|
||||
migrate_project_settings()
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add technical specifications fields to the projects table.
|
||||
|
||||
This script adds the following columns to the projects table:
|
||||
- frame_rate (FLOAT)
|
||||
- data_drive_path (VARCHAR)
|
||||
- publish_storage_path (VARCHAR)
|
||||
- delivery_image_resolution (VARCHAR)
|
||||
- delivery_movie_specs_by_department (JSON)
|
||||
|
||||
Usage:
|
||||
python migrate_project_technical_specs.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Default delivery movie specifications per department
|
||||
DEFAULT_DELIVERY_MOVIE_SPECS = {
|
||||
"layout": {
|
||||
"resolution": "1920x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "medium"
|
||||
},
|
||||
"animation": {
|
||||
"resolution": "1920x1080",
|
||||
"format": "mov",
|
||||
"codec": "h264",
|
||||
"quality": "high"
|
||||
},
|
||||
"lighting": {
|
||||
"resolution": "2048x1080",
|
||||
"format": "exr",
|
||||
"codec": None,
|
||||
"quality": "high"
|
||||
},
|
||||
"composite": {
|
||||
"resolution": "2048x1080",
|
||||
"format": "mov",
|
||||
"codec": "prores",
|
||||
"quality": "high"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
# If no existing database found, use the default name
|
||||
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 technical specifications columns to the projects table."""
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
try:
|
||||
# Connect to the database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if projects table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
if not cursor.fetchone():
|
||||
print("Projects table not found. Creating new database schema...")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Adding technical specifications columns to projects table...")
|
||||
|
||||
# Add technical specification columns if they don't exist
|
||||
columns_to_add = [
|
||||
("frame_rate", "REAL"),
|
||||
("data_drive_path", "VARCHAR"),
|
||||
("publish_storage_path", "VARCHAR"),
|
||||
("delivery_image_resolution", "VARCHAR"),
|
||||
("delivery_movie_specs_by_department", "JSON")
|
||||
]
|
||||
|
||||
for column_name, column_type in columns_to_add:
|
||||
if not check_column_exists(cursor, "projects", column_name):
|
||||
print(f"Adding column: {column_name}")
|
||||
cursor.execute(f"ALTER TABLE projects ADD COLUMN {column_name} {column_type}")
|
||||
else:
|
||||
print(f"Column {column_name} already exists, skipping...")
|
||||
|
||||
# Set default values for existing projects
|
||||
print("Setting default technical specifications for existing projects...")
|
||||
|
||||
# Set default frame rate and image resolution
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET frame_rate = 24.0,
|
||||
delivery_image_resolution = '1920x1080'
|
||||
WHERE frame_rate IS NULL OR delivery_image_resolution IS NULL
|
||||
""")
|
||||
|
||||
# Set default delivery movie specs for projects that don't have them
|
||||
default_specs_json = json.dumps(DEFAULT_DELIVERY_MOVIE_SPECS)
|
||||
cursor.execute("""
|
||||
UPDATE projects
|
||||
SET delivery_movie_specs_by_department = ?
|
||||
WHERE delivery_movie_specs_by_department IS NULL
|
||||
""", (default_specs_json,))
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
|
||||
# Verify the migration
|
||||
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||
project_count = cursor.fetchone()[0]
|
||||
print(f"Migration completed successfully! Updated {project_count} projects.")
|
||||
|
||||
# Show sample of updated data
|
||||
cursor.execute("""
|
||||
SELECT id, name, frame_rate, delivery_image_resolution
|
||||
FROM projects
|
||||
LIMIT 3
|
||||
""")
|
||||
|
||||
print("\nSample of updated projects:")
|
||||
for row in cursor.fetchall():
|
||||
print(f" Project {row[0]}: {row[1]} - {row[2]} fps, {row[3]} resolution")
|
||||
|
||||
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 - Technical Specifications Migration")
|
||||
print("=" * 60)
|
||||
|
||||
migrate_database()
|
||||
|
||||
print("\nMigration completed successfully!")
|
||||
print("Technical specifications have been added to all projects.")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Migration script to add thumbnail_path field to projects table.
|
||||
"""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
# Database path
|
||||
DB_PATH = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Add thumbnail_path column to projects table."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
# Check if column already exists
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if "thumbnail_path" not in columns:
|
||||
print("Adding thumbnail_path column to projects table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE projects
|
||||
ADD COLUMN thumbnail_path VARCHAR
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ Successfully added thumbnail_path column")
|
||||
else:
|
||||
print("✓ thumbnail_path column already exists")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Starting project thumbnail migration...")
|
||||
migrate()
|
||||
print("Migration completed!")
|
||||
@@ -0,0 +1,524 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add project_id column to shots table.
|
||||
|
||||
This script adds the following to the shots table:
|
||||
- project_id column with foreign key constraint to projects table
|
||||
- Index on project_id for performance optimization
|
||||
- Populates project_id for existing shots based on episode relationships
|
||||
- Unique constraint for project-scoped shot names (excluding soft-deleted shots)
|
||||
|
||||
Requirements: 2.1, 2.2, 2.3, 2.4, 2.5
|
||||
|
||||
Usage:
|
||||
python migrate_shot_project_id.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# If no existing database found, use the default name
|
||||
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 check_index_exists(cursor, index_name):
|
||||
"""Check if an index exists."""
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND name=?", (index_name,))
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def check_constraint_exists(cursor, table_name, constraint_name):
|
||||
"""Check if a constraint exists by examining the table schema."""
|
||||
cursor.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,))
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
return constraint_name in result[0]
|
||||
return False
|
||||
|
||||
|
||||
def validate_data_integrity(cursor):
|
||||
"""Validate that all episodes have valid project references."""
|
||||
logger.info("Validating data integrity...")
|
||||
|
||||
# Check for orphaned episodes (episodes without valid project references)
|
||||
cursor.execute("""
|
||||
SELECT e.id, e.name, e.project_id
|
||||
FROM episodes e
|
||||
LEFT JOIN projects p ON e.project_id = p.id
|
||||
WHERE p.id IS NULL
|
||||
""")
|
||||
orphaned_episodes = cursor.fetchall()
|
||||
|
||||
if orphaned_episodes:
|
||||
logger.error(f"Found {len(orphaned_episodes)} orphaned episodes:")
|
||||
for episode in orphaned_episodes:
|
||||
logger.error(f" Episode {episode[0]}: {episode[1]} -> Invalid project_id: {episode[2]}")
|
||||
return False
|
||||
|
||||
# Check for shots with episodes that don't have project references
|
||||
cursor.execute("""
|
||||
SELECT s.id, s.name, s.episode_id, e.name as episode_name, e.project_id
|
||||
FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
LEFT JOIN projects p ON e.project_id = p.id
|
||||
WHERE p.id IS NULL
|
||||
""")
|
||||
shots_with_invalid_episodes = cursor.fetchall()
|
||||
|
||||
if shots_with_invalid_episodes:
|
||||
logger.error(f"Found {len(shots_with_invalid_episodes)} shots with invalid episode references:")
|
||||
for shot in shots_with_invalid_episodes:
|
||||
logger.error(f" Shot {shot[0]}: {shot[1]} -> Episode {shot[2]} ({shot[3]}) -> Invalid project_id: {shot[4]}")
|
||||
return False
|
||||
|
||||
logger.info("Data integrity validation passed")
|
||||
return True
|
||||
|
||||
|
||||
def check_name_conflicts(cursor):
|
||||
"""Check for potential project-scoped name conflicts."""
|
||||
logger.info("Checking for potential project-scoped name conflicts...")
|
||||
|
||||
cursor.execute("""
|
||||
SELECT p.id, p.name as project_name, s.name as shot_name, COUNT(*) as count
|
||||
FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
JOIN projects p ON e.project_id = p.id
|
||||
WHERE s.deleted_at IS NULL
|
||||
GROUP BY p.id, s.name
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY p.id, s.name
|
||||
""")
|
||||
|
||||
conflicts = cursor.fetchall()
|
||||
|
||||
if conflicts:
|
||||
logger.warning(f"Found {len(conflicts)} potential name conflicts:")
|
||||
for conflict in conflicts:
|
||||
logger.warning(f" Project {conflict[0]} ({conflict[1]}): Shot name '{conflict[2]}' appears {conflict[3]} times")
|
||||
|
||||
# Show detailed conflict information
|
||||
for conflict in conflicts:
|
||||
project_id, project_name, shot_name, count = conflict
|
||||
cursor.execute("""
|
||||
SELECT s.id, s.name, e.id as episode_id, e.name as episode_name
|
||||
FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
WHERE e.project_id = ? AND s.name = ? AND s.deleted_at IS NULL
|
||||
ORDER BY s.id
|
||||
""", (project_id, shot_name))
|
||||
|
||||
conflicting_shots = cursor.fetchall()
|
||||
logger.warning(f" Conflicting shots in project {project_name}:")
|
||||
for shot in conflicting_shots:
|
||||
logger.warning(f" Shot {shot[0]}: {shot[1]} in Episode {shot[2]} ({shot[3]})")
|
||||
|
||||
return False
|
||||
|
||||
logger.info("No project-scoped name conflicts found")
|
||||
return True
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add project_id column to shots table and set up constraints."""
|
||||
db_path = get_database_path()
|
||||
logger.info(f"Using database: {db_path}")
|
||||
|
||||
try:
|
||||
# Connect to the database
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Enable foreign key constraints
|
||||
cursor.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# Check if shots table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='shots'")
|
||||
if not cursor.fetchone():
|
||||
logger.error("Shots table not found. Database may not be initialized.")
|
||||
return False
|
||||
|
||||
# Check if projects table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
if not cursor.fetchone():
|
||||
logger.error("Projects table not found. Database may not be initialized.")
|
||||
return False
|
||||
|
||||
# Check if episodes table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='episodes'")
|
||||
if not cursor.fetchone():
|
||||
logger.error("Episodes table not found. Database may not be initialized.")
|
||||
return False
|
||||
|
||||
# Validate data integrity before migration
|
||||
if not validate_data_integrity(cursor):
|
||||
logger.error("Data integrity validation failed. Please fix data issues before running migration.")
|
||||
return False
|
||||
|
||||
# Check for name conflicts before adding unique constraint
|
||||
if not check_name_conflicts(cursor):
|
||||
logger.error("Name conflicts detected. Please resolve conflicts before running migration.")
|
||||
logger.error("You may need to rename conflicting shots to ensure uniqueness within each project.")
|
||||
return False
|
||||
|
||||
# Check if project_id column already exists
|
||||
if check_column_exists(cursor, "shots", "project_id"):
|
||||
logger.warning("project_id column already exists in shots table")
|
||||
|
||||
# Check if migration is already complete
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = {col[1]: col for col in cursor.fetchall()}
|
||||
project_id_col = columns.get('project_id')
|
||||
|
||||
if project_id_col and project_id_col[3] == 1: # NOT NULL constraint exists
|
||||
logger.info("Migration appears to be already complete")
|
||||
return True
|
||||
else:
|
||||
logger.info("project_id column exists but migration is incomplete, continuing...")
|
||||
# Continue with migration to complete it
|
||||
|
||||
logger.info("Starting shots table project_id migration...")
|
||||
|
||||
# Step 1: Add project_id column (nullable initially for data population)
|
||||
if not check_column_exists(cursor, "shots", "project_id"):
|
||||
logger.info("Adding project_id column to shots table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE shots
|
||||
ADD COLUMN project_id INTEGER
|
||||
""")
|
||||
else:
|
||||
logger.info("project_id column already exists, skipping column creation...")
|
||||
|
||||
# Step 2: Populate project_id for existing shots based on episode relationships
|
||||
logger.info("Populating project_id for existing shots...")
|
||||
cursor.execute("""
|
||||
UPDATE shots
|
||||
SET project_id = (
|
||||
SELECT e.project_id
|
||||
FROM episodes e
|
||||
WHERE e.id = shots.episode_id
|
||||
)
|
||||
""")
|
||||
|
||||
# Verify that all shots now have project_id
|
||||
cursor.execute("SELECT COUNT(*) FROM shots WHERE project_id IS NULL")
|
||||
null_project_count = cursor.fetchone()[0]
|
||||
|
||||
if null_project_count > 0:
|
||||
logger.error(f"Failed to populate project_id for {null_project_count} shots")
|
||||
return False
|
||||
|
||||
# Get count of updated shots
|
||||
cursor.execute("SELECT COUNT(*) FROM shots WHERE project_id IS NOT NULL")
|
||||
updated_count = cursor.fetchone()[0]
|
||||
logger.info(f"Successfully populated project_id for {updated_count} shots")
|
||||
|
||||
# Step 3: Create a new table with the proper constraints
|
||||
logger.info("Creating new shots table with proper constraints...")
|
||||
|
||||
# First, get the current table schema to preserve other columns
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns_info = cursor.fetchall()
|
||||
|
||||
# Create new table with project_id as NOT NULL and foreign key constraint
|
||||
cursor.execute("""
|
||||
CREATE TABLE shots_new (
|
||||
id INTEGER PRIMARY KEY,
|
||||
project_id INTEGER NOT NULL,
|
||||
episode_id INTEGER NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
frame_start INTEGER NOT NULL DEFAULT 1001,
|
||||
frame_end INTEGER NOT NULL DEFAULT 1001,
|
||||
status VARCHAR(11) NOT NULL DEFAULT 'not_started',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
deleted_by INTEGER,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id),
|
||||
FOREIGN KEY (episode_id) REFERENCES episodes(id),
|
||||
FOREIGN KEY (deleted_by) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data from old table to new table
|
||||
logger.info("Copying data to new table...")
|
||||
cursor.execute("""
|
||||
INSERT INTO shots_new
|
||||
SELECT id, project_id, episode_id, name, description, frame_start, frame_end,
|
||||
status, created_at, updated_at, deleted_at, deleted_by
|
||||
FROM shots
|
||||
""")
|
||||
|
||||
# Verify data copy
|
||||
cursor.execute("SELECT COUNT(*) FROM shots")
|
||||
original_count = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM shots_new")
|
||||
new_count = cursor.fetchone()[0]
|
||||
|
||||
if original_count != new_count:
|
||||
logger.error(f"Data copy failed: original={original_count}, new={new_count}")
|
||||
return False
|
||||
|
||||
logger.info(f"Successfully copied {new_count} shots to new table")
|
||||
|
||||
# Drop old table and rename new table
|
||||
logger.info("Replacing old table with new table...")
|
||||
cursor.execute("DROP TABLE shots")
|
||||
cursor.execute("ALTER TABLE shots_new RENAME TO shots")
|
||||
|
||||
# Step 4: Create indexes for performance
|
||||
logger.info("Creating indexes for performance optimization...")
|
||||
|
||||
# Index on project_id for filtering
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_shots_project_id ON shots(project_id)
|
||||
""")
|
||||
|
||||
# Index on name for searching
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_shots_name ON shots(name)
|
||||
""")
|
||||
|
||||
# Partial index for active (non-deleted) shots
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_shots_active ON shots(id) WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Composite index for project + episode queries
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_shots_project_episode ON shots(project_id, episode_id)
|
||||
""")
|
||||
|
||||
# Unique constraint for project-scoped shot names (excluding soft-deleted shots)
|
||||
cursor.execute("""
|
||||
CREATE UNIQUE INDEX idx_shots_project_name_unique
|
||||
ON shots(project_id, name) WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
logger.info("Successfully created performance indexes and unique constraint")
|
||||
|
||||
# Commit all changes
|
||||
conn.commit()
|
||||
|
||||
# Step 5: Verify the migration
|
||||
logger.info("Verifying migration...")
|
||||
|
||||
# Check that project_id column exists and is properly constrained
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = {col[1]: col for col in cursor.fetchall()}
|
||||
|
||||
if 'project_id' not in columns:
|
||||
logger.error("project_id column not found after migration")
|
||||
return False
|
||||
|
||||
project_id_col = columns['project_id']
|
||||
if project_id_col[3] != 1: # NOT NULL constraint
|
||||
logger.error("project_id column is not properly constrained as NOT NULL")
|
||||
return False
|
||||
|
||||
# Check foreign key constraints
|
||||
cursor.execute("PRAGMA foreign_key_list(shots)")
|
||||
foreign_keys = cursor.fetchall()
|
||||
|
||||
project_fk_found = False
|
||||
for fk in foreign_keys:
|
||||
if fk[2] == 'projects' and fk[3] == 'project_id':
|
||||
project_fk_found = True
|
||||
break
|
||||
|
||||
if not project_fk_found:
|
||||
logger.error("Foreign key constraint to projects table not found")
|
||||
return False
|
||||
|
||||
# Check unique constraint (implemented as partial unique index)
|
||||
cursor.execute("PRAGMA index_list(shots)")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
if "idx_shots_project_name_unique" not in indexes:
|
||||
logger.error("Unique constraint for project-scoped names not found")
|
||||
return False
|
||||
|
||||
# Verify data integrity after migration
|
||||
cursor.execute("""
|
||||
SELECT s.id, s.name, s.project_id, e.project_id as episode_project_id
|
||||
FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
WHERE s.project_id != e.project_id
|
||||
""")
|
||||
|
||||
inconsistent_data = cursor.fetchall()
|
||||
if inconsistent_data:
|
||||
logger.error(f"Found {len(inconsistent_data)} shots with inconsistent project_id")
|
||||
return False
|
||||
|
||||
# Show migration summary
|
||||
cursor.execute("SELECT COUNT(*) FROM shots")
|
||||
total_shots = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("""
|
||||
SELECT p.name, COUNT(s.id) as shot_count
|
||||
FROM projects p
|
||||
LEFT JOIN shots s ON p.id = s.project_id AND s.deleted_at IS NULL
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY shot_count DESC
|
||||
""")
|
||||
|
||||
project_summary = cursor.fetchall()
|
||||
|
||||
logger.info("Migration completed successfully!")
|
||||
logger.info(f"Total shots: {total_shots}")
|
||||
logger.info("Shots per project:")
|
||||
for project_name, shot_count in project_summary:
|
||||
logger.info(f" {project_name}: {shot_count} shots")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def verify_migration():
|
||||
"""Verify that the migration was successful."""
|
||||
db_path = get_database_path()
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Performing post-migration verification...")
|
||||
|
||||
# Check table structure
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = {col[1]: col for col in cursor.fetchall()}
|
||||
|
||||
# Verify project_id column exists and is NOT NULL
|
||||
if 'project_id' not in columns:
|
||||
logger.error("❌ project_id column not found")
|
||||
return False
|
||||
|
||||
if columns['project_id'][3] != 1: # NOT NULL constraint
|
||||
logger.error("❌ project_id column is not NOT NULL")
|
||||
return False
|
||||
|
||||
logger.info("✅ project_id column exists and is properly constrained")
|
||||
|
||||
# Check foreign key constraints
|
||||
cursor.execute("PRAGMA foreign_key_list(shots)")
|
||||
foreign_keys = cursor.fetchall()
|
||||
|
||||
project_fk_found = False
|
||||
for fk in foreign_keys:
|
||||
if fk[2] == 'projects' and fk[3] == 'project_id':
|
||||
project_fk_found = True
|
||||
break
|
||||
|
||||
if not project_fk_found:
|
||||
logger.error("❌ Foreign key constraint to projects table not found")
|
||||
return False
|
||||
|
||||
logger.info("✅ Foreign key constraint to projects table verified")
|
||||
|
||||
# Check indexes
|
||||
cursor.execute("PRAGMA index_list(shots)")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
|
||||
required_indexes = ['idx_shots_project_id', 'idx_shots_name', 'idx_shots_active', 'idx_shots_project_episode', 'idx_shots_project_name_unique']
|
||||
for index_name in required_indexes:
|
||||
if index_name not in indexes:
|
||||
logger.error(f"❌ Index {index_name} not found")
|
||||
return False
|
||||
|
||||
logger.info("✅ All required indexes verified")
|
||||
|
||||
# Check unique constraint (implemented as partial unique index)
|
||||
if "idx_shots_project_name_unique" not in indexes:
|
||||
logger.error("❌ Unique constraint for project-scoped names not found")
|
||||
return False
|
||||
|
||||
logger.info("✅ Unique constraint for project-scoped names verified")
|
||||
|
||||
# Verify data consistency
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
WHERE s.project_id = e.project_id
|
||||
""")
|
||||
consistent_count = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM shots")
|
||||
total_count = cursor.fetchone()[0]
|
||||
|
||||
if consistent_count != total_count:
|
||||
logger.error(f"❌ Data consistency check failed: {consistent_count}/{total_count} shots have consistent project_id")
|
||||
return False
|
||||
|
||||
logger.info(f"✅ Data consistency verified: {consistent_count} shots have consistent project_id")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Shot Project ID Migration")
|
||||
print("=" * 60)
|
||||
|
||||
success = migrate_database()
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 60)
|
||||
print("VERIFYING MIGRATION")
|
||||
print("=" * 60)
|
||||
|
||||
if verify_migration():
|
||||
print("\n✅ SUCCESS: Migration completed successfully!")
|
||||
print("✅ All shots now have project_id with proper constraints and indexes")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ FAILED: Migration verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ FAILED: Migration failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add soft deletion columns to assets table.
|
||||
Adds deleted_at and deleted_by columns and creates partial index for efficient querying.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def migrate_assets_soft_deletion():
|
||||
"""Add soft deletion columns to assets table and create partial index."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("Starting assets table soft deletion migration...")
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(assets)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'deleted_at' in columns:
|
||||
print("Soft deletion columns already exist in assets table")
|
||||
return True
|
||||
|
||||
# Add deleted_at column
|
||||
print("Adding deleted_at column to assets table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN deleted_at TIMESTAMP NULL
|
||||
""")
|
||||
|
||||
# Add deleted_by column
|
||||
print("Adding deleted_by column to assets table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE assets
|
||||
ADD COLUMN deleted_by INTEGER NULL
|
||||
REFERENCES users(id)
|
||||
""")
|
||||
|
||||
# Create partial index for efficient querying of non-deleted records
|
||||
print("Creating partial index idx_assets_not_deleted...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_assets_not_deleted
|
||||
ON assets (id)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print("SUCCESS: Successfully added soft deletion columns to assets table")
|
||||
print("SUCCESS: Created partial index idx_assets_not_deleted")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def verify_migration():
|
||||
"""Verify that the migration was successful."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check table structure
|
||||
cursor.execute("PRAGMA table_info(assets)")
|
||||
columns = {column[1]: column[2] for column in cursor.fetchall()}
|
||||
|
||||
# Verify columns exist
|
||||
if 'deleted_at' not in columns:
|
||||
print("❌ deleted_at column not found")
|
||||
return False
|
||||
if 'deleted_by' not in columns:
|
||||
print("❌ deleted_by column not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Soft deletion columns verified in assets table")
|
||||
|
||||
# Check index exists
|
||||
cursor.execute("PRAGMA index_list(assets)")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
|
||||
if 'idx_assets_not_deleted' not in indexes:
|
||||
print("❌ Partial index idx_assets_not_deleted not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Partial index idx_assets_not_deleted verified")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Assets Table Soft Deletion Migration ===")
|
||||
|
||||
success = migrate_assets_soft_deletion()
|
||||
|
||||
if success:
|
||||
print("\n=== Verifying Migration ===")
|
||||
if verify_migration():
|
||||
print("\nSUCCESS: Migration completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Migration verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ Migration failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Complete migration script for soft deletion functionality.
|
||||
Runs all individual migration scripts in the correct order and verifies the results.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def run_migration_script(script_name):
|
||||
"""Run a migration script and return success status."""
|
||||
|
||||
script_path = Path(__file__).parent / script_name
|
||||
|
||||
if not script_path.exists():
|
||||
print(f"ERROR: Migration script {script_name} not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
print(f"\n{'='*50}")
|
||||
print(f"Running {script_name}")
|
||||
print(f"{'='*50}")
|
||||
|
||||
result = subprocess.run([sys.executable, str(script_path)],
|
||||
capture_output=True, text=True, cwd=Path(__file__).parent)
|
||||
|
||||
print(result.stdout)
|
||||
|
||||
if result.stderr:
|
||||
print("STDERR:", result.stderr)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(f"SUCCESS: {script_name} completed successfully")
|
||||
return True
|
||||
else:
|
||||
print(f"ERROR: {script_name} failed with exit code {result.returncode}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"ERROR: Error running {script_name}: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
"""Run all soft deletion migration scripts."""
|
||||
|
||||
print("=== Complete Soft Deletion Migration ===")
|
||||
print("This will add soft deletion columns to all relevant tables")
|
||||
|
||||
# Migration scripts in order
|
||||
migration_scripts = [
|
||||
"migrate_soft_deletion_shots.py",
|
||||
"migrate_soft_deletion_assets.py",
|
||||
"migrate_soft_deletion_tasks.py",
|
||||
"migrate_soft_deletion_related_tables.py"
|
||||
]
|
||||
|
||||
all_successful = True
|
||||
|
||||
for script in migration_scripts:
|
||||
success = run_migration_script(script)
|
||||
if not success:
|
||||
all_successful = False
|
||||
print(f"\nERROR: Migration failed at {script}")
|
||||
break
|
||||
|
||||
if all_successful:
|
||||
print(f"\n{'='*50}")
|
||||
print("SUCCESS: ALL MIGRATIONS COMPLETED SUCCESSFULLY!")
|
||||
print("SUCCESS: Soft deletion columns added to all tables:")
|
||||
print(" - shots (deleted_at, deleted_by)")
|
||||
print(" - assets (deleted_at, deleted_by)")
|
||||
print(" - tasks (deleted_at, deleted_by)")
|
||||
print(" - submissions (deleted_at, deleted_by)")
|
||||
print(" - task_attachments (deleted_at, deleted_by)")
|
||||
print(" - production_notes (deleted_at, deleted_by)")
|
||||
print(" - reviews (deleted_at, deleted_by)")
|
||||
print("SUCCESS: Partial indexes created for efficient querying")
|
||||
print(f"{'='*50}")
|
||||
return True
|
||||
else:
|
||||
print(f"\n{'='*50}")
|
||||
print("ERROR: MIGRATION FAILED!")
|
||||
print("Please check the error messages above and fix any issues.")
|
||||
print(f"{'='*50}")
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add soft deletion columns to related tables.
|
||||
Adds deleted_at and deleted_by columns to submissions, task_attachments, production_notes, and reviews tables.
|
||||
Creates appropriate partial indexes for each table.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def migrate_related_tables_soft_deletion():
|
||||
"""Add soft deletion columns to related tables and create partial indexes."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("Starting related tables soft deletion migration...")
|
||||
|
||||
# Tables to migrate
|
||||
tables = [
|
||||
("submissions", "task_id"),
|
||||
("task_attachments", "task_id"),
|
||||
("production_notes", "task_id"),
|
||||
("reviews", "submission_id")
|
||||
]
|
||||
|
||||
for table_name, index_column in tables:
|
||||
print(f"\n--- Migrating {table_name} table ---")
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'deleted_at' in columns:
|
||||
print(f"Soft deletion columns already exist in {table_name} table")
|
||||
continue
|
||||
|
||||
# Add deleted_at column
|
||||
print(f"Adding deleted_at column to {table_name} table...")
|
||||
cursor.execute(f"""
|
||||
ALTER TABLE {table_name}
|
||||
ADD COLUMN deleted_at TIMESTAMP NULL
|
||||
""")
|
||||
|
||||
# Add deleted_by column
|
||||
print(f"Adding deleted_by column to {table_name} table...")
|
||||
cursor.execute(f"""
|
||||
ALTER TABLE {table_name}
|
||||
ADD COLUMN deleted_by INTEGER NULL
|
||||
REFERENCES users(id)
|
||||
""")
|
||||
|
||||
# Create partial index for efficient querying of non-deleted records
|
||||
index_name = f"idx_{table_name}_not_deleted"
|
||||
print(f"Creating partial index {index_name}...")
|
||||
cursor.execute(f"""
|
||||
CREATE INDEX {index_name}
|
||||
ON {table_name} ({index_column})
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
print(f"SUCCESS: Successfully migrated {table_name} table")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print("\nSUCCESS: Successfully added soft deletion columns to all related tables")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def verify_migration():
|
||||
"""Verify that the migration was successful."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Tables to verify
|
||||
tables = [
|
||||
("submissions", "idx_submissions_not_deleted"),
|
||||
("task_attachments", "idx_task_attachments_not_deleted"),
|
||||
("production_notes", "idx_production_notes_not_deleted"),
|
||||
("reviews", "idx_reviews_not_deleted")
|
||||
]
|
||||
|
||||
all_verified = True
|
||||
|
||||
for table_name, index_name in tables:
|
||||
print(f"\n--- Verifying {table_name} table ---")
|
||||
|
||||
# Check table structure
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = {column[1]: column[2] for column in cursor.fetchall()}
|
||||
|
||||
# Verify columns exist
|
||||
if 'deleted_at' not in columns:
|
||||
print(f"❌ deleted_at column not found in {table_name}")
|
||||
all_verified = False
|
||||
continue
|
||||
if 'deleted_by' not in columns:
|
||||
print(f"❌ deleted_by column not found in {table_name}")
|
||||
all_verified = False
|
||||
continue
|
||||
|
||||
print(f"SUCCESS: Soft deletion columns verified in {table_name} table")
|
||||
|
||||
# Check index exists
|
||||
cursor.execute(f"PRAGMA index_list({table_name})")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
|
||||
if index_name not in indexes:
|
||||
print(f"❌ Partial index {index_name} not found")
|
||||
all_verified = False
|
||||
continue
|
||||
|
||||
print(f"SUCCESS: Partial index {index_name} verified")
|
||||
|
||||
return all_verified
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Related Tables Soft Deletion Migration ===")
|
||||
|
||||
success = migrate_related_tables_soft_deletion()
|
||||
|
||||
if success:
|
||||
print("\n=== Verifying Migration ===")
|
||||
if verify_migration():
|
||||
print("\nSUCCESS: Migration completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Migration verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ Migration failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add soft deletion columns to shots table.
|
||||
Adds deleted_at and deleted_by columns and creates partial index for efficient querying.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def migrate_shots_soft_deletion():
|
||||
"""Add soft deletion columns to shots table and create partial index."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("Starting shots table soft deletion migration...")
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'deleted_at' in columns:
|
||||
print("Soft deletion columns already exist in shots table")
|
||||
return True
|
||||
|
||||
# Add deleted_at column
|
||||
print("Adding deleted_at column to shots table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE shots
|
||||
ADD COLUMN deleted_at TIMESTAMP NULL
|
||||
""")
|
||||
|
||||
# Add deleted_by column
|
||||
print("Adding deleted_by column to shots table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE shots
|
||||
ADD COLUMN deleted_by INTEGER NULL
|
||||
REFERENCES users(id)
|
||||
""")
|
||||
|
||||
# Create partial index for efficient querying of non-deleted records
|
||||
print("Creating partial index idx_shots_not_deleted...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_shots_not_deleted
|
||||
ON shots (id)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print("SUCCESS: Successfully added soft deletion columns to shots table")
|
||||
print("SUCCESS: Created partial index idx_shots_not_deleted")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def verify_migration():
|
||||
"""Verify that the migration was successful."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check table structure
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = {column[1]: column[2] for column in cursor.fetchall()}
|
||||
|
||||
# Verify columns exist
|
||||
if 'deleted_at' not in columns:
|
||||
print("❌ deleted_at column not found")
|
||||
return False
|
||||
if 'deleted_by' not in columns:
|
||||
print("❌ deleted_by column not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Soft deletion columns verified in shots table")
|
||||
|
||||
# Check index exists
|
||||
cursor.execute("PRAGMA index_list(shots)")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
|
||||
if 'idx_shots_not_deleted' not in indexes:
|
||||
print("❌ Partial index idx_shots_not_deleted not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Partial index idx_shots_not_deleted verified")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Shots Table Soft Deletion Migration ===")
|
||||
|
||||
success = migrate_shots_soft_deletion()
|
||||
|
||||
if success:
|
||||
print("\n=== Verifying Migration ===")
|
||||
if verify_migration():
|
||||
print("\nSUCCESS: Migration completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Migration verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ Migration failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add soft deletion columns to tasks table.
|
||||
Adds deleted_at and deleted_by columns and creates partial index for efficient querying.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def migrate_tasks_soft_deletion():
|
||||
"""Add soft deletion columns to tasks table and create partial index."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("Starting tasks table soft deletion migration...")
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(tasks)")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
|
||||
if 'deleted_at' in columns:
|
||||
print("Soft deletion columns already exist in tasks table")
|
||||
return True
|
||||
|
||||
# Add deleted_at column
|
||||
print("Adding deleted_at column to tasks table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN deleted_at TIMESTAMP NULL
|
||||
""")
|
||||
|
||||
# Add deleted_by column
|
||||
print("Adding deleted_by column to tasks table...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE tasks
|
||||
ADD COLUMN deleted_by INTEGER NULL
|
||||
REFERENCES users(id)
|
||||
""")
|
||||
|
||||
# Create partial index for efficient querying of non-deleted records
|
||||
# Index on shot_id and asset_id for efficient filtering
|
||||
print("Creating partial index idx_tasks_not_deleted...")
|
||||
cursor.execute("""
|
||||
CREATE INDEX idx_tasks_not_deleted
|
||||
ON tasks (shot_id, asset_id)
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
print("SUCCESS: Successfully added soft deletion columns to tasks table")
|
||||
print("SUCCESS: Created partial index idx_tasks_not_deleted")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def verify_migration():
|
||||
"""Verify that the migration was successful."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check table structure
|
||||
cursor.execute("PRAGMA table_info(tasks)")
|
||||
columns = {column[1]: column[2] for column in cursor.fetchall()}
|
||||
|
||||
# Verify columns exist
|
||||
if 'deleted_at' not in columns:
|
||||
print("❌ deleted_at column not found")
|
||||
return False
|
||||
if 'deleted_by' not in columns:
|
||||
print("❌ deleted_by column not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Soft deletion columns verified in tasks table")
|
||||
|
||||
# Check index exists
|
||||
cursor.execute("PRAGMA index_list(tasks)")
|
||||
indexes = [index[1] for index in cursor.fetchall()]
|
||||
|
||||
if 'idx_tasks_not_deleted' not in indexes:
|
||||
print("❌ Partial index idx_tasks_not_deleted not found")
|
||||
return False
|
||||
|
||||
print("SUCCESS: Partial index idx_tasks_not_deleted verified")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== Tasks Table Soft Deletion Migration ===")
|
||||
|
||||
success = migrate_tasks_soft_deletion()
|
||||
|
||||
if success:
|
||||
print("\n=== Verifying Migration ===")
|
||||
if verify_migration():
|
||||
print("\nSUCCESS: Migration completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Migration verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ Migration failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,39 @@
|
||||
# Models package
|
||||
from .user import User, UserRole, DepartmentRole
|
||||
from .project import Project, ProjectMember, ProjectStatus
|
||||
from .episode import Episode, EpisodeStatus
|
||||
from .asset import Asset, AssetCategory, AssetStatus
|
||||
from .shot import Shot, ShotStatus
|
||||
from .task import (
|
||||
Task, Submission, Review, ProductionNote, TaskAttachment,
|
||||
TaskType, TaskStatus, ReviewDecision, AttachmentType
|
||||
)
|
||||
from .api_key import APIKey, APIKeyScope
|
||||
from .api_key_usage import APIKeyUsage
|
||||
from .global_settings import GlobalSettings
|
||||
from .notification import Notification, UserNotificationPreference, NotificationType
|
||||
from .activity import Activity, ActivityType
|
||||
|
||||
__all__ = [
|
||||
# User models
|
||||
"User", "UserRole", "DepartmentRole",
|
||||
# Project models
|
||||
"Project", "ProjectMember", "ProjectStatus",
|
||||
# Episode models
|
||||
"Episode", "EpisodeStatus",
|
||||
# Asset models
|
||||
"Asset", "AssetCategory", "AssetStatus",
|
||||
# Shot models
|
||||
"Shot", "ShotStatus",
|
||||
# Task models
|
||||
"Task", "Submission", "Review", "ProductionNote", "TaskAttachment",
|
||||
"TaskType", "TaskStatus", "ReviewDecision", "AttachmentType",
|
||||
# API Key models
|
||||
"APIKey", "APIKeyScope", "APIKeyUsage",
|
||||
# Global Settings models
|
||||
"GlobalSettings",
|
||||
# Notification models
|
||||
"Notification", "UserNotificationPreference", "NotificationType",
|
||||
# Activity models
|
||||
"Activity", "ActivityType"
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, Enum as SQLEnum, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
|
||||
class ActivityType(str, enum.Enum):
|
||||
TASK_CREATED = "task_created"
|
||||
TASK_UPDATED = "task_updated"
|
||||
TASK_ASSIGNED = "task_assigned"
|
||||
TASK_STATUS_CHANGED = "task_status_changed"
|
||||
SUBMISSION_CREATED = "submission_created"
|
||||
SUBMISSION_REVIEWED = "submission_reviewed"
|
||||
COMMENT_ADDED = "comment_added"
|
||||
ASSET_CREATED = "asset_created"
|
||||
ASSET_UPDATED = "asset_updated"
|
||||
SHOT_CREATED = "shot_created"
|
||||
SHOT_UPDATED = "shot_updated"
|
||||
PROJECT_CREATED = "project_created"
|
||||
PROJECT_UPDATED = "project_updated"
|
||||
USER_JOINED_PROJECT = "user_joined_project"
|
||||
SHOT_DELETED = "shot_deleted"
|
||||
ASSET_DELETED = "asset_deleted"
|
||||
SHOT_RECOVERED = "shot_recovered"
|
||||
ASSET_RECOVERED = "asset_recovered"
|
||||
|
||||
|
||||
class Activity(Base):
|
||||
__tablename__ = "activities"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
type = Column(SQLEnum(ActivityType), nullable=False, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
|
||||
# Optional references to related entities
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True, index=True)
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True)
|
||||
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True)
|
||||
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=True)
|
||||
|
||||
# Activity details
|
||||
description = Column(Text, nullable=False)
|
||||
activity_metadata = Column(JSON, nullable=True) # Additional context data
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", foreign_keys=[user_id])
|
||||
project = relationship("Project", foreign_keys=[project_id])
|
||||
task = relationship("Task", foreign_keys=[task_id])
|
||||
asset = relationship("Asset", foreign_keys=[asset_id])
|
||||
shot = relationship("Shot", foreign_keys=[shot_id])
|
||||
submission = relationship("Submission", foreign_keys=[submission_id])
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class APIKeyScope(str, enum.Enum):
|
||||
READ_PROJECTS = "read:projects"
|
||||
READ_TASKS = "read:tasks"
|
||||
READ_SUBMISSIONS = "read:submissions"
|
||||
READ_USERS = "read:users"
|
||||
WRITE_TASKS = "write:tasks"
|
||||
WRITE_SUBMISSIONS = "write:submissions"
|
||||
ADMIN_USERS = "admin:users"
|
||||
FULL_ACCESS = "full:access"
|
||||
|
||||
|
||||
class APIKey(Base):
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
key_hash = Column(String, unique=True, index=True, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
scopes = Column(Text, nullable=False) # JSON string of scopes
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=True)
|
||||
last_used_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<APIKey(id={self.id}, name='{self.name}', user_id={self.user_id})>"
|
||||
@@ -0,0 +1,22 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class APIKeyUsage(Base):
|
||||
__tablename__ = "api_key_usage"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
api_key_id = Column(Integer, ForeignKey("api_keys.id"), nullable=False)
|
||||
endpoint = Column(String, nullable=False)
|
||||
method = Column(String, nullable=False)
|
||||
timestamp = Column(DateTime(timezone=True), server_default=func.now())
|
||||
ip_address = Column(String, nullable=True)
|
||||
user_agent = Column(String, nullable=True)
|
||||
|
||||
# Relationships
|
||||
api_key = relationship("APIKey")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<APIKeyUsage(id={self.id}, api_key_id={self.api_key_id}, endpoint='{self.endpoint}')>"
|
||||
@@ -0,0 +1,65 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship, Query
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class AssetCategory(str, enum.Enum):
|
||||
CHARACTERS = "characters"
|
||||
PROPS = "props"
|
||||
SETS = "sets"
|
||||
VEHICLES = "vehicles"
|
||||
|
||||
|
||||
class AssetStatus(str, enum.Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
ON_HOLD = "on_hold"
|
||||
COMPLETED = "completed"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class Asset(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
category = Column(Enum(AssetCategory), nullable=False)
|
||||
status = Column(Enum(AssetStatus), nullable=False, default=AssetStatus.NOT_STARTED)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="assets")
|
||||
tasks = relationship("Task", back_populates="asset", cascade="all, delete-orphan")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the asset is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted assets."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted assets."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Asset(id={self.id}, name='{self.name}', category='{self.category}')>"
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class EpisodeStatus(str, enum.Enum):
|
||||
PLANNING = "planning"
|
||||
IN_PROGRESS = "in_progress"
|
||||
ON_HOLD = "on_hold"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class Episode(Base):
|
||||
__tablename__ = "episodes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
episode_number = Column(Integer, nullable=False)
|
||||
status = Column(Enum(EpisodeStatus), nullable=False, default=EpisodeStatus.PLANNING)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="episodes")
|
||||
shots = relationship("Shot", back_populates="episode", cascade="all, delete-orphan")
|
||||
tasks = relationship("Task", back_populates="episode", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Episode(id={self.id}, name='{self.name}', episode_number={self.episode_number})>"
|
||||
@@ -0,0 +1,14 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Text
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
|
||||
|
||||
class GlobalSettings(Base):
|
||||
__tablename__ = "global_settings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
setting_key = Column(String(100), unique=True, index=True, nullable=False)
|
||||
setting_value = Column(Text, nullable=False)
|
||||
description = Column(Text)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
@@ -0,0 +1,89 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, ForeignKey, Text, Enum as SQLEnum
|
||||
from sqlalchemy.orm import relationship
|
||||
from database import Base
|
||||
from datetime import datetime
|
||||
import enum
|
||||
|
||||
|
||||
class NotificationType(str, enum.Enum):
|
||||
TASK_ASSIGNED = "task_assigned"
|
||||
TASK_STATUS_CHANGED = "task_status_changed"
|
||||
SUBMISSION_REVIEWED = "submission_reviewed"
|
||||
WORK_SUBMITTED = "work_submitted"
|
||||
DEADLINE_APPROACHING = "deadline_approaching"
|
||||
PROJECT_UPDATE = "project_update"
|
||||
COMMENT_ADDED = "comment_added"
|
||||
|
||||
|
||||
class NotificationPriority(str, enum.Enum):
|
||||
LOW = "low"
|
||||
NORMAL = "normal"
|
||||
HIGH = "high"
|
||||
URGENT = "urgent"
|
||||
|
||||
|
||||
class Notification(Base):
|
||||
__tablename__ = "notifications"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||
type = Column(SQLEnum(NotificationType), nullable=False)
|
||||
priority = Column(SQLEnum(NotificationPriority), default=NotificationPriority.NORMAL)
|
||||
title = Column(String(255), nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
read = Column(Boolean, default=False, index=True)
|
||||
|
||||
# Optional references to related entities
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=True)
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=True)
|
||||
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=True)
|
||||
|
||||
# Email notification tracking
|
||||
email_sent = Column(Boolean, default=False)
|
||||
email_sent_at = Column(DateTime, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
read_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="notifications")
|
||||
project = relationship("Project", foreign_keys=[project_id])
|
||||
task = relationship("Task", foreign_keys=[task_id])
|
||||
submission = relationship("Submission", foreign_keys=[submission_id])
|
||||
|
||||
|
||||
class UserNotificationPreference(Base):
|
||||
__tablename__ = "user_notification_preferences"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
|
||||
|
||||
# Email notification preferences
|
||||
email_enabled = Column(Boolean, default=True)
|
||||
email_task_assigned = Column(Boolean, default=True)
|
||||
email_task_status_changed = Column(Boolean, default=True)
|
||||
email_submission_reviewed = Column(Boolean, default=True)
|
||||
email_work_submitted = Column(Boolean, default=True)
|
||||
email_deadline_approaching = Column(Boolean, default=True)
|
||||
email_project_update = Column(Boolean, default=True)
|
||||
email_comment_added = Column(Boolean, default=True)
|
||||
|
||||
# In-app notification preferences
|
||||
inapp_enabled = Column(Boolean, default=True)
|
||||
inapp_task_assigned = Column(Boolean, default=True)
|
||||
inapp_task_status_changed = Column(Boolean, default=True)
|
||||
inapp_submission_reviewed = Column(Boolean, default=True)
|
||||
inapp_work_submitted = Column(Boolean, default=True)
|
||||
inapp_deadline_approaching = Column(Boolean, default=True)
|
||||
inapp_project_update = Column(Boolean, default=True)
|
||||
inapp_comment_added = Column(Boolean, default=True)
|
||||
|
||||
# Digest settings
|
||||
email_digest_enabled = Column(Boolean, default=False)
|
||||
email_digest_frequency = Column(String(50), default="daily") # daily, weekly
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="notification_preferences")
|
||||
@@ -0,0 +1,88 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Date, Enum, ForeignKey, Float, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
from .user import DepartmentRole
|
||||
import enum
|
||||
|
||||
|
||||
class ProjectStatus(str, enum.Enum):
|
||||
PLANNING = "planning"
|
||||
IN_PROGRESS = "in_progress"
|
||||
ON_HOLD = "on_hold"
|
||||
COMPLETED = "completed"
|
||||
CANCELLED = "cancelled"
|
||||
|
||||
|
||||
class ProjectType(str, enum.Enum):
|
||||
TV = "tv"
|
||||
CINEMA = "cinema"
|
||||
GAME = "game"
|
||||
|
||||
|
||||
class Project(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
code_name = Column(String, nullable=False, unique=True, index=True) # Unique project code
|
||||
client_name = Column(String, nullable=False, index=True) # Client/studio name
|
||||
project_type = Column(Enum(ProjectType), nullable=False) # TV, Cinema, Game
|
||||
description = Column(String)
|
||||
status = Column(Enum(ProjectStatus), nullable=False, default=ProjectStatus.PLANNING)
|
||||
start_date = Column(Date)
|
||||
end_date = Column(Date)
|
||||
|
||||
# Technical specifications
|
||||
frame_rate = Column(Float, nullable=True) # Frames per second (1-120 fps)
|
||||
data_drive_path = Column(String, nullable=True) # Physical path for project data storage
|
||||
publish_storage_path = Column(String, nullable=True) # Path for approved work delivery
|
||||
delivery_image_resolution = Column(String, nullable=True) # Required image resolution
|
||||
delivery_movie_specs_by_department = Column(JSON, nullable=True) # Delivery movie specs per department
|
||||
|
||||
# Project-specific settings
|
||||
upload_data_location = Column(String, nullable=True) # Custom upload storage path
|
||||
asset_task_templates = Column(JSON, nullable=True) # Default tasks per asset category
|
||||
shot_task_templates = Column(JSON, nullable=True) # Default tasks for shots
|
||||
enabled_asset_tasks = Column(JSON, nullable=True) # Enabled/disabled asset tasks per category
|
||||
enabled_shot_tasks = Column(JSON, nullable=True) # Enabled/disabled shot tasks
|
||||
|
||||
# Custom task types
|
||||
custom_asset_task_types = Column(JSON, nullable=True) # Custom task types for assets
|
||||
custom_shot_task_types = Column(JSON, nullable=True) # Custom task types for shots
|
||||
|
||||
# Custom task statuses
|
||||
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
||||
|
||||
# Project thumbnail
|
||||
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
episodes = relationship("Episode", back_populates="project", cascade="all, delete-orphan")
|
||||
shots = relationship("Shot", back_populates="project", cascade="all, delete-orphan")
|
||||
assets = relationship("Asset", back_populates="project", cascade="all, delete-orphan")
|
||||
project_members = relationship("ProjectMember", back_populates="project", cascade="all, delete-orphan")
|
||||
tasks = relationship("Task", back_populates="project", cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Project(id={self.id}, name='{self.name}', status='{self.status}')>"
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
__tablename__ = "project_members"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
department_role = Column(Enum(DepartmentRole), nullable=True)
|
||||
joined_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
user = relationship("User", back_populates="project_memberships")
|
||||
project = relationship("Project", back_populates="project_members")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProjectMember(user_id={self.user_id}, project_id={self.project_id}, role='{self.department_role}')>"
|
||||
@@ -0,0 +1,66 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Enum, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship, Query
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class ShotStatus(str, enum.Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
ON_HOLD = "on_hold"
|
||||
COMPLETED = "completed"
|
||||
APPROVED = "approved"
|
||||
|
||||
|
||||
class Shot(Base):
|
||||
__tablename__ = "shots"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False, index=True)
|
||||
episode_id = Column(Integer, ForeignKey("episodes.id"), nullable=False)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
description = Column(String)
|
||||
frame_start = Column(Integer, nullable=False, default=1001)
|
||||
frame_end = Column(Integer, nullable=False, default=1001)
|
||||
status = Column(Enum(ShotStatus), nullable=False, default=ShotStatus.NOT_STARTED)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="shots")
|
||||
episode = relationship("Episode", back_populates="shots")
|
||||
tasks = relationship("Task", back_populates="shot", cascade="all, delete-orphan")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
# Constraints
|
||||
__table_args__ = (
|
||||
UniqueConstraint('project_id', 'name', name='uq_shot_project_name'),
|
||||
)
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the shot is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted shots."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted shots."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Shot(id={self.id}, name='{self.name}', frames={self.frame_start}-{self.frame_end})>"
|
||||
@@ -0,0 +1,276 @@
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Date, ForeignKey, Enum, Text
|
||||
from sqlalchemy.orm import relationship, Query
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class TaskType(str, enum.Enum):
|
||||
# Shot tasks
|
||||
LAYOUT = "layout"
|
||||
ANIMATION = "animation"
|
||||
SIMULATION = "simulation"
|
||||
LIGHTING = "lighting"
|
||||
COMPOSITING = "compositing"
|
||||
# Asset tasks
|
||||
MODELING = "modeling"
|
||||
SURFACING = "surfacing"
|
||||
RIGGING = "rigging"
|
||||
|
||||
|
||||
class TaskStatus(str, enum.Enum):
|
||||
NOT_STARTED = "not_started"
|
||||
IN_PROGRESS = "in_progress"
|
||||
SUBMITTED = "submitted"
|
||||
APPROVED = "approved"
|
||||
RETAKE = "retake"
|
||||
|
||||
|
||||
class ReviewDecision(str, enum.Enum):
|
||||
APPROVED = "approved"
|
||||
RETAKE = "retake"
|
||||
|
||||
|
||||
class AttachmentType(str, enum.Enum):
|
||||
REFERENCE = "reference"
|
||||
WORK_FILE = "work_file"
|
||||
PREVIEW = "preview"
|
||||
DOCUMENTATION = "documentation"
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
|
||||
episode_id = Column(Integer, ForeignKey("episodes.id"), nullable=True)
|
||||
shot_id = Column(Integer, ForeignKey("shots.id"), nullable=True)
|
||||
asset_id = Column(Integer, ForeignKey("assets.id"), nullable=True)
|
||||
assigned_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
task_type = Column(String, nullable=False) # Changed from Enum to String to support custom task types
|
||||
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
|
||||
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())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
project = relationship("Project", back_populates="tasks")
|
||||
episode = relationship("Episode", back_populates="tasks")
|
||||
shot = relationship("Shot", back_populates="tasks")
|
||||
asset = relationship("Asset", back_populates="tasks")
|
||||
assigned_user = relationship("User", foreign_keys=[assigned_user_id], back_populates="assigned_tasks")
|
||||
submissions = relationship("Submission", back_populates="task", cascade="all, delete-orphan")
|
||||
production_notes = relationship("ProductionNote", back_populates="task", cascade="all, delete-orphan")
|
||||
attachments = relationship("TaskAttachment", back_populates="task", cascade="all, delete-orphan")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the task is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted tasks."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted tasks."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Task(id={self.id}, name='{self.name}', type='{self.task_type}', status='{self.status}')>"
|
||||
|
||||
|
||||
class Submission(Base):
|
||||
__tablename__ = "submissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
file_name = Column(String, nullable=False)
|
||||
version_number = Column(Integer, nullable=False, default=1)
|
||||
notes = Column(Text)
|
||||
submitted_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
task = relationship("Task", back_populates="submissions")
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="submissions")
|
||||
reviews = relationship("Review", back_populates="submission", cascade="all, delete-orphan")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the submission is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted submissions."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted submissions."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Submission(id={self.id}, task_id={self.task_id}, version={self.version_number})>"
|
||||
|
||||
|
||||
class Review(Base):
|
||||
__tablename__ = "reviews"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
submission_id = Column(Integer, ForeignKey("submissions.id"), nullable=False)
|
||||
reviewer_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
decision = Column(Enum(ReviewDecision), nullable=False)
|
||||
feedback = Column(Text)
|
||||
reviewed_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
submission = relationship("Submission", back_populates="reviews")
|
||||
reviewer = relationship("User", foreign_keys=[reviewer_id], back_populates="reviews")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the review is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted reviews."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted reviews."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Review(id={self.id}, decision='{self.decision}', submission_id={self.submission_id})>"
|
||||
|
||||
|
||||
class ProductionNote(Base):
|
||||
__tablename__ = "production_notes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
parent_note_id = Column(Integer, ForeignKey("production_notes.id"), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
task = relationship("Task", back_populates="production_notes")
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="production_notes")
|
||||
parent_note = relationship("ProductionNote", remote_side=[id], back_populates="child_notes")
|
||||
child_notes = relationship("ProductionNote", back_populates="parent_note", cascade="all, delete-orphan")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the production note is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted production notes."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted production notes."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ProductionNote(id={self.id}, task_id={self.task_id}, user_id={self.user_id})>"
|
||||
|
||||
|
||||
class TaskAttachment(Base):
|
||||
__tablename__ = "task_attachments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
task_id = Column(Integer, ForeignKey("tasks.id"), nullable=False)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
file_name = Column(String, nullable=False)
|
||||
file_path = Column(String, nullable=False)
|
||||
file_type = Column(String, nullable=False)
|
||||
file_size = Column(Integer, nullable=False)
|
||||
attachment_type = Column(Enum(AttachmentType), nullable=False, default=AttachmentType.REFERENCE)
|
||||
description = Column(Text)
|
||||
uploaded_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Soft deletion columns
|
||||
deleted_at = Column(DateTime(timezone=True), nullable=True)
|
||||
deleted_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||
|
||||
# Relationships
|
||||
task = relationship("Task", back_populates="attachments")
|
||||
user = relationship("User", foreign_keys=[user_id], back_populates="task_attachments")
|
||||
deleted_by_user = relationship("User", foreign_keys=[deleted_by])
|
||||
|
||||
@property
|
||||
def is_deleted(self) -> bool:
|
||||
"""Check if the task attachment is soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
@classmethod
|
||||
def query_active(cls, query: Query) -> Query:
|
||||
"""Filter query to exclude soft deleted task attachments."""
|
||||
return query.filter(cls.deleted_at.is_(None))
|
||||
|
||||
@classmethod
|
||||
def query_deleted(cls, query: Query) -> Query:
|
||||
"""Filter query to include only soft deleted task attachments."""
|
||||
return query.filter(cls.deleted_at.isnot(None))
|
||||
|
||||
@classmethod
|
||||
def query_all_including_deleted(cls, query: Query) -> Query:
|
||||
"""Return query without soft deletion filtering (for admin use)."""
|
||||
return query
|
||||
|
||||
def __repr__(self):
|
||||
return f"<TaskAttachment(id={self.id}, file_name='{self.file_name}', type='{self.attachment_type}')>"
|
||||
@@ -0,0 +1,52 @@
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Enum
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from database import Base
|
||||
import enum
|
||||
|
||||
|
||||
class UserRole(str, enum.Enum):
|
||||
DIRECTOR = "director"
|
||||
COORDINATOR = "coordinator"
|
||||
ARTIST = "artist"
|
||||
DEVELOPER = "developer"
|
||||
|
||||
|
||||
class DepartmentRole(str, enum.Enum):
|
||||
LAYOUT = "layout"
|
||||
ANIMATION = "animation"
|
||||
LIGHTING = "lighting"
|
||||
COMPOSITE = "composite"
|
||||
MODELING = "modeling"
|
||||
RIGGING = "rigging"
|
||||
SURFACING = "surfacing"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
first_name = Column(String, nullable=False)
|
||||
last_name = Column(String, nullable=False)
|
||||
role = Column(Enum(UserRole), nullable=False, default=UserRole.ARTIST)
|
||||
is_admin = Column(Boolean, default=False, nullable=False)
|
||||
is_approved = Column(Boolean, default=False, nullable=False)
|
||||
avatar_url = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
project_memberships = relationship("ProjectMember", back_populates="user", cascade="all, delete-orphan")
|
||||
assigned_tasks = relationship("Task", foreign_keys="Task.assigned_user_id", back_populates="assigned_user")
|
||||
submissions = relationship("Submission", foreign_keys="Submission.user_id", back_populates="user")
|
||||
reviews = relationship("Review", foreign_keys="Review.reviewer_id", back_populates="reviewer")
|
||||
production_notes = relationship("ProductionNote", foreign_keys="ProductionNote.user_id", back_populates="user")
|
||||
task_attachments = relationship("TaskAttachment", foreign_keys="TaskAttachment.user_id", back_populates="user")
|
||||
api_keys = relationship("APIKey", back_populates="user", cascade="all, delete-orphan")
|
||||
notifications = relationship("Notification", back_populates="user", cascade="all, delete-orphan")
|
||||
notification_preferences = relationship("UserNotificationPreference", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(id={self.id}, email='{self.email}', role='{self.role}')>"
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Query performance monitoring script for VFX Project Management System.
|
||||
Analyzes query execution plans and provides performance insights.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
def monitor_query_performance():
|
||||
"""Monitor and analyze query performance for key operations."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("=== Query Performance Monitoring ===\n")
|
||||
|
||||
# Define key queries to test
|
||||
test_queries = [
|
||||
{
|
||||
"name": "Get active tasks by user",
|
||||
"query": """
|
||||
SELECT t.id, t.name, t.status, t.task_type
|
||||
FROM tasks t
|
||||
WHERE t.assigned_user_id = 1
|
||||
AND t.deleted_at IS NULL
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
"description": "Common query for user task dashboard"
|
||||
},
|
||||
{
|
||||
"name": "Get shots by episode (non-deleted)",
|
||||
"query": """
|
||||
SELECT s.id, s.name, s.status, s.frame_start, s.frame_end
|
||||
FROM shots s
|
||||
WHERE s.episode_id = 1
|
||||
AND s.deleted_at IS NULL
|
||||
ORDER BY s.name
|
||||
""",
|
||||
"description": "Common query for shot browser"
|
||||
},
|
||||
{
|
||||
"name": "Get assets by project (non-deleted)",
|
||||
"query": """
|
||||
SELECT a.id, a.name, a.category, a.status
|
||||
FROM assets a
|
||||
WHERE a.project_id = 1
|
||||
AND a.deleted_at IS NULL
|
||||
ORDER BY a.category, a.name
|
||||
""",
|
||||
"description": "Common query for asset browser"
|
||||
},
|
||||
{
|
||||
"name": "Get task submissions with reviews",
|
||||
"query": """
|
||||
SELECT s.id, s.file_name, s.version_number, s.submitted_at,
|
||||
r.decision, r.feedback
|
||||
FROM submissions s
|
||||
LEFT JOIN reviews r ON s.id = r.submission_id AND r.deleted_at IS NULL
|
||||
WHERE s.task_id = 1
|
||||
AND s.deleted_at IS NULL
|
||||
ORDER BY s.version_number DESC
|
||||
""",
|
||||
"description": "Common query for task detail panel"
|
||||
},
|
||||
{
|
||||
"name": "Get project tasks with status filter",
|
||||
"query": """
|
||||
SELECT t.id, t.name, t.task_type, t.status,
|
||||
u.first_name, u.last_name,
|
||||
COALESCE(s.name, a.name) as parent_name
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON t.assigned_user_id = u.id
|
||||
LEFT JOIN shots s ON t.shot_id = s.id AND s.deleted_at IS NULL
|
||||
LEFT JOIN assets a ON t.asset_id = a.id AND a.deleted_at IS NULL
|
||||
WHERE t.project_id = 1
|
||||
AND t.status IN ('in_progress', 'pending_review')
|
||||
AND t.deleted_at IS NULL
|
||||
ORDER BY t.created_at DESC
|
||||
""",
|
||||
"description": "Complex query for project task overview"
|
||||
},
|
||||
{
|
||||
"name": "Get recent activity feed",
|
||||
"query": """
|
||||
SELECT a.type, a.description, a.created_at,
|
||||
u.first_name, u.last_name
|
||||
FROM activities a
|
||||
LEFT JOIN users u ON a.user_id = u.id
|
||||
WHERE a.project_id = 1
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 20
|
||||
""",
|
||||
"description": "Common query for activity feed"
|
||||
},
|
||||
{
|
||||
"name": "Get user workload summary",
|
||||
"query": """
|
||||
SELECT u.id, u.first_name, u.last_name,
|
||||
COUNT(t.id) as task_count,
|
||||
COUNT(CASE WHEN t.status = 'in_progress' THEN 1 END) as active_tasks
|
||||
FROM users u
|
||||
LEFT JOIN tasks t ON u.id = t.assigned_user_id AND t.deleted_at IS NULL
|
||||
WHERE u.role = 'artist'
|
||||
GROUP BY u.id, u.first_name, u.last_name
|
||||
ORDER BY task_count DESC
|
||||
""",
|
||||
"description": "Query for workload management"
|
||||
}
|
||||
]
|
||||
|
||||
print("Testing query performance...\n")
|
||||
|
||||
for test in test_queries:
|
||||
print(f"Query: {test['name']}")
|
||||
print(f"Description: {test['description']}")
|
||||
|
||||
# Get query plan
|
||||
explain_query = f"EXPLAIN QUERY PLAN {test['query']}"
|
||||
cursor.execute(explain_query)
|
||||
plan = cursor.fetchall()
|
||||
|
||||
print("Execution Plan:")
|
||||
for step in plan:
|
||||
print(f" {step[0]}: {step[3]}")
|
||||
|
||||
# Measure execution time
|
||||
start_time = time.time()
|
||||
cursor.execute(test['query'])
|
||||
results = cursor.fetchall()
|
||||
end_time = time.time()
|
||||
|
||||
execution_time = (end_time - start_time) * 1000 # Convert to milliseconds
|
||||
|
||||
print(f"Execution Time: {execution_time:.2f}ms")
|
||||
print(f"Result Count: {len(results)}")
|
||||
|
||||
# Check if indexes are being used
|
||||
plan_text = " ".join([step[3] for step in plan])
|
||||
if "USING INDEX" in plan_text:
|
||||
print("✓ Index usage detected")
|
||||
else:
|
||||
print("⚠ No index usage detected")
|
||||
|
||||
print("-" * 60)
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def analyze_index_usage():
|
||||
"""Analyze which indexes are being used effectively."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("\n=== Index Usage Analysis ===\n")
|
||||
|
||||
# Get all indexes
|
||||
cursor.execute("""
|
||||
SELECT name, tbl_name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
ORDER BY tbl_name, name
|
||||
""")
|
||||
indexes = cursor.fetchall()
|
||||
|
||||
print(f"Total custom indexes: {len(indexes)}")
|
||||
print("\nIndexes by table:")
|
||||
|
||||
current_table = None
|
||||
for index_name, table_name in indexes:
|
||||
if table_name != current_table:
|
||||
print(f"\n{table_name}:")
|
||||
current_table = table_name
|
||||
print(f" - {index_name}")
|
||||
|
||||
# Analyze soft deletion indexes
|
||||
print("\n=== Soft Deletion Index Analysis ===")
|
||||
|
||||
soft_deletion_tables = ['shots', 'assets', 'tasks', 'submissions',
|
||||
'task_attachments', 'production_notes', 'reviews']
|
||||
|
||||
for table in soft_deletion_tables:
|
||||
cursor.execute(f"PRAGMA index_list({table})")
|
||||
table_indexes = cursor.fetchall()
|
||||
|
||||
soft_del_indexes = [idx for idx in table_indexes if 'not_deleted' in idx[1]]
|
||||
|
||||
print(f"{table}: {len(soft_del_indexes)} soft deletion indexes")
|
||||
for idx in soft_del_indexes:
|
||||
print(f" - {idx[1]}")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def benchmark_soft_deletion_queries():
|
||||
"""Benchmark queries with and without soft deletion filtering."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("\n=== Soft Deletion Query Benchmarks ===\n")
|
||||
|
||||
# Test queries with soft deletion filtering
|
||||
benchmark_queries = [
|
||||
{
|
||||
"name": "Tasks with soft deletion filter",
|
||||
"query": "SELECT COUNT(*) FROM tasks WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"name": "Tasks without filter (all records)",
|
||||
"query": "SELECT COUNT(*) FROM tasks"
|
||||
},
|
||||
{
|
||||
"name": "Shots with soft deletion filter",
|
||||
"query": "SELECT COUNT(*) FROM shots WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"name": "Shots without filter (all records)",
|
||||
"query": "SELECT COUNT(*) FROM shots"
|
||||
},
|
||||
{
|
||||
"name": "Assets with soft deletion filter",
|
||||
"query": "SELECT COUNT(*) FROM assets WHERE deleted_at IS NULL"
|
||||
},
|
||||
{
|
||||
"name": "Assets without filter (all records)",
|
||||
"query": "SELECT COUNT(*) FROM assets"
|
||||
}
|
||||
]
|
||||
|
||||
for benchmark in benchmark_queries:
|
||||
# Measure execution time
|
||||
start_time = time.time()
|
||||
cursor.execute(benchmark['query'])
|
||||
result = cursor.fetchone()[0]
|
||||
end_time = time.time()
|
||||
|
||||
execution_time = (end_time - start_time) * 1000
|
||||
|
||||
print(f"{benchmark['name']}: {result} records ({execution_time:.2f}ms)")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Query Performance Monitoring Tool")
|
||||
print("=" * 50)
|
||||
|
||||
success = monitor_query_performance()
|
||||
|
||||
if success:
|
||||
analyze_index_usage()
|
||||
benchmark_soft_deletion_queries()
|
||||
print("\nSUCCESS: Query performance analysis completed!")
|
||||
else:
|
||||
print("\n❌ Query performance analysis failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database index optimization script for VFX Project Management System.
|
||||
Adds missing indexes to improve query performance, especially for soft deletion filtering.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
def optimize_database_indexes():
|
||||
"""Add missing indexes to optimize database performance."""
|
||||
|
||||
# Database path
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
if not db_path.exists():
|
||||
print(f"Database file not found at {db_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to database
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("=== Database Index Optimization ===\n")
|
||||
|
||||
# Define indexes to create
|
||||
indexes_to_create = [
|
||||
# Task-related indexes for performance
|
||||
{
|
||||
"name": "idx_tasks_assigned_user_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "assigned_user_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for tasks by assigned user (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_tasks_status_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "status",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for tasks by status (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_tasks_type_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "task_type",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for tasks by type (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_tasks_project_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "project_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for tasks by project (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_tasks_episode_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "episode_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for tasks by episode (non-deleted)"
|
||||
},
|
||||
|
||||
# Shot-related indexes
|
||||
{
|
||||
"name": "idx_shots_episode_not_deleted",
|
||||
"table": "shots",
|
||||
"columns": "episode_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for shots by episode (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_shots_status_not_deleted",
|
||||
"table": "shots",
|
||||
"columns": "status",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for shots by status (non-deleted)"
|
||||
},
|
||||
|
||||
# Asset-related indexes
|
||||
{
|
||||
"name": "idx_assets_project_not_deleted",
|
||||
"table": "assets",
|
||||
"columns": "project_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for assets by project (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_assets_category_not_deleted",
|
||||
"table": "assets",
|
||||
"columns": "category",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for assets by category (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_assets_status_not_deleted",
|
||||
"table": "assets",
|
||||
"columns": "status",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for assets by status (non-deleted)"
|
||||
},
|
||||
|
||||
# Submission-related indexes
|
||||
{
|
||||
"name": "idx_submissions_user_not_deleted",
|
||||
"table": "submissions",
|
||||
"columns": "user_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for submissions by user (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_submissions_submitted_at_not_deleted",
|
||||
"table": "submissions",
|
||||
"columns": "submitted_at",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries ordering submissions by date (non-deleted)"
|
||||
},
|
||||
|
||||
# Production notes indexes
|
||||
{
|
||||
"name": "idx_production_notes_user_not_deleted",
|
||||
"table": "production_notes",
|
||||
"columns": "user_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for notes by user (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_production_notes_created_at_not_deleted",
|
||||
"table": "production_notes",
|
||||
"columns": "created_at",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries ordering notes by date (non-deleted)"
|
||||
},
|
||||
|
||||
# Task attachments indexes
|
||||
{
|
||||
"name": "idx_task_attachments_user_not_deleted",
|
||||
"table": "task_attachments",
|
||||
"columns": "user_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for attachments by user (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_task_attachments_type_not_deleted",
|
||||
"table": "task_attachments",
|
||||
"columns": "attachment_type",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for attachments by type (non-deleted)"
|
||||
},
|
||||
|
||||
# Reviews indexes
|
||||
{
|
||||
"name": "idx_reviews_reviewer_not_deleted",
|
||||
"table": "reviews",
|
||||
"columns": "reviewer_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for reviews by reviewer (non-deleted)"
|
||||
},
|
||||
{
|
||||
"name": "idx_reviews_decision_not_deleted",
|
||||
"table": "reviews",
|
||||
"columns": "decision",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for reviews by decision (non-deleted)"
|
||||
},
|
||||
|
||||
# Foreign key indexes for better join performance
|
||||
{
|
||||
"name": "idx_project_members_user_id",
|
||||
"table": "project_members",
|
||||
"columns": "user_id",
|
||||
"condition": "",
|
||||
"description": "Optimize joins on project members by user"
|
||||
},
|
||||
{
|
||||
"name": "idx_project_members_project_id",
|
||||
"table": "project_members",
|
||||
"columns": "project_id",
|
||||
"condition": "",
|
||||
"description": "Optimize joins on project members by project"
|
||||
},
|
||||
{
|
||||
"name": "idx_episodes_project_id",
|
||||
"table": "episodes",
|
||||
"columns": "project_id",
|
||||
"condition": "",
|
||||
"description": "Optimize queries for episodes by project"
|
||||
},
|
||||
|
||||
# Composite indexes for complex queries
|
||||
{
|
||||
"name": "idx_tasks_composite_not_deleted",
|
||||
"table": "tasks",
|
||||
"columns": "project_id, status, assigned_user_id",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize complex task queries (project + status + user)"
|
||||
},
|
||||
{
|
||||
"name": "idx_submissions_task_version_not_deleted",
|
||||
"table": "submissions",
|
||||
"columns": "task_id, version_number",
|
||||
"condition": "WHERE deleted_at IS NULL",
|
||||
"description": "Optimize queries for latest submission versions"
|
||||
}
|
||||
]
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for index_def in indexes_to_create:
|
||||
index_name = index_def["name"]
|
||||
table_name = index_def["table"]
|
||||
columns = index_def["columns"]
|
||||
condition = index_def["condition"]
|
||||
description = index_def["description"]
|
||||
|
||||
# Check if index already exists
|
||||
cursor.execute(f"PRAGMA index_list({table_name})")
|
||||
existing_indexes = [idx[1] for idx in cursor.fetchall()]
|
||||
|
||||
if index_name in existing_indexes:
|
||||
print(f"SKIP: Index {index_name} already exists")
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create the index
|
||||
try:
|
||||
sql = f"CREATE INDEX {index_name} ON {table_name} ({columns})"
|
||||
if condition:
|
||||
sql += f" {condition}"
|
||||
|
||||
print(f"Creating index: {index_name}")
|
||||
print(f" Table: {table_name}")
|
||||
print(f" Columns: {columns}")
|
||||
print(f" Description: {description}")
|
||||
|
||||
cursor.execute(sql)
|
||||
created_count += 1
|
||||
print(f"SUCCESS: Created index {index_name}")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"ERROR: Failed to create index {index_name}: {e}")
|
||||
continue
|
||||
|
||||
print()
|
||||
|
||||
# Commit all changes
|
||||
conn.commit()
|
||||
|
||||
print(f"=== Index Optimization Complete ===")
|
||||
print(f"Created: {created_count} new indexes")
|
||||
print(f"Skipped: {skipped_count} existing indexes")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def verify_index_optimization():
|
||||
"""Verify that the index optimization was successful."""
|
||||
|
||||
db_path = Path(__file__).parent / "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("\n=== Verifying Index Optimization ===\n")
|
||||
|
||||
# Count total indexes
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM sqlite_master
|
||||
WHERE type = 'index' AND name NOT LIKE 'sqlite_%'
|
||||
""")
|
||||
total_indexes = cursor.fetchone()[0]
|
||||
|
||||
print(f"Total indexes in database: {total_indexes}")
|
||||
|
||||
# Check key performance indexes
|
||||
key_indexes = [
|
||||
"idx_tasks_assigned_user_not_deleted",
|
||||
"idx_tasks_status_not_deleted",
|
||||
"idx_shots_episode_not_deleted",
|
||||
"idx_assets_project_not_deleted",
|
||||
"idx_submissions_submitted_at_not_deleted"
|
||||
]
|
||||
|
||||
print("\nKey performance indexes:")
|
||||
for index_name in key_indexes:
|
||||
cursor.execute("""
|
||||
SELECT name FROM sqlite_master
|
||||
WHERE type = 'index' AND name = ?
|
||||
""", (index_name,))
|
||||
|
||||
if cursor.fetchone():
|
||||
print(f" ✓ {index_name}")
|
||||
else:
|
||||
print(f" ✗ {index_name} (missing)")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Database Index Optimization Tool")
|
||||
print("=" * 50)
|
||||
|
||||
success = optimize_database_indexes()
|
||||
|
||||
if success:
|
||||
verify_index_optimization()
|
||||
print("\nSUCCESS: Database index optimization completed!")
|
||||
else:
|
||||
print("\n❌ Database index optimization failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Recreate the database with proper schema and migrate existing data.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def backup_projects_data():
|
||||
"""Backup existing projects data."""
|
||||
|
||||
print("Backing up existing projects data...")
|
||||
|
||||
db_path = "vfx_project_management.db"
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all projects data
|
||||
cursor.execute("SELECT * FROM projects")
|
||||
projects = cursor.fetchall()
|
||||
|
||||
# Get column names
|
||||
cursor.execute("PRAGMA table_info(projects)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
return projects, columns
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error backing up data: {e}")
|
||||
return [], []
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
def recreate_database():
|
||||
"""Recreate the database with proper schema."""
|
||||
|
||||
print("Recreating Database with Proper Schema")
|
||||
print("=" * 40)
|
||||
|
||||
# Backup existing data
|
||||
projects_data, columns = backup_projects_data()
|
||||
print(f"Backed up {len(projects_data)} projects")
|
||||
|
||||
# Remove old database
|
||||
db_path = "vfx_project_management.db"
|
||||
if os.path.exists(db_path):
|
||||
os.remove(db_path)
|
||||
print("Removed old database")
|
||||
|
||||
# Import and create new database schema
|
||||
try:
|
||||
from database import engine, Base
|
||||
import models # This imports all models
|
||||
|
||||
# Create all tables with proper schema
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Created new database schema")
|
||||
|
||||
# Restore projects data if any
|
||||
if projects_data:
|
||||
print("Restoring projects data...")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
for project in projects_data:
|
||||
# Map old data to new schema
|
||||
insert_sql = """
|
||||
INSERT INTO projects (
|
||||
id, name, description, status, start_date, end_date,
|
||||
created_at, updated_at, code_name, client_name, project_type,
|
||||
frame_rate, data_drive_path, publish_storage_path,
|
||||
delivery_image_resolution, delivery_movie_specs_by_department
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"""
|
||||
|
||||
cursor.execute(insert_sql, project)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"Restored {len(projects_data)} projects")
|
||||
|
||||
print("✅ Database recreated successfully!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error recreating database: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
recreate_database()
|
||||
@@ -0,0 +1,12 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
python-multipart==0.0.6
|
||||
python-jose[cryptography]==3.3.0
|
||||
passlib[bcrypt]==1.7.4
|
||||
python-dotenv==1.0.0
|
||||
pydantic==2.5.0
|
||||
pydantic-settings==2.1.0
|
||||
httpx==0.28.1
|
||||
email-validator==2.1.0
|
||||
Pillow==10.1.0
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rollback script for shot project_id migration.
|
||||
This script removes the project_id column and related constraints/indexes.
|
||||
|
||||
WARNING: This will permanently remove the project_id column and all related data.
|
||||
Only use this if you need to rollback the migration.
|
||||
|
||||
Usage:
|
||||
python rollback_shot_project_id.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path, trying multiple possible locations."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db",
|
||||
"database.db",
|
||||
"../vfx_project_management.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
|
||||
def rollback_migration():
|
||||
"""Remove project_id column and related constraints from shots table."""
|
||||
db_path = get_database_path()
|
||||
logger.info(f"Using database: {db_path}")
|
||||
|
||||
# Confirm rollback
|
||||
print("⚠️ WARNING: This will permanently remove the project_id column from shots table!")
|
||||
print("⚠️ All project_id data will be lost!")
|
||||
response = input("Are you sure you want to continue? (type 'YES' to confirm): ")
|
||||
|
||||
if response != 'YES':
|
||||
print("Rollback cancelled.")
|
||||
return True
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if shots table exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='shots'")
|
||||
if not cursor.fetchone():
|
||||
logger.error("Shots table not found")
|
||||
return False
|
||||
|
||||
# Check if project_id column exists
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if 'project_id' not in columns:
|
||||
logger.info("project_id column does not exist, nothing to rollback")
|
||||
return True
|
||||
|
||||
logger.info("Starting rollback of shot project_id migration...")
|
||||
|
||||
# Create new table without project_id column
|
||||
logger.info("Creating new shots table without project_id...")
|
||||
cursor.execute("""
|
||||
CREATE TABLE shots_rollback (
|
||||
id INTEGER PRIMARY KEY,
|
||||
episode_id INTEGER NOT NULL,
|
||||
name VARCHAR NOT NULL,
|
||||
description VARCHAR,
|
||||
frame_start INTEGER NOT NULL DEFAULT 1001,
|
||||
frame_end INTEGER NOT NULL DEFAULT 1001,
|
||||
status VARCHAR(11) NOT NULL DEFAULT 'not_started',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at TIMESTAMP,
|
||||
deleted_by INTEGER,
|
||||
FOREIGN KEY (episode_id) REFERENCES episodes(id),
|
||||
FOREIGN KEY (deleted_by) REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data from old table to new table (excluding project_id)
|
||||
logger.info("Copying data to new table...")
|
||||
cursor.execute("""
|
||||
INSERT INTO shots_rollback
|
||||
SELECT id, episode_id, name, description, frame_start, frame_end,
|
||||
status, created_at, updated_at, deleted_at, deleted_by
|
||||
FROM shots
|
||||
""")
|
||||
|
||||
# Verify data copy
|
||||
cursor.execute("SELECT COUNT(*) FROM shots")
|
||||
original_count = cursor.fetchone()[0]
|
||||
cursor.execute("SELECT COUNT(*) FROM shots_rollback")
|
||||
new_count = cursor.fetchone()[0]
|
||||
|
||||
if original_count != new_count:
|
||||
logger.error(f"Data copy failed: original={original_count}, new={new_count}")
|
||||
return False
|
||||
|
||||
logger.info(f"Successfully copied {new_count} shots to new table")
|
||||
|
||||
# Drop old table and rename new table
|
||||
logger.info("Replacing table...")
|
||||
cursor.execute("DROP TABLE shots")
|
||||
cursor.execute("ALTER TABLE shots_rollback RENAME TO shots")
|
||||
|
||||
# Recreate basic indexes (without project_id related ones)
|
||||
logger.info("Recreating basic indexes...")
|
||||
|
||||
cursor.execute("CREATE INDEX idx_shots_name ON shots(name)")
|
||||
cursor.execute("CREATE INDEX idx_shots_episode_id ON shots(episode_id)")
|
||||
cursor.execute("CREATE INDEX idx_shots_active ON shots(id) WHERE deleted_at IS NULL")
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
|
||||
logger.info("Rollback completed successfully!")
|
||||
logger.info(f"Removed project_id column from {new_count} shots")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
def verify_rollback():
|
||||
"""Verify that the rollback was successful."""
|
||||
db_path = get_database_path()
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
logger.info("Verifying rollback...")
|
||||
|
||||
# Check that project_id column is gone
|
||||
cursor.execute("PRAGMA table_info(shots)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if 'project_id' in columns:
|
||||
logger.error("❌ project_id column still exists")
|
||||
return False
|
||||
|
||||
logger.info("✅ project_id column successfully removed")
|
||||
|
||||
# Check that basic functionality still works
|
||||
cursor.execute("SELECT COUNT(*) FROM shots")
|
||||
shot_count = cursor.fetchone()[0]
|
||||
logger.info(f"✅ {shot_count} shots remain in table")
|
||||
|
||||
# Check that episode relationships still work
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM shots s
|
||||
JOIN episodes e ON s.episode_id = e.id
|
||||
""")
|
||||
valid_relationships = cursor.fetchone()[0]
|
||||
|
||||
if valid_relationships != shot_count:
|
||||
logger.error(f"❌ Episode relationships broken: {valid_relationships}/{shot_count}")
|
||||
return False
|
||||
|
||||
logger.info("✅ Episode relationships intact")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
logger.error(f"Verification error: {e}")
|
||||
return False
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Shot Project ID Rollback")
|
||||
print("=" * 60)
|
||||
|
||||
success = rollback_migration()
|
||||
|
||||
if success:
|
||||
print("\n" + "=" * 60)
|
||||
print("VERIFYING ROLLBACK")
|
||||
print("=" * 60)
|
||||
|
||||
if verify_rollback():
|
||||
print("\n✅ SUCCESS: Rollback completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ FAILED: Rollback verification failed!")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\n❌ FAILED: Rollback failed!")
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
@@ -0,0 +1,239 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import List, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.activity import Activity, ActivityType
|
||||
from models.project import ProjectMember
|
||||
from schemas.activity import ActivityResponse
|
||||
from utils.auth import get_current_user
|
||||
from utils.activity import ActivityService
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[ActivityResponse])
|
||||
def get_project_activities(
|
||||
project_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity feed for a specific project (excludes activities for deleted records)."""
|
||||
# Verify user has access to the project
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/task/{task_id}", response_model=List[ActivityResponse])
|
||||
def get_task_activities(
|
||||
task_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity timeline for a specific task (excludes activities for deleted records)."""
|
||||
from models.task import Task
|
||||
|
||||
# Verify user has access to the task and it's not deleted
|
||||
task = db.query(Task).filter(Task.id == task_id, Task.deleted_at.is_(None)).first()
|
||||
if not task:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="Task not found")
|
||||
|
||||
# Check if user is a member of the project
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == task.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not member and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied to this task")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
task_id=task_id,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=List[ActivityResponse])
|
||||
def get_user_activities(
|
||||
user_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get activity history for a specific user (excludes activities for deleted records)."""
|
||||
# Users can only view their own activity unless they're admin
|
||||
if user_id != current_user.id and not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
# Use ActivityService to get activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/recent", response_model=List[ActivityResponse])
|
||||
def get_recent_activities(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get recent activities from all projects the user has access to (excludes activities for deleted records)."""
|
||||
# Get all projects the user is a member of
|
||||
project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
|
||||
if not project_ids and not current_user.is_admin:
|
||||
return []
|
||||
|
||||
# For non-admin users, filter by their project access
|
||||
if not current_user.is_admin:
|
||||
# Get activities from user's projects, excluding deleted records
|
||||
all_activities = []
|
||||
for project_id in project_ids:
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=0,
|
||||
limit=limit * 2 # Get more to account for filtering
|
||||
)
|
||||
all_activities.extend(activities)
|
||||
|
||||
# Sort by created_at and apply pagination
|
||||
all_activities.sort(key=lambda x: x.created_at, reverse=True)
|
||||
return all_activities[skip:skip + limit]
|
||||
else:
|
||||
# Admin gets all activities excluding deleted records
|
||||
activities = ActivityService.get_activities_excluding_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit
|
||||
)
|
||||
return activities
|
||||
|
||||
|
||||
# Admin-only endpoints that include activities for deleted records
|
||||
@router.get("/admin/project/{project_id}/all", response_model=List[ActivityResponse])
|
||||
def get_project_activities_including_deleted(
|
||||
project_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activity feed for a specific project including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
project_id=project_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/admin/user/{user_id}/all", response_model=List[ActivityResponse])
|
||||
def get_user_activities_including_deleted(
|
||||
user_id: int,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activity history for a specific user including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
user_id=user_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/admin/all", response_model=List[ActivityResponse])
|
||||
def get_all_activities_including_deleted(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
type_filter: Optional[ActivityType] = None,
|
||||
days: Optional[int] = Query(None, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all activities including deleted records (admin only)."""
|
||||
if not current_user.is_admin:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
|
||||
# Use ActivityService to get all activities including deleted records
|
||||
activities = ActivityService.get_activities_including_deleted(
|
||||
db=db,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type_filter=type_filter,
|
||||
days=days
|
||||
)
|
||||
|
||||
return activities
|
||||
@@ -0,0 +1,800 @@
|
||||
"""
|
||||
Admin Router
|
||||
|
||||
This router contains admin-only endpoints for managing soft-deleted data
|
||||
and other administrative functions.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_current_user_from_token
|
||||
from services.recovery_service import RecoveryService
|
||||
from services.batch_operations import BatchOperationsService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Simple rate limiting storage (in production, use Redis or similar)
|
||||
_rate_limit_storage = defaultdict(list)
|
||||
PERMANENT_DELETE_RATE_LIMIT = 10 # Max 10 permanent delete operations per minute per user
|
||||
|
||||
|
||||
class BulkRecoveryRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
|
||||
|
||||
class BulkDeletionRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
batch_size: Optional[int] = 50
|
||||
|
||||
|
||||
class BatchPreviewRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
|
||||
|
||||
class PermanentDeleteRequest(BaseModel):
|
||||
confirmation_token: str
|
||||
|
||||
|
||||
class BulkPermanentDeleteRequest(BaseModel):
|
||||
shot_ids: List[int] = []
|
||||
asset_ids: List[int] = []
|
||||
confirmation_token: str
|
||||
|
||||
|
||||
def require_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def check_permanent_delete_rate_limit(user_id: int):
|
||||
"""Check if user has exceeded permanent delete rate limit."""
|
||||
current_time = time.time()
|
||||
user_requests = _rate_limit_storage[user_id]
|
||||
|
||||
# Remove requests older than 1 minute
|
||||
user_requests[:] = [req_time for req_time in user_requests if current_time - req_time < 60]
|
||||
|
||||
if len(user_requests) >= PERMANENT_DELETE_RATE_LIMIT:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"Rate limit exceeded. Maximum {PERMANENT_DELETE_RATE_LIMIT} permanent delete operations per minute."
|
||||
)
|
||||
|
||||
# Add current request
|
||||
user_requests.append(current_time)
|
||||
|
||||
|
||||
def validate_confirmation_token(token: str, expected_action: str):
|
||||
"""Validate confirmation token for permanent delete operations."""
|
||||
# Simple token validation - in production, use proper token generation/validation
|
||||
expected_token = f"CONFIRM_{expected_action}_PERMANENT_DELETE"
|
||||
if token != expected_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid confirmation token. Permanent deletion requires explicit confirmation."
|
||||
)
|
||||
|
||||
|
||||
@router.get("/deleted-shots/")
|
||||
async def get_deleted_shots(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get list of deleted shots for admin recovery interface"""
|
||||
recovery_service = RecoveryService()
|
||||
deleted_shots = recovery_service.get_deleted_shots(project_id, db)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": shot.id,
|
||||
"name": shot.name,
|
||||
"episode_name": shot.episode_name,
|
||||
"project_id": shot.project_id,
|
||||
"project_name": shot.project_name,
|
||||
"deleted_at": shot.deleted_at,
|
||||
"deleted_by": shot.deleted_by,
|
||||
"deleted_by_name": shot.deleted_by_name,
|
||||
"task_count": shot.task_count,
|
||||
"submission_count": shot.submission_count,
|
||||
"attachment_count": shot.attachment_count,
|
||||
"note_count": shot.note_count,
|
||||
"review_count": shot.review_count
|
||||
}
|
||||
for shot in deleted_shots
|
||||
]
|
||||
|
||||
|
||||
@router.get("/deleted-assets/")
|
||||
async def get_deleted_assets(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get list of deleted assets for admin recovery interface"""
|
||||
recovery_service = RecoveryService()
|
||||
deleted_assets = recovery_service.get_deleted_assets(project_id, db)
|
||||
|
||||
return [
|
||||
{
|
||||
"id": asset.id,
|
||||
"name": asset.name,
|
||||
"category": asset.category,
|
||||
"project_id": asset.project_id,
|
||||
"project_name": asset.project_name,
|
||||
"deleted_at": asset.deleted_at,
|
||||
"deleted_by": asset.deleted_by,
|
||||
"deleted_by_name": asset.deleted_by_name,
|
||||
"task_count": asset.task_count,
|
||||
"submission_count": asset.submission_count,
|
||||
"attachment_count": asset.attachment_count,
|
||||
"note_count": asset.note_count,
|
||||
"review_count": asset.review_count
|
||||
}
|
||||
for asset in deleted_assets
|
||||
]
|
||||
|
||||
|
||||
@router.get("/shots/{shot_id}/recovery-preview")
|
||||
async def get_shot_recovery_preview(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get information about what will be recovered when restoring a shot"""
|
||||
recovery_service = RecoveryService()
|
||||
recovery_info = recovery_service.preview_shot_recovery(shot_id, db)
|
||||
|
||||
if not recovery_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Deleted shot not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"shot_id": recovery_info.shot_id,
|
||||
"name": recovery_info.name,
|
||||
"episode_name": recovery_info.episode_name,
|
||||
"project_id": recovery_info.project_id,
|
||||
"project_name": recovery_info.project_name,
|
||||
"task_count": recovery_info.task_count,
|
||||
"submission_count": recovery_info.submission_count,
|
||||
"attachment_count": recovery_info.attachment_count,
|
||||
"note_count": recovery_info.note_count,
|
||||
"review_count": recovery_info.review_count,
|
||||
"deleted_at": recovery_info.deleted_at,
|
||||
"deleted_by": recovery_info.deleted_by,
|
||||
"deleted_by_name": recovery_info.deleted_by_name,
|
||||
"files_preserved": recovery_info.files_preserved,
|
||||
"file_count": recovery_info.file_count
|
||||
}
|
||||
|
||||
|
||||
@router.get("/assets/{asset_id}/recovery-preview")
|
||||
async def get_asset_recovery_preview(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get information about what will be recovered when restoring an asset"""
|
||||
recovery_service = RecoveryService()
|
||||
recovery_info = recovery_service.preview_asset_recovery(asset_id, db)
|
||||
|
||||
if not recovery_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Deleted asset not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"asset_id": recovery_info.asset_id,
|
||||
"name": recovery_info.name,
|
||||
"project_name": recovery_info.project_name,
|
||||
"task_count": recovery_info.task_count,
|
||||
"submission_count": recovery_info.submission_count,
|
||||
"attachment_count": recovery_info.attachment_count,
|
||||
"note_count": recovery_info.note_count,
|
||||
"review_count": recovery_info.review_count,
|
||||
"deleted_at": recovery_info.deleted_at,
|
||||
"deleted_by": recovery_info.deleted_by,
|
||||
"deleted_by_name": recovery_info.deleted_by_name,
|
||||
"files_preserved": recovery_info.files_preserved,
|
||||
"file_count": recovery_info.file_count
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/{shot_id}/recover")
|
||||
async def recover_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Recover a soft-deleted shot and all its related data"""
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.recover_shot(shot_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to recover shot",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Shot '{result.name}' and all related data have been recovered",
|
||||
"shot_id": result.shot_id,
|
||||
"name": result.name,
|
||||
"recovered_at": result.recovered_at,
|
||||
"recovered_by": result.recovered_by,
|
||||
"recovered_tasks": result.recovered_tasks,
|
||||
"recovered_submissions": result.recovered_submissions,
|
||||
"recovered_attachments": result.recovered_attachments,
|
||||
"recovered_notes": result.recovered_notes,
|
||||
"recovered_reviews": result.recovered_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/{asset_id}/recover")
|
||||
async def recover_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Recover a soft-deleted asset and all its related data"""
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.recover_asset(asset_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to recover asset",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Asset '{result.name}' and all related data have been recovered",
|
||||
"asset_id": result.asset_id,
|
||||
"name": result.name,
|
||||
"recovered_at": result.recovered_at,
|
||||
"recovered_by": result.recovered_by,
|
||||
"recovered_tasks": result.recovered_tasks,
|
||||
"recovered_submissions": result.recovered_submissions,
|
||||
"recovered_attachments": result.recovered_attachments,
|
||||
"recovered_notes": result.recovered_notes,
|
||||
"recovered_reviews": result.recovered_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/bulk-recover")
|
||||
async def bulk_recover_shots(
|
||||
request: BulkRecoveryRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Bulk recover multiple shots"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_recover_shots(request.shot_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"shot_id": r.shot_id,
|
||||
"name": r.name,
|
||||
"recovered_tasks": r.recovered_tasks,
|
||||
"recovered_submissions": r.recovered_submissions,
|
||||
"recovered_attachments": r.recovered_attachments,
|
||||
"recovered_notes": r.recovered_notes,
|
||||
"recovered_reviews": r.recovered_reviews,
|
||||
"operation_duration": r.operation_duration,
|
||||
"errors": r.errors,
|
||||
"warnings": r.warnings
|
||||
}
|
||||
for r in result.results
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"item_id": e.item_id,
|
||||
"item_type": e.item_type,
|
||||
"error": e.error
|
||||
}
|
||||
for e in result.errors
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/bulk-recover")
|
||||
async def bulk_recover_assets(
|
||||
request: BulkRecoveryRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Bulk recover multiple assets"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_recover_assets(request.asset_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"results": [
|
||||
{
|
||||
"success": r.success,
|
||||
"asset_id": r.asset_id,
|
||||
"name": r.name,
|
||||
"recovered_tasks": r.recovered_tasks,
|
||||
"recovered_submissions": r.recovered_submissions,
|
||||
"recovered_attachments": r.recovered_attachments,
|
||||
"recovered_notes": r.recovered_notes,
|
||||
"recovered_reviews": r.recovered_reviews,
|
||||
"operation_duration": r.operation_duration,
|
||||
"errors": r.errors,
|
||||
"warnings": r.warnings
|
||||
}
|
||||
for r in result.results
|
||||
],
|
||||
"errors": [
|
||||
{
|
||||
"item_id": e.item_id,
|
||||
"item_type": e.item_type,
|
||||
"error": e.error
|
||||
}
|
||||
for e in result.errors
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/recovery-stats/")
|
||||
async def get_recovery_stats(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get recovery statistics for admin dashboard"""
|
||||
recovery_service = RecoveryService()
|
||||
stats = recovery_service.get_recovery_stats(project_id, db)
|
||||
|
||||
return {
|
||||
"deleted_shots_count": stats.deleted_shots_count,
|
||||
"deleted_assets_count": stats.deleted_assets_count,
|
||||
"total_deleted_tasks": stats.total_deleted_tasks,
|
||||
"total_deleted_files": stats.total_deleted_files,
|
||||
"oldest_deletion_date": stats.oldest_deletion_date
|
||||
}
|
||||
|
||||
|
||||
# Permanent Delete Endpoints
|
||||
|
||||
@router.delete("/shots/{shot_id}/permanent")
|
||||
async def permanent_delete_shot(
|
||||
shot_id: int,
|
||||
request: PermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete a soft-deleted shot and all its related data"""
|
||||
# Check rate limit
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "SHOT")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.permanent_delete_shot(shot_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to permanently delete shot",
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Shot '{result.name}' has been permanently deleted",
|
||||
"shot_id": result.shot_id,
|
||||
"name": result.name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"deleted_tasks": result.deleted_tasks,
|
||||
"deleted_submissions": result.deleted_submissions,
|
||||
"deleted_attachments": result.deleted_attachments,
|
||||
"deleted_notes": result.deleted_notes,
|
||||
"deleted_reviews": result.deleted_reviews,
|
||||
"deleted_files": result.deleted_files,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"operation_duration": result.operation_duration,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assets/{asset_id}/permanent")
|
||||
async def permanent_delete_asset(
|
||||
asset_id: int,
|
||||
request: PermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete a soft-deleted asset and all its related data"""
|
||||
# Check rate limit
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "ASSET")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.permanent_delete_asset(asset_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to permanently delete asset",
|
||||
"errors": result.errors,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Asset '{result.name}' has been permanently deleted",
|
||||
"asset_id": result.asset_id,
|
||||
"name": result.name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"deleted_tasks": result.deleted_tasks,
|
||||
"deleted_submissions": result.deleted_submissions,
|
||||
"deleted_attachments": result.deleted_attachments,
|
||||
"deleted_notes": result.deleted_notes,
|
||||
"deleted_reviews": result.deleted_reviews,
|
||||
"deleted_files": result.deleted_files,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"operation_duration": result.operation_duration,
|
||||
"warnings": result.warnings
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/shots/bulk-permanent")
|
||||
async def bulk_permanent_delete_shots(
|
||||
request: BulkPermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete multiple shots in bulk"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
# Check rate limit (bulk operations count as multiple operations)
|
||||
for _ in request.shot_ids:
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "BULK_SHOTS")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_permanent_delete_shots(request.shot_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Bulk permanent deletion completed: {result.successful_deletions} successful, {result.failed_deletions} failed",
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"deleted_items": result.deleted_items,
|
||||
"files_deleted": result.files_deleted,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"errors": result.errors
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/assets/bulk-permanent")
|
||||
async def bulk_permanent_delete_assets(
|
||||
request: BulkPermanentDeleteRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Permanently delete multiple assets in bulk"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
# Check rate limit (bulk operations count as multiple operations)
|
||||
for _ in request.asset_ids:
|
||||
check_permanent_delete_rate_limit(current_user.id)
|
||||
|
||||
# Validate confirmation token
|
||||
validate_confirmation_token(request.confirmation_token, "BULK_ASSETS")
|
||||
|
||||
recovery_service = RecoveryService()
|
||||
result = recovery_service.bulk_permanent_delete_assets(request.asset_ids, db, current_user)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Bulk permanent deletion completed: {result.successful_deletions} successful, {result.failed_deletions} failed",
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"deleted_items": result.deleted_items,
|
||||
"files_deleted": result.files_deleted,
|
||||
"database_records_deleted": result.database_records_deleted,
|
||||
"errors": result.errors
|
||||
}
|
||||
|
||||
|
||||
# Batch Operations Endpoints
|
||||
|
||||
@router.post("/batch-deletion-preview")
|
||||
async def get_batch_deletion_preview(
|
||||
request: BatchPreviewRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Get preview information for a batch deletion operation"""
|
||||
if not request.shot_ids and not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot or asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
preview = batch_service.get_batch_deletion_preview(
|
||||
request.shot_ids, request.asset_ids, db
|
||||
)
|
||||
|
||||
return preview
|
||||
|
||||
|
||||
@router.post("/shots/batch-delete")
|
||||
async def batch_delete_shots(
|
||||
request: BulkDeletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Batch delete multiple shots"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_delete_shots(
|
||||
request.shot_ids, db, current_user, request.batch_size or 50
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_deleted_tasks": result.total_deleted_tasks,
|
||||
"total_deleted_submissions": result.total_deleted_submissions,
|
||||
"total_deleted_attachments": result.total_deleted_attachments,
|
||||
"total_deleted_notes": result.total_deleted_notes,
|
||||
"total_deleted_reviews": result.total_deleted_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"deleted_counts": item.deleted_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/batch-delete")
|
||||
async def batch_delete_assets(
|
||||
request: BulkDeletionRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Batch delete multiple assets"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_delete_assets(
|
||||
request.asset_ids, db, current_user, request.batch_size or 50
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_deletions": result.successful_deletions,
|
||||
"failed_deletions": result.failed_deletions,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_deleted_tasks": result.total_deleted_tasks,
|
||||
"total_deleted_submissions": result.total_deleted_submissions,
|
||||
"total_deleted_attachments": result.total_deleted_attachments,
|
||||
"total_deleted_notes": result.total_deleted_notes,
|
||||
"total_deleted_reviews": result.total_deleted_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"deleted_counts": item.deleted_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/shots/batch-recover")
|
||||
async def batch_recover_shots_enhanced(
|
||||
request: BulkRecoveryRequest,
|
||||
batch_size: Optional[int] = Query(50, description="Batch size for processing"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Enhanced batch recover multiple shots with configurable batch size"""
|
||||
if not request.shot_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No shot IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_recover_shots(
|
||||
request.shot_ids, db, current_user, batch_size
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_recovered_tasks": result.total_recovered_tasks,
|
||||
"total_recovered_submissions": result.total_recovered_submissions,
|
||||
"total_recovered_attachments": result.total_recovered_attachments,
|
||||
"total_recovered_notes": result.total_recovered_notes,
|
||||
"total_recovered_reviews": result.total_recovered_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"recovered_counts": item.recovered_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/assets/batch-recover")
|
||||
async def batch_recover_assets_enhanced(
|
||||
request: BulkRecoveryRequest,
|
||||
batch_size: Optional[int] = Query(50, description="Batch size for processing"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin)
|
||||
):
|
||||
"""Enhanced batch recover multiple assets with configurable batch size"""
|
||||
if not request.asset_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No asset IDs provided"
|
||||
)
|
||||
|
||||
batch_service = BatchOperationsService()
|
||||
result = batch_service.batch_recover_assets(
|
||||
request.asset_ids, db, current_user, batch_size
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"total_items": result.total_items,
|
||||
"successful_recoveries": result.successful_recoveries,
|
||||
"failed_recoveries": result.failed_recoveries,
|
||||
"operation_duration": result.operation_duration,
|
||||
"total_recovered_tasks": result.total_recovered_tasks,
|
||||
"total_recovered_submissions": result.total_recovered_submissions,
|
||||
"total_recovered_attachments": result.total_recovered_attachments,
|
||||
"total_recovered_notes": result.total_recovered_notes,
|
||||
"total_recovered_reviews": result.total_recovered_reviews,
|
||||
"items": [
|
||||
{
|
||||
"id": item.id,
|
||||
"name": item.name,
|
||||
"type": item.type,
|
||||
"success": item.success,
|
||||
"error": item.error,
|
||||
"recovered_counts": item.recovered_counts
|
||||
}
|
||||
for item in result.items
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict
|
||||
|
||||
from database import get_db
|
||||
from models.asset import Asset, AssetCategory
|
||||
from models.project import Project, ProjectMember
|
||||
from models.task import Task, TaskType, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.asset import AssetCreate, AssetUpdate, AssetResponse, AssetListResponse, TaskStatusInfo
|
||||
from schemas.task import TaskCreate
|
||||
from utils.auth import get_current_user_from_token
|
||||
from services.asset_soft_deletion import AssetSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user_with_db(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
from utils.auth import _get_user_from_db
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
|
||||
"""Get sort order for task status, including custom statuses."""
|
||||
# Default system status order
|
||||
system_status_order = {
|
||||
"not_started": 0,
|
||||
"in_progress": 1,
|
||||
"submitted": 2,
|
||||
"retake": 3,
|
||||
"approved": 4
|
||||
}
|
||||
|
||||
# If it's a system status, return its order
|
||||
if status in system_status_order:
|
||||
return system_status_order[status]
|
||||
|
||||
# For custom statuses, use their defined order + offset to place them after system statuses
|
||||
if project_custom_statuses:
|
||||
for custom_status in project_custom_statuses:
|
||||
if isinstance(custom_status, dict) and custom_status.get('id') == status:
|
||||
# Custom statuses start after system statuses (5+)
|
||||
return 5 + custom_status.get('order', 0)
|
||||
|
||||
# Unknown status defaults to 0 (same as not_started)
|
||||
return 0
|
||||
|
||||
|
||||
def get_project_custom_statuses(project_id: int, db: Session) -> list:
|
||||
"""Get custom task statuses for a project."""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project or not project.custom_task_statuses:
|
||||
return []
|
||||
|
||||
custom_statuses_data = project.custom_task_statuses
|
||||
if isinstance(custom_statuses_data, str):
|
||||
try:
|
||||
import json
|
||||
custom_statuses_data = json.loads(custom_statuses_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
return custom_statuses_data if isinstance(custom_statuses_data, list) else []
|
||||
|
||||
|
||||
def check_project_access(project_id: int, current_user: User, db: Session):
|
||||
"""Check if user has access to the project."""
|
||||
# Check if project exists
|
||||
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"
|
||||
)
|
||||
|
||||
# Check access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
return project
|
||||
|
||||
|
||||
# Standard asset task types (read-only)
|
||||
STANDARD_ASSET_TASK_TYPES = ["modeling", "surfacing", "rigging"]
|
||||
|
||||
# Default asset tasks by category (using string values instead of enums)
|
||||
DEFAULT_ASSET_TASKS = {
|
||||
AssetCategory.CHARACTERS: [TaskType.MODELING.value, TaskType.SURFACING.value, TaskType.RIGGING.value],
|
||||
AssetCategory.PROPS: [TaskType.MODELING.value, TaskType.SURFACING.value],
|
||||
AssetCategory.SETS: [TaskType.MODELING.value, TaskType.SURFACING.value],
|
||||
AssetCategory.VEHICLES: [TaskType.MODELING.value, TaskType.SURFACING.value, TaskType.RIGGING.value]
|
||||
}
|
||||
|
||||
|
||||
def get_default_asset_task_types(category: AssetCategory) -> List[str]:
|
||||
"""Get default task types for an asset category."""
|
||||
return DEFAULT_ASSET_TASKS.get(category, [])
|
||||
|
||||
|
||||
def get_all_asset_task_types(project_id: int, db: Session) -> List[str]:
|
||||
"""Get all task types (standard + custom) for assets in a project."""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
return STANDARD_ASSET_TASK_TYPES
|
||||
|
||||
custom_types = project.custom_asset_task_types or []
|
||||
return STANDARD_ASSET_TASK_TYPES + custom_types
|
||||
|
||||
|
||||
def create_default_tasks_for_asset(asset: Asset, task_types: List[str], db: Session) -> List[Task]:
|
||||
"""Create default tasks for an asset."""
|
||||
created_tasks = []
|
||||
|
||||
for task_type in task_types:
|
||||
# Create task name based on type
|
||||
task_name = f"{asset.name} - {task_type.title()}"
|
||||
|
||||
# Create the task
|
||||
db_task = Task(
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"Default {task_type} task for {asset.name}",
|
||||
status="not_started"
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
created_tasks.append(db_task)
|
||||
|
||||
return created_tasks
|
||||
|
||||
|
||||
@router.get("/categories", response_model=List[str])
|
||||
async def list_asset_categories():
|
||||
"""List all available asset categories"""
|
||||
return [category.value for category in AssetCategory]
|
||||
|
||||
|
||||
@router.get("/default-tasks/{category}", response_model=List[str])
|
||||
async def get_default_tasks_for_category(
|
||||
category: AssetCategory,
|
||||
project_id: int = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get default task types for an asset category (includes custom types if project_id provided)"""
|
||||
task_types = get_default_asset_task_types(category)
|
||||
|
||||
# If project_id is provided, include custom task types
|
||||
if project_id:
|
||||
all_types = get_all_asset_task_types(project_id, db)
|
||||
# Return only the types that are relevant for this category
|
||||
# For now, return all available types (standard + custom)
|
||||
return all_types
|
||||
|
||||
return task_types # task_types are already strings, no need for .value
|
||||
|
||||
|
||||
@router.get("/", response_model=List[AssetListResponse])
|
||||
async def list_assets(
|
||||
project_id: int = None,
|
||||
category: AssetCategory = None,
|
||||
task_status_filter: str = None,
|
||||
sort_by: str = None,
|
||||
sort_direction: str = "asc",
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List assets with optional filtering by project and category"""
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# Build base query for assets (exclude soft deleted)
|
||||
base_query = db.query(Asset).filter(Asset.deleted_at.is_(None))
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
check_project_access(project_id, current_user, db)
|
||||
base_query = base_query.filter(Asset.project_id == project_id)
|
||||
else:
|
||||
# If no project specified, filter by user's accessible projects for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
accessible_projects = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).subquery()
|
||||
base_query = base_query.filter(Asset.project_id.in_(accessible_projects))
|
||||
|
||||
# Filter by category if specified
|
||||
if category:
|
||||
base_query = base_query.filter(Asset.category == category)
|
||||
|
||||
# Apply sorting if specified (for non-task-status fields)
|
||||
if sort_by and not sort_by.endswith('_status'):
|
||||
if sort_by in ['name', 'category', 'status', 'created_at', 'updated_at']:
|
||||
sort_column = getattr(Asset, sort_by)
|
||||
if sort_direction.lower() == 'desc':
|
||||
base_query = base_query.order_by(sort_column.desc())
|
||||
else:
|
||||
base_query = base_query.order_by(sort_column.asc())
|
||||
|
||||
# OPTIMIZATION: Use single query with optimized JOIN to fetch assets and their tasks
|
||||
# This replaces the N+1 query pattern with a single database operation
|
||||
assets_with_tasks = (
|
||||
base_query
|
||||
.outerjoin(Task, (Task.asset_id == Asset.id) & (Task.deleted_at.is_(None)))
|
||||
.options(
|
||||
joinedload(Asset.project), # Eager load project
|
||||
selectinload(Asset.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users
|
||||
)
|
||||
)
|
||||
.add_columns(
|
||||
Task.id.label('task_id'),
|
||||
Task.task_type,
|
||||
Task.status.label('task_status'),
|
||||
Task.assigned_user_id,
|
||||
Task.updated_at.label('task_updated_at') # Include task update time for better tracking
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Pre-fetch all project data and task types in a single query
|
||||
# This eliminates the need for repeated project queries
|
||||
project_ids = set()
|
||||
for row in assets_with_tasks:
|
||||
asset = row[0]
|
||||
if asset.project_id not in project_ids:
|
||||
project_ids.add(asset.project_id)
|
||||
|
||||
# Get all projects with their custom task types in one optimized query
|
||||
project_data = {}
|
||||
if project_ids:
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.id.in_(project_ids))
|
||||
.all()
|
||||
)
|
||||
for project in projects:
|
||||
custom_types = project.custom_asset_task_types or []
|
||||
project_data[project.id] = {
|
||||
'task_types': STANDARD_ASSET_TASK_TYPES + custom_types,
|
||||
'custom_statuses': get_project_custom_statuses(project.id, db)
|
||||
}
|
||||
|
||||
# OPTIMIZATION: Group results by asset and aggregate task data efficiently
|
||||
assets_dict = {}
|
||||
for row in assets_with_tasks:
|
||||
asset = row[0] # Asset object
|
||||
task_id = row[1] # task_id
|
||||
task_type = row[2] # task_type
|
||||
task_status = row[3] # task_status
|
||||
assigned_user_id = row[4] # assigned_user_id
|
||||
task_updated_at = row[5] # task_updated_at
|
||||
|
||||
if asset.id not in assets_dict:
|
||||
# Initialize asset data with pre-fetched project data
|
||||
project_info = project_data.get(asset.project_id, {
|
||||
'task_types': STANDARD_ASSET_TASK_TYPES,
|
||||
'custom_statuses': []
|
||||
})
|
||||
|
||||
assets_dict[asset.id] = {
|
||||
'asset': asset,
|
||||
'tasks': [],
|
||||
'task_status': {},
|
||||
'task_details': [],
|
||||
'project_info': project_info
|
||||
}
|
||||
|
||||
# Initialize all task types as not started using pre-fetched data
|
||||
for task_type_init in project_info['task_types']:
|
||||
assets_dict[asset.id]['task_status'][task_type_init] = "not_started"
|
||||
|
||||
# Add task data if task exists
|
||||
if task_id is not None:
|
||||
assets_dict[asset.id]['tasks'].append({
|
||||
'task_id': task_id,
|
||||
'task_type': task_type,
|
||||
'status': task_status,
|
||||
'assigned_user_id': assigned_user_id,
|
||||
'updated_at': task_updated_at
|
||||
})
|
||||
|
||||
# Update task status
|
||||
assets_dict[asset.id]['task_status'][task_type] = task_status
|
||||
|
||||
# Add to task details with enhanced information
|
||||
assets_dict[asset.id]['task_details'].append(TaskStatusInfo(
|
||||
task_type=task_type,
|
||||
status=task_status,
|
||||
task_id=task_id,
|
||||
assigned_user_id=assigned_user_id
|
||||
))
|
||||
|
||||
# Build response list efficiently
|
||||
result = []
|
||||
for asset_data in assets_dict.values():
|
||||
asset = asset_data['asset']
|
||||
|
||||
# Create asset response with optimized data
|
||||
asset_response = AssetListResponse.model_validate(asset)
|
||||
asset_response.task_count = len(asset_data['tasks'])
|
||||
asset_response.task_status = asset_data['task_status']
|
||||
asset_response.task_details = asset_data['task_details']
|
||||
|
||||
result.append(asset_response)
|
||||
|
||||
# Apply task status filtering if specified
|
||||
if task_status_filter:
|
||||
try:
|
||||
# Parse task status filter (format: "task_type:status")
|
||||
task_type, status = task_status_filter.split(":")
|
||||
filter_status = status # Use string directly instead of enum
|
||||
result = [
|
||||
asset for asset in result
|
||||
if asset.task_status.get(task_type) == filter_status
|
||||
]
|
||||
except (ValueError, KeyError):
|
||||
# Invalid filter format, ignore
|
||||
pass
|
||||
|
||||
# Apply task status sorting if specified
|
||||
if sort_by and sort_by.endswith('_status'):
|
||||
task_type = sort_by.replace('_status', '')
|
||||
|
||||
# Get custom statuses for proper sorting using pre-fetched data
|
||||
def get_status_order(asset):
|
||||
status = asset.task_status.get(task_type, "not_started")
|
||||
# Use pre-fetched custom statuses from project_data
|
||||
asset_project_data = project_data.get(getattr(asset, 'project_id', None), {})
|
||||
custom_statuses = asset_project_data.get('custom_statuses', [])
|
||||
return get_status_sort_order(status, custom_statuses)
|
||||
|
||||
reverse = sort_direction.lower() == 'desc'
|
||||
result.sort(key=get_status_order, reverse=reverse)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=AssetResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_asset(
|
||||
asset: AssetCreate,
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new asset in a project with optional default tasks"""
|
||||
# Check project access
|
||||
check_project_access(project_id, current_user, db)
|
||||
|
||||
# Check if asset name already exists in project (exclude soft deleted)
|
||||
existing_asset = db.query(Asset).filter(
|
||||
Asset.project_id == project_id,
|
||||
Asset.name == asset.name,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Asset with this name already exists in the project"
|
||||
)
|
||||
|
||||
# Create new asset (exclude fields that don't belong to Asset model)
|
||||
asset_data = asset.model_dump(exclude={'create_default_tasks', 'selected_task_types'})
|
||||
db_asset = Asset(
|
||||
project_id=project_id,
|
||||
**asset_data
|
||||
)
|
||||
|
||||
db.add(db_asset)
|
||||
db.flush() # Flush to get the asset ID
|
||||
|
||||
# Create default tasks if requested
|
||||
task_count = 0
|
||||
if asset.create_default_tasks:
|
||||
# Determine which task types to create
|
||||
if asset.selected_task_types:
|
||||
# Use the selected task types (already strings, can include custom types)
|
||||
task_types = asset.selected_task_types
|
||||
|
||||
# Validate that all selected task types are valid (standard or custom)
|
||||
all_valid_types = get_all_asset_task_types(project_id, db)
|
||||
invalid_types = [t for t in task_types if t not in all_valid_types]
|
||||
if invalid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task types: {', '.join(invalid_types)}"
|
||||
)
|
||||
else:
|
||||
# Use default task types for the asset category
|
||||
task_types = get_default_asset_task_types(asset.category)
|
||||
|
||||
# Create the tasks
|
||||
created_tasks = create_default_tasks_for_asset(db_asset, task_types, db)
|
||||
task_count = len(created_tasks)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_asset)
|
||||
|
||||
# Add task count
|
||||
asset_data = AssetResponse.model_validate(db_asset)
|
||||
asset_data.task_count = task_count
|
||||
|
||||
return asset_data
|
||||
|
||||
|
||||
@router.get("/{asset_id}", response_model=AssetResponse)
|
||||
async def get_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get a specific asset by ID"""
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# OPTIMIZATION: Use single query with optimized JOINs to fetch asset and all related data
|
||||
# This replaces separate queries with a single database operation
|
||||
asset_query = (
|
||||
db.query(Asset)
|
||||
.options(
|
||||
joinedload(Asset.project), # Eager load project
|
||||
selectinload(Asset.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users if needed
|
||||
)
|
||||
)
|
||||
.filter(Asset.id == asset_id, Asset.deleted_at.is_(None))
|
||||
)
|
||||
|
||||
asset = asset_query.first()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(asset.project_id, current_user, db)
|
||||
|
||||
# OPTIMIZATION: Count tasks from the already loaded relationship
|
||||
# This avoids a separate COUNT query
|
||||
active_tasks = [task for task in asset.tasks if task.deleted_at is None]
|
||||
task_count = len(active_tasks)
|
||||
|
||||
asset_data = AssetResponse.model_validate(asset)
|
||||
asset_data.task_count = task_count
|
||||
|
||||
return asset_data
|
||||
|
||||
|
||||
@router.get("/{asset_id}/task-status", response_model=List[TaskStatusInfo])
|
||||
async def get_asset_task_status(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get detailed task status for a specific asset"""
|
||||
# Exclude soft deleted assets
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(asset.project_id, current_user, db)
|
||||
|
||||
# Get all active tasks for this asset (exclude soft deleted)
|
||||
tasks = db.query(Task).filter(
|
||||
Task.asset_id == asset.id,
|
||||
Task.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
# Build detailed task status information
|
||||
task_details = []
|
||||
for task in tasks:
|
||||
task_details.append(TaskStatusInfo(
|
||||
task_type=task.task_type,
|
||||
status=task.status,
|
||||
task_id=task.id,
|
||||
assigned_user_id=task.assigned_user_id
|
||||
))
|
||||
|
||||
return task_details
|
||||
|
||||
|
||||
@router.post("/{asset_id}/tasks", response_model=TaskStatusInfo, status_code=status.HTTP_201_CREATED)
|
||||
async def create_asset_task(
|
||||
asset_id: int,
|
||||
task_type: str, # Changed from TaskType enum to str
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new task for an asset"""
|
||||
# Exclude soft deleted assets
|
||||
asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(asset.project_id, current_user, db)
|
||||
|
||||
# Check if task already exists (exclude soft deleted)
|
||||
existing_task = db.query(Task).filter(
|
||||
Task.asset_id == asset_id,
|
||||
Task.task_type == task_type,
|
||||
Task.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_task:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Task already exists for this asset and task type"
|
||||
)
|
||||
|
||||
# Create the task
|
||||
task_name = f"{asset.name} - {task_type.title()}"
|
||||
db_task = Task(
|
||||
project_id=asset.project_id,
|
||||
asset_id=asset.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"{task_type.title()} task for {asset.name}",
|
||||
status="not_started"
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
return TaskStatusInfo(
|
||||
task_type=db_task.task_type,
|
||||
status=db_task.status,
|
||||
task_id=db_task.id,
|
||||
assigned_user_id=db_task.assigned_user_id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{asset_id}", response_model=AssetResponse)
|
||||
async def update_asset(
|
||||
asset_id: int,
|
||||
asset_update: AssetUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Update an asset"""
|
||||
# Exclude soft deleted assets
|
||||
db_asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not db_asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(db_asset.project_id, current_user, db)
|
||||
|
||||
# Check if new name conflicts with existing assets in the same project
|
||||
if asset_update.name and asset_update.name != db_asset.name:
|
||||
existing_asset = db.query(Asset).filter(
|
||||
Asset.project_id == db_asset.project_id,
|
||||
Asset.name == asset_update.name,
|
||||
Asset.id != asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Asset with this name already exists in the project"
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = asset_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_asset, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_asset)
|
||||
|
||||
# Add task count (exclude soft deleted tasks)
|
||||
task_count = db.query(Task).filter(
|
||||
Task.asset_id == db_asset.id,
|
||||
Task.deleted_at.is_(None)
|
||||
).count()
|
||||
asset_data = AssetResponse.model_validate(db_asset)
|
||||
asset_data.task_count = task_count
|
||||
|
||||
return asset_data
|
||||
|
||||
|
||||
@router.get("/{asset_id}/deletion-info")
|
||||
async def get_asset_deletion_info(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Get information about what will be deleted when deleting an asset"""
|
||||
# Exclude soft deleted assets
|
||||
db_asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not db_asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(db_asset.project_id, current_user, db)
|
||||
|
||||
# Use the soft deletion service to get comprehensive deletion info
|
||||
deletion_service = AssetSoftDeletionService()
|
||||
deletion_info = deletion_service.get_deletion_info(asset_id, db)
|
||||
|
||||
if not deletion_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"asset_id": deletion_info.asset_id,
|
||||
"asset_name": deletion_info.asset_name,
|
||||
"asset_category": deletion_info.asset_category,
|
||||
"project_name": deletion_info.project_name,
|
||||
"task_count": deletion_info.task_count,
|
||||
"submission_count": deletion_info.submission_count,
|
||||
"attachment_count": deletion_info.attachment_count,
|
||||
"note_count": deletion_info.note_count,
|
||||
"review_count": deletion_info.review_count,
|
||||
"total_file_size": deletion_info.total_file_size,
|
||||
"file_count": deletion_info.file_count,
|
||||
"affected_users": deletion_info.affected_users,
|
||||
"last_activity_date": deletion_info.last_activity_date,
|
||||
"created_at": deletion_info.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{asset_id}")
|
||||
async def delete_asset(
|
||||
asset_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Soft delete an asset and all its associated data"""
|
||||
# Exclude soft deleted assets
|
||||
db_asset = db.query(Asset).filter(
|
||||
Asset.id == asset_id,
|
||||
Asset.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not db_asset:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Asset not found"
|
||||
)
|
||||
|
||||
# Check project access
|
||||
check_project_access(db_asset.project_id, current_user, db)
|
||||
|
||||
# Use the soft deletion service to perform cascading soft deletion
|
||||
deletion_service = AssetSoftDeletionService()
|
||||
result = deletion_service.soft_delete_asset_cascade(asset_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to delete asset",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Asset '{result.asset_name}' and all related data have been deleted",
|
||||
"asset_id": result.asset_id,
|
||||
"asset_name": result.asset_name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"marked_deleted_tasks": result.marked_deleted_tasks,
|
||||
"marked_deleted_submissions": result.marked_deleted_submissions,
|
||||
"marked_deleted_attachments": result.marked_deleted_attachments,
|
||||
"marked_deleted_notes": result.marked_deleted_notes,
|
||||
"marked_deleted_reviews": result.marked_deleted_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import timedelta
|
||||
from typing import List
|
||||
import json
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.api_key import APIKey, APIKeyScope
|
||||
from models.api_key_usage import APIKeyUsage
|
||||
from schemas.auth import UserLogin, UserRegister, Token, RefreshToken
|
||||
from schemas.api_key import APIKeyCreate, APIKeyResponse, APIKeyWithToken, APIKeyUpdate, APIKeyUsageLog
|
||||
from utils.auth import (
|
||||
verify_password,
|
||||
get_password_hash,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
verify_token,
|
||||
security,
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES,
|
||||
REFRESH_TOKEN_EXPIRE_DAYS,
|
||||
generate_api_key,
|
||||
hash_api_key,
|
||||
get_current_user_flexible,
|
||||
require_role
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(user_data: UserRegister, db: Session = Depends(get_db)):
|
||||
"""Register a new user account."""
|
||||
# Check if user already exists
|
||||
existing_user = db.query(User).filter(User.email == user_data.email).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Email already registered"
|
||||
)
|
||||
|
||||
# Create new user
|
||||
hashed_password = get_password_hash(user_data.password)
|
||||
new_user = User(
|
||||
email=user_data.email,
|
||||
password_hash=hashed_password,
|
||||
first_name=user_data.first_name,
|
||||
last_name=user_data.last_name,
|
||||
role=UserRole.ARTIST, # Default role
|
||||
is_approved=False # Requires admin approval
|
||||
)
|
||||
|
||||
db.add(new_user)
|
||||
db.commit()
|
||||
db.refresh(new_user)
|
||||
|
||||
return {
|
||||
"message": "User registered successfully. Awaiting admin approval.",
|
||||
"user_id": new_user.id
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
async def login(user_credentials: UserLogin, db: Session = Depends(get_db)):
|
||||
"""Authenticate user and return JWT tokens."""
|
||||
# Find user by email
|
||||
print(user_credentials.email)
|
||||
user = db.query(User).filter(User.email == user_credentials.email).first()
|
||||
if not user or not verify_password(user_credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password"
|
||||
)
|
||||
|
||||
# Check if user is approved
|
||||
if not user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account not approved by administrator"
|
||||
)
|
||||
|
||||
# Create tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
token_data = {"sub": str(user.id), "email": user.email, "role": user.role}
|
||||
|
||||
access_token = create_access_token(
|
||||
data=token_data,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
refresh_token = create_refresh_token(
|
||||
data=token_data,
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
async def refresh_token(refresh_data: RefreshToken, db: Session = Depends(get_db)):
|
||||
"""Refresh access token using refresh token."""
|
||||
# Verify refresh token
|
||||
payload = verify_token(refresh_data.refresh_token, "refresh")
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token"
|
||||
)
|
||||
|
||||
# Get user from database
|
||||
user_id = payload.get("sub")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user or not user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found or not approved"
|
||||
)
|
||||
|
||||
# Create new tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
refresh_token_expires = timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
|
||||
token_data = {"sub": str(user.id), "email": user.email, "role": user.role}
|
||||
|
||||
new_access_token = create_access_token(
|
||||
data=token_data,
|
||||
expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(
|
||||
data=token_data,
|
||||
expires_delta=refresh_token_expires
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=new_refresh_token
|
||||
)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout user (client should discard tokens)."""
|
||||
return {"message": "Successfully logged out"}
|
||||
|
||||
|
||||
# API Key Management Endpoints
|
||||
|
||||
@router.post("/api-keys", response_model=APIKeyWithToken)
|
||||
async def create_api_key(
|
||||
api_key_data: APIKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Create a new API key. Only developers and admins can create API keys."""
|
||||
# Check if user has permission to create API keys
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can create API keys"
|
||||
)
|
||||
|
||||
# Determine target user for the API key
|
||||
target_user_id = current_user.id # Default to current user
|
||||
target_user = current_user
|
||||
|
||||
# If user_id is specified and current user has admin permission, allow creating for other users
|
||||
if api_key_data.user_id is not None:
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only users with admin permission can create API keys for other users"
|
||||
)
|
||||
|
||||
# Verify target user exists and is approved
|
||||
target_user = db.query(User).filter(User.id == api_key_data.user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Target user not found"
|
||||
)
|
||||
|
||||
if not target_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot create API key for unapproved user"
|
||||
)
|
||||
|
||||
target_user_id = target_user.id
|
||||
|
||||
# Generate API key
|
||||
api_key_token = generate_api_key()
|
||||
key_hash = hash_api_key(api_key_token)
|
||||
|
||||
# Convert scopes to JSON string
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
|
||||
# Create API key record
|
||||
new_api_key = APIKey(
|
||||
user_id=target_user_id,
|
||||
key_hash=key_hash,
|
||||
name=api_key_data.name,
|
||||
scopes=scopes_json,
|
||||
expires_at=api_key_data.expires_at
|
||||
)
|
||||
|
||||
db.add(new_api_key)
|
||||
db.commit()
|
||||
db.refresh(new_api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(new_api_key.scopes)
|
||||
|
||||
api_key_response = APIKeyResponse(
|
||||
id=new_api_key.id,
|
||||
user_id=new_api_key.user_id,
|
||||
name=new_api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=new_api_key.is_active,
|
||||
expires_at=new_api_key.expires_at,
|
||||
last_used_at=new_api_key.last_used_at,
|
||||
created_at=new_api_key.created_at,
|
||||
user_email=target_user.email
|
||||
)
|
||||
|
||||
return APIKeyWithToken(
|
||||
api_key=api_key_response,
|
||||
token=api_key_token
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_api_keys(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
user_id: int = None
|
||||
):
|
||||
"""List API keys. Developers see their own, admins can see all or filter by user."""
|
||||
# Developers and users with admin permission can see API keys
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Build query based on user permissions and parameters
|
||||
if current_user.is_admin:
|
||||
if user_id is not None:
|
||||
# Admin requesting specific user's API keys
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == user_id).all()
|
||||
else:
|
||||
# Admin requesting all API keys
|
||||
api_keys = db.query(APIKey).all()
|
||||
else:
|
||||
# Developer can only see their own API keys
|
||||
if user_id is not None and user_id != current_user.id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Developers can only view their own API keys"
|
||||
)
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == current_user.id).all()
|
||||
|
||||
result = []
|
||||
for api_key in api_keys:
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
|
||||
# Get user email for admin view
|
||||
user_email = None
|
||||
if current_user.is_admin:
|
||||
user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||
if user:
|
||||
user_email = user.email
|
||||
|
||||
result.append(APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=user_email
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/api-keys/{api_key_id}", response_model=APIKeyResponse)
|
||||
async def update_api_key(
|
||||
api_key_id: int,
|
||||
api_key_data: APIKeyUpdate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Update an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Get API key with different access rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can update any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only update their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
if api_key_data.name is not None:
|
||||
api_key.name = api_key_data.name
|
||||
|
||||
if api_key_data.scopes is not None:
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
api_key.scopes = scopes_json
|
||||
|
||||
if api_key_data.is_active is not None:
|
||||
api_key.is_active = api_key_data.is_active
|
||||
|
||||
if api_key_data.expires_at is not None:
|
||||
api_key.expires_at = api_key_data.expires_at
|
||||
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
|
||||
# Get user email for admin view
|
||||
user_email = None
|
||||
if current_user.is_admin:
|
||||
user = db.query(User).filter(User.id == api_key.user_id).first()
|
||||
if user:
|
||||
user_email = user.email
|
||||
|
||||
return APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=user_email
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/api-keys/{api_key_id}")
|
||||
async def delete_api_key(
|
||||
api_key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Delete (revoke) an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can manage API keys"
|
||||
)
|
||||
|
||||
# Get API key with different access rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can delete any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only delete their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Delete the API key
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
|
||||
return {"message": "API key revoked successfully"}
|
||||
|
||||
|
||||
@router.get("/api-keys/{api_key_id}/usage", response_model=List[APIKeyUsageLog])
|
||||
async def get_api_key_usage(
|
||||
api_key_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
limit: int = 100
|
||||
):
|
||||
"""Get usage logs for an API key."""
|
||||
# Check if user has permission
|
||||
if current_user.role != UserRole.DEVELOPER and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers and users with admin permission can view API key usage"
|
||||
)
|
||||
|
||||
# Verify API key access with different rules for admin vs developer
|
||||
if current_user.is_admin:
|
||||
# Users with admin permission can view usage for any API key
|
||||
api_key = db.query(APIKey).filter(APIKey.id == api_key_id).first()
|
||||
else:
|
||||
# Developers can only view usage for their own API keys
|
||||
api_key = db.query(APIKey).filter(
|
||||
APIKey.id == api_key_id,
|
||||
APIKey.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="API key not found"
|
||||
)
|
||||
|
||||
# Get usage logs
|
||||
usage_logs = db.query(APIKeyUsage).filter(
|
||||
APIKeyUsage.api_key_id == api_key_id
|
||||
).order_by(APIKeyUsage.timestamp.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
APIKeyUsageLog(
|
||||
api_key_id=log.api_key_id,
|
||||
endpoint=log.endpoint,
|
||||
method=log.method,
|
||||
timestamp=log.timestamp,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent
|
||||
)
|
||||
for log in usage_logs
|
||||
]
|
||||
|
||||
|
||||
# Admin-only endpoints for API key management
|
||||
|
||||
@router.get("/admin/users/{user_id}/api-keys", response_model=List[APIKeyResponse])
|
||||
async def list_user_api_keys_admin(
|
||||
user_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Admin endpoint to list API keys for a specific user."""
|
||||
# Only users with admin permission can access this endpoint
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required to access this endpoint"
|
||||
)
|
||||
|
||||
# Verify target user exists
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
# Get API keys for the specified user
|
||||
api_keys = db.query(APIKey).filter(APIKey.user_id == user_id).all()
|
||||
|
||||
result = []
|
||||
for api_key in api_keys:
|
||||
scopes_list = json.loads(api_key.scopes)
|
||||
result.append(APIKeyResponse(
|
||||
id=api_key.id,
|
||||
user_id=api_key.user_id,
|
||||
name=api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=api_key.is_active,
|
||||
expires_at=api_key.expires_at,
|
||||
last_used_at=api_key.last_used_at,
|
||||
created_at=api_key.created_at,
|
||||
user_email=target_user.email
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/admin/users/{user_id}/api-keys", response_model=APIKeyWithToken)
|
||||
async def create_api_key_for_user_admin(
|
||||
user_id: int,
|
||||
api_key_data: APIKeyCreate,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Admin endpoint to create an API key for a specific user."""
|
||||
# Only users with admin permission can access this endpoint
|
||||
if not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin permission required to access this endpoint"
|
||||
)
|
||||
|
||||
# Verify target user exists and is approved
|
||||
target_user = db.query(User).filter(User.id == user_id).first()
|
||||
if not target_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found"
|
||||
)
|
||||
|
||||
if not target_user.is_approved:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot create API key for unapproved user"
|
||||
)
|
||||
|
||||
# Generate API key
|
||||
api_key_token = generate_api_key()
|
||||
key_hash = hash_api_key(api_key_token)
|
||||
|
||||
# Convert scopes to JSON string
|
||||
scopes_json = json.dumps([scope.value for scope in api_key_data.scopes])
|
||||
|
||||
# Create API key record
|
||||
new_api_key = APIKey(
|
||||
user_id=user_id,
|
||||
key_hash=key_hash,
|
||||
name=api_key_data.name,
|
||||
scopes=scopes_json,
|
||||
expires_at=api_key_data.expires_at
|
||||
)
|
||||
|
||||
db.add(new_api_key)
|
||||
db.commit()
|
||||
db.refresh(new_api_key)
|
||||
|
||||
# Convert scopes back to list for response
|
||||
scopes_list = json.loads(new_api_key.scopes)
|
||||
|
||||
api_key_response = APIKeyResponse(
|
||||
id=new_api_key.id,
|
||||
user_id=new_api_key.user_id,
|
||||
name=new_api_key.name,
|
||||
scopes=scopes_list,
|
||||
is_active=new_api_key.is_active,
|
||||
expires_at=new_api_key.expires_at,
|
||||
last_used_at=new_api_key.last_used_at,
|
||||
created_at=new_api_key.created_at,
|
||||
user_email=target_user.email
|
||||
)
|
||||
|
||||
return APIKeyWithToken(
|
||||
api_key=api_key_response,
|
||||
token=api_key_token
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Data Consistency API endpoints for validating and monitoring task aggregation consistency.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from services.data_consistency import create_data_consistency_service
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user_with_db(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_admin_or_coordinator(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require admin or coordinator role for consistency operations."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if current_user.role not in [UserRole.COORDINATOR] and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Admin or Coordinator role required for consistency operations"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/validate/{entity_type}/{entity_id}")
|
||||
async def validate_entity_consistency(
|
||||
entity_type: str,
|
||||
entity_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""
|
||||
Validate task aggregation consistency for a specific shot or asset.
|
||||
|
||||
Args:
|
||||
entity_type: 'shot' or 'asset'
|
||||
entity_id: ID of the shot or asset
|
||||
"""
|
||||
if entity_type not in ['shot', 'asset']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="entity_type must be 'shot' or 'asset'"
|
||||
)
|
||||
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.validate_task_aggregation_consistency(entity_id, entity_type)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/validate/bulk")
|
||||
async def validate_bulk_consistency(
|
||||
entity_ids: List[int],
|
||||
entity_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Validate task aggregation consistency for multiple shots or assets.
|
||||
|
||||
Args:
|
||||
entity_ids: List of shot or asset IDs
|
||||
entity_type: 'shot' or 'asset'
|
||||
"""
|
||||
if entity_type not in ['shot', 'asset']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="entity_type must be 'shot' or 'asset'"
|
||||
)
|
||||
|
||||
if len(entity_ids) > 100:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Maximum 100 entities can be validated at once"
|
||||
)
|
||||
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.validate_bulk_consistency(entity_ids, entity_type)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Bulk validation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/report")
|
||||
async def get_consistency_report(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Generate a comprehensive consistency report for shots and assets.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.get_consistency_report(project_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Report generation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/propagate/{task_id}")
|
||||
async def propagate_task_update(
|
||||
task_id: int,
|
||||
old_status: Optional[str] = None,
|
||||
new_status: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_or_coordinator)
|
||||
):
|
||||
"""
|
||||
Manually propagate a task update and validate consistency.
|
||||
|
||||
Args:
|
||||
task_id: ID of the task to propagate
|
||||
old_status: Previous task status (optional)
|
||||
new_status: New task status (optional)
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
result = consistency_service.propagate_task_update(task_id, old_status, new_status)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Task propagation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def consistency_health_check(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""
|
||||
Quick health check for data consistency across the system.
|
||||
|
||||
Args:
|
||||
project_id: Optional project ID to filter by
|
||||
"""
|
||||
consistency_service = create_data_consistency_service(db)
|
||||
|
||||
try:
|
||||
report = consistency_service.get_consistency_report(project_id)
|
||||
|
||||
# Extract key health metrics
|
||||
summary = report['summary']
|
||||
health_status = "healthy" if summary['consistency_percentage'] >= 95 else "degraded" if summary['consistency_percentage'] >= 80 else "unhealthy"
|
||||
|
||||
return {
|
||||
'status': health_status,
|
||||
'consistency_percentage': summary['consistency_percentage'],
|
||||
'total_entities': summary['total_entities'],
|
||||
'valid_entities': summary['valid_entities'],
|
||||
'invalid_entities': summary['invalid_entities'],
|
||||
'total_inconsistencies': summary['total_inconsistencies'],
|
||||
'project_id': project_id,
|
||||
'timestamp': report['report_timestamp']
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'error',
|
||||
'error': str(e),
|
||||
'timestamp': None
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.user import User, UserRole
|
||||
from models.project import Project, ProjectMember
|
||||
from models.task import Task, Submission
|
||||
from models.api_key import APIKey
|
||||
from models.api_key_usage import APIKeyUsage
|
||||
from schemas.project import ProjectResponse
|
||||
from schemas.task import TaskResponse, SubmissionResponse
|
||||
from schemas.api_key import APIKeyUsageLog
|
||||
from utils.auth import get_current_user_flexible, require_api_key_scope
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/projects", response_model=List[ProjectResponse])
|
||||
async def get_all_projects_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:projects"))
|
||||
):
|
||||
"""Get read-only access to all projects for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get all projects
|
||||
projects = db.query(Project).all()
|
||||
|
||||
result = []
|
||||
for project in projects:
|
||||
# Get member count
|
||||
member_count = db.query(ProjectMember).filter(ProjectMember.project_id == project.id).count()
|
||||
|
||||
result.append(ProjectResponse(
|
||||
id=project.id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
status=project.status,
|
||||
start_date=project.start_date,
|
||||
end_date=project.end_date,
|
||||
created_at=project.created_at,
|
||||
updated_at=project.updated_at,
|
||||
member_count=member_count
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/tasks", response_model=List[TaskResponse])
|
||||
async def get_all_tasks_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:tasks")),
|
||||
project_id: int = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get read-only access to all tasks across projects for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Build query
|
||||
query = db.query(Task)
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
# Apply pagination
|
||||
tasks = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for task in tasks:
|
||||
# Get assigned user info
|
||||
assigned_user = None
|
||||
if task.assigned_user_id:
|
||||
user = db.query(User).filter(User.id == task.assigned_user_id).first()
|
||||
if user:
|
||||
assigned_user = {
|
||||
"id": user.id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
# Get project info
|
||||
project = db.query(Project).filter(Project.id == task.project_id).first()
|
||||
project_info = None
|
||||
if project:
|
||||
project_info = {
|
||||
"id": project.id,
|
||||
"name": project.name
|
||||
}
|
||||
|
||||
result.append(TaskResponse(
|
||||
id=task.id,
|
||||
project_id=task.project_id,
|
||||
episode_id=task.episode_id,
|
||||
shot_id=task.shot_id,
|
||||
asset_id=task.asset_id,
|
||||
assigned_user_id=task.assigned_user_id,
|
||||
task_type=task.task_type,
|
||||
name=task.name,
|
||||
description=task.description,
|
||||
status=task.status,
|
||||
deadline=task.deadline,
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
assigned_user=assigned_user,
|
||||
project=project_info
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/submissions", response_model=List[SubmissionResponse])
|
||||
async def get_all_submissions_for_developer(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_api_key_scope("read:submissions")),
|
||||
project_id: int = None,
|
||||
task_id: int = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get read-only access to all submissions for developers."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Build query
|
||||
query = db.query(Submission).filter(Submission.deleted_at.is_(None))
|
||||
|
||||
# Filter by task if specified
|
||||
if task_id:
|
||||
query = query.filter(Submission.task_id == task_id)
|
||||
elif project_id:
|
||||
# Filter by project through task relationship
|
||||
query = query.join(Task).filter(
|
||||
Task.project_id == project_id,
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
submissions = query.offset(offset).limit(limit).all()
|
||||
|
||||
result = []
|
||||
for submission in submissions:
|
||||
# Get task info
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
task_info = None
|
||||
if task:
|
||||
task_info = {
|
||||
"id": task.id,
|
||||
"name": task.name,
|
||||
"task_type": task.task_type,
|
||||
"project_id": task.project_id
|
||||
}
|
||||
|
||||
# Get user info
|
||||
user = db.query(User).filter(User.id == submission.user_id).first()
|
||||
user_info = None
|
||||
if user:
|
||||
user_info = {
|
||||
"id": user.id,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"email": user.email
|
||||
}
|
||||
|
||||
result.append(SubmissionResponse(
|
||||
id=submission.id,
|
||||
task_id=submission.task_id,
|
||||
user_id=submission.user_id,
|
||||
file_path=submission.file_path,
|
||||
file_name=submission.file_name,
|
||||
version_number=submission.version_number,
|
||||
notes=submission.notes,
|
||||
submitted_at=submission.submitted_at,
|
||||
task=task_info,
|
||||
user=user_info
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api-usage", response_model=List[APIKeyUsageLog])
|
||||
async def get_api_usage_logs(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible),
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
):
|
||||
"""Get API key usage logs for the current developer."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get usage logs for all API keys belonging to the current user
|
||||
usage_logs = db.query(APIKeyUsage).join(
|
||||
APIKey, APIKeyUsage.api_key_id == APIKey.id
|
||||
).filter(
|
||||
APIKey.user_id == current_user.id
|
||||
).order_by(
|
||||
APIKeyUsage.timestamp.desc()
|
||||
).offset(offset).limit(limit).all()
|
||||
|
||||
return [
|
||||
APIKeyUsageLog(
|
||||
api_key_id=log.api_key_id,
|
||||
endpoint=log.endpoint,
|
||||
method=log.method,
|
||||
timestamp=log.timestamp,
|
||||
ip_address=log.ip_address,
|
||||
user_agent=log.user_agent
|
||||
)
|
||||
for log in usage_logs
|
||||
]
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_developer_stats(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_flexible)
|
||||
):
|
||||
"""Get statistics for developer dashboard."""
|
||||
# Ensure user is a developer
|
||||
if current_user.role != UserRole.DEVELOPER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only developers can access this endpoint"
|
||||
)
|
||||
|
||||
# Get various counts
|
||||
total_projects = db.query(Project).count()
|
||||
total_tasks = db.query(Task).count()
|
||||
total_submissions = db.query(Submission).count()
|
||||
|
||||
# Get API key usage count for current user
|
||||
api_usage_count = db.query(APIKeyUsage).join(
|
||||
APIKey, APIKeyUsage.api_key_id == APIKey.id
|
||||
).filter(
|
||||
APIKey.user_id == current_user.id
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_projects": total_projects,
|
||||
"total_tasks": total_tasks,
|
||||
"total_submissions": total_submissions,
|
||||
"api_usage_count": api_usage_count
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.episode import Episode
|
||||
from models.project import Project, ProjectMember
|
||||
from models.user import User, UserRole
|
||||
from models.shot import Shot
|
||||
from schemas.episode import EpisodeCreate, EpisodeUpdate, EpisodeResponse, EpisodeListResponse
|
||||
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user_with_db(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
from utils.auth import _get_user_from_db
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/", response_model=List[EpisodeListResponse])
|
||||
async def list_episodes(
|
||||
project_id: int = None,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List episodes, optionally filtered by project"""
|
||||
query = db.query(Episode)
|
||||
|
||||
if project_id:
|
||||
# Check if project exists and user has access
|
||||
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"
|
||||
)
|
||||
|
||||
# Check access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
query = query.filter(Episode.project_id == project_id)
|
||||
else:
|
||||
# For artists, only show episodes from projects they're members of
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
user_project_ids = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).subquery()
|
||||
query = query.filter(Episode.project_id.in_(user_project_ids))
|
||||
|
||||
episodes = query.order_by(Episode.episode_number).offset(skip).limit(limit).all()
|
||||
|
||||
# Add shot count for each episode
|
||||
result = []
|
||||
for episode in episodes:
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeListResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
result.append(episode_data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_episode(
|
||||
episode: EpisodeCreate,
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new episode within a project"""
|
||||
# Check if project exists
|
||||
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"
|
||||
)
|
||||
|
||||
# Check if episode number is already used in this project
|
||||
existing_episode = db.query(Episode).filter(
|
||||
Episode.project_id == project_id,
|
||||
Episode.episode_number == episode.episode_number
|
||||
).first()
|
||||
|
||||
if existing_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Episode number {episode.episode_number} already exists in this project"
|
||||
)
|
||||
|
||||
# Create new episode
|
||||
db_episode = Episode(
|
||||
project_id=project_id,
|
||||
**episode.model_dump()
|
||||
)
|
||||
|
||||
db.add(db_episode)
|
||||
db.commit()
|
||||
db.refresh(db_episode)
|
||||
|
||||
episode_data = EpisodeResponse.model_validate(db_episode)
|
||||
episode_data.shot_count = 0 # New episode has no shots yet
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.get("/{episode_id}", response_model=EpisodeResponse)
|
||||
async def get_episode(
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get a specific episode by ID"""
|
||||
episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
|
||||
if not episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
# Check access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == episode.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this episode"
|
||||
)
|
||||
|
||||
# Add shot count
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.put("/{episode_id}", response_model=EpisodeResponse)
|
||||
async def update_episode(
|
||||
episode_id: int,
|
||||
episode_update: EpisodeUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Update an episode"""
|
||||
db_episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
|
||||
if not db_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
# If updating episode number, check for conflicts
|
||||
update_data = episode_update.model_dump(exclude_unset=True)
|
||||
if 'episode_number' in update_data:
|
||||
existing_episode = db.query(Episode).filter(
|
||||
Episode.project_id == db_episode.project_id,
|
||||
Episode.episode_number == update_data['episode_number'],
|
||||
Episode.id != episode_id
|
||||
).first()
|
||||
|
||||
if existing_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Episode number {update_data['episode_number']} already exists in this project"
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
for field, value in update_data.items():
|
||||
setattr(db_episode, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_episode)
|
||||
|
||||
# Add shot count
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == db_episode.id).count()
|
||||
episode_data = EpisodeResponse.model_validate(db_episode)
|
||||
episode_data.shot_count = shot_count
|
||||
|
||||
return episode_data
|
||||
|
||||
|
||||
@router.delete("/{episode_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_episode(
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Delete an episode"""
|
||||
db_episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
|
||||
if not db_episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
db.delete(db_episode)
|
||||
db.commit()
|
||||
|
||||
|
||||
# Project-specific episode endpoints
|
||||
|
||||
@router.get("/projects/{project_id}/episodes", response_model=List[EpisodeListResponse])
|
||||
async def list_project_episodes(
|
||||
project_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List all episodes for a specific project"""
|
||||
# Check if project exists
|
||||
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"
|
||||
)
|
||||
|
||||
# Check access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
episodes = db.query(Episode).filter(
|
||||
Episode.project_id == project_id
|
||||
).order_by(Episode.episode_number).offset(skip).limit(limit).all()
|
||||
|
||||
# Add shot count for each episode
|
||||
result = []
|
||||
for episode in episodes:
|
||||
shot_count = db.query(Shot).filter(Shot.episode_id == episode.id).count()
|
||||
episode_data = EpisodeListResponse.model_validate(episode)
|
||||
episode_data.shot_count = shot_count
|
||||
result.append(episode_data)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/projects/{project_id}/episodes", response_model=EpisodeResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_project_episode(
|
||||
project_id: int,
|
||||
episode: EpisodeCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new episode within a specific project"""
|
||||
return await create_episode(episode, project_id, db, current_user)
|
||||
@@ -0,0 +1,447 @@
|
||||
"""
|
||||
File serving router for VFX Project Management System.
|
||||
Handles authenticated file serving, thumbnails, and access control.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from pathlib import Path
|
||||
import os
|
||||
import mimetypes
|
||||
from typing import Optional
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, TaskAttachment, Submission
|
||||
from models.user import User, UserRole
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def check_file_access_permission(user: User, task: Task, db: Session) -> bool:
|
||||
"""Check if user has permission to access files for a task."""
|
||||
from models.project import ProjectMember
|
||||
|
||||
# Admins and coordinators can access all files
|
||||
if user.role == UserRole.COORDINATOR or user.is_admin:
|
||||
return True
|
||||
|
||||
# Directors can access all files for review
|
||||
if user.role == UserRole.DIRECTOR:
|
||||
return True
|
||||
|
||||
# Artists can access files for their assigned tasks
|
||||
if task.assigned_user_id == user.id:
|
||||
return True
|
||||
|
||||
# Artists can also access files for tasks in projects they're members of
|
||||
if user.role == UserRole.ARTIST:
|
||||
# Get the project_id from the task's asset or shot
|
||||
project_id = None
|
||||
if task.asset_id:
|
||||
from models.asset import Asset
|
||||
asset = db.query(Asset).filter(Asset.id == task.asset_id).first()
|
||||
if asset:
|
||||
project_id = asset.project_id
|
||||
elif task.shot_id:
|
||||
from models.shot import Shot
|
||||
shot = db.query(Shot).filter(Shot.id == task.shot_id).first()
|
||||
if shot:
|
||||
from models.episode import Episode
|
||||
episode = db.query(Episode).filter(Episode.id == shot.episode_id).first()
|
||||
if episode:
|
||||
project_id = episode.project_id
|
||||
|
||||
# Check if user is a project member
|
||||
if project_id:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == user.id
|
||||
).first()
|
||||
if member:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/attachments/{attachment_id}")
|
||||
async def serve_attachment(
|
||||
attachment_id: int,
|
||||
thumbnail: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a task attachment file with access control."""
|
||||
|
||||
# Get attachment
|
||||
attachment = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.id == attachment_id,
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == attachment.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Determine file path
|
||||
if thumbnail and file_handler.is_image_file(attachment.file_path):
|
||||
# Try to serve thumbnail
|
||||
thumbnail_path = file_handler.get_thumbnail_path(attachment.file_path)
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path)
|
||||
if os.path.exists(absolute_thumbnail_path):
|
||||
file_path = absolute_thumbnail_path
|
||||
filename = f"thumb_{attachment.file_name}"
|
||||
else:
|
||||
# Create thumbnail on-demand
|
||||
created_thumbnail = file_handler.create_thumbnail(attachment.file_path)
|
||||
if created_thumbnail:
|
||||
absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail)
|
||||
if os.path.exists(absolute_created_thumbnail):
|
||||
file_path = absolute_created_thumbnail
|
||||
filename = f"thumb_{attachment.file_name}"
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
else:
|
||||
file_path = file_handler.resolve_absolute_path(attachment.file_path)
|
||||
filename = attachment.file_name
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}")
|
||||
async def serve_submission(
|
||||
submission_id: int,
|
||||
thumbnail: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a submission file with access control."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Determine file path
|
||||
if thumbnail and file_handler.is_image_file(submission.file_path):
|
||||
# Try to serve thumbnail
|
||||
thumbnail_path = file_handler.get_thumbnail_path(submission.file_path)
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(thumbnail_path)
|
||||
if os.path.exists(absolute_thumbnail_path):
|
||||
file_path = absolute_thumbnail_path
|
||||
filename = f"thumb_{submission.file_name}"
|
||||
else:
|
||||
# Create thumbnail on-demand
|
||||
created_thumbnail = file_handler.create_thumbnail(submission.file_path)
|
||||
if created_thumbnail:
|
||||
absolute_created_thumbnail = file_handler.resolve_absolute_path(created_thumbnail)
|
||||
if os.path.exists(absolute_created_thumbnail):
|
||||
file_path = absolute_created_thumbnail
|
||||
filename = f"thumb_{submission.file_name}"
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
else:
|
||||
# Fall back to original file
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
else:
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
filename = submission.file_name
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=file_path,
|
||||
filename=filename,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/submissions/{submission_id}/stream")
|
||||
async def stream_submission(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Stream a submission file for video playback."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
file_path = file_handler.resolve_absolute_path(submission.file_path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(status_code=404, detail="File not found on disk")
|
||||
|
||||
# Only stream video files
|
||||
if not file_handler.is_video_file(file_path):
|
||||
raise HTTPException(status_code=400, detail="File is not a video")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
if not mime_type:
|
||||
mime_type = 'video/mp4' # Default for video
|
||||
|
||||
def iterfile(file_path: str):
|
||||
"""Generator to stream file in chunks."""
|
||||
with open(file_path, mode="rb") as file_like:
|
||||
while True:
|
||||
chunk = file_like.read(1024 * 1024) # 1MB chunks
|
||||
if not chunk:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
return StreamingResponse(
|
||||
iterfile(file_path),
|
||||
media_type=mime_type,
|
||||
headers={
|
||||
"Content-Disposition": f"inline; filename={submission.file_name}",
|
||||
"Accept-Ranges": "bytes"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/info/attachment/{attachment_id}")
|
||||
async def get_attachment_info(
|
||||
attachment_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get file information for an attachment."""
|
||||
|
||||
# Get attachment
|
||||
attachment = db.query(TaskAttachment).filter(
|
||||
TaskAttachment.id == attachment_id,
|
||||
TaskAttachment.deleted_at.is_(None)
|
||||
).first()
|
||||
if not attachment:
|
||||
raise HTTPException(status_code=404, detail="Attachment not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == attachment.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Get file info
|
||||
file_info = file_handler.get_file_info(attachment.file_path)
|
||||
|
||||
return {
|
||||
"id": attachment.id,
|
||||
"file_name": attachment.file_name,
|
||||
"file_type": attachment.file_type,
|
||||
"file_size": attachment.file_size,
|
||||
"attachment_type": attachment.attachment_type,
|
||||
"description": attachment.description,
|
||||
"uploaded_at": attachment.uploaded_at,
|
||||
"is_image": file_handler.is_image_file(attachment.file_path),
|
||||
"is_video": file_handler.is_video_file(attachment.file_path),
|
||||
"has_thumbnail": file_handler.is_image_file(attachment.file_path),
|
||||
"file_exists": file_info.get('exists', False),
|
||||
**file_info
|
||||
}
|
||||
|
||||
|
||||
@router.get("/info/submission/{submission_id}")
|
||||
async def get_submission_info(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get file information for a submission."""
|
||||
|
||||
# Get submission
|
||||
submission = db.query(Submission).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Get associated task
|
||||
task = db.query(Task).filter(Task.id == submission.task_id).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="Associated task not found")
|
||||
|
||||
# Check permissions
|
||||
if not check_file_access_permission(current_user, task, db):
|
||||
raise HTTPException(status_code=403, detail="Not authorized to access this file")
|
||||
|
||||
# Get file info
|
||||
file_info = file_handler.get_file_info(submission.file_path)
|
||||
|
||||
return {
|
||||
"id": submission.id,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"is_image": file_handler.is_image_file(submission.file_path),
|
||||
"is_video": file_handler.is_video_file(submission.file_path),
|
||||
"has_thumbnail": file_handler.is_image_file(submission.file_path),
|
||||
"file_exists": file_info.get('exists', False),
|
||||
**file_info
|
||||
}
|
||||
|
||||
|
||||
@router.get("/projects/{project_id}/thumbnail")
|
||||
async def serve_project_thumbnail(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Serve a project thumbnail image with access control."""
|
||||
from models.project import Project
|
||||
from models.project import ProjectMember
|
||||
|
||||
# Get project
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
# Check if user has access to this project
|
||||
# Artists can only access projects they're members of
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(status_code=403, detail="Access denied to this project")
|
||||
|
||||
# Check if project has a thumbnail
|
||||
if not project.thumbnail_path:
|
||||
raise HTTPException(status_code=404, detail="Project has no thumbnail")
|
||||
|
||||
# Resolve to absolute path for file serving
|
||||
absolute_thumbnail_path = file_handler.resolve_absolute_path(project.thumbnail_path)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_thumbnail_path):
|
||||
raise HTTPException(status_code=404, detail="Thumbnail file not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(absolute_thumbnail_path)
|
||||
if not mime_type:
|
||||
mime_type = 'image/jpeg' # Default for thumbnails
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=absolute_thumbnail_path,
|
||||
media_type=mime_type
|
||||
)
|
||||
|
||||
|
||||
@router.get("/users/{user_id}/avatar")
|
||||
async def serve_user_avatar(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Serve user avatar (public access for simplicity)."""
|
||||
|
||||
# Get user
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
# Check if user has avatar
|
||||
if not user.avatar_url:
|
||||
raise HTTPException(status_code=404, detail="User has no avatar")
|
||||
|
||||
# Note: Avatar access is public for simplicity since img tags can't send auth headers
|
||||
# More restrictive access control can be added later if needed
|
||||
|
||||
# Resolve to absolute path for file serving
|
||||
absolute_avatar_path = file_handler.resolve_absolute_path(user.avatar_url)
|
||||
|
||||
# Check if file exists
|
||||
if not os.path.exists(absolute_avatar_path):
|
||||
raise HTTPException(status_code=404, detail="Avatar file not found on disk")
|
||||
|
||||
# Get MIME type
|
||||
mime_type, _ = mimetypes.guess_type(absolute_avatar_path)
|
||||
if not mime_type:
|
||||
mime_type = 'image/jpeg' # Default for avatars
|
||||
|
||||
# Return file
|
||||
return FileResponse(
|
||||
path=absolute_avatar_path,
|
||||
media_type=mime_type
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from database import get_db
|
||||
from models.user import User
|
||||
from models.notification import Notification, UserNotificationPreference, NotificationType
|
||||
from schemas.notification import (
|
||||
NotificationResponse,
|
||||
NotificationMarkRead,
|
||||
NotificationPreferencesResponse,
|
||||
NotificationPreferencesUpdate,
|
||||
NotificationStats
|
||||
)
|
||||
from utils.auth import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[NotificationResponse])
|
||||
def get_notifications(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(50, ge=1, le=100),
|
||||
unread_only: bool = Query(False),
|
||||
type_filter: Optional[NotificationType] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notifications for the current user."""
|
||||
query = db.query(Notification).filter(Notification.user_id == current_user.id)
|
||||
|
||||
if unread_only:
|
||||
query = query.filter(Notification.read == False)
|
||||
|
||||
if type_filter:
|
||||
query = query.filter(Notification.type == type_filter)
|
||||
|
||||
notifications = query.order_by(desc(Notification.created_at)).offset(skip).limit(limit).all()
|
||||
return notifications
|
||||
|
||||
|
||||
@router.get("/stats", response_model=NotificationStats)
|
||||
def get_notification_stats(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notification statistics for the current user."""
|
||||
total = db.query(Notification).filter(Notification.user_id == current_user.id).count()
|
||||
unread = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).count()
|
||||
|
||||
# Get counts by type
|
||||
by_type_query = db.query(
|
||||
Notification.type,
|
||||
func.count(Notification.id).label('count')
|
||||
).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).group_by(Notification.type).all()
|
||||
|
||||
by_type = {str(type_): count for type_, count in by_type_query}
|
||||
|
||||
return NotificationStats(total=total, unread=unread, by_type=by_type)
|
||||
|
||||
|
||||
@router.post("/mark-read")
|
||||
def mark_notifications_read(
|
||||
data: NotificationMarkRead,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark notifications as read."""
|
||||
notifications = db.query(Notification).filter(
|
||||
Notification.id.in_(data.notification_ids),
|
||||
Notification.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
if not notifications:
|
||||
raise HTTPException(status_code=404, detail="No notifications found")
|
||||
|
||||
for notification in notifications:
|
||||
notification.read = True
|
||||
notification.read_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {len(notifications)} notifications as read"}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
def mark_all_notifications_read(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Mark all notifications as read for the current user."""
|
||||
count = db.query(Notification).filter(
|
||||
Notification.user_id == current_user.id,
|
||||
Notification.read == False
|
||||
).update({
|
||||
"read": True,
|
||||
"read_at": datetime.utcnow()
|
||||
})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Marked {count} notifications as read"}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
def delete_notification(
|
||||
notification_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Delete a notification."""
|
||||
notification = db.query(Notification).filter(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not notification:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
|
||||
db.delete(notification)
|
||||
db.commit()
|
||||
|
||||
return {"message": "Notification deleted"}
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=NotificationPreferencesResponse)
|
||||
def get_notification_preferences(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get notification preferences for the current user."""
|
||||
preferences = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not preferences:
|
||||
# Create default preferences
|
||||
preferences = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(preferences)
|
||||
db.commit()
|
||||
db.refresh(preferences)
|
||||
|
||||
return preferences
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=NotificationPreferencesResponse)
|
||||
def update_notification_preferences(
|
||||
data: NotificationPreferencesUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Update notification preferences for the current user."""
|
||||
preferences = db.query(UserNotificationPreference).filter(
|
||||
UserNotificationPreference.user_id == current_user.id
|
||||
).first()
|
||||
|
||||
if not preferences:
|
||||
preferences = UserNotificationPreference(user_id=current_user.id)
|
||||
db.add(preferences)
|
||||
|
||||
# Update all fields
|
||||
for field, value in data.model_dump().items():
|
||||
setattr(preferences, field, value)
|
||||
|
||||
preferences.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
db.refresh(preferences)
|
||||
|
||||
return preferences
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from sqlalchemy import and_
|
||||
from typing import List, Optional
|
||||
|
||||
from database import get_db
|
||||
from models.task import Task, Submission, Review, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.task import ReviewCreate, ReviewResponse, SubmissionResponse
|
||||
from utils.auth import get_current_user_from_token, _get_user_from_db
|
||||
from utils.notifications import notification_service
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_director_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Dependency to require director, coordinator role, or admin permission."""
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if (current_user.role not in [UserRole.DIRECTOR, UserRole.COORDINATOR] and
|
||||
not current_user.is_admin):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Director role, Coordinator role, or Admin permission required"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def require_role(required_roles: list):
|
||||
"""Create a dependency that requires specific user roles."""
|
||||
def role_checker(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
if current_user.role not in required_roles:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
return role_checker
|
||||
|
||||
|
||||
@router.get("/pending", response_model=List[SubmissionResponse])
|
||||
async def get_pending_reviews(
|
||||
project_id: Optional[int] = Query(None, description="Filter by project ID"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Get all submissions pending review. Only directors, coordinators, and users with admin permission can access."""
|
||||
|
||||
# Get submissions that don't have any reviews yet or have retake reviews
|
||||
query = db.query(Submission).options(
|
||||
joinedload(Submission.user),
|
||||
joinedload(Submission.task).joinedload(Task.project),
|
||||
joinedload(Submission.reviews).joinedload("reviewer")
|
||||
).join(Task).filter(
|
||||
Submission.deleted_at.is_(None),
|
||||
Task.deleted_at.is_(None)
|
||||
)
|
||||
|
||||
# Filter by project if specified
|
||||
if project_id:
|
||||
query = query.filter(Task.project_id == project_id)
|
||||
|
||||
# Only get submitted tasks
|
||||
query = query.filter(Task.status == "submitted")
|
||||
|
||||
submissions = query.order_by(Submission.submitted_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
# Filter to only include submissions that need review
|
||||
pending_submissions = []
|
||||
for submission in submissions:
|
||||
# Check if submission has any approved reviews
|
||||
has_approved_review = any(review.decision == "approved" for review in submission.reviews)
|
||||
|
||||
if not has_approved_review:
|
||||
# Get latest review
|
||||
latest_review = None
|
||||
if submission.reviews:
|
||||
latest_review_obj = max(submission.reviews, key=lambda r: r.reviewed_at)
|
||||
latest_review = {
|
||||
"id": latest_review_obj.id,
|
||||
"submission_id": latest_review_obj.submission_id,
|
||||
"reviewer_id": latest_review_obj.reviewer_id,
|
||||
"decision": latest_review_obj.decision,
|
||||
"feedback": latest_review_obj.feedback,
|
||||
"reviewed_at": latest_review_obj.reviewed_at,
|
||||
"reviewer_first_name": latest_review_obj.reviewer.first_name,
|
||||
"reviewer_last_name": latest_review_obj.reviewer.last_name
|
||||
}
|
||||
|
||||
submission_data = {
|
||||
"id": submission.id,
|
||||
"task_id": submission.task_id,
|
||||
"user_id": submission.user_id,
|
||||
"file_path": submission.file_path,
|
||||
"file_name": submission.file_name,
|
||||
"version_number": submission.version_number,
|
||||
"notes": submission.notes,
|
||||
"submitted_at": submission.submitted_at,
|
||||
"user_first_name": submission.user.first_name,
|
||||
"user_last_name": submission.user.last_name,
|
||||
"latest_review": latest_review
|
||||
}
|
||||
pending_submissions.append(SubmissionResponse(**submission_data))
|
||||
|
||||
return pending_submissions
|
||||
|
||||
|
||||
@router.post("/{submission_id}/approve", response_model=ReviewResponse)
|
||||
async def approve_submission(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Approve a submission. Only directors, coordinators, and users with admin permission can approve."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Check if submission is in submitted state
|
||||
if submission.task.status != "submitted":
|
||||
raise HTTPException(status_code=400, detail="Submission is not in submitted state")
|
||||
|
||||
# Force decision to approved
|
||||
review.decision = "approved"
|
||||
|
||||
# Create review record
|
||||
db_review = Review(
|
||||
submission_id=submission_id,
|
||||
reviewer_id=current_user.id,
|
||||
decision=review.decision,
|
||||
feedback=review.feedback
|
||||
)
|
||||
db.add(db_review)
|
||||
|
||||
# Update task status to approved
|
||||
submission.task.status = "approved"
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_review)
|
||||
|
||||
# Send notification to artist
|
||||
notification_service.notify_submission_reviewed(db, submission, db_review, current_user)
|
||||
|
||||
# Load reviewer information for response
|
||||
db_review = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(Review.id == db_review.id).first()
|
||||
|
||||
review_data = {
|
||||
"id": db_review.id,
|
||||
"submission_id": db_review.submission_id,
|
||||
"reviewer_id": db_review.reviewer_id,
|
||||
"decision": db_review.decision,
|
||||
"feedback": db_review.feedback,
|
||||
"reviewed_at": db_review.reviewed_at,
|
||||
"reviewer_first_name": db_review.reviewer.first_name,
|
||||
"reviewer_last_name": db_review.reviewer.last_name
|
||||
}
|
||||
|
||||
return ReviewResponse(**review_data)
|
||||
|
||||
|
||||
@router.post("/{submission_id}/retake", response_model=ReviewResponse)
|
||||
async def request_retake(
|
||||
submission_id: int,
|
||||
review: ReviewCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_director_coordinator_or_admin)
|
||||
):
|
||||
"""Request a retake for a submission. Only directors, coordinators, and users with admin permission can request retakes."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Check if submission is in submitted state
|
||||
if submission.task.status != "submitted":
|
||||
raise HTTPException(status_code=400, detail="Submission is not in submitted state")
|
||||
|
||||
# Force decision to retake and require feedback
|
||||
review.decision = "retake"
|
||||
if not review.feedback or review.feedback.strip() == "":
|
||||
raise HTTPException(status_code=400, detail="Feedback is required when requesting a retake")
|
||||
|
||||
# Create review record
|
||||
db_review = Review(
|
||||
submission_id=submission_id,
|
||||
reviewer_id=current_user.id,
|
||||
decision=review.decision,
|
||||
feedback=review.feedback
|
||||
)
|
||||
db.add(db_review)
|
||||
|
||||
# Update task status to retake
|
||||
submission.task.status = "retake"
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_review)
|
||||
|
||||
# Send notification to artist
|
||||
notification_service.notify_submission_reviewed(db, submission, db_review, current_user)
|
||||
|
||||
# Load reviewer information for response
|
||||
db_review = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(Review.id == db_review.id).first()
|
||||
|
||||
review_data = {
|
||||
"id": db_review.id,
|
||||
"submission_id": db_review.submission_id,
|
||||
"reviewer_id": db_review.reviewer_id,
|
||||
"decision": db_review.decision,
|
||||
"feedback": db_review.feedback,
|
||||
"reviewed_at": db_review.reviewed_at,
|
||||
"reviewer_first_name": db_review.reviewer.first_name,
|
||||
"reviewer_last_name": db_review.reviewer.last_name
|
||||
}
|
||||
|
||||
return ReviewResponse(**review_data)
|
||||
|
||||
|
||||
@router.get("/{submission_id}/reviews", response_model=List[ReviewResponse])
|
||||
async def get_submission_reviews(
|
||||
submission_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get all reviews for a submission."""
|
||||
|
||||
submission = db.query(Submission).options(
|
||||
joinedload(Submission.task)
|
||||
).filter(
|
||||
Submission.id == submission_id,
|
||||
Submission.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not submission:
|
||||
raise HTTPException(status_code=404, detail="Submission not found")
|
||||
|
||||
# Artists can only view reviews for their own submissions
|
||||
if current_user.role == UserRole.ARTIST and submission.user_id != current_user.id:
|
||||
raise HTTPException(status_code=403, detail="Not authorized to view reviews for this submission")
|
||||
|
||||
reviews = db.query(Review).options(
|
||||
joinedload(Review.reviewer)
|
||||
).filter(
|
||||
Review.submission_id == submission_id,
|
||||
Review.deleted_at.is_(None)
|
||||
).order_by(Review.reviewed_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for review in reviews:
|
||||
review_data = {
|
||||
"id": review.id,
|
||||
"submission_id": review.submission_id,
|
||||
"reviewer_id": review.reviewer_id,
|
||||
"decision": review.decision,
|
||||
"feedback": review.feedback,
|
||||
"reviewed_at": review.reviewed_at,
|
||||
"reviewer_first_name": review.reviewer.first_name,
|
||||
"reviewer_last_name": review.reviewer.last_name
|
||||
}
|
||||
result.append(ReviewResponse(**review_data))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,166 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.global_settings import GlobalSettings
|
||||
from schemas.global_settings import (
|
||||
GlobalSetting,
|
||||
GlobalSettingCreate,
|
||||
GlobalSettingUpdate,
|
||||
UploadLimitResponse,
|
||||
UploadLimitUpdate
|
||||
)
|
||||
from utils.auth import get_current_user, require_admin_permission
|
||||
from models.user import User
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
# Default upload limit in MB (1GB)
|
||||
DEFAULT_UPLOAD_LIMIT_MB = 1000
|
||||
UPLOAD_LIMIT_KEY = "global_upload_limit_mb"
|
||||
|
||||
|
||||
def get_or_create_upload_limit_setting(db: Session) -> GlobalSettings:
|
||||
"""Get or create the upload limit setting with default value"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == UPLOAD_LIMIT_KEY
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
setting = GlobalSettings(
|
||||
setting_key=UPLOAD_LIMIT_KEY,
|
||||
setting_value=str(DEFAULT_UPLOAD_LIMIT_MB),
|
||||
description="Global upload size limit for movie files in MB"
|
||||
)
|
||||
db.add(setting)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
@router.get("/upload-limit", response_model=UploadLimitResponse)
|
||||
async def get_upload_limit(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""Get the global upload size limit for movie files"""
|
||||
setting = get_or_create_upload_limit_setting(db)
|
||||
|
||||
return UploadLimitResponse(
|
||||
upload_limit_mb=int(setting.setting_value),
|
||||
description=setting.description or "Global upload size limit for movie files"
|
||||
)
|
||||
|
||||
|
||||
@router.put("/upload-limit", response_model=UploadLimitResponse)
|
||||
async def update_upload_limit(
|
||||
upload_limit: UploadLimitUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Update the global upload size limit for movie files (admin only)"""
|
||||
setting = get_or_create_upload_limit_setting(db)
|
||||
|
||||
setting.setting_value = str(upload_limit.upload_limit_mb)
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return UploadLimitResponse(
|
||||
upload_limit_mb=int(setting.setting_value),
|
||||
description=setting.description or "Global upload size limit for movie files"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response_model=List[GlobalSetting])
|
||||
async def get_all_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Get all global settings (admin only)"""
|
||||
settings = db.query(GlobalSettings).all()
|
||||
return settings
|
||||
|
||||
|
||||
@router.post("/", response_model=GlobalSetting)
|
||||
async def create_setting(
|
||||
setting: GlobalSettingCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Create a new global setting (admin only)"""
|
||||
# Check if setting already exists
|
||||
existing = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting.setting_key
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Setting with key '{setting.setting_key}' already exists"
|
||||
)
|
||||
|
||||
db_setting = GlobalSettings(**setting.dict())
|
||||
db.add(db_setting)
|
||||
db.commit()
|
||||
db.refresh(db_setting)
|
||||
|
||||
return db_setting
|
||||
|
||||
|
||||
@router.put("/{setting_key}", response_model=GlobalSetting)
|
||||
async def update_setting(
|
||||
setting_key: str,
|
||||
setting_update: GlobalSettingUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Update a specific global setting (admin only)"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting_key
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Setting with key '{setting_key}' not found"
|
||||
)
|
||||
|
||||
for field, value in setting_update.dict(exclude_unset=True).items():
|
||||
setattr(setting, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(setting)
|
||||
|
||||
return setting
|
||||
|
||||
|
||||
@router.delete("/{setting_key}")
|
||||
async def delete_setting(
|
||||
setting_key: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_admin_permission)
|
||||
):
|
||||
"""Delete a global setting (admin only)"""
|
||||
setting = db.query(GlobalSettings).filter(
|
||||
GlobalSettings.setting_key == setting_key
|
||||
).first()
|
||||
|
||||
if not setting:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Setting with key '{setting_key}' not found"
|
||||
)
|
||||
|
||||
# Prevent deletion of critical settings
|
||||
if setting_key == UPLOAD_LIMIT_KEY:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete the global upload limit setting"
|
||||
)
|
||||
|
||||
db.delete(setting)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Setting '{setting_key}' deleted successfully"}
|
||||
@@ -0,0 +1,858 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
|
||||
from database import get_db
|
||||
from models.shot import Shot
|
||||
from models.episode import Episode
|
||||
from models.project import Project, ProjectMember
|
||||
from models.task import Task, TaskType, TaskStatus
|
||||
from models.user import User, UserRole
|
||||
from schemas.shot import (
|
||||
ShotCreate, ShotUpdate, ShotResponse, ShotListResponse,
|
||||
BulkShotCreate, BulkShotResponse, TaskStatusInfo
|
||||
)
|
||||
from utils.auth import get_current_user_from_token
|
||||
from services.shot_soft_deletion import ShotSoftDeletionService
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def get_current_user_with_db(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get current user with proper database dependency."""
|
||||
from utils.auth import _get_user_from_db
|
||||
return _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
|
||||
def require_coordinator_or_admin(
|
||||
token_data: dict = Depends(get_current_user_from_token),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Require coordinator or admin role."""
|
||||
from utils.auth import _get_user_from_db
|
||||
current_user = _get_user_from_db(db, token_data["user_id"])
|
||||
|
||||
if current_user.role != UserRole.COORDINATOR and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient permissions"
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def check_episode_access(episode_id: int, current_user: User, db: Session):
|
||||
"""Check if user has access to the episode and its project."""
|
||||
# Debug logging
|
||||
print(f"[DEBUG] check_episode_access called")
|
||||
print(f"[DEBUG] User: {current_user.email}, Role: {current_user.role}, is_admin: {current_user.is_admin}")
|
||||
|
||||
# Check if episode exists
|
||||
episode = db.query(Episode).filter(Episode.id == episode_id).first()
|
||||
if not episode:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Episode not found"
|
||||
)
|
||||
|
||||
# Admins and coordinators have access to all episodes
|
||||
if current_user.is_admin or current_user.role == UserRole.COORDINATOR:
|
||||
print(f"[DEBUG] Access GRANTED - Admin or Coordinator")
|
||||
return episode
|
||||
|
||||
# Check project access for artists and other roles
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
print(f"[DEBUG] Checking project membership for artist")
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == episode.project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
print(f"[DEBUG] Access DENIED - Not a project member")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
print(f"[DEBUG] Access GRANTED - Project member")
|
||||
|
||||
return episode
|
||||
|
||||
|
||||
def get_status_sort_order(status: str, project_custom_statuses: list = None) -> int:
|
||||
"""Get sort order for task status, including custom statuses."""
|
||||
# Default system status order
|
||||
system_status_order = {
|
||||
"not_started": 0,
|
||||
"in_progress": 1,
|
||||
"submitted": 2,
|
||||
"retake": 3,
|
||||
"approved": 4
|
||||
}
|
||||
|
||||
# If it's a system status, return its order
|
||||
if status in system_status_order:
|
||||
return system_status_order[status]
|
||||
|
||||
# For custom statuses, use their defined order + offset to place them after system statuses
|
||||
if project_custom_statuses:
|
||||
for custom_status in project_custom_statuses:
|
||||
if isinstance(custom_status, dict) and custom_status.get('id') == status:
|
||||
# Custom statuses start after system statuses (5+)
|
||||
return 5 + custom_status.get('order', 0)
|
||||
|
||||
# Unknown status defaults to 0 (same as not_started)
|
||||
return 0
|
||||
|
||||
|
||||
def get_project_custom_statuses(project_id: int, db: Session) -> list:
|
||||
"""Get custom task statuses for a project."""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project or not project.custom_task_statuses:
|
||||
return []
|
||||
|
||||
custom_statuses_data = project.custom_task_statuses
|
||||
if isinstance(custom_statuses_data, str):
|
||||
try:
|
||||
import json
|
||||
custom_statuses_data = json.loads(custom_statuses_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return []
|
||||
|
||||
return custom_statuses_data if isinstance(custom_statuses_data, list) else []
|
||||
|
||||
|
||||
# Standard shot task types (read-only)
|
||||
STANDARD_SHOT_TASK_TYPES = ["layout", "animation", "simulation", "lighting", "compositing"]
|
||||
|
||||
|
||||
def get_default_shot_task_types():
|
||||
"""Get default task types for shots."""
|
||||
return [
|
||||
TaskType.LAYOUT.value,
|
||||
TaskType.ANIMATION.value,
|
||||
TaskType.LIGHTING.value,
|
||||
TaskType.COMPOSITING.value
|
||||
]
|
||||
|
||||
|
||||
def get_all_shot_task_types(project_id: int, db: Session) -> List[str]:
|
||||
"""Get all task types (standard + custom) for shots in a project."""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
if not project:
|
||||
return STANDARD_SHOT_TASK_TYPES
|
||||
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
return STANDARD_SHOT_TASK_TYPES + custom_types
|
||||
|
||||
|
||||
def create_default_tasks_for_shot(shot: Shot, task_types: List[str], db: Session):
|
||||
"""Create default tasks for a shot."""
|
||||
created_tasks = []
|
||||
|
||||
for task_type in task_types:
|
||||
task_name = f"{shot.name}_{task_type}"
|
||||
task_description = f"{task_type.title()} task for shot {shot.name}"
|
||||
|
||||
task = Task(
|
||||
project_id=shot.project_id,
|
||||
episode_id=shot.episode_id,
|
||||
shot_id=shot.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=task_description
|
||||
)
|
||||
|
||||
db.add(task)
|
||||
created_tasks.append(task)
|
||||
|
||||
return created_tasks
|
||||
|
||||
|
||||
@router.get("/", response_model=List[ShotListResponse])
|
||||
async def list_shots(
|
||||
episode_id: int = None,
|
||||
project_id: int = None,
|
||||
task_status_filter: str = None,
|
||||
sort_by: str = None,
|
||||
sort_direction: str = "asc",
|
||||
skip: int = 0,
|
||||
limit: int = 100,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""List shots with optional filtering by episode, project and task status"""
|
||||
from sqlalchemy import func, case
|
||||
from sqlalchemy.orm import selectinload, joinedload
|
||||
|
||||
# Build base query for shots (exclude soft deleted)
|
||||
base_query = db.query(Shot).filter(Shot.deleted_at.is_(None))
|
||||
|
||||
# Filter by project_id if specified
|
||||
if project_id:
|
||||
# Check project access for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member and not current_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
base_query = base_query.filter(Shot.project_id == project_id)
|
||||
|
||||
# Filter by episode if specified
|
||||
if episode_id:
|
||||
check_episode_access(episode_id, current_user, db)
|
||||
base_query = base_query.filter(Shot.episode_id == episode_id)
|
||||
elif not project_id:
|
||||
# If no episode or project specified, filter by user's accessible projects for artists
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
accessible_projects = db.query(ProjectMember.project_id).filter(
|
||||
ProjectMember.user_id == current_user.id
|
||||
).subquery()
|
||||
base_query = base_query.filter(Shot.project_id.in_(accessible_projects))
|
||||
|
||||
# Apply sorting if specified (for non-task-status fields)
|
||||
if sort_by and not sort_by.endswith('_status'):
|
||||
if sort_by in ['name', 'status', 'frame_start', 'frame_end', 'created_at', 'updated_at']:
|
||||
sort_column = getattr(Shot, sort_by)
|
||||
if sort_direction.lower() == 'desc':
|
||||
base_query = base_query.order_by(sort_column.desc())
|
||||
else:
|
||||
base_query = base_query.order_by(sort_column.asc())
|
||||
|
||||
# OPTIMIZATION: Use single query with optimized JOIN to fetch shots and their tasks
|
||||
# This replaces the N+1 query pattern with a single database operation
|
||||
shots_with_tasks = (
|
||||
base_query
|
||||
.outerjoin(Task, (Task.shot_id == Shot.id) & (Task.deleted_at.is_(None)))
|
||||
.options(
|
||||
joinedload(Shot.episode).joinedload(Episode.project), # Eager load episode and project
|
||||
selectinload(Shot.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users
|
||||
)
|
||||
)
|
||||
.add_columns(
|
||||
Task.id.label('task_id'),
|
||||
Task.task_type,
|
||||
Task.status.label('task_status'),
|
||||
Task.assigned_user_id,
|
||||
Task.updated_at.label('task_updated_at') # Include task update time for better tracking
|
||||
)
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Pre-fetch all project data and task types in a single query
|
||||
# This eliminates the need for repeated project queries
|
||||
project_ids = set()
|
||||
for row in shots_with_tasks:
|
||||
shot = row[0]
|
||||
if shot.project_id not in project_ids:
|
||||
project_ids.add(shot.project_id)
|
||||
|
||||
# Get all projects with their custom task types in one optimized query
|
||||
project_data = {}
|
||||
if project_ids:
|
||||
projects = (
|
||||
db.query(Project)
|
||||
.filter(Project.id.in_(project_ids))
|
||||
.all()
|
||||
)
|
||||
for project in projects:
|
||||
custom_types = project.custom_shot_task_types or []
|
||||
project_data[project.id] = {
|
||||
'task_types': STANDARD_SHOT_TASK_TYPES + custom_types,
|
||||
'custom_statuses': get_project_custom_statuses(project.id, db)
|
||||
}
|
||||
|
||||
# OPTIMIZATION: Group results by shot and aggregate task data efficiently
|
||||
shots_dict = {}
|
||||
for row in shots_with_tasks:
|
||||
shot = row[0] # Shot object
|
||||
task_id = row[1] # task_id
|
||||
task_type = row[2] # task_type
|
||||
task_status = row[3] # task_status
|
||||
assigned_user_id = row[4] # assigned_user_id
|
||||
task_updated_at = row[5] # task_updated_at
|
||||
|
||||
if shot.id not in shots_dict:
|
||||
# Initialize shot data with pre-fetched project data
|
||||
project_info = project_data.get(shot.project_id, {
|
||||
'task_types': STANDARD_SHOT_TASK_TYPES,
|
||||
'custom_statuses': []
|
||||
})
|
||||
|
||||
shots_dict[shot.id] = {
|
||||
'shot': shot,
|
||||
'tasks': [],
|
||||
'task_status': {},
|
||||
'task_details': [],
|
||||
'project_info': project_info
|
||||
}
|
||||
|
||||
# Initialize all task types as not started using pre-fetched data
|
||||
for task_type_init in project_info['task_types']:
|
||||
shots_dict[shot.id]['task_status'][task_type_init] = "not_started"
|
||||
|
||||
# Add task data if task exists
|
||||
if task_id is not None:
|
||||
shots_dict[shot.id]['tasks'].append({
|
||||
'task_id': task_id,
|
||||
'task_type': task_type,
|
||||
'status': task_status,
|
||||
'assigned_user_id': assigned_user_id,
|
||||
'updated_at': task_updated_at
|
||||
})
|
||||
|
||||
# Update task status
|
||||
shots_dict[shot.id]['task_status'][task_type] = task_status
|
||||
|
||||
# Add to task details with enhanced information
|
||||
shots_dict[shot.id]['task_details'].append(TaskStatusInfo(
|
||||
task_type=task_type,
|
||||
status=task_status,
|
||||
task_id=task_id,
|
||||
assigned_user_id=assigned_user_id
|
||||
))
|
||||
|
||||
# Build response list efficiently
|
||||
result = []
|
||||
for shot_data in shots_dict.values():
|
||||
shot = shot_data['shot']
|
||||
|
||||
# Create shot response with optimized data
|
||||
shot_response = ShotListResponse.model_validate(shot)
|
||||
shot_response.task_count = len(shot_data['tasks'])
|
||||
shot_response.task_status = shot_data['task_status']
|
||||
shot_response.task_details = shot_data['task_details']
|
||||
|
||||
result.append(shot_response)
|
||||
|
||||
# Apply task status filtering if specified
|
||||
if task_status_filter:
|
||||
try:
|
||||
# Parse task status filter (format: "task_type:status")
|
||||
task_type, status = task_status_filter.split(":")
|
||||
filter_status = status # Use string directly instead of enum
|
||||
result = [
|
||||
shot for shot in result
|
||||
if shot.task_status.get(task_type) == filter_status
|
||||
]
|
||||
except (ValueError, KeyError):
|
||||
# Invalid filter format, ignore
|
||||
pass
|
||||
|
||||
# Apply task status sorting if specified using pre-fetched custom status data
|
||||
if sort_by and sort_by.endswith('_status'):
|
||||
task_type = sort_by.replace('_status', '')
|
||||
|
||||
def get_status_order(shot):
|
||||
status = shot.task_status.get(task_type, "not_started")
|
||||
# Use pre-fetched custom statuses from shots_dict
|
||||
shot_id = shot.id
|
||||
if shot_id in shots_dict:
|
||||
custom_statuses = shots_dict[shot_id]['project_info']['custom_statuses']
|
||||
return get_status_sort_order(status, custom_statuses)
|
||||
return get_status_sort_order(status, [])
|
||||
|
||||
reverse = sort_direction.lower() == 'desc'
|
||||
result.sort(key=get_status_order, reverse=reverse)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response_model=ShotResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shot(
|
||||
shot: ShotCreate,
|
||||
episode_id: int,
|
||||
create_default_tasks: bool = True,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new shot in an episode"""
|
||||
# Check episode access
|
||||
episode = check_episode_access(episode_id, current_user, db)
|
||||
|
||||
# Auto-populate project_id from episode if not provided
|
||||
if shot.project_id is None:
|
||||
shot.project_id = episode.project_id
|
||||
else:
|
||||
# Validate that provided project_id matches episode's project
|
||||
if shot.project_id != episode.project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Project ID must match the episode's project"
|
||||
)
|
||||
|
||||
# Validate frame range
|
||||
if shot.frame_end < shot.frame_start:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Frame end must be greater than or equal to frame start"
|
||||
)
|
||||
|
||||
# Check if shot name already exists in project (project-scoped uniqueness)
|
||||
existing_shot = db.query(Shot).filter(
|
||||
Shot.project_id == shot.project_id,
|
||||
Shot.name == shot.name,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Shot with this name already exists in the project"
|
||||
)
|
||||
|
||||
# Create new shot
|
||||
db_shot = Shot(
|
||||
episode_id=episode_id,
|
||||
project_id=shot.project_id,
|
||||
**shot.model_dump(exclude={'project_id'})
|
||||
)
|
||||
|
||||
db.add(db_shot)
|
||||
db.commit()
|
||||
db.refresh(db_shot)
|
||||
|
||||
# Create default tasks if requested
|
||||
task_count = 0
|
||||
if create_default_tasks:
|
||||
# Get all task types (standard + custom) for this project
|
||||
all_task_types = get_all_shot_task_types(episode.project_id, db)
|
||||
# Use default standard types for now (can be customized via project settings)
|
||||
default_task_types = get_default_shot_task_types()
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, default_task_types, db)
|
||||
db.commit()
|
||||
task_count = len(created_tasks)
|
||||
|
||||
# Return response with task count
|
||||
shot_data = ShotResponse.model_validate(db_shot)
|
||||
shot_data.task_count = task_count
|
||||
|
||||
return shot_data
|
||||
|
||||
|
||||
@router.post("/bulk", response_model=BulkShotResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shots_bulk(
|
||||
bulk_shot: BulkShotCreate,
|
||||
episode_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create multiple shots with naming pattern and default tasks"""
|
||||
# Check episode access
|
||||
episode = check_episode_access(episode_id, current_user, db)
|
||||
|
||||
# Auto-populate project_id from episode - all shots in bulk operation belong to same project
|
||||
project_id = episode.project_id
|
||||
|
||||
# Validate frame range
|
||||
if bulk_shot.frame_end < bulk_shot.frame_start:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Frame end must be greater than or equal to frame start"
|
||||
)
|
||||
|
||||
# Determine task types to create
|
||||
if bulk_shot.task_types:
|
||||
# Validate that all selected task types are valid (standard or custom)
|
||||
all_valid_types = get_all_shot_task_types(episode.project_id, db)
|
||||
invalid_types = [t for t in bulk_shot.task_types if t not in all_valid_types]
|
||||
if invalid_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid task types: {', '.join(invalid_types)}"
|
||||
)
|
||||
task_types = bulk_shot.task_types
|
||||
else:
|
||||
task_types = get_default_shot_task_types()
|
||||
|
||||
# Pre-validate all shot names for project-scoped uniqueness before creating any shots
|
||||
shot_names_to_create = []
|
||||
for i in range(bulk_shot.shot_count):
|
||||
shot_number = bulk_shot.start_number + i
|
||||
shot_name = f"{bulk_shot.name_prefix}{shot_number:0{bulk_shot.number_padding}d}"
|
||||
shot_names_to_create.append(shot_name)
|
||||
|
||||
# Check for existing shots with any of the names in this project
|
||||
existing_shots = db.query(Shot.name).filter(
|
||||
Shot.project_id == project_id,
|
||||
Shot.name.in_(shot_names_to_create),
|
||||
Shot.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
if existing_shots:
|
||||
existing_names = [shot.name for shot in existing_shots]
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"The following shot names already exist in project {project_id}: {', '.join(existing_names)}"
|
||||
)
|
||||
|
||||
# Check for duplicate names within the bulk creation itself
|
||||
if len(shot_names_to_create) != len(set(shot_names_to_create)):
|
||||
duplicates = [name for name in shot_names_to_create if shot_names_to_create.count(name) > 1]
|
||||
unique_duplicates = list(set(duplicates))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Duplicate shot names in bulk creation: {', '.join(unique_duplicates)}"
|
||||
)
|
||||
|
||||
created_shots = []
|
||||
total_tasks_created = 0
|
||||
|
||||
try:
|
||||
# Create all shots - validation already done above
|
||||
for i, shot_name in enumerate(shot_names_to_create):
|
||||
shot_number = bulk_shot.start_number + i
|
||||
|
||||
# Create description from template
|
||||
description = None
|
||||
if bulk_shot.description_template:
|
||||
description = bulk_shot.description_template.replace("{shot_name}", shot_name).replace("{shot_number}", str(shot_number))
|
||||
|
||||
# Create shot - project consistency guaranteed by using episode's project_id
|
||||
db_shot = Shot(
|
||||
project_id=project_id,
|
||||
episode_id=episode_id,
|
||||
name=shot_name,
|
||||
description=description,
|
||||
frame_start=bulk_shot.frame_start,
|
||||
frame_end=bulk_shot.frame_end
|
||||
)
|
||||
|
||||
db.add(db_shot)
|
||||
db.flush() # Flush to get the shot ID
|
||||
|
||||
# Create default tasks if requested
|
||||
task_count = 0
|
||||
if bulk_shot.create_default_tasks:
|
||||
created_tasks = create_default_tasks_for_shot(db_shot, task_types, db)
|
||||
task_count = len(created_tasks)
|
||||
total_tasks_created += task_count
|
||||
|
||||
# Add to response list
|
||||
shot_data = ShotResponse.model_validate(db_shot)
|
||||
shot_data.task_count = task_count
|
||||
created_shots.append(shot_data)
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
|
||||
message = f"Successfully created {len(created_shots)} shots in project {project_id}"
|
||||
if total_tasks_created > 0:
|
||||
message += f" with {total_tasks_created} tasks"
|
||||
|
||||
return BulkShotResponse(
|
||||
created_shots=created_shots,
|
||||
created_tasks_count=total_tasks_created,
|
||||
message=message
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
if isinstance(e, HTTPException):
|
||||
raise e
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error creating shots: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{shot_id}", response_model=ShotResponse)
|
||||
async def get_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get a specific shot by ID"""
|
||||
from sqlalchemy.orm import joinedload, selectinload
|
||||
|
||||
# OPTIMIZATION: Use single query with optimized JOINs to fetch shot and all related data
|
||||
# This replaces separate queries with a single database operation
|
||||
shot_query = (
|
||||
db.query(Shot)
|
||||
.options(
|
||||
joinedload(Shot.episode).joinedload(Episode.project), # Eager load episode and project
|
||||
selectinload(Shot.tasks).options( # Use selectinload for better performance with tasks
|
||||
selectinload(Task.assigned_user) # Eager load assigned users if needed
|
||||
)
|
||||
)
|
||||
.filter(Shot.id == shot_id, Shot.deleted_at.is_(None))
|
||||
)
|
||||
|
||||
shot = shot_query.first()
|
||||
|
||||
if not shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
check_episode_access(shot.episode_id, current_user, db)
|
||||
|
||||
# OPTIMIZATION: Count tasks from the already loaded relationship
|
||||
# This avoids a separate COUNT query
|
||||
active_tasks = [task for task in shot.tasks if task.deleted_at is None]
|
||||
task_count = len(active_tasks)
|
||||
|
||||
shot_data = ShotResponse.model_validate(shot)
|
||||
shot_data.task_count = task_count
|
||||
|
||||
return shot_data
|
||||
|
||||
|
||||
@router.post("/{shot_id}/tasks", response_model=TaskStatusInfo, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shot_task(
|
||||
shot_id: int,
|
||||
task_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Create a new task for a shot"""
|
||||
# Exclude soft deleted shots
|
||||
shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
episode = check_episode_access(shot.episode_id, current_user, db)
|
||||
|
||||
# Check if task already exists (exclude soft deleted)
|
||||
existing_task = db.query(Task).filter(
|
||||
Task.shot_id == shot_id,
|
||||
Task.task_type == task_type,
|
||||
Task.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_task:
|
||||
# Return existing task info instead of error
|
||||
return TaskStatusInfo(
|
||||
task_type=existing_task.task_type,
|
||||
status=existing_task.status,
|
||||
task_id=existing_task.id,
|
||||
assigned_user_id=existing_task.assigned_user_id
|
||||
)
|
||||
|
||||
# Create the task
|
||||
task_name = f"{shot.name} - {task_type.title()}"
|
||||
db_task = Task(
|
||||
project_id=shot.project_id,
|
||||
episode_id=shot.episode_id,
|
||||
shot_id=shot.id,
|
||||
task_type=task_type,
|
||||
name=task_name,
|
||||
description=f"{task_type.title()} task for {shot.name}",
|
||||
status="not_started"
|
||||
)
|
||||
|
||||
db.add(db_task)
|
||||
db.commit()
|
||||
db.refresh(db_task)
|
||||
|
||||
return TaskStatusInfo(
|
||||
task_type=db_task.task_type,
|
||||
status=db_task.status,
|
||||
task_id=db_task.id,
|
||||
assigned_user_id=db_task.assigned_user_id
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{shot_id}", response_model=ShotResponse)
|
||||
async def update_shot(
|
||||
shot_id: int,
|
||||
shot_update: ShotUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Update a shot"""
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
# OPTIMIZATION: Use eager loading to fetch shot with tasks in single query
|
||||
db_shot = (
|
||||
db.query(Shot)
|
||||
.options(selectinload(Shot.tasks)) # Eager load tasks for counting
|
||||
.filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not db_shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
check_episode_access(db_shot.episode_id, current_user, db)
|
||||
|
||||
# Validate project_id if provided
|
||||
if shot_update.project_id is not None:
|
||||
if shot_update.project_id != db_shot.project_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot change project_id - must match episode's project"
|
||||
)
|
||||
|
||||
# Validate frame range if both values are provided
|
||||
frame_start = shot_update.frame_start if shot_update.frame_start is not None else db_shot.frame_start
|
||||
frame_end = shot_update.frame_end if shot_update.frame_end is not None else db_shot.frame_end
|
||||
|
||||
if frame_end < frame_start:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Frame end must be greater than or equal to frame start"
|
||||
)
|
||||
|
||||
# Check if new name conflicts with existing shots in the same project (project-scoped uniqueness)
|
||||
if shot_update.name and shot_update.name != db_shot.name:
|
||||
existing_shot = db.query(Shot).filter(
|
||||
Shot.project_id == db_shot.project_id,
|
||||
Shot.name == shot_update.name,
|
||||
Shot.id != shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if existing_shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Shot with this name already exists in the project"
|
||||
)
|
||||
|
||||
# Update only provided fields
|
||||
update_data = shot_update.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(db_shot, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_shot)
|
||||
|
||||
# OPTIMIZATION: Count tasks using the relationship instead of separate query
|
||||
# This avoids an additional database query
|
||||
active_tasks = [task for task in db_shot.tasks if task.deleted_at is None]
|
||||
task_count = len(active_tasks)
|
||||
|
||||
shot_data = ShotResponse.model_validate(db_shot)
|
||||
shot_data.task_count = task_count
|
||||
|
||||
return shot_data
|
||||
|
||||
|
||||
@router.get("/{shot_id}/deletion-info")
|
||||
async def get_shot_deletion_info(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Get information about what will be deleted when deleting a shot"""
|
||||
# Exclude soft deleted shots
|
||||
db_shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not db_shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
check_episode_access(db_shot.episode_id, current_user, db)
|
||||
|
||||
# Use the soft deletion service to get comprehensive deletion info
|
||||
deletion_service = ShotSoftDeletionService()
|
||||
deletion_info = deletion_service.get_deletion_info(shot_id, db)
|
||||
|
||||
if not deletion_info:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
return {
|
||||
"shot_id": deletion_info.shot_id,
|
||||
"shot_name": deletion_info.shot_name,
|
||||
"episode_name": deletion_info.episode_name,
|
||||
"project_name": deletion_info.project_name,
|
||||
"task_count": deletion_info.task_count,
|
||||
"submission_count": deletion_info.submission_count,
|
||||
"attachment_count": deletion_info.attachment_count,
|
||||
"note_count": deletion_info.note_count,
|
||||
"review_count": deletion_info.review_count,
|
||||
"total_file_size": deletion_info.total_file_size,
|
||||
"file_count": deletion_info.file_count,
|
||||
"affected_users": deletion_info.affected_users,
|
||||
"last_activity_date": deletion_info.last_activity_date,
|
||||
"created_at": deletion_info.created_at
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/{shot_id}")
|
||||
async def delete_shot(
|
||||
shot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Soft delete a shot and all its associated data"""
|
||||
# Exclude soft deleted shots
|
||||
db_shot = db.query(Shot).filter(
|
||||
Shot.id == shot_id,
|
||||
Shot.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
if not db_shot:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Shot not found"
|
||||
)
|
||||
|
||||
# Check episode access
|
||||
check_episode_access(db_shot.episode_id, current_user, db)
|
||||
|
||||
# Use the soft deletion service to perform cascading soft deletion
|
||||
deletion_service = ShotSoftDeletionService()
|
||||
result = deletion_service.soft_delete_shot_cascade(shot_id, db, current_user)
|
||||
|
||||
if not result.success:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail={
|
||||
"message": "Failed to delete shot",
|
||||
"errors": result.errors
|
||||
}
|
||||
)
|
||||
|
||||
# Commit the transaction
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": f"Shot '{result.shot_name}' and all related data have been deleted",
|
||||
"shot_id": result.shot_id,
|
||||
"shot_name": result.shot_name,
|
||||
"deleted_at": result.deleted_at,
|
||||
"deleted_by": result.deleted_by,
|
||||
"marked_deleted_tasks": result.marked_deleted_tasks,
|
||||
"marked_deleted_submissions": result.marked_deleted_submissions,
|
||||
"marked_deleted_attachments": result.marked_deleted_attachments,
|
||||
"marked_deleted_notes": result.marked_deleted_notes,
|
||||
"marked_deleted_reviews": result.marked_deleted_reviews,
|
||||
"operation_duration": result.operation_duration
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user