#!/usr/bin/env python3 """ Test script to verify custom status support across multiple projects with different configurations. This test ensures that: 1. Projects with no custom statuses work correctly 2. Projects with different custom status sets work independently 3. Mixed project queries handle custom statuses correctly 4. Default status fallback works when custom statuses are missing """ import sys import os sys.path.append(os.path.dirname(os.path.abspath(__file__))) import json from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from database import Base from models.user import User, UserRole from models.project import Project, ProjectType, ProjectStatus, ProjectMember from models.task import Task from models.asset import Asset, AssetCategory, AssetStatus from models.episode import Episode, EpisodeStatus from models.shot import Shot, ShotStatus # Create test database TEST_DB = "test_multi_project_custom.db" if os.path.exists(TEST_DB): try: os.remove(TEST_DB) except PermissionError: pass # File in use, continue anyway engine = create_engine(f"sqlite:///{TEST_DB}") Base.metadata.create_all(engine) SessionLocal = sessionmaker(bind=engine) def setup_multi_project_data(): """Set up test data with multiple projects having different custom status configurations.""" print("Setting up multi-project test data...") db = SessionLocal() try: # Create test user user = User( email="coordinator@test.com", password_hash="test", first_name="Test", last_name="Coordinator", role=UserRole.COORDINATOR, is_approved=True ) db.add(user) db.commit() # Project 1: No custom statuses (uses only default statuses) project1 = Project( name="Default Status Project", code_name="DSP", client_name="Client 1", project_type=ProjectType.CINEMA, status=ProjectStatus.IN_PROGRESS, custom_task_statuses=json.dumps([]) # Empty custom statuses ) db.add(project1) # Project 2: Custom statuses with different names project2_custom = [ { "id": "review_needed", "name": "Review Needed", "color": "#FFA500", "order": 0, "is_default": False }, { "id": "client_approved", "name": "Client Approved", "color": "#32CD32", "order": 1, "is_default": True } ] project2 = Project( name="Custom Status Project", code_name="CSP", client_name="Client 2", project_type=ProjectType.TV, status=ProjectStatus.IN_PROGRESS, custom_task_statuses=json.dumps(project2_custom) ) db.add(project2) # Project 3: Different custom statuses project3_custom = [ { "id": "waiting_assets", "name": "Waiting for Assets", "color": "#FFD700", "order": 0, "is_default": False }, { "id": "final_review", "name": "Final Review", "color": "#9370DB", "order": 1, "is_default": False } ] project3 = Project( name="Different Custom Project", code_name="DCP", client_name="Client 3", project_type=ProjectType.CINEMA, status=ProjectStatus.IN_PROGRESS, custom_task_statuses=json.dumps(project3_custom) ) db.add(project3) db.commit() # Add user as member of all projects for project in [project1, project2, project3]: member = ProjectMember( user_id=user.id, project_id=project.id ) db.add(member) db.commit() # Create episodes for each project episodes = [] for i, project in enumerate([project1, project2, project3], 1): episode = Episode( project_id=project.id, name=f"Episode {i}", episode_number=i, status=EpisodeStatus.IN_PROGRESS ) db.add(episode) episodes.append(episode) db.commit() # Create shots with different status patterns for each project # Project 1: Only default statuses shot1 = Shot( project_id=project1.id, episode_id=episodes[0].id, name="default_shot_001", frame_start=1001, frame_end=1100, status=ShotStatus.IN_PROGRESS ) db.add(shot1) db.flush() # Tasks with only default statuses for task_type, status in [("layout", "not_started"), ("animation", "in_progress"), ("lighting", "approved")]: task = Task( project_id=project1.id, episode_id=episodes[0].id, shot_id=shot1.id, name=f"{shot1.name}_{task_type}", task_type=task_type, status=status ) db.add(task) # Project 2: Mix of default and custom statuses shot2 = Shot( project_id=project2.id, episode_id=episodes[1].id, name="custom_shot_001", frame_start=1001, frame_end=1100, status=ShotStatus.IN_PROGRESS ) db.add(shot2) db.flush() # Tasks with mix of default and custom statuses for task_type, status in [("layout", "review_needed"), ("animation", "submitted"), ("lighting", "client_approved")]: task = Task( project_id=project2.id, episode_id=episodes[1].id, shot_id=shot2.id, name=f"{shot2.name}_{task_type}", task_type=task_type, status=status ) db.add(task) # Project 3: Different custom statuses shot3 = Shot( project_id=project3.id, episode_id=episodes[2].id, name="different_shot_001", frame_start=1001, frame_end=1100, status=ShotStatus.IN_PROGRESS ) db.add(shot3) db.flush() # Tasks with different custom statuses for task_type, status in [("layout", "waiting_assets"), ("animation", "final_review"), ("lighting", "retake")]: task = Task( project_id=project3.id, episode_id=episodes[2].id, shot_id=shot3.id, name=f"{shot3.name}_{task_type}", task_type=task_type, status=status ) db.add(task) # Create assets for each project with different status patterns # Project 1 asset: Only default statuses asset1 = Asset( project_id=project1.id, name="default_character", category=AssetCategory.CHARACTERS, status=AssetStatus.IN_PROGRESS ) db.add(asset1) db.flush() for task_type, status in [("modeling", "not_started"), ("surfacing", "in_progress")]: task = Task( project_id=project1.id, asset_id=asset1.id, name=f"{asset1.name}_{task_type}", task_type=task_type, status=status ) db.add(task) # Project 2 asset: Custom statuses asset2 = Asset( project_id=project2.id, name="custom_prop", category=AssetCategory.PROPS, status=AssetStatus.IN_PROGRESS ) db.add(asset2) db.flush() for task_type, status in [("modeling", "review_needed"), ("surfacing", "client_approved")]: task = Task( project_id=project2.id, asset_id=asset2.id, name=f"{asset2.name}_{task_type}", task_type=task_type, status=status ) db.add(task) # Project 3 asset: Different custom statuses asset3 = Asset( project_id=project3.id, name="different_set", category=AssetCategory.SETS, status=AssetStatus.IN_PROGRESS ) db.add(asset3) db.flush() for task_type, status in [("modeling", "waiting_assets"), ("surfacing", "final_review")]: task = Task( project_id=project3.id, asset_id=asset3.id, name=f"{asset3.name}_{task_type}", task_type=task_type, status=status ) db.add(task) db.commit() print("✅ Multi-project test data setup complete") return user, [project1, project2, project3], episodes except Exception as e: db.rollback() print(f"❌ Failed to setup multi-project test data: {e}") raise finally: db.close() def test_project_isolation(): """Test that custom statuses are properly isolated between projects.""" print("\n=== Test 1: Project Custom Status Isolation ===") db = SessionLocal() try: user = db.query(User).first() projects = db.query(Project).all() from routers.shots import list_shots import asyncio # Test each project individually for project in projects: episode = db.query(Episode).filter(Episode.project_id == project.id).first() async def get_project_shots(): return await list_shots( episode_id=episode.id, project_id=project.id, task_status_filter=None, sort_by=None, sort_direction="asc", skip=0, limit=100, db=db, current_user=user ) shots = asyncio.run(get_project_shots()) print(f"\nProject: {project.name} (ID: {project.id})") print(f" Custom statuses: {json.loads(project.custom_task_statuses or '[]')}") for shot in shots: print(f" Shot: {shot.name}") print(f" Task statuses: {shot.task_status}") # Verify that only appropriate statuses are present for task_type, status in shot.task_status.items(): if status.startswith('custom_') or status in ['review_needed', 'client_approved', 'waiting_assets', 'final_review']: # This is a custom status, verify it belongs to this project project_custom_statuses = json.loads(project.custom_task_statuses or '[]') custom_status_ids = [cs['id'] for cs in project_custom_statuses] if status not in custom_status_ids and status not in ['not_started', 'in_progress', 'submitted', 'approved', 'retake']: # This might be a status from another project - this would be an error print(f" ⚠️ Status '{status}' not found in project custom statuses: {custom_status_ids}") else: print(f" ✅ Status '{status}' correctly belongs to this project") print("✅ Test 1 PASSED: Project custom status isolation works correctly") return True except Exception as e: print(f"❌ Test 1 FAILED: {e}") import traceback traceback.print_exc() return False finally: db.close() def test_mixed_project_queries(): """Test queries that span multiple projects with different custom statuses.""" print("\n=== Test 2: Mixed Project Queries ===") db = SessionLocal() try: user = db.query(User).first() from routers.shots import list_shots from routers.assets import list_assets import asyncio # Test shots across all projects (no project filter) async def get_all_shots(): return await list_shots( episode_id=None, project_id=None, # No project filter - should get all accessible shots task_status_filter=None, sort_by=None, sort_direction="asc", skip=0, limit=100, db=db, current_user=user ) all_shots = asyncio.run(get_all_shots()) print(f"✅ Retrieved {len(all_shots)} shots across all projects") # Verify that each shot has appropriate statuses for its project for shot in all_shots: print(f"\nShot: {shot.name}") print(f" Task statuses: {shot.task_status}") # Get the project for this shot to verify statuses shot_project = db.query(Project).filter(Project.id == shot.project_id).first() project_custom_statuses = json.loads(shot_project.custom_task_statuses or '[]') custom_status_ids = [cs['id'] for cs in project_custom_statuses] for task_type, status in shot.task_status.items(): if status not in ['not_started', 'in_progress', 'submitted', 'approved', 'retake']: # This is a custom status, verify it belongs to the shot's project assert status in custom_status_ids, f"Custom status '{status}' not found in project {shot_project.name} custom statuses" print(f" ✅ Custom status '{status}' correctly belongs to project {shot_project.name}") # Test assets across all projects async def get_all_assets(): return await list_assets( project_id=None, # No project filter category=None, task_status_filter=None, sort_by=None, sort_direction="asc", skip=0, limit=100, db=db, current_user=user ) all_assets = asyncio.run(get_all_assets()) print(f"\n✅ Retrieved {len(all_assets)} assets across all projects") # Verify that each asset has appropriate statuses for its project for asset in all_assets: print(f"\nAsset: {asset.name}") print(f" Task statuses: {asset.task_status}") # Get the project for this asset to verify statuses asset_project = db.query(Project).filter(Project.id == asset.project_id).first() project_custom_statuses = json.loads(asset_project.custom_task_statuses or '[]') custom_status_ids = [cs['id'] for cs in project_custom_statuses] for task_type, status in asset.task_status.items(): if status not in ['not_started', 'in_progress', 'submitted', 'approved', 'retake']: # This is a custom status, verify it belongs to the asset's project assert status in custom_status_ids, f"Custom status '{status}' not found in project {asset_project.name} custom statuses" print(f" ✅ Custom status '{status}' correctly belongs to project {asset_project.name}") print("✅ Test 2 PASSED: Mixed project queries handle custom statuses correctly") return True except Exception as e: print(f"❌ Test 2 FAILED: {e}") import traceback traceback.print_exc() return False finally: db.close() def test_custom_status_sorting_across_projects(): """Test that custom status sorting works correctly across different projects.""" print("\n=== Test 3: Custom Status Sorting Across Projects ===") db = SessionLocal() try: user = db.query(User).first() from routers.shots import list_shots import asyncio # Test sorting by task status across all projects async def get_sorted_shots(): return await list_shots( episode_id=None, project_id=None, task_status_filter=None, sort_by="layout_status", # Sort by layout task status sort_direction="asc", skip=0, limit=100, db=db, current_user=user ) sorted_shots = asyncio.run(get_sorted_shots()) print(f"✅ Retrieved {len(sorted_shots)} shots sorted by layout status") # Verify sorting works with mixed custom statuses from different projects for shot in sorted_shots: layout_status = shot.task_status.get('layout', 'not_started') print(f" Shot {shot.name}: layout = {layout_status}") # Get the project to access custom statuses for sorting verification shot_project = db.query(Project).filter(Project.id == shot.project_id).first() project_custom_statuses = json.loads(shot_project.custom_task_statuses or '[]') # Import the sorting function to verify order from routers.shots import get_status_sort_order sort_order = get_status_sort_order(layout_status, project_custom_statuses) print(f" Project: {shot_project.name}, Sort order: {sort_order}") print("✅ Test 3 PASSED: Custom status sorting across projects works correctly") return True except Exception as e: print(f"❌ Test 3 FAILED: {e}") import traceback traceback.print_exc() return False finally: db.close() def main(): """Run all multi-project custom status tests.""" print("🚀 Starting Multi-Project Custom Status Tests") print("=" * 60) try: # Setup test data setup_multi_project_data() # Run tests tests = [ test_project_isolation, test_mixed_project_queries, test_custom_status_sorting_across_projects ] passed = 0 failed = 0 for test in tests: try: if test(): passed += 1 else: failed += 1 except Exception as e: print(f"❌ Test failed with exception: {e}") failed += 1 print("\n" + "=" * 60) print(f"📊 Test Results: {passed} passed, {failed} failed") if failed == 0: print("🎉 All multi-project tests passed! Custom status support works across projects.") return True else: print("❌ Some tests failed. Multi-project custom status support needs fixes.") return False except Exception as e: print(f"❌ Test setup failed: {e}") import traceback traceback.print_exc() return False finally: # Cleanup try: if os.path.exists(TEST_DB): os.remove(TEST_DB) except PermissionError: pass # File in use, ignore if __name__ == "__main__": success = main() sys.exit(0 if success else 1)