Init Repo

This commit is contained in:
2026-02-28 03:22:04 +08:00
commit de59b57ee7
883 changed files with 156857 additions and 0 deletions
+397
View File
@@ -0,0 +1,397 @@
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from models.activity import Activity, ActivityType
from models.user import User
from models.task import Task
import logging
logger = logging.getLogger(__name__)
class ActivityService:
"""Service for creating and managing activity logs."""
@staticmethod
def log_activity(
db: Session,
type: ActivityType,
user_id: int,
description: str,
project_id: Optional[int] = None,
task_id: Optional[int] = None,
asset_id: Optional[int] = None,
shot_id: Optional[int] = None,
submission_id: Optional[int] = None,
activity_metadata: Optional[Dict[str, Any]] = None
) -> Optional[Activity]:
"""Log an activity to the database."""
try:
activity = Activity(
type=type,
user_id=user_id,
description=description,
project_id=project_id,
task_id=task_id,
asset_id=asset_id,
shot_id=shot_id,
submission_id=submission_id,
activity_metadata=activity_metadata
)
db.add(activity)
db.commit()
db.refresh(activity)
logger.info(f"Activity logged: {type} by user {user_id}")
return activity
except Exception as e:
logger.error(f"Failed to log activity: {str(e)}")
db.rollback()
return None
@staticmethod
def log_task_created(db: Session, task: Task, user: User):
"""Log task creation activity."""
description = f"{user.first_name} {user.last_name} created task '{task.name}'"
ActivityService.log_activity(
db=db,
type=ActivityType.TASK_CREATED,
user_id=user.id,
description=description,
project_id=task.project_id,
task_id=task.id,
activity_metadata={"task_type": task.task_type, "status": task.status}
)
@staticmethod
def log_task_assigned(db: Session, task: Task, assigned_user: User, assigner: User):
"""Log task assignment activity."""
description = f"{assigner.first_name} {assigner.last_name} assigned task '{task.name}' to {assigned_user.first_name} {assigned_user.last_name}"
ActivityService.log_activity(
db=db,
type=ActivityType.TASK_ASSIGNED,
user_id=assigner.id,
description=description,
project_id=task.project_id,
task_id=task.id,
activity_metadata={"assigned_to": assigned_user.id}
)
@staticmethod
def log_task_status_changed(db: Session, task: Task, old_status: str, new_status: str, user: User):
"""Log task status change activity."""
description = f"{user.first_name} {user.last_name} changed task '{task.name}' status from {old_status} to {new_status}"
ActivityService.log_activity(
db=db,
type=ActivityType.TASK_STATUS_CHANGED,
user_id=user.id,
description=description,
project_id=task.project_id,
task_id=task.id,
activity_metadata={"old_status": old_status, "new_status": new_status}
)
@staticmethod
def log_submission_created(db: Session, submission, user: User):
"""Log submission creation activity."""
description = f"{user.first_name} {user.last_name} submitted work for task '{submission.task.name}' (Version {submission.version_number})"
ActivityService.log_activity(
db=db,
type=ActivityType.SUBMISSION_CREATED,
user_id=user.id,
description=description,
project_id=submission.task.project_id,
task_id=submission.task_id,
submission_id=submission.id,
activity_metadata={"version": submission.version_number}
)
@staticmethod
def log_submission_reviewed(db: Session, submission, review, reviewer: User):
"""Log submission review activity."""
decision = "approved" if review.decision == "approved" else "requested retakes for"
description = f"{reviewer.first_name} {reviewer.last_name} {decision} submission for task '{submission.task.name}'"
ActivityService.log_activity(
db=db,
type=ActivityType.SUBMISSION_REVIEWED,
user_id=reviewer.id,
description=description,
project_id=submission.task.project_id,
task_id=submission.task_id,
submission_id=submission.id,
activity_metadata={"decision": review.decision, "has_feedback": bool(review.feedback)}
)
@staticmethod
def log_comment_added(db: Session, task: Task, user: User, comment_text: str):
"""Log comment addition activity."""
preview = comment_text[:50] + "..." if len(comment_text) > 50 else comment_text
description = f"{user.first_name} {user.last_name} commented on task '{task.name}': {preview}"
ActivityService.log_activity(
db=db,
type=ActivityType.COMMENT_ADDED,
user_id=user.id,
description=description,
project_id=task.project_id,
task_id=task.id
)
@staticmethod
def log_asset_created(db: Session, asset, user: User):
"""Log asset creation activity."""
description = f"{user.first_name} {user.last_name} created asset '{asset.name}' ({asset.category})"
ActivityService.log_activity(
db=db,
type=ActivityType.ASSET_CREATED,
user_id=user.id,
description=description,
project_id=asset.project_id,
asset_id=asset.id,
activity_metadata={"category": asset.category}
)
@staticmethod
def log_shot_created(db: Session, shot, user: User):
"""Log shot creation activity."""
description = f"{user.first_name} {user.last_name} created shot '{shot.name}'"
ActivityService.log_activity(
db=db,
type=ActivityType.SHOT_CREATED,
user_id=user.id,
description=description,
project_id=shot.episode.project_id if shot.episode else None,
shot_id=shot.id
)
@staticmethod
def log_project_created(db: Session, project, user: User):
"""Log project creation activity."""
description = f"{user.first_name} {user.last_name} created project '{project.name}'"
ActivityService.log_activity(
db=db,
type=ActivityType.PROJECT_CREATED,
user_id=user.id,
description=description,
project_id=project.id,
activity_metadata={"project_type": project.project_type}
)
@staticmethod
def log_user_joined_project(db: Session, project, user: User, added_by: User):
"""Log user joining project activity."""
description = f"{added_by.first_name} {added_by.last_name} added {user.first_name} {user.last_name} to project '{project.name}'"
ActivityService.log_activity(
db=db,
type=ActivityType.USER_JOINED_PROJECT,
user_id=added_by.id,
description=description,
project_id=project.id,
activity_metadata={"added_user_id": user.id}
)
@staticmethod
def log_shot_soft_deletion(db: Session, shot, user: User, deletion_info: Dict[str, Any]):
"""Log shot soft deletion activity."""
description = f"{user.first_name} {user.last_name} deleted shot '{shot.name}' from episode '{deletion_info.get('episode_name', 'Unknown')}'"
ActivityService.log_activity(
db=db,
type=ActivityType.SHOT_DELETED,
user_id=user.id,
description=description,
project_id=deletion_info.get('project_id'),
shot_id=shot.id,
activity_metadata={
"shot_name": shot.name,
"episode_name": deletion_info.get('episode_name'),
"project_name": deletion_info.get('project_name'),
"task_count": deletion_info.get('task_count', 0),
"submission_count": deletion_info.get('submission_count', 0),
"attachment_count": deletion_info.get('attachment_count', 0),
"note_count": deletion_info.get('note_count', 0),
"review_count": deletion_info.get('review_count', 0),
"affected_users": deletion_info.get('affected_users', [])
}
)
@staticmethod
def log_asset_soft_deletion(db: Session, asset, user: User, deletion_info: Dict[str, Any]):
"""Log asset soft deletion activity."""
description = f"{user.first_name} {user.last_name} deleted asset '{asset.name}' ({asset.category})"
ActivityService.log_activity(
db=db,
type=ActivityType.ASSET_DELETED,
user_id=user.id,
description=description,
project_id=asset.project_id,
asset_id=asset.id,
activity_metadata={
"asset_name": asset.name,
"asset_category": asset.category,
"project_name": deletion_info.get('project_name'),
"task_count": deletion_info.get('task_count', 0),
"submission_count": deletion_info.get('submission_count', 0),
"attachment_count": deletion_info.get('attachment_count', 0),
"note_count": deletion_info.get('note_count', 0),
"review_count": deletion_info.get('review_count', 0),
"affected_users": deletion_info.get('affected_users', [])
}
)
@staticmethod
def log_shot_recovery(db: Session, shot, user: User, recovery_info: Dict[str, Any]):
"""Log shot recovery activity."""
description = f"{user.first_name} {user.last_name} recovered shot '{shot.name}' from episode '{recovery_info.get('episode_name', 'Unknown')}'"
ActivityService.log_activity(
db=db,
type=ActivityType.SHOT_RECOVERED,
user_id=user.id,
description=description,
project_id=recovery_info.get('project_id'),
shot_id=shot.id,
activity_metadata={
"shot_name": shot.name,
"episode_name": recovery_info.get('episode_name'),
"project_name": recovery_info.get('project_name'),
"recovered_tasks": recovery_info.get('recovered_tasks', 0),
"recovered_submissions": recovery_info.get('recovered_submissions', 0),
"recovered_attachments": recovery_info.get('recovered_attachments', 0),
"recovered_notes": recovery_info.get('recovered_notes', 0),
"recovered_reviews": recovery_info.get('recovered_reviews', 0),
"deleted_at": recovery_info.get('deleted_at'),
"deleted_by": recovery_info.get('deleted_by')
}
)
@staticmethod
def log_asset_recovery(db: Session, asset, user: User, recovery_info: Dict[str, Any]):
"""Log asset recovery activity."""
description = f"{user.first_name} {user.last_name} recovered asset '{asset.name}' ({asset.category})"
ActivityService.log_activity(
db=db,
type=ActivityType.ASSET_RECOVERED,
user_id=user.id,
description=description,
project_id=asset.project_id,
asset_id=asset.id,
activity_metadata={
"asset_name": asset.name,
"asset_category": asset.category,
"project_name": recovery_info.get('project_name'),
"recovered_tasks": recovery_info.get('recovered_tasks', 0),
"recovered_submissions": recovery_info.get('recovered_submissions', 0),
"recovered_attachments": recovery_info.get('recovered_attachments', 0),
"recovered_notes": recovery_info.get('recovered_notes', 0),
"recovered_reviews": recovery_info.get('recovered_reviews', 0),
"deleted_at": recovery_info.get('deleted_at'),
"deleted_by": recovery_info.get('deleted_by')
}
)
@staticmethod
def get_activities_excluding_deleted(
db: Session,
project_id: Optional[int] = None,
task_id: Optional[int] = None,
user_id: Optional[int] = None,
skip: int = 0,
limit: int = 50,
type_filter: Optional[ActivityType] = None,
days: Optional[int] = None
):
"""Get activities excluding those related to deleted records."""
from sqlalchemy import and_, or_, desc
from datetime import datetime, timedelta
from models.task import Task
from models.shot import Shot
from models.asset import Asset
from models.submission import Submission
query = db.query(Activity)
# Base filters
if project_id:
query = query.filter(Activity.project_id == project_id)
if task_id:
query = query.filter(Activity.task_id == task_id)
if user_id:
query = query.filter(Activity.user_id == user_id)
if type_filter:
query = query.filter(Activity.type == type_filter)
if days:
cutoff_date = datetime.utcnow() - timedelta(days=days)
query = query.filter(Activity.created_at >= cutoff_date)
# Exclude activities related to deleted records
# For task-related activities, exclude if task is deleted
task_subquery = db.query(Task.id).filter(Task.deleted_at.is_(None)).subquery()
# For shot-related activities, exclude if shot is deleted
shot_subquery = db.query(Shot.id).filter(Shot.deleted_at.is_(None)).subquery()
# For asset-related activities, exclude if asset is deleted
asset_subquery = db.query(Asset.id).filter(Asset.deleted_at.is_(None)).subquery()
# For submission-related activities, exclude if submission is deleted
submission_subquery = db.query(Submission.id).filter(Submission.deleted_at.is_(None)).subquery()
# Apply exclusion filters
query = query.filter(
or_(
Activity.task_id.is_(None),
Activity.task_id.in_(task_subquery)
)
).filter(
or_(
Activity.shot_id.is_(None),
Activity.shot_id.in_(shot_subquery)
)
).filter(
or_(
Activity.asset_id.is_(None),
Activity.asset_id.in_(asset_subquery)
)
).filter(
or_(
Activity.submission_id.is_(None),
Activity.submission_id.in_(submission_subquery)
)
)
return query.order_by(desc(Activity.created_at)).offset(skip).limit(limit).all()
@staticmethod
def get_activities_including_deleted(
db: Session,
project_id: Optional[int] = None,
task_id: Optional[int] = None,
user_id: Optional[int] = None,
skip: int = 0,
limit: int = 50,
type_filter: Optional[ActivityType] = None,
days: Optional[int] = None
):
"""Get all activities including those related to deleted records (admin only)."""
from sqlalchemy import desc
from datetime import datetime, timedelta
query = db.query(Activity)
# Base filters
if project_id:
query = query.filter(Activity.project_id == project_id)
if task_id:
query = query.filter(Activity.task_id == task_id)
if user_id:
query = query.filter(Activity.user_id == user_id)
if type_filter:
query = query.filter(Activity.type == type_filter)
if days:
cutoff_date = datetime.utcnow() - timedelta(days=days)
query = query.filter(Activity.created_at >= cutoff_date)
return query.order_by(desc(Activity.created_at)).offset(skip).limit(limit).all()
# Global activity service instance
activity_service = ActivityService()
+473
View File
@@ -0,0 +1,473 @@
from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional, Union
from fastapi import HTTPException, status, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
import os
import secrets
import hashlib
import json
import logging
# Setup logger
logger = logging.getLogger("vfx_auth")
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
REFRESH_SECRET_KEY = os.getenv("REFRESH_SECRET_KEY", "your-refresh-secret-key-here-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Security scheme
security = HTTPBearer()
# API Key settings
API_KEY_PREFIX = "vfx_"
API_KEY_LENGTH = 32
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire, "type": "access"})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def create_refresh_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Create a JWT refresh token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
encoded_jwt = jwt.encode(to_encode, REFRESH_SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str, token_type: str = "access") -> Optional[dict]:
"""Verify and decode a JWT token."""
try:
secret_key = SECRET_KEY if token_type == "access" else REFRESH_SECRET_KEY
logger.debug(f"🔐 Decoding {token_type} token with secret: {secret_key[:10]}...")
payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM])
logger.debug(f"🔐 Token decoded successfully: {payload}")
# Verify token type
if payload.get("type") != token_type:
logger.warning(f"🔐 Token type mismatch: expected {token_type}, got {payload.get('type')}")
return None
return payload
except JWTError as e:
logger.warning(f"🔐 JWT decode error: {e}")
return None
def get_current_user_from_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Extract user information from JWT token."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
logger.debug(f"🔐 Verifying token: {credentials.credentials[:20]}...")
payload = verify_token(credentials.credentials, "access")
if payload is None:
logger.warning("🔐 Token verification failed - invalid token")
raise credentials_exception
logger.debug(f"🔐 Token payload: {payload}")
user_id_str = payload.get("sub")
if user_id_str is None:
logger.warning("🔐 Token verification failed - no sub field")
raise credentials_exception
try:
user_id = int(user_id_str)
logger.debug(f"🔐 Extracted user_id: {user_id}")
except (ValueError, TypeError):
logger.warning(f"🔐 Token verification failed - invalid user_id: {user_id_str}")
raise credentials_exception
return {"user_id": user_id, "email": payload.get("email")}
except HTTPException:
raise
except Exception as e:
logger.error(f"🔐 Token verification error: {e}")
raise credentials_exception
def get_current_user(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
"""Get current user from database using token data."""
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
return _get_user_from_db(db, token_data["user_id"])
def _get_user_from_db(db: Session, user_id: int):
"""Helper function to get user from database."""
from models.user import User
logger.debug(f"🔐 Looking up user_id: {user_id}")
user = db.query(User).filter(User.id == user_id).first()
if user is None:
logger.warning(f"🔐 User not found: {user_id}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found"
)
logger.debug(f"🔐 Found user: {user.email} (approved: {user.is_approved})")
if not user.is_approved:
logger.warning(f"🔐 User not approved: {user.email}")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account not approved"
)
return user
def get_current_user_with_db(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
"""Get current user with database dependency injection."""
from database import get_db
if db is None:
# This should not happen in normal FastAPI usage
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Database session not available"
)
return _get_user_from_db(db, token_data["user_id"])
def require_role(required_roles: list):
"""Decorator to require specific user roles."""
def role_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
def require_admin_permission():
"""Decorator to require admin permission regardless of role."""
def admin_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = Depends(lambda: None)
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
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
return admin_checker
def create_role_dependency(required_roles: list):
"""Create a dependency that requires specific user roles with proper DB injection."""
def role_checker(
token_data: dict = Depends(get_current_user_from_token),
db: Session = None
):
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
current_user = _get_user_from_db(db, token_data["user_id"])
finally:
db.close()
else:
current_user = _get_user_from_db(db, token_data["user_id"])
if current_user.role not in required_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
# API Key utilities
def generate_api_key() -> str:
"""Generate a new API key."""
random_part = secrets.token_urlsafe(API_KEY_LENGTH)
return f"{API_KEY_PREFIX}{random_part}"
def hash_api_key(api_key: str) -> str:
"""Hash an API key for secure storage."""
return hashlib.sha256(api_key.encode()).hexdigest()
def verify_api_key_format(api_key: str) -> bool:
"""Verify that an API key has the correct format."""
return api_key.startswith(API_KEY_PREFIX) and len(api_key) > len(API_KEY_PREFIX)
def get_current_user_from_api_key(
request: Request,
db: Session = Depends(lambda: None)
) -> Optional[dict]:
"""Extract user information from API key."""
from database import get_db
from models.api_key import APIKey
from models.api_key_usage import APIKeyUsage
# Get API key from header
api_key = request.headers.get("X-API-Key")
if not api_key:
return None
# Verify format
if not verify_api_key_format(api_key):
return None
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _verify_api_key_and_get_user(db, api_key, request)
finally:
db.close()
else:
return _verify_api_key_and_get_user(db, api_key, request)
def _verify_api_key_and_get_user(db: Session, api_key: str, request: Request) -> Optional[dict]:
"""Helper function to verify API key and get user."""
from models.api_key import APIKey
from models.api_key_usage import APIKeyUsage
from models.user import User
# Hash the provided key
key_hash = hash_api_key(api_key)
# Find the API key in database
api_key_record = db.query(APIKey).filter(
APIKey.key_hash == key_hash,
APIKey.is_active == True
).first()
if not api_key_record:
return None
# Check if key is expired
if api_key_record.expires_at and api_key_record.expires_at < datetime.utcnow():
return None
# Get the user
user = db.query(User).filter(User.id == api_key_record.user_id).first()
if not user or not user.is_approved:
return None
# Log API key usage
usage_log = APIKeyUsage(
api_key_id=api_key_record.id,
endpoint=str(request.url.path),
method=request.method,
ip_address=request.client.host if request.client else None,
user_agent=request.headers.get("User-Agent")
)
db.add(usage_log)
# Update last used timestamp
api_key_record.last_used_at = datetime.utcnow()
db.commit()
# Parse scopes
try:
scopes = json.loads(api_key_record.scopes)
except json.JSONDecodeError:
scopes = []
return {
"user_id": user.id,
"email": user.email,
"role": user.role,
"api_key_id": api_key_record.id,
"scopes": scopes,
"auth_type": "api_key"
}
def get_current_user_flexible(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: None)
):
"""Get current user from either JWT token or API key."""
from database import get_db
# Get database session if not provided
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
return _get_current_user_flexible_with_db(request, credentials, db)
finally:
db.close()
else:
return _get_current_user_flexible_with_db(request, credentials, db)
def _get_current_user_flexible_with_db(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials],
db: Session
):
"""Helper function for flexible authentication with database session."""
# Try API key first
api_key_user = get_current_user_from_api_key(request, db)
if api_key_user:
# Get full user object
user = _get_user_from_db(db, api_key_user["user_id"])
# Add API key specific data
user.api_key_id = api_key_user["api_key_id"]
user.scopes = api_key_user["scopes"]
user.auth_type = "api_key"
return user
# Try JWT token
if credentials:
try:
payload = verify_token(credentials.credentials, "access")
if payload:
user = _get_user_from_db(db, payload.get("sub"))
user.auth_type = "jwt"
return user
except Exception:
pass
# No valid authentication found
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
def check_api_key_scope(user, required_scope: str) -> bool:
"""Check if the current user (authenticated via API key) has the required scope."""
if not hasattr(user, 'auth_type') or user.auth_type != 'api_key':
# JWT tokens have full access based on user role
return True
if not hasattr(user, 'scopes'):
return False
# Check if user has the specific scope or full access
return required_scope in user.scopes or "full:access" in user.scopes
def require_api_key_scope(required_scope: str):
"""Decorator to require specific API key scope."""
def scope_checker(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
db: Session = Depends(lambda: None)
):
from database import get_db
if db is None:
db_gen = get_db()
db = next(db_gen)
try:
user = _get_current_user_flexible_with_db(request, credentials, db)
finally:
db.close()
else:
user = _get_current_user_flexible_with_db(request, credentials, db)
if not check_api_key_scope(user, required_scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Insufficient permissions. Required scope: {required_scope}"
)
return user
return scope_checker
+315
View File
@@ -0,0 +1,315 @@
"""
File handling utilities for VFX Project Management System.
Provides secure file upload, validation, and serving functionality.
"""
import os
import shutil
import mimetypes
from datetime import datetime
from typing import Optional, List, Tuple
from pathlib import Path
from PIL import Image
from fastapi import HTTPException, UploadFile
from sqlalchemy.orm import Session
import hashlib
class FileHandler:
"""Handles file operations for the VFX system."""
# Supported VFX media formats
SUPPORTED_FORMATS = {
# Video formats
'.mov', '.mp4', '.avi', '.mkv', '.webm',
# Image formats
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
# Document formats
'.pdf', '.txt', '.doc', '.docx',
# Archive formats
'.zip', '.rar', '.7z'
}
# File size limits (in bytes)
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10MB for attachments
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
# Movie file extensions that should use global upload limit
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
# Thumbnail settings
THUMBNAIL_SIZE = (200, 200)
THUMBNAIL_QUALITY = 85
def __init__(self, base_upload_dir: str = "uploads"):
# Use absolute path relative to this file's location
# This ensures it works whether run from workspace root or backend directory
current_file = Path(__file__).resolve()
self.backend_dir = current_file.parent.parent # Go up from utils/ to backend/
self.base_upload_dir = self.backend_dir / base_upload_dir
self.base_upload_dir.mkdir(parents=True, exist_ok=True)
# Create subdirectories
self.attachments_dir = self.base_upload_dir / "attachments"
self.submissions_dir = self.base_upload_dir / "submissions"
self.thumbnails_dir = self.base_upload_dir / "thumbnails"
self.project_thumbnails_dir = self.base_upload_dir / "project_thumbnails"
for directory in [self.attachments_dir, self.submissions_dir, self.thumbnails_dir, self.project_thumbnails_dir]:
directory.mkdir(parents=True, exist_ok=True)
def validate_file(self, file: UploadFile, max_size: int, db: Optional[Session] = None) -> None:
"""Validate uploaded file format and size."""
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
# Check file extension
file_extension = Path(file.filename).suffix.lower()
if file_extension not in self.SUPPORTED_FORMATS:
raise HTTPException(
status_code=400,
detail=f"Unsupported file format. Supported formats: {', '.join(sorted(self.SUPPORTED_FORMATS))}"
)
# For movie files, use global upload limit if available
effective_max_size = max_size
if file_extension in self.MOVIE_EXTENSIONS and db:
global_limit = self.get_global_upload_limit(db)
if global_limit:
effective_max_size = global_limit
# Check file size (we'll read content later, so this is a preliminary check)
if hasattr(file, 'size') and file.size and file.size > effective_max_size:
size_mb = effective_max_size // (1024*1024)
raise HTTPException(
status_code=413,
detail=f"File too large. Maximum size is {size_mb}MB"
)
def generate_unique_filename(self, original_filename: str, prefix: str = "") -> str:
"""Generate a unique filename with timestamp and hash."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_stem = Path(original_filename).stem
file_extension = Path(original_filename).suffix
# Create a short hash for uniqueness
hash_input = f"{original_filename}{timestamp}".encode()
short_hash = hashlib.md5(hash_input).hexdigest()[:8]
if prefix:
return f"{prefix}_{file_stem}_{timestamp}_{short_hash}{file_extension}"
else:
return f"{file_stem}_{timestamp}_{short_hash}{file_extension}"
def create_directory_structure(self, task_id: int, file_type: str) -> Path:
"""Create organized directory structure for file storage."""
if file_type == "attachment":
base_dir = self.attachments_dir
elif file_type == "submission":
base_dir = self.submissions_dir
else:
raise ValueError(f"Unknown file type: {file_type}")
# Create task-specific directory
task_dir = base_dir / str(task_id)
task_dir.mkdir(exist_ok=True)
return task_dir
def get_global_upload_limit(self, db: Session) -> Optional[int]:
"""Get the global upload limit in bytes from database."""
try:
from models.global_settings import GlobalSettings
setting = db.query(GlobalSettings).filter(
GlobalSettings.setting_key == "global_upload_limit_mb"
).first()
if setting:
limit_mb = int(setting.setting_value)
return limit_mb * 1024 * 1024 # Convert MB to bytes
except Exception:
pass
return None
def store_relative_path(self, absolute_path: str) -> str:
"""Convert absolute path to relative path for database storage."""
absolute_path_obj = Path(absolute_path).resolve()
try:
# Convert to relative path from backend directory
relative_path = absolute_path_obj.relative_to(self.backend_dir)
return str(relative_path).replace('\\', '/') # Use forward slashes for consistency
except ValueError:
# If path is not under backend directory, return as-is
# This handles edge cases during migration
return str(absolute_path_obj).replace('\\', '/')
def resolve_absolute_path(self, stored_path: str) -> str:
"""Resolve stored path (relative or absolute) to absolute path."""
stored_path_obj = Path(stored_path)
# If already absolute, return as-is
if stored_path_obj.is_absolute():
return str(stored_path_obj)
# Otherwise, resolve relative to backend directory
absolute_path = self.backend_dir / stored_path_obj
return str(absolute_path.resolve())
def is_relative_path(self, path: str) -> bool:
"""Check if a path is relative to backend directory."""
path_obj = Path(path)
return not path_obj.is_absolute()
def migrate_path_to_relative(self, absolute_path: str) -> str:
"""Convert legacy absolute path to new relative format."""
return self.store_relative_path(absolute_path)
async def save_file(self, file: UploadFile, task_id: int, file_type: str,
version_number: Optional[int] = None, db: Optional[Session] = None) -> Tuple[str, int]:
"""Save uploaded file and return relative file path and size."""
# Read file content
file_content = await file.read()
file_size = len(file_content)
# Determine max size based on file type and global settings
max_size = self.MAX_SUBMISSION_SIZE if file_type == "submission" else self.MAX_ATTACHMENT_SIZE
# For movie files, use global upload limit if available
file_extension = Path(file.filename).suffix.lower()
if file_extension in self.MOVIE_EXTENSIONS and db:
global_limit = self.get_global_upload_limit(db)
if global_limit:
max_size = global_limit
# Validate size after reading
if file_size > max_size:
size_mb = max_size // (1024*1024)
raise HTTPException(
status_code=413,
detail=f"File too large. Maximum size is {size_mb}MB"
)
# Create directory structure
task_dir = self.create_directory_structure(task_id, file_type)
# Generate unique filename
prefix = f"v{version_number:03d}" if version_number else ""
unique_filename = self.generate_unique_filename(file.filename, prefix)
file_path = task_dir / unique_filename
# Save file
with open(file_path, "wb") as buffer:
buffer.write(file_content)
# Return relative path for database storage
relative_path = self.store_relative_path(str(file_path))
return relative_path, file_size
def delete_file(self, file_path: str) -> bool:
"""Delete a file from the filesystem."""
try:
# Resolve to absolute path for filesystem operations
absolute_path = self.resolve_absolute_path(file_path)
if os.path.exists(absolute_path):
os.remove(absolute_path)
# Also delete thumbnail if it exists
thumbnail_path = self.get_thumbnail_path(file_path)
absolute_thumbnail_path = self.resolve_absolute_path(thumbnail_path)
if os.path.exists(absolute_thumbnail_path):
os.remove(absolute_thumbnail_path)
return True
except Exception:
pass
return False
def get_thumbnail_path(self, file_path: str) -> str:
"""Get the thumbnail path for a given file (returns relative path)."""
# Resolve to absolute path to get the filename
absolute_path = self.resolve_absolute_path(file_path)
file_path_obj = Path(absolute_path)
thumbnail_name = f"{file_path_obj.stem}_thumb.jpg"
# Return relative path for consistency
absolute_thumbnail_path = self.thumbnails_dir / thumbnail_name
return self.store_relative_path(str(absolute_thumbnail_path))
def create_thumbnail(self, file_path: str) -> Optional[str]:
"""Create a thumbnail for image files (returns relative path)."""
try:
# Resolve to absolute path for filesystem operations
absolute_file_path = self.resolve_absolute_path(file_path)
file_extension = Path(absolute_file_path).suffix.lower()
# Only create thumbnails for image formats (excluding EXR for now due to complexity)
image_formats = {'.jpg', '.jpeg', '.png', '.tiff', '.tif'}
if file_extension not in image_formats:
return None
# Open and resize image
with Image.open(absolute_file_path) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
img = img.convert('RGB')
# Create thumbnail
img.thumbnail(self.THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
# Get thumbnail path (relative)
thumbnail_path = self.get_thumbnail_path(file_path)
absolute_thumbnail_path = self.resolve_absolute_path(thumbnail_path)
# Save thumbnail
img.save(absolute_thumbnail_path, 'JPEG', quality=self.THUMBNAIL_QUALITY)
return thumbnail_path # Return relative path
except Exception as e:
# Log error but don't fail the upload
print(f"Failed to create thumbnail for {file_path}: {e}")
return None
def get_file_info(self, file_path: str) -> dict:
"""Get file information including size, type, and modification time."""
try:
# Resolve to absolute path for filesystem operations
absolute_path = self.resolve_absolute_path(file_path)
file_path_obj = Path(absolute_path)
if not file_path_obj.exists():
return {'exists': False}
stat = file_path_obj.stat()
mime_type, _ = mimetypes.guess_type(str(file_path_obj))
return {
'size': stat.st_size,
'mime_type': mime_type or 'application/octet-stream',
'modified_at': datetime.fromtimestamp(stat.st_mtime),
'exists': True
}
except Exception:
return {'exists': False}
def is_image_file(self, file_path: str) -> bool:
"""Check if file is an image."""
# Works with both relative and absolute paths
file_extension = Path(file_path).suffix.lower()
image_formats = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.exr', '.hdr', '.dpx'}
return file_extension in image_formats
def is_video_file(self, file_path: str) -> bool:
"""Check if file is a video."""
# Works with both relative and absolute paths
file_extension = Path(file_path).suffix.lower()
video_formats = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
return file_extension in video_formats
# Global file handler instance
file_handler = FileHandler()
+259
View File
@@ -0,0 +1,259 @@
from typing import Optional
from sqlalchemy.orm import Session
from models.user import User
from models.task import Task, Submission, Review
from models.notification import Notification, UserNotificationPreference, NotificationType, NotificationPriority
import logging
# Set up logging for notifications
logger = logging.getLogger(__name__)
class NotificationService:
"""Notification service for creating and managing notifications."""
def _create_notification(
self,
db: Session,
user_id: int,
type: NotificationType,
title: str,
message: str,
priority: NotificationPriority = NotificationPriority.NORMAL,
project_id: Optional[int] = None,
task_id: Optional[int] = None,
submission_id: Optional[int] = None
) -> Notification:
"""Create a notification in the database."""
try:
# Check user preferences
preferences = db.query(UserNotificationPreference).filter(
UserNotificationPreference.user_id == user_id
).first()
# Create default preferences if they don't exist
if not preferences:
preferences = UserNotificationPreference(user_id=user_id)
db.add(preferences)
db.commit()
# Check if in-app notifications are enabled for this type
if not preferences.inapp_enabled:
return None
type_pref_map = {
NotificationType.TASK_ASSIGNED: preferences.inapp_task_assigned,
NotificationType.TASK_STATUS_CHANGED: preferences.inapp_task_status_changed,
NotificationType.SUBMISSION_REVIEWED: preferences.inapp_submission_reviewed,
NotificationType.WORK_SUBMITTED: preferences.inapp_work_submitted,
NotificationType.DEADLINE_APPROACHING: preferences.inapp_deadline_approaching,
NotificationType.PROJECT_UPDATE: preferences.inapp_project_update,
NotificationType.COMMENT_ADDED: preferences.inapp_comment_added,
}
if not type_pref_map.get(type, True):
return None
# Create notification
notification = Notification(
user_id=user_id,
type=type,
priority=priority,
title=title,
message=message,
project_id=project_id,
task_id=task_id,
submission_id=submission_id
)
db.add(notification)
db.commit()
db.refresh(notification)
logger.info(f"Created notification {notification.id} for user {user_id}: {title}")
# TODO: Send email if email notifications are enabled
if preferences.email_enabled and type_pref_map.get(type, True):
self._send_email_notification(notification, preferences)
return notification
except Exception as e:
logger.error(f"Failed to create notification: {str(e)}")
db.rollback()
return None
def _send_email_notification(self, notification: Notification, preferences: UserNotificationPreference):
"""Send email notification (placeholder for future implementation)."""
# TODO: Implement actual email sending
logger.info(f"Email notification would be sent for notification {notification.id}")
def notify_submission_reviewed(
self,
db: Session,
submission: Submission,
review: Review,
reviewer: User
):
"""Notify artist when their submission is reviewed."""
try:
artist = submission.user
decision_text = "approved" if review.decision == "approved" else "requires retakes"
title = f"Submission {decision_text}"
message = f"Your submission for task '{submission.task.name}' has been {decision_text} by {reviewer.first_name} {reviewer.last_name}"
if review.feedback:
message += f"\n\nFeedback: {review.feedback}"
priority = NotificationPriority.HIGH if review.decision == "retake" else NotificationPriority.NORMAL
self._create_notification(
db=db,
user_id=artist.id,
type=NotificationType.SUBMISSION_REVIEWED,
title=title,
message=message,
priority=priority,
project_id=submission.task.project_id,
task_id=submission.task_id,
submission_id=submission.id
)
except Exception as e:
logger.error(f"Failed to send notification for submission {submission.id}: {str(e)}")
def notify_task_assigned(
self,
db: Session,
task: Task,
assigned_user: User,
assigner: User
):
"""Notify user when they are assigned a task."""
try:
title = "New Task Assigned"
message = f"You have been assigned a new task: '{task.name}' by {assigner.first_name} {assigner.last_name}"
if task.deadline:
message += f"\nDeadline: {task.deadline.strftime('%Y-%m-%d')}"
if task.description:
message += f"\nDescription: {task.description}"
self._create_notification(
db=db,
user_id=assigned_user.id,
type=NotificationType.TASK_ASSIGNED,
title=title,
message=message,
priority=NotificationPriority.NORMAL,
project_id=task.project_id,
task_id=task.id
)
except Exception as e:
logger.error(f"Failed to send task assignment notification for task {task.id}: {str(e)}")
def notify_work_submitted(
self,
db: Session,
submission: Submission,
task: Task
):
"""Notify directors/coordinators when work is submitted for review."""
try:
from models.project import ProjectMember
project_members = db.query(ProjectMember).join(User).filter(
ProjectMember.project_id == task.project_id,
User.role.in_(["director", "coordinator"])
).all()
artist_name = f"{submission.user.first_name} {submission.user.last_name}"
title = "New Submission for Review"
message = f"New submission ready for review: '{task.name}' by {artist_name} (Version {submission.version_number})"
if submission.notes:
message += f"\nArtist notes: {submission.notes}"
for member in project_members:
self._create_notification(
db=db,
user_id=member.user_id,
type=NotificationType.WORK_SUBMITTED,
title=title,
message=message,
priority=NotificationPriority.NORMAL,
project_id=task.project_id,
task_id=task.id,
submission_id=submission.id
)
except Exception as e:
logger.error(f"Failed to send submission notification for submission {submission.id}: {str(e)}")
def notify_task_status_changed(
self,
db: Session,
task: Task,
old_status: str,
new_status: str,
changed_by: User
):
"""Notify relevant users when task status changes."""
try:
# Notify task owner if someone else changed the status
if task.assigned_user_id and task.assigned_user_id != changed_by.id:
title = "Task Status Updated"
message = f"Task '{task.name}' status changed from {old_status} to {new_status} by {changed_by.first_name} {changed_by.last_name}"
self._create_notification(
db=db,
user_id=task.assigned_user_id,
type=NotificationType.TASK_STATUS_CHANGED,
title=title,
message=message,
priority=NotificationPriority.NORMAL,
project_id=task.project_id,
task_id=task.id
)
except Exception as e:
logger.error(f"Failed to send task status change notification for task {task.id}: {str(e)}")
def notify_comment_added(
self,
db: Session,
task: Task,
comment_author: User,
comment_text: str
):
"""Notify task assignee when a comment is added."""
try:
if task.assigned_user_id and task.assigned_user_id != comment_author.id:
title = "New Comment on Task"
message = f"{comment_author.first_name} {comment_author.last_name} commented on task '{task.name}'"
if len(comment_text) > 100:
message += f"\n\n{comment_text[:100]}..."
else:
message += f"\n\n{comment_text}"
self._create_notification(
db=db,
user_id=task.assigned_user_id,
type=NotificationType.COMMENT_ADDED,
title=title,
message=message,
priority=NotificationPriority.LOW,
project_id=task.project_id,
task_id=task.id
)
except Exception as e:
logger.error(f"Failed to send comment notification for task {task.id}: {str(e)}")
# Global notification service instance
notification_service = NotificationService()