Init Repo
This commit is contained in:
@@ -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()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user