feat: Add new analysis documents and update installation scripts for backend integration
This commit introduces several new analysis documents, including Auto-Match Load Performance Analysis, Folder Picker Analysis, Monorepo Migration Summary, and various performance analysis documents. Additionally, the installation scripts are updated to reflect changes in backend service paths, ensuring proper integration with the new backend structure. These enhancements provide better documentation and streamline the setup process for users.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""API routers package for PunimTag Web."""
|
||||
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Authentication endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
)
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import User
|
||||
from backend.utils.password import verify_password, hash_password
|
||||
from backend.schemas.auth import (
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResponse,
|
||||
)
|
||||
from backend.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
security = HTTPBearer()
|
||||
|
||||
# Placeholder secrets - replace with env vars in production
|
||||
SECRET_KEY = "dev-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 360
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
# Single user mode placeholder - read from environment or use defaults
|
||||
SINGLE_USER_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
SINGLE_USER_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") # Change in production
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta) -> str:
|
||||
"""Create JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Create JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
|
||||
) -> dict:
|
||||
"""Get current user from JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
return {"username": username}
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
|
||||
|
||||
def get_current_user_with_id(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get current user with ID from main database.
|
||||
|
||||
Looks up the user in the main database and returns username and user_id.
|
||||
If user doesn't exist, creates them (for bootstrap scenarios).
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if user exists in main database
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
# If user doesn't exist, create them (for bootstrap scenarios)
|
||||
if not user:
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Generate unique email to avoid conflicts
|
||||
base_email = f"{username}@example.com"
|
||||
email = base_email
|
||||
counter = 1
|
||||
# Ensure email is unique
|
||||
while db.query(User).filter(User.email == email).first():
|
||||
email = f"{username}+{counter}@example.com"
|
||||
counter += 1
|
||||
|
||||
# Create user (they should change password)
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
email=email,
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {"username": username, "user_id": user.id}
|
||||
|
||||
|
||||
def _resolve_user_role(user: User | None, is_admin_flag: bool) -> str:
|
||||
"""Determine the role value for a user, ensuring it is valid."""
|
||||
if user and user.role in ROLE_VALUES:
|
||||
return user.role
|
||||
return DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
"""Authenticate user and return tokens.
|
||||
|
||||
First checks main database for users, falls back to hardcoded admin/admin
|
||||
for backward compatibility.
|
||||
"""
|
||||
# First, try to find user in main database
|
||||
user = db.query(User).filter(User.username == credentials.username).first()
|
||||
|
||||
if user:
|
||||
# User exists in main database - verify password
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is inactive",
|
||||
)
|
||||
|
||||
# Check if password_hash exists (migration might not have run)
|
||||
if not user.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Password not set. Please contact administrator to set your password.",
|
||||
)
|
||||
|
||||
if not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
# Generate tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": credentials.username},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": credentials.username})
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
password_change_required=user.password_change_required,
|
||||
)
|
||||
|
||||
# Fallback to hardcoded admin/admin for backward compatibility
|
||||
if (
|
||||
credentials.username == SINGLE_USER_USERNAME
|
||||
and credentials.password == SINGLE_USER_PASSWORD
|
||||
):
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": credentials.username},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": credentials.username})
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
password_change_required=False, # Hardcoded admin doesn't require password change
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(request: RefreshRequest) -> TokenResponse:
|
||||
"""Refresh access token using refresh token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
request.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": username}, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data={"sub": username})
|
||||
return TokenResponse(
|
||||
access_token=access_token, refresh_token=new_refresh_token
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user_info(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Get current user information including admin status."""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if user exists in main database to get admin status
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
# If user doesn't exist in main database, check if we should bootstrap them
|
||||
if not user:
|
||||
# Check if any admin users exist
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
|
||||
# If no admins exist, bootstrap current user as admin
|
||||
if admin_count == 0:
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Generate unique email to avoid conflicts
|
||||
base_email = f"{username}@example.com"
|
||||
email = base_email
|
||||
counter = 1
|
||||
# Ensure email is unique
|
||||
while db.query(User).filter(User.email == email).first():
|
||||
email = f"{username}+{counter}@example.com"
|
||||
counter += 1
|
||||
|
||||
# Create user as admin for bootstrap (they should change password)
|
||||
default_password_hash = hash_password("changeme")
|
||||
try:
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
email=email,
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
is_admin = True
|
||||
except Exception:
|
||||
# If creation fails (e.g., race condition), try to get existing user
|
||||
db.rollback()
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user:
|
||||
# Update existing user to be admin if no admins exist
|
||||
if not user.is_admin:
|
||||
user.is_admin = True
|
||||
user.role = DEFAULT_ADMIN_ROLE
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
is_admin = user.is_admin
|
||||
else:
|
||||
is_admin = False
|
||||
else:
|
||||
is_admin = False
|
||||
else:
|
||||
is_admin = user.is_admin if user else False
|
||||
|
||||
role_value = _resolve_user_role(user, is_admin)
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
permissions = permissions_map.get(role_value, {})
|
||||
|
||||
return UserResponse(
|
||||
username=username,
|
||||
is_admin=is_admin,
|
||||
role=role_value,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=PasswordChangeResponse)
|
||||
def change_password(
|
||||
request: PasswordChangeRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> PasswordChangeResponse:
|
||||
"""Change user password.
|
||||
|
||||
Requires current password verification.
|
||||
After successful change, clears password_change_required flag.
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Find user in main database
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(request.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = hash_password(request.new_password)
|
||||
user.password_change_required = False # Clear the flag after password change
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
return PasswordChangeResponse(
|
||||
success=True,
|
||||
message="Password changed successfully",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,699 @@
|
||||
"""Auth database user management endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.api.users import get_current_admin_user
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.schemas.auth_users import (
|
||||
AuthUserCreateRequest,
|
||||
AuthUserResponse,
|
||||
AuthUserUpdateRequest,
|
||||
AuthUsersListResponse,
|
||||
)
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/auth-users", tags=["auth-users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_column_exists(auth_db: Session, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a table."""
|
||||
try:
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
inspector = sqlalchemy_inspect(auth_db.bind)
|
||||
columns = {col["name"] for col in inspector.get_columns(table_name)}
|
||||
return column_name in columns
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_role_from_is_admin(auth_db: Session, is_admin: bool) -> str:
|
||||
"""Get role value from is_admin boolean. Returns 'Admin' if is_admin is True, 'User' otherwise."""
|
||||
return "Admin" if is_admin else "User"
|
||||
|
||||
|
||||
def _get_is_admin_from_role(role: str | None) -> bool:
|
||||
"""Get is_admin boolean from role string. Returns True if role is 'Admin', False otherwise."""
|
||||
return role == "Admin" if role else False
|
||||
|
||||
|
||||
@router.get("", response_model=AuthUsersListResponse)
|
||||
def list_auth_users(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUsersListResponse:
|
||||
"""List all users from auth database - admin only."""
|
||||
try:
|
||||
# Check if optional columns exist
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
|
||||
|
||||
# Query users from auth database with all columns from schema
|
||||
# Try to include is_active and role if columns exist
|
||||
result = None
|
||||
try:
|
||||
# Build SELECT query based on which columns exist
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
except Exception:
|
||||
# Rollback the failed transaction before trying again
|
||||
auth_db.rollback()
|
||||
try:
|
||||
# Try with is_active only (no role)
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
except Exception:
|
||||
# Rollback again before final attempt
|
||||
auth_db.rollback()
|
||||
# Base columns only
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
is_admin,
|
||||
has_write_access,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to query auth users",
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
users = []
|
||||
for row in rows:
|
||||
try:
|
||||
# Access row attributes directly - SQLAlchemy Row objects support attribute access
|
||||
user_id = int(row.id)
|
||||
email = str(row.email)
|
||||
name = row.name if row.name is not None else None
|
||||
|
||||
# Get boolean fields - convert to proper boolean
|
||||
# These columns have defaults so they should always have values
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
|
||||
# Optional columns - try to get from row, default to None if not selected
|
||||
is_active = None
|
||||
try:
|
||||
is_active = row.is_active
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
except (AttributeError, KeyError):
|
||||
# Column not selected or doesn't exist - default to True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
created_at = getattr(row, 'created_at', None)
|
||||
updated_at = getattr(row, 'updated_at', None)
|
||||
|
||||
users.append(AuthUserResponse(
|
||||
id=user_id,
|
||||
name=name,
|
||||
email=email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
))
|
||||
except Exception as row_error:
|
||||
logger.warning(f"Error processing auth user row: {row_error}")
|
||||
# Skip this row and continue
|
||||
continue
|
||||
|
||||
return AuthUsersListResponse(items=users, total=len(users))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
error_detail = f"Failed to list auth users: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(f"Error listing auth users: {error_detail}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list auth users: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AuthUserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: AuthUserCreateRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Create a new user in auth database - admin only."""
|
||||
try:
|
||||
# Check if user with same email already exists (email is unique)
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""), {"email": request.email})
|
||||
|
||||
existing = check_result.first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User with email '{request.email}' already exists",
|
||||
)
|
||||
|
||||
# Insert new user
|
||||
# Check database dialect for RETURNING support
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
|
||||
supports_returning = dialect == 'postgresql'
|
||||
|
||||
# Hash the password
|
||||
password_hash = hash_password(request.password)
|
||||
|
||||
if supports_returning:
|
||||
result = auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": request.has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
row = result.first()
|
||||
else:
|
||||
# SQLite - insert then select
|
||||
auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": request.has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
# Get the last inserted row
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = last_insert_rowid()
|
||||
"""))
|
||||
row = result.first()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create user",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=AuthUserResponse)
|
||||
def get_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Get a specific auth user by ID - admin only."""
|
||||
try:
|
||||
# Check if optional columns exist
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
|
||||
|
||||
# Try to include is_active and role if columns exist
|
||||
try:
|
||||
# Build SELECT query based on which columns exist
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
except Exception:
|
||||
# Rollback the failed transaction before trying again
|
||||
auth_db.rollback()
|
||||
try:
|
||||
# Try with is_active only (no role)
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
except Exception:
|
||||
# Rollback again before final attempt
|
||||
auth_db.rollback()
|
||||
# Columns don't exist, select without them
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
is_active = getattr(row, 'is_active', None)
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=AuthUserResponse)
|
||||
def update_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
request: AuthUserUpdateRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Update an auth user - admin only."""
|
||||
try:
|
||||
# Check if user exists
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT id FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
if not check_result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
# Check if email conflicts with another user (email is unique)
|
||||
check_conflict = auth_db.execute(text("""
|
||||
SELECT id FROM users
|
||||
WHERE id != :user_id AND email = :email
|
||||
"""), {
|
||||
"user_id": user_id,
|
||||
"email": request.email,
|
||||
})
|
||||
|
||||
if check_conflict.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User with email '{request.email}' already exists",
|
||||
)
|
||||
|
||||
# Check if role column exists
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
|
||||
# Update all fields (all are required, is_active and role are optional)
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
|
||||
supports_returning = dialect == 'postgresql'
|
||||
|
||||
# Determine is_admin value - if role is provided and role column doesn't exist, use role to set is_admin
|
||||
is_admin_value = request.is_admin
|
||||
if request.role is not None and not has_role_column:
|
||||
# Role column doesn't exist, derive is_admin from role
|
||||
is_admin_value = _get_is_admin_from_role(request.role)
|
||||
|
||||
# Build UPDATE query - include is_active and role if provided and column exists
|
||||
update_fields = ["email = :email", "name = :name", "is_admin = :is_admin", "has_write_access = :has_write_access"]
|
||||
update_params = {
|
||||
"user_id": user_id,
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"is_admin": is_admin_value,
|
||||
"has_write_access": request.has_write_access,
|
||||
}
|
||||
|
||||
# Update password if provided
|
||||
if request.password and request.password.strip():
|
||||
password_hash = hash_password(request.password)
|
||||
update_fields.append("password_hash = :password_hash")
|
||||
update_params["password_hash"] = password_hash
|
||||
|
||||
if request.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
update_params["is_active"] = request.is_active
|
||||
|
||||
if request.role is not None and has_role_column:
|
||||
# Only update role if the column exists
|
||||
update_fields.append("role = :role")
|
||||
update_params["role"] = request.role
|
||||
|
||||
update_sql = f"""
|
||||
UPDATE users
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = :user_id
|
||||
"""
|
||||
|
||||
if supports_returning:
|
||||
# Build select fields - try to include optional columns
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if request.is_active is not None or _check_column_exists(auth_db, "users", "is_active"):
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
{update_sql}
|
||||
RETURNING {select_fields}
|
||||
"""), update_params)
|
||||
auth_db.commit()
|
||||
row = result.first()
|
||||
else:
|
||||
# SQLite - update then select
|
||||
auth_db.execute(text(update_sql), update_params)
|
||||
auth_db.commit()
|
||||
# Get the updated row - try to include optional columns
|
||||
try:
|
||||
if has_role_column:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
except Exception:
|
||||
auth_db.rollback()
|
||||
try:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
except Exception:
|
||||
auth_db.rollback()
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
is_active = getattr(row, 'is_active', None)
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> Response:
|
||||
"""Delete an auth user - admin only.
|
||||
|
||||
If the user has linked data (pending_photos, pending_identifications,
|
||||
inappropriate_photo_reports), the user will be set to inactive instead
|
||||
of deleted. Admins will be notified via logging.
|
||||
"""
|
||||
try:
|
||||
# Check if user exists and get user info
|
||||
user_result = auth_db.execute(text("""
|
||||
SELECT id, email, name FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
user_row = user_result.first()
|
||||
if not user_row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
user_email = user_row.email
|
||||
user_name = user_row.name or user_email
|
||||
|
||||
# Check for linked data in auth database
|
||||
pending_photos_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM pending_photos WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
pending_identifications_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM pending_identifications WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
inappropriate_reports_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM inappropriate_photo_reports WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
has_linked_data = (
|
||||
pending_photos_count > 0 or
|
||||
pending_identifications_count > 0 or
|
||||
inappropriate_reports_count > 0
|
||||
)
|
||||
|
||||
if has_linked_data:
|
||||
# Check if is_active column exists by trying to query it
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
|
||||
has_is_active_column = False
|
||||
|
||||
try:
|
||||
# Try to select is_active column to check if it exists
|
||||
test_result = auth_db.execute(text("""
|
||||
SELECT is_active FROM users WHERE id = :user_id LIMIT 1
|
||||
"""), {"user_id": user_id})
|
||||
test_result.first()
|
||||
has_is_active_column = True
|
||||
except Exception:
|
||||
# Column doesn't exist - this should have been added at startup
|
||||
# but if it wasn't, we can't proceed
|
||||
error_msg = "is_active column does not exist in auth database users table"
|
||||
logger.error(
|
||||
f"Cannot deactivate auth user '{user_name}' (ID: {user_id}): {error_msg}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Cannot delete user '{user_name}' because they have linked data "
|
||||
f"({pending_photos_count} pending photo(s), "
|
||||
f"{pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s)) "
|
||||
f"and the is_active column does not exist in the auth database users table. "
|
||||
f"Please restart the server to add the column automatically, or contact "
|
||||
f"your database administrator to add it manually: "
|
||||
f"ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE"
|
||||
),
|
||||
)
|
||||
|
||||
# Set user inactive instead of deleting
|
||||
if dialect == "postgresql":
|
||||
auth_db.execute(text("""
|
||||
UPDATE users SET is_active = FALSE WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
else:
|
||||
# SQLite uses 0 for FALSE
|
||||
auth_db.execute(text("""
|
||||
UPDATE users SET is_active = 0 WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
auth_db.commit()
|
||||
|
||||
# Notify admins via logging
|
||||
logger.warning(
|
||||
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was set to inactive "
|
||||
f"instead of deleted because they have linked data: {pending_photos_count} pending "
|
||||
f"photo(s), {pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s). "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"pending_photos_count": pending_photos_count,
|
||||
"pending_identifications_count": pending_identifications_count,
|
||||
"inappropriate_reports_count": inappropriate_reports_count,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
# Return success but indicate user was deactivated
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"message": (
|
||||
f"User '{user_name}' has been set to inactive because they have "
|
||||
f"linked data ({pending_photos_count} pending photo(s), "
|
||||
f"{pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s))."
|
||||
),
|
||||
"deactivated": True,
|
||||
"pending_photos_count": pending_photos_count,
|
||||
"pending_identifications_count": pending_identifications_count,
|
||||
"inappropriate_reports_count": inappropriate_reports_count,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No linked data - safe to delete
|
||||
auth_db.execute(text("""
|
||||
DELETE FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
auth_db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
error_str = str(e)
|
||||
# Check for permission errors
|
||||
if "permission denied" in error_str.lower() or "insufficient privilege" in error_str.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Permission denied: The database user does not have DELETE permission on the users table. Please contact your database administrator.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete auth user: {error_str}",
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
"""Basic health endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Job management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from rq import Queue
|
||||
from rq.job import Job
|
||||
from redis import Redis
|
||||
import json
|
||||
import time
|
||||
|
||||
from backend.schemas.jobs import JobResponse, JobStatus
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
queue = Queue(connection=redis_conn)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobResponse)
|
||||
def get_job(job_id: str) -> JobResponse:
|
||||
"""Get job status by ID."""
|
||||
try:
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
rq_status = job.get_status()
|
||||
status_map = {
|
||||
"queued": JobStatus.PENDING,
|
||||
"started": JobStatus.STARTED, # Job is actively running
|
||||
"finished": JobStatus.SUCCESS,
|
||||
"failed": JobStatus.FAILURE,
|
||||
}
|
||||
job_status = status_map.get(rq_status, JobStatus.PENDING)
|
||||
|
||||
# If job is started, check if it has progress
|
||||
if rq_status == "started":
|
||||
# Job is running - show progress if available
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
message = job.meta.get("message", "Processing...") if job.meta else "Processing..."
|
||||
# Map to PROGRESS status if we have actual progress
|
||||
if progress > 0:
|
||||
job_status = JobStatus.PROGRESS
|
||||
elif job_status == JobStatus.STARTED or job_status == JobStatus.PROGRESS:
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
elif job_status == JobStatus.SUCCESS:
|
||||
progress = 100
|
||||
else:
|
||||
progress = 0
|
||||
|
||||
message = job.meta.get("message", "") if job.meta else ""
|
||||
|
||||
# Check if job was cancelled
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
job_status = JobStatus.FAILURE
|
||||
message = job.meta.get("message", "Cancelled by user")
|
||||
|
||||
# If job failed, include error message
|
||||
if rq_status == "failed" and job.exc_info:
|
||||
# Extract error message from exception info
|
||||
error_lines = job.exc_info.split("\n")
|
||||
if error_lines:
|
||||
message = f"Failed: {error_lines[0]}"
|
||||
|
||||
return JobResponse(
|
||||
id=job.id,
|
||||
status=job_status,
|
||||
progress=progress,
|
||||
message=message,
|
||||
created_at=datetime.fromisoformat(str(job.created_at)),
|
||||
updated_at=datetime.fromisoformat(
|
||||
str(job.ended_at or job.started_at or job.created_at)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job {job_id} not found: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream/{job_id}")
|
||||
def stream_job_progress(job_id: str):
|
||||
"""Stream job progress via Server-Sent Events (SSE)."""
|
||||
|
||||
def event_generator():
|
||||
"""Generate SSE events for job progress."""
|
||||
last_progress = -1
|
||||
last_message = ""
|
||||
|
||||
while True:
|
||||
try:
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
status_map = {
|
||||
"queued": JobStatus.PENDING,
|
||||
"started": JobStatus.STARTED,
|
||||
"finished": JobStatus.SUCCESS,
|
||||
"failed": JobStatus.FAILURE,
|
||||
}
|
||||
job_status = status_map.get(job.get_status(), JobStatus.PENDING)
|
||||
|
||||
# Check if job was cancelled first
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
job_status = JobStatus.FAILURE
|
||||
message = job.meta.get("message", "Cancelled by user")
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
else:
|
||||
progress = 0
|
||||
if job_status == JobStatus.STARTED:
|
||||
# Job is running - show progress if available
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
# Map to PROGRESS status if we have actual progress
|
||||
if progress > 0:
|
||||
job_status = JobStatus.PROGRESS
|
||||
elif job_status == JobStatus.PROGRESS:
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
elif job_status == JobStatus.SUCCESS:
|
||||
progress = 100
|
||||
elif job_status == JobStatus.FAILURE:
|
||||
progress = 0
|
||||
|
||||
message = job.meta.get("message", "") if job.meta else ""
|
||||
|
||||
# Only send event if progress or message changed
|
||||
if progress != last_progress or message != last_message:
|
||||
event_data = {
|
||||
"id": job.id,
|
||||
"status": job_status.value,
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
"processed": job.meta.get("processed", 0) if job.meta else 0,
|
||||
"total": job.meta.get("total", 0) if job.meta else 0,
|
||||
"faces_detected": job.meta.get("faces_detected", 0) if job.meta else 0,
|
||||
"faces_stored": job.meta.get("faces_stored", 0) if job.meta else 0,
|
||||
}
|
||||
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
last_progress = progress
|
||||
last_message = message
|
||||
|
||||
# Stop streaming if job is complete or failed
|
||||
if job_status in (JobStatus.SUCCESS, JobStatus.FAILURE):
|
||||
break
|
||||
|
||||
time.sleep(0.5) # Poll every 500ms
|
||||
|
||||
except Exception as e:
|
||||
error_data = {"error": str(e)}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
def cancel_job(job_id: str) -> dict:
|
||||
"""Cancel a job (if queued) or stop a running job.
|
||||
|
||||
Note: For running jobs, this sets a cancellation flag.
|
||||
The job will check this flag and exit gracefully.
|
||||
"""
|
||||
try:
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
rq_status = job.get_status()
|
||||
|
||||
if rq_status == "finished":
|
||||
return {
|
||||
"message": f"Job {job_id} is already finished",
|
||||
"status": "finished",
|
||||
}
|
||||
|
||||
if rq_status == "failed":
|
||||
return {
|
||||
"message": f"Job {job_id} already failed",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
if rq_status == "queued":
|
||||
# Cancel queued job - remove from queue
|
||||
job.cancel()
|
||||
return {
|
||||
"message": f"Job {job_id} cancelled (was queued)",
|
||||
"status": "cancelled",
|
||||
}
|
||||
|
||||
if rq_status == "started":
|
||||
# For running jobs, set cancellation flag in metadata
|
||||
# The task will check this and exit gracefully
|
||||
if job.meta is None:
|
||||
job.meta = {}
|
||||
job.meta["cancelled"] = True
|
||||
job.meta["message"] = "Cancellation requested..."
|
||||
job.save_meta()
|
||||
|
||||
# Also try to cancel the job (which will interrupt it if possible)
|
||||
try:
|
||||
job.cancel()
|
||||
except Exception:
|
||||
# Job might already be running, that's OK
|
||||
pass
|
||||
|
||||
return {
|
||||
"message": f"Job {job_id} cancellation requested",
|
||||
"status": "cancelling",
|
||||
}
|
||||
|
||||
return {
|
||||
"message": f"Job {job_id} status: {rq_status}",
|
||||
"status": rq_status,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job {job_id} not found: {str(e)}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Metrics endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def get_metrics() -> dict:
|
||||
"""Basic metrics endpoint - placeholder for Phase 1."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Metrics endpoint - to be enhanced in future phases",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
"""Pending identifications endpoints for approval workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Face, Person, PersonEncoding, User
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
|
||||
|
||||
|
||||
def get_or_create_frontend_user(db: Session) -> User:
|
||||
"""Get or create the special 'FrontEndUser' system user.
|
||||
|
||||
This user represents identifications made through the frontend approval UI,
|
||||
distinguishing them from direct user identifications.
|
||||
"""
|
||||
FRONTEND_USERNAME = "FrontEndUser"
|
||||
|
||||
# Try to get existing user
|
||||
user = db.query(User).filter(User.username == FRONTEND_USERNAME).first()
|
||||
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create the system user if it doesn't exist
|
||||
# Use a non-loginable password hash (random, won't be used for login)
|
||||
default_password_hash = hash_password("system_user_not_for_login")
|
||||
|
||||
user = User(
|
||||
username=FRONTEND_USERNAME,
|
||||
password_hash=default_password_hash,
|
||||
email="frontend@punimtag.system",
|
||||
full_name="Frontend System User",
|
||||
is_active=False, # Not an active user, just a system marker
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
password_change_required=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PendingIdentificationResponse(BaseModel):
|
||||
"""Pending identification DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
face_id: int
|
||||
photo_id: Optional[int] = None
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PendingIdentificationsListResponse(BaseModel):
|
||||
"""List of pending identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingIdentificationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ApproveDenyDecision(BaseModel):
|
||||
"""Decision for a single pending identification."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'deny'
|
||||
|
||||
|
||||
class ApproveDenyRequest(BaseModel):
|
||||
"""Request to approve/deny multiple pending identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ApproveDenyDecision]
|
||||
|
||||
|
||||
class ApproveDenyResponse(BaseModel):
|
||||
"""Response from approve/deny operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
denied: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class UserIdentificationStats(BaseModel):
|
||||
"""Statistics for a single user's identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
full_name: str
|
||||
email: str
|
||||
face_count: int
|
||||
first_identification_date: Optional[datetime] = None
|
||||
last_identification_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class IdentificationReportResponse(BaseModel):
|
||||
"""Response containing identification statistics grouped by user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[UserIdentificationStats]
|
||||
total_faces: int
|
||||
total_users: int
|
||||
|
||||
|
||||
class ClearDatabaseResponse(BaseModel):
|
||||
"""Response from clearing denied records."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingIdentificationsListResponse)
|
||||
def list_pending_identifications(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
include_denied: bool = False,
|
||||
db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> PendingIdentificationsListResponse:
|
||||
"""List all pending identifications from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all pending identifications from the pending_identifications table.
|
||||
By default, only shows records with status='pending' for approval.
|
||||
Set include_denied=True to also show denied records.
|
||||
"""
|
||||
try:
|
||||
# Query pending_identifications from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
# Filter by status='pending' to show only records awaiting approval
|
||||
# Optionally include denied records if include_denied is True
|
||||
if include_denied:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth,
|
||||
pi.status,
|
||||
pi.created_at,
|
||||
pi.updated_at
|
||||
FROM pending_identifications pi
|
||||
LEFT JOIN users u ON pi.user_id = u.id
|
||||
WHERE pi.status IN ('pending', 'denied')
|
||||
ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
|
||||
"""))
|
||||
else:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth,
|
||||
pi.status,
|
||||
pi.created_at,
|
||||
pi.updated_at
|
||||
FROM pending_identifications pi
|
||||
LEFT JOIN users u ON pi.user_id = u.id
|
||||
WHERE pi.status = 'pending'
|
||||
ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
# Get photo_id from main database
|
||||
photo_id = None
|
||||
face = main_db.query(Face).filter(Face.id == row.face_id).first()
|
||||
if face:
|
||||
photo_id = face.photo_id
|
||||
|
||||
items.append(PendingIdentificationResponse(
|
||||
id=row.id,
|
||||
face_id=row.face_id,
|
||||
photo_id=photo_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
middle_name=row.middle_name,
|
||||
maiden_name=row.maiden_name,
|
||||
date_of_birth=row.date_of_birth,
|
||||
status=row.status,
|
||||
created_at=str(row.created_at) if row.created_at else '',
|
||||
updated_at=str(row.updated_at) if row.updated_at else '',
|
||||
))
|
||||
|
||||
return PendingIdentificationsListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approve-deny", response_model=ApproveDenyResponse)
|
||||
def approve_deny_pending_identifications(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
request: ApproveDenyRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ApproveDenyResponse:
|
||||
"""Approve or deny pending identifications.
|
||||
|
||||
For approved identifications:
|
||||
- Updates status in auth database to 'approved'
|
||||
- Identifies the face in main database
|
||||
- Creates person if needed
|
||||
|
||||
For denied identifications:
|
||||
- Updates status in auth database to 'denied'
|
||||
"""
|
||||
approved_count = 0
|
||||
denied_count = 0
|
||||
errors = []
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get pending identification from auth database
|
||||
# Allow processing of both 'pending' and 'denied' status (to allow re-approval)
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth
|
||||
FROM pending_identifications pi
|
||||
WHERE pi.id = :id AND pi.status IN ('pending', 'denied')
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Pending identification {decision.id} not found or already processed")
|
||||
continue
|
||||
|
||||
if decision.decision == 'approve':
|
||||
# Identify the face in main database
|
||||
face = main_db.query(Face).filter(Face.id == row.face_id).first()
|
||||
if not face:
|
||||
errors.append(f"Face {row.face_id} not found in main database")
|
||||
# Still update status to denied since we can't process it
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'denied', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
denied_count += 1
|
||||
continue
|
||||
|
||||
# Check if person already exists (by name and DOB)
|
||||
# Match the unique constraint: first_name, last_name, middle_name, maiden_name, date_of_birth
|
||||
# Build query with proper None handling
|
||||
query = main_db.query(Person).filter(
|
||||
Person.first_name == row.first_name,
|
||||
Person.last_name == row.last_name,
|
||||
)
|
||||
# Handle optional fields - use IS NULL for None values
|
||||
if row.middle_name:
|
||||
query = query.filter(Person.middle_name == row.middle_name)
|
||||
else:
|
||||
query = query.filter(Person.middle_name.is_(None))
|
||||
|
||||
if row.maiden_name:
|
||||
query = query.filter(Person.maiden_name == row.maiden_name)
|
||||
else:
|
||||
query = query.filter(Person.maiden_name.is_(None))
|
||||
|
||||
if row.date_of_birth:
|
||||
query = query.filter(Person.date_of_birth == row.date_of_birth)
|
||||
else:
|
||||
query = query.filter(Person.date_of_birth.is_(None))
|
||||
|
||||
person = query.first()
|
||||
|
||||
# Create person if doesn't exist
|
||||
created_person = False
|
||||
if not person:
|
||||
person = Person(
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
middle_name=row.middle_name,
|
||||
maiden_name=row.maiden_name,
|
||||
date_of_birth=row.date_of_birth,
|
||||
)
|
||||
main_db.add(person)
|
||||
main_db.flush() # get person.id
|
||||
created_person = True
|
||||
|
||||
# Link face to person
|
||||
# Use FrontEndUser to indicate this was approved through the frontend UI
|
||||
frontend_user = get_or_create_frontend_user(main_db)
|
||||
face.person_id = person.id
|
||||
face.identified_by_user_id = frontend_user.id
|
||||
main_db.add(face)
|
||||
|
||||
# Insert person_encoding
|
||||
pe = PersonEncoding(
|
||||
person_id=person.id,
|
||||
face_id=face.id,
|
||||
encoding=face.encoding,
|
||||
quality_score=face.quality_score,
|
||||
detector_backend=face.detector_backend,
|
||||
model_name=face.model_name,
|
||||
)
|
||||
main_db.add(pe)
|
||||
main_db.commit()
|
||||
|
||||
# Update status in auth database
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'approved', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
|
||||
approved_count += 1
|
||||
|
||||
elif decision.decision == 'deny':
|
||||
# Update status to denied
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'denied', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
|
||||
denied_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for pending identification {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending identification {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
|
||||
return ApproveDenyResponse(
|
||||
approved=approved_count,
|
||||
denied=denied_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.get("/report", response_model=IdentificationReportResponse)
|
||||
def get_identification_report(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> IdentificationReportResponse:
|
||||
"""Get identification statistics grouped by user.
|
||||
|
||||
Shows how many faces each user identified and when.
|
||||
Can be filtered by date range using PersonEncoding.created_date.
|
||||
"""
|
||||
# Query faces that have been identified (have person_id and identified_by_user_id)
|
||||
# Join with PersonEncoding to get created_date (when face was identified)
|
||||
# Join with User to get user information
|
||||
# Use distinct count to avoid counting the same face multiple times
|
||||
# (in case person encodings were updated, creating multiple PersonEncoding records)
|
||||
query = (
|
||||
main_db.query(
|
||||
User.id.label('user_id'),
|
||||
User.username,
|
||||
User.full_name,
|
||||
User.email,
|
||||
func.count(func.distinct(Face.id)).label('face_count'),
|
||||
func.min(PersonEncoding.created_date).label('first_date'),
|
||||
func.max(PersonEncoding.created_date).label('last_date')
|
||||
)
|
||||
.join(Face, User.id == Face.identified_by_user_id)
|
||||
.join(PersonEncoding, Face.id == PersonEncoding.face_id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.filter(Face.identified_by_user_id.isnot(None))
|
||||
.group_by(User.id, User.username, User.full_name, User.email)
|
||||
)
|
||||
|
||||
# Apply date filtering if provided (filter before grouping)
|
||||
if date_from:
|
||||
try:
|
||||
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) >= date_from_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_from format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) <= date_to_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_to format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Execute query and get results
|
||||
results = query.order_by(func.count(Face.id).desc(), User.username.asc()).all()
|
||||
|
||||
# Convert to response model
|
||||
items = []
|
||||
total_faces = 0
|
||||
|
||||
for row in results:
|
||||
total_faces += row.face_count
|
||||
items.append(
|
||||
UserIdentificationStats(
|
||||
user_id=row.user_id,
|
||||
username=row.username,
|
||||
full_name=row.full_name or row.username,
|
||||
email=row.email,
|
||||
face_count=row.face_count,
|
||||
first_identification_date=row.first_date,
|
||||
last_identification_date=row.last_date,
|
||||
)
|
||||
)
|
||||
|
||||
return IdentificationReportResponse(
|
||||
items=items,
|
||||
total_faces=total_faces,
|
||||
total_users=len(items)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
|
||||
def clear_denied_identifications(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> ClearDatabaseResponse:
|
||||
"""Delete all denied pending identifications from the database.
|
||||
|
||||
This permanently removes all records with status='denied' from the
|
||||
pending_identifications table in the auth database.
|
||||
"""
|
||||
deleted_records = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# First check if there are any denied records
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
denied_count = check_result.fetchone().count if check_result else 0
|
||||
|
||||
if denied_count == 0:
|
||||
# No denied records to delete
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Delete all denied records
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0 and denied_count > 0:
|
||||
errors.append("No records were deleted despite finding denied records")
|
||||
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
error_msg = str(e)
|
||||
errors.append(f"Error deleting denied records: {error_msg}")
|
||||
|
||||
# Check if it's a permission error
|
||||
if "permission denied" in error_msg.lower() or "insufficient privilege" in error_msg.lower():
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_identifications TO punimtag;\""
|
||||
)
|
||||
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Pending linkage review endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.users import require_feature_permission
|
||||
from backend.db.models import Photo, PhotoTagLinkage, Tag
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
|
||||
router = APIRouter(prefix="/pending-linkages", tags=["pending-linkages"])
|
||||
|
||||
|
||||
def _get_or_create_tag_by_name(db: Session, tag_name: str) -> tuple[Tag, bool]:
|
||||
"""Return a tag for the provided name, creating it if necessary."""
|
||||
normalized = (tag_name or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Tag name cannot be empty")
|
||||
|
||||
existing = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(normalized))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
tag = Tag(tag_name=normalized)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag, True
|
||||
|
||||
|
||||
def _format_datetime(value: Union[str, datetime, None]) -> Optional[str]:
|
||||
"""Safely serialize datetime values returned from different drivers."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
class PendingLinkageResponse(BaseModel):
|
||||
"""Pending linkage DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
tag_id: Optional[int] = None
|
||||
proposed_tag_name: Optional[str] = None
|
||||
resolved_tag_name: Optional[str] = None
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
photo_media_type: Optional[str] = None
|
||||
photo_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PendingLinkagesListResponse(BaseModel):
|
||||
"""List of pending linkage rows."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingLinkageResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision payload for a pending linkage row."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'deny'
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request payload for reviewing pending linkages."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Review summary returned after processing decisions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
denied: int
|
||||
tags_created: int
|
||||
linkages_created: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingLinkagesListResponse)
|
||||
def list_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
description="Optional status filter: pending, approved, or denied."
|
||||
),
|
||||
] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> PendingLinkagesListResponse:
|
||||
"""List all pending linkages stored in the auth database."""
|
||||
valid_statuses = {"pending", "approved", "denied"}
|
||||
if status_filter and status_filter not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid status_filter. Use pending, approved, or denied.",
|
||||
)
|
||||
|
||||
try:
|
||||
params = {}
|
||||
status_clause = ""
|
||||
if status_filter:
|
||||
status_clause = "WHERE pl.status = :status_filter"
|
||||
params["status_filter"] = status_filter
|
||||
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
pl.id,
|
||||
pl.photo_id,
|
||||
pl.tag_id,
|
||||
pl.tag_name,
|
||||
pl.user_id,
|
||||
pl.status,
|
||||
pl.notes,
|
||||
pl.created_at,
|
||||
pl.updated_at,
|
||||
u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM pending_linkages pl
|
||||
LEFT JOIN users u ON pl.user_id = u.id
|
||||
{status_clause}
|
||||
ORDER BY pl.created_at DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
photo_ids = {row.photo_id for row in rows if row.photo_id}
|
||||
tag_ids = {row.tag_id for row in rows if row.tag_id}
|
||||
|
||||
photo_map: dict[int, Photo] = {}
|
||||
if photo_ids:
|
||||
photos = (
|
||||
main_db.query(Photo)
|
||||
.filter(Photo.id.in_(photo_ids))
|
||||
.all()
|
||||
)
|
||||
photo_map = {photo.id: photo for photo in photos}
|
||||
|
||||
tag_map: dict[int, str] = {}
|
||||
if tag_ids:
|
||||
tags = (
|
||||
main_db.query(Tag)
|
||||
.filter(Tag.id.in_(tag_ids))
|
||||
.all()
|
||||
)
|
||||
tag_map = {tag.id: tag.tag_name for tag in tags}
|
||||
|
||||
photo_tags_map: dict[int, list[str]] = {
|
||||
photo_id: [] for photo_id in photo_ids
|
||||
}
|
||||
if photo_ids:
|
||||
tag_rows = (
|
||||
main_db.query(PhotoTagLinkage.photo_id, Tag.tag_name)
|
||||
.join(Tag, Tag.id == PhotoTagLinkage.tag_id)
|
||||
.filter(PhotoTagLinkage.photo_id.in_(photo_ids))
|
||||
.all()
|
||||
)
|
||||
for photo_id, tag_name in tag_rows:
|
||||
photo_tags_map.setdefault(photo_id, []).append(tag_name)
|
||||
|
||||
items: list[PendingLinkageResponse] = []
|
||||
for row in rows:
|
||||
created_at = _format_datetime(getattr(row, "created_at", None)) or ""
|
||||
updated_at = _format_datetime(getattr(row, "updated_at", None))
|
||||
photo = photo_map.get(row.photo_id)
|
||||
resolved_tag_name = None
|
||||
if row.tag_id:
|
||||
resolved_tag_name = tag_map.get(row.tag_id)
|
||||
proposal_name = row.tag_name
|
||||
items.append(
|
||||
PendingLinkageResponse(
|
||||
id=row.id,
|
||||
photo_id=row.photo_id,
|
||||
tag_id=row.tag_id,
|
||||
proposed_tag_name=proposal_name,
|
||||
resolved_tag_name=resolved_tag_name or proposal_name,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
status=row.status,
|
||||
notes=row.notes,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
photo_filename=photo.filename if photo else None,
|
||||
photo_path=photo.path if photo else None,
|
||||
photo_media_type=photo.media_type if photo else None,
|
||||
photo_tags=photo_tags_map.get(row.photo_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return PendingLinkagesListResponse(items=items, total=len(items))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading pending linkages: {exc}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Approve or deny pending user-proposed tag linkages."""
|
||||
approved = 0
|
||||
denied = 0
|
||||
tags_created = 0
|
||||
linkages_created = 0
|
||||
errors: list[str] = []
|
||||
now = datetime.utcnow()
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
row = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, photo_id, tag_id, tag_name, status
|
||||
FROM pending_linkages
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": decision.id},
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
errors.append(
|
||||
f"Pending linkage {decision.id} not found or already deleted"
|
||||
)
|
||||
continue
|
||||
|
||||
if row.status != "pending":
|
||||
errors.append(
|
||||
f"Pending linkage {decision.id} cannot be reviewed (status={row.status})"
|
||||
)
|
||||
continue
|
||||
|
||||
if decision.decision == "deny":
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_linkages
|
||||
SET status = 'denied',
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": decision.id, "updated_at": now},
|
||||
)
|
||||
auth_db.commit()
|
||||
denied += 1
|
||||
continue
|
||||
|
||||
if decision.decision != "approve":
|
||||
errors.append(
|
||||
f"Invalid decision '{decision.decision}' for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
|
||||
photo = (
|
||||
main_db.query(Photo)
|
||||
.filter(Photo.id == row.photo_id)
|
||||
.first()
|
||||
)
|
||||
if not photo:
|
||||
errors.append(
|
||||
f"Photo {row.photo_id} not found for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
|
||||
tag_obj: Optional[Tag] = None
|
||||
created_tag = False
|
||||
|
||||
if row.tag_id:
|
||||
tag_obj = (
|
||||
main_db.query(Tag)
|
||||
.filter(Tag.id == row.tag_id)
|
||||
.first()
|
||||
)
|
||||
if not tag_obj and row.tag_name:
|
||||
tag_obj, created_tag = _get_or_create_tag_by_name(
|
||||
main_db, row.tag_name
|
||||
)
|
||||
elif not tag_obj:
|
||||
errors.append(
|
||||
f"Tag {row.tag_id} missing for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if not row.tag_name:
|
||||
errors.append(
|
||||
f"No tag information provided for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
tag_obj, created_tag = _get_or_create_tag_by_name(
|
||||
main_db, row.tag_name
|
||||
)
|
||||
|
||||
if created_tag:
|
||||
tags_created += 1
|
||||
|
||||
resolved_tag_id = tag_obj.id # type: ignore[union-attr]
|
||||
|
||||
existing_linkage = (
|
||||
main_db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == row.photo_id,
|
||||
PhotoTagLinkage.tag_id == resolved_tag_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing_linkage:
|
||||
linkage = PhotoTagLinkage(
|
||||
photo_id=row.photo_id,
|
||||
tag_id=resolved_tag_id,
|
||||
)
|
||||
main_db.add(linkage)
|
||||
linkages_created += 1
|
||||
|
||||
main_db.commit()
|
||||
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_linkages
|
||||
SET status = 'approved',
|
||||
tag_id = :tag_id,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": decision.id,
|
||||
"tag_id": resolved_tag_id,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
approved += 1
|
||||
except ValueError as exc:
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
errors.append(f"Validation error for linkage {decision.id}: {exc}")
|
||||
except Exception as exc:
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
errors.append(f"Error processing linkage {decision.id}: {exc}")
|
||||
|
||||
return ReviewResponse(
|
||||
approved=approved,
|
||||
denied=denied,
|
||||
tags_created=tags_created,
|
||||
linkages_created=linkages_created,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response payload for cleanup operations."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete all approved or denied records from pending_linkages table."""
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM pending_linkages
|
||||
WHERE status IN ('approved', 'denied')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0:
|
||||
warnings.append("No approved or denied pending linkages to delete.")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cleanup pending linkages: {exc}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Pending photos endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.services.photo_service import import_photo_from_path, calculate_file_hash
|
||||
from backend.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
|
||||
|
||||
|
||||
class PendingPhotoResponse(BaseModel):
|
||||
"""Pending photo DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
filename: str
|
||||
original_filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
status: str
|
||||
submitted_at: str
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PendingPhotosListResponse(BaseModel):
|
||||
"""List of pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingPhotoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision for a single pending photo."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'reject'
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request to review multiple pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Response from review operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
rejected: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = [] # Informational messages (e.g., duplicates)
|
||||
|
||||
|
||||
@router.get("", response_model=PendingPhotosListResponse)
|
||||
def list_pending_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> PendingPhotosListResponse:
|
||||
"""List all pending photos from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all pending photos from the pending_photos table.
|
||||
Optionally filter by status: 'pending', 'approved', or 'rejected'.
|
||||
"""
|
||||
try:
|
||||
# Query pending_photos from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
WHERE pp.status = :status_filter
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(PendingPhotoResponse(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
filename=row.filename,
|
||||
original_filename=row.original_filename,
|
||||
file_path=row.file_path,
|
||||
file_size=row.file_size,
|
||||
mime_type=row.mime_type,
|
||||
status=row.status,
|
||||
submitted_at=str(row.submitted_at) if row.submitted_at else '',
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
rejection_reason=row.rejection_reason,
|
||||
))
|
||||
|
||||
return PendingPhotosListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/image")
|
||||
def get_pending_photo_image(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> FileResponse:
|
||||
"""Get the image file for a pending photo.
|
||||
|
||||
Photos are stored in /mnt/db-server-uploads. The file_path in the database
|
||||
may be relative (just filename) or absolute. This function handles both cases.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT file_path, mime_type, filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Pending photo {photo_id} not found"
|
||||
)
|
||||
|
||||
# Base directory for uploaded photos
|
||||
base_dir = Path("/mnt/db-server-uploads")
|
||||
|
||||
# Handle both absolute and relative paths
|
||||
db_file_path = row.file_path
|
||||
if os.path.isabs(db_file_path):
|
||||
# Absolute path - use as is
|
||||
file_path = Path(db_file_path)
|
||||
else:
|
||||
# Relative path - prepend base directory
|
||||
file_path = base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist at constructed path, try just the filename
|
||||
if not file_path.exists():
|
||||
# Try with just the filename from database
|
||||
file_path = base_dir / row.filename
|
||||
if not file_path.exists():
|
||||
# Try with original_filename if available
|
||||
result2 = auth_db.execute(text("""
|
||||
SELECT original_filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
row2 = result2.fetchone()
|
||||
if row2 and row2.original_filename:
|
||||
file_path = base_dir / row2.original_filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo file not found at {file_path}"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=row.mime_type or "image/jpeg",
|
||||
filename=file_path.name
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error retrieving photo: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Review pending photos - approve or reject them.
|
||||
|
||||
For 'approve' decision:
|
||||
- Moves photo file from /mnt/db-server-uploads to main photo storage
|
||||
- Imports photo into main database (Scan process)
|
||||
- Updates status in auth database to 'approved'
|
||||
|
||||
For 'reject' decision:
|
||||
- Updates status in auth database to 'rejected'
|
||||
- Photo file remains in place (can be deleted later if needed)
|
||||
"""
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
approved_count = 0
|
||||
rejected_count = 0
|
||||
duplicate_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Base directories
|
||||
upload_base_dir = Path("/mnt/db-server-uploads")
|
||||
main_storage_dir = Path(PHOTO_STORAGE_DIR)
|
||||
main_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get pending photo from auth database with file info
|
||||
# Only allow processing 'pending' status photos
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.status,
|
||||
pp.file_path,
|
||||
pp.filename,
|
||||
pp.original_filename
|
||||
FROM pending_photos pp
|
||||
WHERE pp.id = :id AND pp.status = 'pending'
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Pending photo {decision.id} not found or already reviewed")
|
||||
continue
|
||||
|
||||
if decision.decision == 'approve':
|
||||
# Find the source file
|
||||
db_file_path = row.file_path
|
||||
source_path = None
|
||||
|
||||
# Try to find the file - handle both absolute and relative paths
|
||||
if os.path.isabs(db_file_path):
|
||||
source_path = Path(db_file_path)
|
||||
else:
|
||||
source_path = upload_base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist, try with filename
|
||||
if not source_path.exists():
|
||||
source_path = upload_base_dir / row.filename
|
||||
if not source_path.exists() and row.original_filename:
|
||||
source_path = upload_base_dir / row.original_filename
|
||||
|
||||
if not source_path.exists():
|
||||
errors.append(f"Photo file not found for pending photo {decision.id}: {source_path}")
|
||||
continue
|
||||
|
||||
# Calculate file hash and check for duplicates BEFORE moving file
|
||||
try:
|
||||
file_hash = calculate_file_hash(str(source_path))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Check if photo with same hash already exists in main database
|
||||
existing_photo = main_db.execute(text("""
|
||||
SELECT id, path FROM photos WHERE file_hash = :file_hash
|
||||
"""), {"file_hash": file_hash}).fetchone()
|
||||
|
||||
if existing_photo:
|
||||
# Photo already exists - mark as duplicate and skip import
|
||||
# Don't add to errors - we'll show a summary message instead
|
||||
# Update status to rejected with duplicate reason
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'rejected',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
rejection_reason = 'Duplicate photo already exists in database'
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
})
|
||||
auth_db.commit()
|
||||
rejected_count += 1
|
||||
duplicate_count += 1
|
||||
continue
|
||||
|
||||
# Generate unique filename for main storage to avoid conflicts
|
||||
file_ext = source_path.suffix
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
dest_path = main_storage_dir / unique_filename
|
||||
|
||||
# Copy file to main storage (keep original in shared location)
|
||||
try:
|
||||
shutil.copy2(str(source_path), str(dest_path))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Import photo into main database (Scan process)
|
||||
# This will also check for duplicates by hash, but we've already checked above
|
||||
try:
|
||||
photo, is_new = import_photo_from_path(main_db, str(dest_path))
|
||||
if not is_new:
|
||||
# Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
errors.append(f"Photo already exists in main database: {photo.path}")
|
||||
continue
|
||||
except Exception as e:
|
||||
# If import fails, delete the copied file (original remains in shared location)
|
||||
if dest_path.exists():
|
||||
try:
|
||||
dest_path.unlink()
|
||||
except:
|
||||
pass
|
||||
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
|
||||
continue
|
||||
|
||||
# Update status to approved in auth database
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'approved',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
approved_count += 1
|
||||
|
||||
elif decision.decision == 'reject':
|
||||
# Update status to rejected
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'rejected',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
rejection_reason = :rejection_reason
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"rejection_reason": decision.rejection_reason or None,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
rejected_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
auth_db.rollback()
|
||||
main_db.rollback()
|
||||
|
||||
# Add friendly message about duplicates if any were found
|
||||
warnings = []
|
||||
if duplicate_count > 0:
|
||||
if duplicate_count == 1:
|
||||
warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
|
||||
else:
|
||||
warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
|
||||
|
||||
return ReviewResponse(
|
||||
approved=approved_count,
|
||||
rejected=rejected_count,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response from cleanup operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_files: int
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = [] # Informational messages (e.g., files already deleted)
|
||||
|
||||
|
||||
@router.post("/cleanup-files", response_model=CleanupResponse)
|
||||
def cleanup_shared_files(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete photo files from shared space for approved or rejected photos.
|
||||
|
||||
Args:
|
||||
status_filter: Optional filter - 'approved', 'rejected', or None for both
|
||||
"""
|
||||
deleted_files = 0
|
||||
errors = []
|
||||
warnings = []
|
||||
upload_base_dir = Path("/mnt/db-server-uploads")
|
||||
|
||||
# Build query based on status filter
|
||||
if status_filter:
|
||||
query = text("""
|
||||
SELECT id, file_path, filename, original_filename, status
|
||||
FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
""")
|
||||
result = auth_db.execute(query, {"status_filter": status_filter})
|
||||
else:
|
||||
query = text("""
|
||||
SELECT id, file_path, filename, original_filename, status
|
||||
FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
""")
|
||||
result = auth_db.execute(query)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
# Find the file - handle both absolute and relative paths
|
||||
db_file_path = row.file_path
|
||||
file_path = None
|
||||
|
||||
if os.path.isabs(db_file_path):
|
||||
file_path = Path(db_file_path)
|
||||
else:
|
||||
file_path = upload_base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist, try with filename
|
||||
if not file_path.exists():
|
||||
file_path = upload_base_dir / row.filename
|
||||
if not file_path.exists() and row.original_filename:
|
||||
file_path = upload_base_dir / row.original_filename
|
||||
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_files += 1
|
||||
except PermissionError:
|
||||
errors.append(f"Permission denied deleting file for pending photo {row.id}: {file_path}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to delete file for pending photo {row.id}: {str(e)}")
|
||||
else:
|
||||
# File not found is expected if already deleted - show as warning, not error
|
||||
warnings.append(f"File already deleted for pending photo {row.id}")
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending photo {row.id}: {str(e)}")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_files=deleted_files,
|
||||
deleted_records=0, # Files only, not records
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup-database", response_model=CleanupResponse)
|
||||
def cleanup_pending_photos_database(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for approved+rejected (excludes pending)"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete records from pending_photos table.
|
||||
|
||||
Args:
|
||||
status_filter: Optional filter - 'approved', 'rejected', or None for approved+rejected (excludes pending)
|
||||
"""
|
||||
deleted_records = 0
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
# First check if table exists and has records
|
||||
if status_filter:
|
||||
# Check count for specific status
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
# Check count for approved and rejected (exclude pending)
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
"""))
|
||||
total_count = check_result.fetchone().count if check_result else 0
|
||||
|
||||
if total_count == 0:
|
||||
# No records to delete - not an error, just return success
|
||||
return CleanupResponse(
|
||||
deleted_files=0,
|
||||
deleted_records=0,
|
||||
errors=[],
|
||||
warnings=[]
|
||||
)
|
||||
|
||||
# Perform deletion
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
# Default behavior: delete only approved and rejected, exclude pending
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
"""))
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0 and total_count > 0:
|
||||
# No records matched the filter - this shouldn't be an error if status_filter was provided
|
||||
# But if no filter and total_count > 0, something went wrong
|
||||
if not status_filter:
|
||||
errors.append(f"Expected to delete {total_count} record(s) but deleted 0. Check database permissions.")
|
||||
else:
|
||||
warnings.append(f"No records found matching status filter: {status_filter}")
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
|
||||
# Check if this is a permission error
|
||||
error_str = str(e)
|
||||
if "InsufficientPrivilege" in error_str or "permission denied" in error_str.lower():
|
||||
# Try to automatically grant the permission using sudo (non-interactive)
|
||||
import subprocess
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
# Get database name from connection
|
||||
auth_db_url = os.getenv("DATABASE_URL_AUTH", "")
|
||||
if auth_db_url:
|
||||
# Parse database URL to get database name
|
||||
if auth_db_url.startswith("postgresql+psycopg2://"):
|
||||
auth_db_url = auth_db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
parsed = urlparse(auth_db_url)
|
||||
db_name = parsed.path.lstrip("/")
|
||||
|
||||
# Try to grant permission using sudo -n (non-interactive, requires passwordless sudo)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo", "-n", "-u", "postgres", "psql", "-d", db_name,
|
||||
"-c", "GRANT DELETE ON TABLE pending_photos TO punimtag;"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Permission granted, try deletion again
|
||||
try:
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
"""))
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
# Success - return early
|
||||
return CleanupResponse(
|
||||
deleted_files=0,
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=[]
|
||||
)
|
||||
except Exception as retry_e:
|
||||
errors.append(f"Permission granted but deletion still failed: {str(retry_e)}")
|
||||
else:
|
||||
# Sudo failed (needs password) - provide instructions
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
f"sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
else:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
except Exception as grant_e:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
else:
|
||||
errors.append(f"Failed to delete records from database: {str(e)}")
|
||||
|
||||
# Log full traceback for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Cleanup database error: {error_details}")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_files=0, # Database only, not files
|
||||
deleted_records=deleted_records,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""People management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import Person, Face, PersonEncoding, PhotoPersonLinkage, Photo
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
from backend.schemas.people import (
|
||||
PeopleListResponse,
|
||||
PersonCreateRequest,
|
||||
PersonResponse,
|
||||
PersonUpdateRequest,
|
||||
PersonWithFacesResponse,
|
||||
PeopleWithFacesListResponse,
|
||||
)
|
||||
from backend.schemas.faces import PersonFacesResponse, PersonFaceItem, AcceptMatchesRequest, IdentifyFaceResponse
|
||||
from backend.services.face_service import accept_auto_match_matches
|
||||
|
||||
router = APIRouter(prefix="/people", tags=["people"])
|
||||
|
||||
|
||||
@router.get("", response_model=PeopleListResponse)
|
||||
def list_people(
|
||||
last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PeopleListResponse:
|
||||
"""List all people sorted by last_name, first_name.
|
||||
|
||||
Optionally filter by last_name if provided (case-insensitive search).
|
||||
"""
|
||||
query = db.query(Person)
|
||||
|
||||
if last_name:
|
||||
# Case-insensitive search on last_name
|
||||
query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name)))
|
||||
|
||||
people = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
|
||||
items = [PersonResponse.model_validate(p) for p in people]
|
||||
return PeopleListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
|
||||
def list_people_with_faces(
|
||||
last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PeopleWithFacesListResponse:
|
||||
"""List all people with face counts and video counts, sorted by last_name, first_name.
|
||||
|
||||
Optionally filter by last_name or maiden_name if provided (case-insensitive search).
|
||||
Returns all people, including those with zero faces or videos.
|
||||
"""
|
||||
# Query people with face counts using LEFT OUTER JOIN to include people with no faces
|
||||
query = (
|
||||
db.query(
|
||||
Person,
|
||||
func.count(Face.id.distinct()).label('face_count')
|
||||
)
|
||||
.outerjoin(Face, Person.id == Face.person_id)
|
||||
.group_by(Person.id)
|
||||
)
|
||||
|
||||
if last_name:
|
||||
# Case-insensitive search on both last_name and maiden_name
|
||||
search_term = last_name.lower()
|
||||
query = query.filter(
|
||||
(func.lower(Person.last_name).contains(search_term)) |
|
||||
((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
|
||||
)
|
||||
|
||||
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
|
||||
|
||||
# Get video counts separately for each person
|
||||
person_ids = [person.id for person, _ in results]
|
||||
video_counts = {}
|
||||
if person_ids:
|
||||
video_count_query = (
|
||||
db.query(
|
||||
PhotoPersonLinkage.person_id,
|
||||
func.count(PhotoPersonLinkage.id).label('video_count')
|
||||
)
|
||||
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
|
||||
.filter(
|
||||
PhotoPersonLinkage.person_id.in_(person_ids),
|
||||
Photo.media_type == "video"
|
||||
)
|
||||
.group_by(PhotoPersonLinkage.person_id)
|
||||
)
|
||||
for person_id, video_count in video_count_query.all():
|
||||
video_counts[person_id] = video_count
|
||||
|
||||
items = [
|
||||
PersonWithFacesResponse(
|
||||
id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
face_count=face_count or 0, # Convert None to 0 for people with no faces
|
||||
video_count=video_counts.get(person.id, 0), # Get video count or default to 0
|
||||
)
|
||||
for person, face_count in results
|
||||
]
|
||||
|
||||
return PeopleWithFacesListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=PersonResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) -> PersonResponse:
|
||||
"""Create a new person."""
|
||||
first_name = request.first_name.strip()
|
||||
last_name = request.last_name.strip()
|
||||
middle_name = request.middle_name.strip() if request.middle_name else None
|
||||
maiden_name = request.maiden_name.strip() if request.maiden_name else None
|
||||
person = Person(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
middle_name=middle_name,
|
||||
maiden_name=maiden_name,
|
||||
date_of_birth=request.date_of_birth,
|
||||
)
|
||||
db.add(person)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
db.refresh(person)
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{person_id}", response_model=PersonResponse)
|
||||
def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse:
|
||||
"""Get person by ID."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.put("/{person_id}", response_model=PersonResponse)
|
||||
def update_person(
|
||||
person_id: int,
|
||||
request: PersonUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonResponse:
|
||||
"""Update person information."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
# Update fields
|
||||
person.first_name = request.first_name.strip()
|
||||
person.last_name = request.last_name.strip()
|
||||
person.middle_name = request.middle_name.strip() if request.middle_name else None
|
||||
person.maiden_name = request.maiden_name.strip() if request.maiden_name else None
|
||||
person.date_of_birth = request.date_of_birth
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{person_id}/faces", response_model=PersonFacesResponse)
|
||||
def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFacesResponse:
|
||||
"""Get all faces for a specific person."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
from backend.db.models import Photo
|
||||
|
||||
faces = (
|
||||
db.query(Face)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id == person_id)
|
||||
.order_by(Photo.filename)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
PersonFaceItem(
|
||||
id=face.id,
|
||||
photo_id=face.photo_id,
|
||||
photo_path=face.photo.path,
|
||||
photo_filename=face.photo.filename,
|
||||
location=face.location,
|
||||
face_confidence=float(face.face_confidence),
|
||||
quality_score=float(face.quality_score),
|
||||
detector_backend=face.detector_backend,
|
||||
model_name=face.model_name,
|
||||
)
|
||||
for face in faces
|
||||
]
|
||||
|
||||
return PersonFacesResponse(person_id=person_id, items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{person_id}/videos")
|
||||
def get_person_videos(person_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""Get all videos linked to a specific person."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
# Get all video linkages for this person
|
||||
linkages = (
|
||||
db.query(PhotoPersonLinkage, Photo)
|
||||
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
|
||||
.filter(
|
||||
PhotoPersonLinkage.person_id == person_id,
|
||||
Photo.media_type == "video"
|
||||
)
|
||||
.order_by(Photo.filename)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": photo.id,
|
||||
"filename": photo.filename,
|
||||
"path": photo.path,
|
||||
"date_taken": photo.date_taken.isoformat() if photo.date_taken else None,
|
||||
"date_added": photo.date_added.isoformat() if photo.date_added else None,
|
||||
"linkage_id": linkage.id,
|
||||
}
|
||||
for linkage, photo in linkages
|
||||
]
|
||||
|
||||
return {
|
||||
"person_id": person_id,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse)
|
||||
def accept_matches(
|
||||
person_id: int,
|
||||
request: AcceptMatchesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyFaceResponse:
|
||||
"""Accept auto-match matches for a person.
|
||||
|
||||
Matches desktop auto-match save workflow exactly:
|
||||
1. Identifies selected faces with this person
|
||||
2. Inserts person_encodings for each identified face
|
||||
3. Updates person encodings (removes old, adds current)
|
||||
Tracks which user identified the faces.
|
||||
"""
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
|
||||
user_id = current_user["user_id"]
|
||||
identified_count, updated_count = accept_auto_match_matches(
|
||||
db, person_id, request.face_ids, user_id=user_id
|
||||
)
|
||||
|
||||
return IdentifyFaceResponse(
|
||||
identified_face_ids=request.face_ids,
|
||||
person_id=person_id,
|
||||
created_person=False,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{person_id}")
|
||||
def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response:
|
||||
"""Delete a person and all their linkages.
|
||||
|
||||
This will:
|
||||
1. Delete all person_encodings for this person
|
||||
2. Unlink all faces (set person_id to NULL)
|
||||
3. Delete all video linkages (PhotoPersonLinkage records)
|
||||
4. Delete the person record
|
||||
"""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Person {person_id} not found",
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete all person_encodings for this person
|
||||
db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete(synchronize_session=False)
|
||||
|
||||
# Unlink all faces (set person_id to NULL)
|
||||
db.query(Face).filter(Face.person_id == person_id).update(
|
||||
{"person_id": None}, synchronize_session=False
|
||||
)
|
||||
|
||||
# Delete all video linkages (PhotoPersonLinkage records)
|
||||
db.query(PhotoPersonLinkage).filter(PhotoPersonLinkage.person_id == person_id).delete(synchronize_session=False)
|
||||
|
||||
# Delete the person record
|
||||
db.delete(person)
|
||||
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete person: {str(e)}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,971 @@
|
||||
"""Photo management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import JSONResponse, FileResponse
|
||||
from typing import Annotated
|
||||
from rq import Queue
|
||||
from redis import Redis
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.api.users import get_current_admin_user
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
queue = Queue(connection=redis_conn)
|
||||
from backend.schemas.photos import (
|
||||
PhotoImportRequest,
|
||||
PhotoImportResponse,
|
||||
PhotoResponse,
|
||||
BulkAddFavoritesRequest,
|
||||
BulkAddFavoritesResponse,
|
||||
BulkDeletePhotosRequest,
|
||||
BulkDeletePhotosResponse,
|
||||
BulkRemoveFavoritesRequest,
|
||||
BulkRemoveFavoritesResponse,
|
||||
)
|
||||
from backend.schemas.search import (
|
||||
PhotoSearchResult,
|
||||
SearchPhotosResponse,
|
||||
)
|
||||
from backend.services.photo_service import (
|
||||
find_photos_in_folder,
|
||||
import_photo_from_path,
|
||||
)
|
||||
from backend.services.search_service import (
|
||||
get_favorite_photos,
|
||||
get_photo_face_count,
|
||||
get_photo_person,
|
||||
get_photo_tags,
|
||||
get_photos_without_faces,
|
||||
get_photos_without_tags,
|
||||
get_processed_photos,
|
||||
get_unprocessed_photos,
|
||||
search_photos_by_date,
|
||||
search_photos_by_name,
|
||||
search_photos_by_tags,
|
||||
)
|
||||
# Note: Function passed as string path to avoid RQ serialization issues
|
||||
|
||||
router = APIRouter(prefix="/photos", tags=["photos"])
|
||||
|
||||
|
||||
@router.get("", response_model=SearchPhotosResponse)
|
||||
def search_photos(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
search_type: str = Query("name", description="Search type: name, date, tags, no_faces, no_tags, processed, unprocessed, favorites"),
|
||||
person_name: Optional[str] = Query(None, description="Person name for name search"),
|
||||
tag_names: Optional[str] = Query(None, description="Comma-separated tag names for tag search"),
|
||||
match_all: bool = Query(False, description="Match all tags (for tag search)"),
|
||||
date_from: Optional[str] = Query(None, description="Date from (YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Date to (YYYY-MM-DD)"),
|
||||
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
||||
media_type: Optional[str] = Query(None, description="Filter by media type: 'all', 'image', or 'video'"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SearchPhotosResponse:
|
||||
"""Search photos with filters.
|
||||
|
||||
Matches desktop search functionality exactly:
|
||||
- Search by name: person_name required
|
||||
- Search by date: date_from or date_to required
|
||||
- Search by tags: tag_names required (comma-separated)
|
||||
- Search no faces: returns photos without faces
|
||||
- Search no tags: returns photos without tags
|
||||
- Search processed: returns photos that have been processed for face detection
|
||||
- Search unprocessed: returns photos that have not been processed for face detection
|
||||
- Search favorites: returns photos favorited by current user
|
||||
"""
|
||||
from backend.db.models import PhotoFavorite
|
||||
|
||||
items: List[PhotoSearchResult] = []
|
||||
total = 0
|
||||
username = current_user["username"] if current_user else None
|
||||
|
||||
# Helper function to check if photo is favorite
|
||||
def check_is_favorite(photo_id: int) -> bool:
|
||||
if not username:
|
||||
return False
|
||||
favorite = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
return favorite is not None
|
||||
|
||||
# Parse date filters for use as additional filters (when not using date search type)
|
||||
df = date.fromisoformat(date_from) if date_from else None
|
||||
dt = date.fromisoformat(date_to) if date_to else None
|
||||
|
||||
# Parse tag filters for use as additional filters
|
||||
tag_list = None
|
||||
if tag_names:
|
||||
tag_list = [t.strip() for t in tag_names.split(",") if t.strip()]
|
||||
|
||||
if search_type == "name":
|
||||
if not person_name:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="person_name is required for name search",
|
||||
)
|
||||
results, total = search_photos_by_name(
|
||||
db, person_name, folder_path, media_type, df, dt, tag_list, match_all, page, page_size
|
||||
)
|
||||
for photo, full_name in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=full_name,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "date":
|
||||
if not date_from and not date_to:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one of date_from or date_to is required",
|
||||
)
|
||||
results, total = search_photos_by_date(db, df, dt, folder_path, media_type, tag_list, match_all, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "tags":
|
||||
if not tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="tag_names is required for tag search",
|
||||
)
|
||||
if not tag_list or len(tag_list) == 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="At least one tag name is required",
|
||||
)
|
||||
results, total = search_photos_by_tags(
|
||||
db, tag_list, match_all, folder_path, media_type, df, dt, page, page_size
|
||||
)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "no_faces":
|
||||
results, total = get_photos_without_faces(db, folder_path, media_type, df, dt, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=None,
|
||||
tags=tags,
|
||||
has_faces=False,
|
||||
face_count=0,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "no_tags":
|
||||
results, total = get_photos_without_tags(db, folder_path, media_type, df, dt, page, page_size)
|
||||
for photo in results:
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=[],
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "processed":
|
||||
results, total = get_processed_photos(db, folder_path, media_type, df, dt, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "unprocessed":
|
||||
results, total = get_unprocessed_photos(db, folder_path, media_type, df, dt, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=check_is_favorite(photo.id),
|
||||
)
|
||||
)
|
||||
elif search_type == "favorites":
|
||||
if not username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Authentication required for favorites search",
|
||||
)
|
||||
results, total = get_favorite_photos(db, username, folder_path, media_type, df, dt, page, page_size)
|
||||
for photo in results:
|
||||
tags = get_photo_tags(db, photo.id)
|
||||
face_count = get_photo_face_count(db, photo.id)
|
||||
person_name_val = get_photo_person(db, photo.id)
|
||||
# Convert datetime to date for date_added
|
||||
date_added = photo.date_added.date() if isinstance(photo.date_added, datetime) else photo.date_added
|
||||
items.append(
|
||||
PhotoSearchResult(
|
||||
id=photo.id,
|
||||
path=photo.path,
|
||||
filename=photo.filename,
|
||||
date_taken=photo.date_taken,
|
||||
date_added=date_added,
|
||||
processed=photo.processed,
|
||||
person_name=person_name_val,
|
||||
tags=tags,
|
||||
has_faces=face_count > 0,
|
||||
face_count=face_count,
|
||||
is_favorite=True, # All results are favorites
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid search_type: {search_type}",
|
||||
)
|
||||
|
||||
return SearchPhotosResponse(items=items, page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
@router.post("/import", response_model=PhotoImportResponse)
|
||||
def import_photos(
|
||||
request: PhotoImportRequest,
|
||||
) -> PhotoImportResponse:
|
||||
"""Import photos from a folder path.
|
||||
|
||||
This endpoint enqueues a background job to scan and import photos.
|
||||
"""
|
||||
if not request.folder_path:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="folder_path is required",
|
||||
)
|
||||
|
||||
# Validate folder exists
|
||||
import os
|
||||
|
||||
if not os.path.isdir(request.folder_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Folder not found: {request.folder_path}",
|
||||
)
|
||||
|
||||
# Estimate number of photos (quick scan)
|
||||
estimated_photos = len(find_photos_in_folder(request.folder_path, request.recursive))
|
||||
|
||||
# Enqueue job
|
||||
# Pass function as string path to avoid serialization issues
|
||||
job = queue.enqueue(
|
||||
"backend.services.tasks.import_photos_task",
|
||||
request.folder_path,
|
||||
request.recursive,
|
||||
job_timeout="1h", # Allow up to 1 hour for large imports
|
||||
)
|
||||
|
||||
return PhotoImportResponse(
|
||||
job_id=job.id,
|
||||
message=f"Photo import job queued for {request.folder_path}",
|
||||
folder_path=request.folder_path,
|
||||
estimated_photos=estimated_photos,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/import/upload")
|
||||
async def upload_photos(
|
||||
files: list[UploadFile] = File(...),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Upload photo files directly.
|
||||
|
||||
This endpoint accepts file uploads and imports them immediately.
|
||||
Files are saved to PHOTO_STORAGE_DIR before import.
|
||||
For large batches, prefer the /import endpoint with folder_path.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
# Ensure storage directory exists
|
||||
storage_dir = Path(PHOTO_STORAGE_DIR)
|
||||
storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
added_count = 0
|
||||
existing_count = 0
|
||||
errors = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
# Generate unique filename to avoid conflicts
|
||||
import uuid
|
||||
|
||||
file_ext = Path(file.filename).suffix
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
stored_path = storage_dir / unique_filename
|
||||
|
||||
# Save uploaded file to storage
|
||||
content = await file.read()
|
||||
with open(stored_path, "wb") as f:
|
||||
f.write(content)
|
||||
|
||||
# Import photo from stored location
|
||||
photo, is_new = import_photo_from_path(db, str(stored_path))
|
||||
if is_new:
|
||||
added_count += 1
|
||||
else:
|
||||
existing_count += 1
|
||||
# If photo already exists, delete duplicate upload
|
||||
if os.path.exists(stored_path):
|
||||
os.remove(stored_path)
|
||||
except Exception as e:
|
||||
errors.append(f"Error uploading {file.filename}: {str(e)}")
|
||||
|
||||
return {
|
||||
"message": f"Uploaded {len(files)} files",
|
||||
"added": added_count,
|
||||
"existing": existing_count,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/browse-folder")
|
||||
def browse_folder() -> dict:
|
||||
"""Open native folder picker dialog and return selected folder path.
|
||||
|
||||
Uses tkinter to show a native OS folder picker dialog.
|
||||
Returns the full absolute path of the selected folder.
|
||||
|
||||
Returns:
|
||||
dict with 'path' (str) and 'success' (bool) keys
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="tkinter is not available. Cannot show folder picker.",
|
||||
)
|
||||
|
||||
try:
|
||||
# Create root window (hidden)
|
||||
root = tk.Tk()
|
||||
root.withdraw() # Hide main window
|
||||
root.attributes('-topmost', True) # Bring to front
|
||||
|
||||
# Show folder picker dialog
|
||||
folder_path = filedialog.askdirectory(
|
||||
title="Select folder to scan",
|
||||
mustexist=True
|
||||
)
|
||||
|
||||
# Clean up
|
||||
root.destroy()
|
||||
|
||||
if folder_path:
|
||||
# Normalize path to absolute
|
||||
abs_path = os.path.abspath(folder_path)
|
||||
return {
|
||||
"path": abs_path,
|
||||
"success": True,
|
||||
"message": f"Selected folder: {abs_path}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"path": "",
|
||||
"success": False,
|
||||
"message": "No folder selected"
|
||||
}
|
||||
except Exception as e:
|
||||
# Handle errors gracefully
|
||||
error_msg = str(e)
|
||||
|
||||
# Check for common issues (display/headless server)
|
||||
if "display" in error_msg.lower() or "DISPLAY" not in os.environ:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="No display available. Cannot show folder picker. "
|
||||
"If running on a remote server, ensure X11 forwarding is enabled.",
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error showing folder picker: {error_msg}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{photo_id}", response_model=PhotoResponse)
|
||||
def get_photo(photo_id: int, db: Session = Depends(get_db)) -> PhotoResponse:
|
||||
"""Get photo by ID."""
|
||||
from backend.db.models import Photo
|
||||
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo {photo_id} not found",
|
||||
)
|
||||
|
||||
return PhotoResponse.model_validate(photo)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/image")
|
||||
def get_photo_image(photo_id: int, db: Session = Depends(get_db)) -> FileResponse:
|
||||
"""Serve photo image file for display (not download)."""
|
||||
import os
|
||||
import mimetypes
|
||||
from backend.db.models import Photo
|
||||
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo {photo_id} not found",
|
||||
)
|
||||
|
||||
if not os.path.exists(photo.path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo file not found: {photo.path}",
|
||||
)
|
||||
|
||||
# Determine media type from file extension
|
||||
media_type, _ = mimetypes.guess_type(photo.path)
|
||||
if not media_type or not media_type.startswith('image/'):
|
||||
media_type = "image/jpeg"
|
||||
|
||||
# Use FileResponse but set headers to display inline (not download)
|
||||
response = FileResponse(
|
||||
photo.path,
|
||||
media_type=media_type,
|
||||
)
|
||||
# Set Content-Disposition to inline so browser displays instead of downloads
|
||||
response.headers["Content-Disposition"] = "inline"
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{photo_id}/toggle-favorite")
|
||||
def toggle_favorite(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Toggle favorite status of a photo for current user."""
|
||||
from backend.db.models import Photo, PhotoFavorite
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify photo exists
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo {photo_id} not found",
|
||||
)
|
||||
|
||||
# Check if already favorited
|
||||
existing = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Remove favorite
|
||||
db.delete(existing)
|
||||
is_favorite = False
|
||||
else:
|
||||
# Add favorite
|
||||
favorite = PhotoFavorite(
|
||||
username=username,
|
||||
photo_id=photo_id
|
||||
)
|
||||
db.add(favorite)
|
||||
is_favorite = True
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"is_favorite": is_favorite,
|
||||
"message": "Favorite status updated"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{photo_id}/is-favorite")
|
||||
def check_favorite(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Check if photo is favorited by current user."""
|
||||
from backend.db.models import PhotoFavorite
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
favorite = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id == photo_id
|
||||
).first()
|
||||
|
||||
return {
|
||||
"photo_id": photo_id,
|
||||
"is_favorite": favorite is not None
|
||||
}
|
||||
|
||||
|
||||
@router.post("/bulk-add-favorites", response_model=BulkAddFavoritesResponse)
|
||||
def bulk_add_favorites(
|
||||
request: BulkAddFavoritesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkAddFavoritesResponse:
|
||||
"""Add multiple photos to favorites for current user.
|
||||
|
||||
Only adds favorites for photos that aren't already favorites.
|
||||
Uses a single database transaction for better performance.
|
||||
"""
|
||||
from backend.db.models import Photo, PhotoFavorite
|
||||
|
||||
photo_ids = request.photo_ids
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify all photos exist
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = set(photo_ids) - found_ids
|
||||
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photos not found: {sorted(missing_ids)}",
|
||||
)
|
||||
|
||||
# Get existing favorites in a single query
|
||||
existing_favorites = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id.in_(photo_ids)
|
||||
).all()
|
||||
existing_ids = {fav.photo_id for fav in existing_favorites}
|
||||
|
||||
# Only add favorites for photos that aren't already favorites
|
||||
photos_to_add = [photo_id for photo_id in photo_ids if photo_id not in existing_ids]
|
||||
|
||||
added_count = 0
|
||||
for photo_id in photos_to_add:
|
||||
favorite = PhotoFavorite(
|
||||
username=username,
|
||||
photo_id=photo_id
|
||||
)
|
||||
db.add(favorite)
|
||||
added_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return BulkAddFavoritesResponse(
|
||||
message=f"Added {added_count} photo(s) to favorites",
|
||||
added_count=added_count,
|
||||
already_favorite_count=len(existing_ids),
|
||||
total_requested=len(photo_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-remove-favorites", response_model=BulkRemoveFavoritesResponse)
|
||||
def bulk_remove_favorites(
|
||||
request: BulkRemoveFavoritesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkRemoveFavoritesResponse:
|
||||
"""Remove multiple photos from favorites for current user.
|
||||
|
||||
Only removes favorites for photos that are currently favorites.
|
||||
Uses a single database transaction for better performance.
|
||||
"""
|
||||
from backend.db.models import Photo, PhotoFavorite
|
||||
|
||||
photo_ids = request.photo_ids
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
username = current_user["username"]
|
||||
|
||||
# Verify all photos exist
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = set(photo_ids) - found_ids
|
||||
|
||||
if missing_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photos not found: {sorted(missing_ids)}",
|
||||
)
|
||||
|
||||
# Get existing favorites in a single query
|
||||
existing_favorites = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == username,
|
||||
PhotoFavorite.photo_id.in_(photo_ids)
|
||||
).all()
|
||||
existing_ids = {fav.photo_id for fav in existing_favorites}
|
||||
|
||||
# Only remove favorites for photos that are currently favorites
|
||||
photos_to_remove = [photo_id for photo_id in photo_ids if photo_id in existing_ids]
|
||||
|
||||
removed_count = 0
|
||||
for favorite in existing_favorites:
|
||||
if favorite.photo_id in photos_to_remove:
|
||||
db.delete(favorite)
|
||||
removed_count += 1
|
||||
|
||||
db.commit()
|
||||
|
||||
return BulkRemoveFavoritesResponse(
|
||||
message=f"Removed {removed_count} photo(s) from favorites",
|
||||
removed_count=removed_count,
|
||||
not_favorite_count=len(photo_ids) - len(existing_ids),
|
||||
total_requested=len(photo_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/bulk-delete", response_model=BulkDeletePhotosResponse)
|
||||
def bulk_delete_photos(
|
||||
request: BulkDeletePhotosRequest,
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> BulkDeletePhotosResponse:
|
||||
"""Delete multiple photos and all related data (faces, encodings, tags, favorites)."""
|
||||
from backend.db.models import Photo, PhotoTagLinkage
|
||||
|
||||
photo_ids = list(dict.fromkeys(request.photo_ids))
|
||||
if not photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="photo_ids list cannot be empty",
|
||||
)
|
||||
|
||||
try:
|
||||
photos = db.query(Photo).filter(Photo.id.in_(photo_ids)).all()
|
||||
found_ids = {photo.id for photo in photos}
|
||||
missing_ids = sorted(set(photo_ids) - found_ids)
|
||||
|
||||
deleted_count = 0
|
||||
for photo in photos:
|
||||
# Remove tag linkages explicitly (in addition to cascade) to keep counts accurate
|
||||
db.query(PhotoTagLinkage).filter(
|
||||
PhotoTagLinkage.photo_id == photo.id
|
||||
).delete(synchronize_session=False)
|
||||
db.delete(photo)
|
||||
deleted_count += 1
|
||||
|
||||
db.commit()
|
||||
except HTTPException:
|
||||
db.rollback()
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - safety net
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete photos: {exc}",
|
||||
)
|
||||
|
||||
admin_username = current_admin.get("username", "unknown")
|
||||
message_parts = [f"Deleted {deleted_count} photo(s)"]
|
||||
if missing_ids:
|
||||
message_parts.append(f"{len(missing_ids)} photo(s) not found")
|
||||
message_parts.append(f"Request by admin: {admin_username}")
|
||||
|
||||
return BulkDeletePhotosResponse(
|
||||
message="; ".join(message_parts),
|
||||
deleted_count=deleted_count,
|
||||
missing_photo_ids=missing_ids,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{photo_id}/open-folder")
|
||||
def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""Open the folder containing the photo in the system file manager and select the file.
|
||||
|
||||
Matches desktop behavior and enhances it by selecting the specific file:
|
||||
- Windows: uses explorer /select,"file_path"
|
||||
- macOS: uses 'open -R' to reveal and select the file
|
||||
- Linux: tries file manager-specific commands (nautilus, dolphin, etc.) or opens folder
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from backend.db.models import Photo
|
||||
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo {photo_id} not found",
|
||||
)
|
||||
|
||||
# Ensure we have absolute path
|
||||
file_path = os.path.abspath(photo.path)
|
||||
folder = os.path.dirname(file_path)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo file not found: {file_path}",
|
||||
)
|
||||
|
||||
if not os.path.exists(folder):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Folder not found: {folder}",
|
||||
)
|
||||
|
||||
try:
|
||||
# Try using showinfm package first (better cross-platform support)
|
||||
try:
|
||||
from showinfm import show_in_file_manager
|
||||
show_in_file_manager(file_path)
|
||||
return {
|
||||
"message": f"Opened folder and selected file: {os.path.basename(file_path)}",
|
||||
"folder": folder,
|
||||
"file": file_path
|
||||
}
|
||||
except ImportError:
|
||||
# showinfm not installed, fall back to manual commands
|
||||
pass
|
||||
except Exception as e:
|
||||
# showinfm failed, fall back to manual commands
|
||||
pass
|
||||
|
||||
# Fallback: Open folder and select the file using platform-specific commands
|
||||
if os.name == "nt": # Windows
|
||||
# Windows: explorer /select,"file_path" opens folder and selects the file
|
||||
subprocess.run(["explorer", "/select,", file_path], check=False)
|
||||
elif sys.platform == "darwin": # macOS
|
||||
# macOS: open -R reveals the file in Finder and selects it
|
||||
subprocess.run(["open", "-R", file_path], check=False)
|
||||
else: # Linux and others
|
||||
# Linux: Try file manager-specific commands to select the file
|
||||
# Try different file managers based on desktop environment
|
||||
opened = False
|
||||
|
||||
# Detect desktop environment first
|
||||
desktop_env = os.environ.get("XDG_CURRENT_DESKTOP", "").lower()
|
||||
|
||||
# Try Nautilus (GNOME) - supports --select option
|
||||
if "gnome" in desktop_env or not desktop_env:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nautilus", "--select", file_path],
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try Nemo (Cinnamon/MATE) - doesn't support --select, but we can try folder with navigation
|
||||
if not opened and ("cinnamon" in desktop_env or "mate" in desktop_env):
|
||||
try:
|
||||
# For Nemo, we can try opening the folder first, then using a script or
|
||||
# just opening the folder (Nemo may focus on the file if we pass it)
|
||||
# Try opening with the file path - Nemo will open the folder
|
||||
file_uri = f"file://{file_path}"
|
||||
result = subprocess.Popen(
|
||||
["nemo", file_uri],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
# Nemo will open the folder - selection may not work perfectly
|
||||
# This is a limitation of Nemo
|
||||
opened = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try Dolphin (KDE) - supports --select option
|
||||
if not opened and "kde" in desktop_env:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["dolphin", "--select", file_path],
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try PCManFM (LXDE/LXQt) - supports --select option
|
||||
if not opened and ("lxde" in desktop_env or "lxqt" in desktop_env):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pcmanfm", "--select", file_path],
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# Try Thunar (XFCE) - supports --select option
|
||||
if not opened and "xfce" in desktop_env:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["thunar", "--select", file_path],
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
# If desktop-specific didn't work, try all file managers in order
|
||||
if not opened:
|
||||
file_managers = [
|
||||
("nautilus", ["--select", file_path]),
|
||||
("nemo", [f"file://{file_path}"]), # Nemo uses file:// URI
|
||||
("dolphin", ["--select", file_path]),
|
||||
("thunar", ["--select", file_path]),
|
||||
("pcmanfm", ["--select", file_path]),
|
||||
]
|
||||
|
||||
for fm_name, fm_args in file_managers:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[fm_name] + fm_args,
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
opened = True
|
||||
break
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
continue
|
||||
|
||||
# Fallback: try xdg-open with the folder (will open folder but won't select file)
|
||||
if not opened:
|
||||
subprocess.run(["xdg-open", folder], check=False)
|
||||
|
||||
return {
|
||||
"message": f"Opened folder and selected file: {os.path.basename(file_path)}",
|
||||
"folder": folder,
|
||||
"file": file_path
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to open folder: {str(e)}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Reported photos endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Photo, PhotoTagLinkage
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
|
||||
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])
|
||||
|
||||
|
||||
class ReportedPhotoResponse(BaseModel):
|
||||
"""Reported photo DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
status: str
|
||||
reported_at: str
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
review_notes: Optional[str] = None
|
||||
report_comment: Optional[str] = None
|
||||
# Photo details from main database
|
||||
photo_path: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
photo_media_type: Optional[str] = None
|
||||
|
||||
|
||||
class ReportedPhotosListResponse(BaseModel):
|
||||
"""List of reported photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[ReportedPhotoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision for a single reported photo."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'keep' or 'remove'
|
||||
review_notes: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request to review multiple reported photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Response from review operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
kept: int
|
||||
removed: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response payload for cleanup operations."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.get("", response_model=ReportedPhotosListResponse)
|
||||
def list_reported_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReportedPhotosListResponse:
|
||||
"""List all reported photos from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all reported photos from the inappropriate_photo_reports table.
|
||||
Optionally filter by status: 'pending', 'reviewed', or 'dismissed'.
|
||||
"""
|
||||
try:
|
||||
# Query inappropriate_photo_reports from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
ipr.status,
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
WHERE ipr.status = :status_filter
|
||||
ORDER BY ipr.reported_at DESC
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
ipr.status,
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
ORDER BY ipr.reported_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
# Get photo details from main database
|
||||
photo_path = None
|
||||
photo_filename = None
|
||||
photo_media_type = None
|
||||
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
|
||||
if photo:
|
||||
photo_path = photo.path
|
||||
photo_filename = photo.filename
|
||||
photo_media_type = photo.media_type
|
||||
|
||||
items.append(ReportedPhotoResponse(
|
||||
id=row.id,
|
||||
photo_id=row.photo_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
status=row.status,
|
||||
reported_at=str(row.reported_at) if row.reported_at else '',
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
review_notes=row.review_notes,
|
||||
report_comment=row.report_comment,
|
||||
photo_path=photo_path,
|
||||
photo_filename=photo_filename,
|
||||
photo_media_type=photo_media_type,
|
||||
))
|
||||
|
||||
return ReportedPhotosListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_reported_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Review reported photos - keep or remove them.
|
||||
|
||||
For 'keep' decision:
|
||||
- Updates status in auth database to 'reviewed'
|
||||
- Photo remains in main database
|
||||
|
||||
For 'remove' decision:
|
||||
- Updates status in auth database to 'reviewed'
|
||||
- Deletes photo from main database (cascade deletes faces, tags, etc.)
|
||||
"""
|
||||
kept_count = 0
|
||||
removed_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get reported photo from auth database
|
||||
# Allow processing 'pending' and 'reviewed' status reports (to allow changing decisions)
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.status
|
||||
FROM inappropriate_photo_reports ipr
|
||||
WHERE ipr.id = :id AND ipr.status IN ('pending', 'reviewed')
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Reported photo {decision.id} not found or cannot be reviewed (status: dismissed)")
|
||||
continue
|
||||
|
||||
if decision.decision == 'remove':
|
||||
# Delete photo from main database (cascade will handle related records)
|
||||
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
|
||||
if not photo:
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'dismissed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo not found in database; auto-dismissed"
|
||||
})
|
||||
auth_db.commit()
|
||||
removed_count += 1
|
||||
continue
|
||||
|
||||
# Delete tag linkages for this photo
|
||||
main_db.query(PhotoTagLinkage).filter(
|
||||
PhotoTagLinkage.photo_id == photo.id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Delete the photo (cascade will delete faces, etc.)
|
||||
main_db.delete(photo)
|
||||
main_db.commit()
|
||||
|
||||
# Update status in auth database to dismissed
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'dismissed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo removed"
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
removed_count += 1
|
||||
|
||||
elif decision.decision == 'keep':
|
||||
# Update status to reviewed (photo stays in database)
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'reviewed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo kept"
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
kept_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for reported photo {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing reported photo {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
|
||||
return ReviewResponse(
|
||||
kept=kept_count,
|
||||
removed=removed_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_reported_photos(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.")
|
||||
] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete rows from inappropriate_photo_reports based on review status."""
|
||||
status_mapping = {
|
||||
"keep": "reviewed",
|
||||
"remove": "dismissed",
|
||||
}
|
||||
|
||||
if status_filter and status_filter not in status_mapping:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid status_filter. Use 'keep', 'remove', or omit the parameter.",
|
||||
)
|
||||
|
||||
db_status_filter = status_mapping.get(status_filter)
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
if db_status_filter:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status = :status_filter
|
||||
"""
|
||||
),
|
||||
{"status_filter": db_status_filter},
|
||||
)
|
||||
else:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status IN ('reviewed', 'dismissed')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0:
|
||||
if db_status_filter:
|
||||
warnings.append(
|
||||
f"No reported photos matched the '{status_filter}' decision filter."
|
||||
)
|
||||
else:
|
||||
warnings.append("No reviewed or dismissed reported photos to delete.")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cleanup reported photos: {exc}",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Manage role-to-feature permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.users import get_current_admin_user
|
||||
from backend.constants.role_features import ROLE_FEATURES, ROLE_FEATURE_KEYS
|
||||
from backend.constants.roles import ROLE_VALUES
|
||||
from backend.db.session import get_db
|
||||
from backend.schemas.role_permissions import (
|
||||
RoleFeatureSchema,
|
||||
RolePermissionsResponse,
|
||||
RolePermissionsUpdateRequest,
|
||||
)
|
||||
from backend.services.role_permissions import (
|
||||
ensure_role_permissions_initialized,
|
||||
fetch_role_permissions_map,
|
||||
set_role_permissions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/role-permissions", tags=["role-permissions"])
|
||||
|
||||
|
||||
@router.get("", response_model=RolePermissionsResponse)
|
||||
def list_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Return the current role/feature permission matrix."""
|
||||
|
||||
ensure_role_permissions_initialized(db)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
|
||||
@router.put("", response_model=RolePermissionsResponse)
|
||||
def update_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: RolePermissionsUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Update permissions for the provided matrix."""
|
||||
|
||||
invalid_roles = set(request.permissions.keys()) - set(ROLE_VALUES)
|
||||
if invalid_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role(s): {', '.join(sorted(invalid_roles))}",
|
||||
)
|
||||
|
||||
for feature_map in request.permissions.values():
|
||||
invalid_features = set(feature_map.keys()) - set(ROLE_FEATURE_KEYS)
|
||||
if invalid_features:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid feature(s): {', '.join(sorted(invalid_features))}",
|
||||
)
|
||||
|
||||
set_role_permissions(db, request.permissions)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tag management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.schemas.tags import (
|
||||
PhotoTagsRequest,
|
||||
PhotoTagsResponse,
|
||||
TagCreateRequest,
|
||||
TagResponse,
|
||||
TagsResponse,
|
||||
TagUpdateRequest,
|
||||
TagDeleteRequest,
|
||||
PhotoTagsListResponse,
|
||||
PhotoTagItem,
|
||||
PhotosWithTagsResponse,
|
||||
PhotoWithTagsItem,
|
||||
)
|
||||
from backend.services.tag_service import (
|
||||
add_tags_to_photos,
|
||||
get_or_create_tag,
|
||||
list_tags,
|
||||
remove_tags_from_photos,
|
||||
get_photo_tags,
|
||||
update_tag,
|
||||
delete_tags,
|
||||
get_photos_with_tags,
|
||||
)
|
||||
from backend.db.models import Photo
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=TagsResponse)
|
||||
def get_tags(db: Session = Depends(get_db)) -> TagsResponse:
|
||||
"""List all tags."""
|
||||
tags = list_tags(db)
|
||||
items = [TagResponse.model_validate(t) for t in tags]
|
||||
return TagsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse)
|
||||
def create_tag(
|
||||
request: TagCreateRequest, db: Session = Depends(get_db)
|
||||
) -> TagResponse:
|
||||
"""Create a new tag (or return existing if already exists)."""
|
||||
tag = get_or_create_tag(db, request.tag_name)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post("/photos/add", response_model=PhotoTagsResponse)
|
||||
def add_tags_to_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Add tags to multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_added = add_tags_to_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Added tags to {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=tags_added,
|
||||
tags_removed=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/photos/remove", response_model=PhotoTagsResponse)
|
||||
def remove_tags_from_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Remove tags from multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_removed = remove_tags_from_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Removed tags from {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=0,
|
||||
tags_removed=tags_removed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/photos/{photo_id}", response_model=PhotoTagsListResponse)
|
||||
def get_photo_tags_endpoint(
|
||||
photo_id: int, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsListResponse:
|
||||
"""Get all tags for a specific photo."""
|
||||
# Validate photo exists
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Photo {photo_id} not found"
|
||||
)
|
||||
|
||||
tags_data = get_photo_tags(db, photo_id)
|
||||
|
||||
items = [
|
||||
PhotoTagItem(tag_id=tag_id, tag_name=tag_name)
|
||||
for tag_id, tag_name in tags_data
|
||||
]
|
||||
|
||||
return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=TagResponse)
|
||||
def update_tag_endpoint(
|
||||
tag_id: int, request: TagUpdateRequest, db: Session = Depends(get_db)
|
||||
) -> TagResponse:
|
||||
"""Update a tag name."""
|
||||
try:
|
||||
tag = update_tag(db, tag_id, request.tag_name)
|
||||
return TagResponse.model_validate(tag)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/delete", response_model=dict)
|
||||
def delete_tags_endpoint(
|
||||
request: TagDeleteRequest, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Delete tags and all their linkages."""
|
||||
if not request.tag_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="tag_ids list cannot be empty",
|
||||
)
|
||||
|
||||
deleted_count = delete_tags(db, request.tag_ids)
|
||||
|
||||
return {
|
||||
"message": f"Deleted {deleted_count} tag(s)",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/photos", response_model=PhotosWithTagsResponse)
|
||||
def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTagsResponse:
|
||||
"""Get all photos with tags and face counts, matching desktop tag manager query exactly.
|
||||
|
||||
Returns all photos with their tags (comma-separated) and face counts,
|
||||
ordered by date_taken DESC, filename.
|
||||
"""
|
||||
photos_data = get_photos_with_tags(db)
|
||||
|
||||
items = [
|
||||
PhotoWithTagsItem(
|
||||
id=p['id'],
|
||||
filename=p['filename'],
|
||||
path=p['path'],
|
||||
processed=p['processed'],
|
||||
date_taken=p['date_taken'],
|
||||
date_added=p['date_added'],
|
||||
face_count=p['face_count'],
|
||||
unidentified_face_count=p['unidentified_face_count'],
|
||||
tags=p['tags'],
|
||||
people_names=p.get('people_names', ''),
|
||||
)
|
||||
for p in photos_data
|
||||
]
|
||||
|
||||
return PhotosWithTagsResponse(items=items, total=len(items))
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
"""User management endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
UserRole,
|
||||
is_admin_role,
|
||||
)
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Face, PhotoFavorite, PhotoPersonLinkage, User
|
||||
from backend.schemas.users import (
|
||||
UserCreateRequest,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
UsersListResponse,
|
||||
)
|
||||
from backend.utils.password import hash_password
|
||||
from backend.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_role_and_admin(
|
||||
role: str | None,
|
||||
is_admin_flag: bool | None,
|
||||
) -> tuple[str, bool]:
|
||||
"""Normalize requested role/is_admin values into a consistent pair."""
|
||||
selected_role = role or (DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE)
|
||||
if selected_role not in ROLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role '{selected_role}'",
|
||||
)
|
||||
derived_is_admin = is_admin_role(selected_role)
|
||||
if is_admin_flag is not None and is_admin_flag != derived_is_admin:
|
||||
logger.warning(
|
||||
"Role/is_admin mismatch detected. Using role-derived admin flag.",
|
||||
extra={"role": selected_role, "is_admin_flag": is_admin_flag},
|
||||
)
|
||||
return selected_role, derived_is_admin
|
||||
|
||||
|
||||
def _ensure_role_set(user: User) -> None:
|
||||
"""Guarantee that a User instance has a valid role value."""
|
||||
if user.role in ROLE_VALUES:
|
||||
return
|
||||
fallback_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
user.role = fallback_role
|
||||
|
||||
|
||||
def get_auth_db_optional() -> Session | None:
|
||||
"""Get auth database session if available, otherwise return None."""
|
||||
try:
|
||||
return next(get_auth_db())
|
||||
except ValueError:
|
||||
# Auth database not configured
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_user_if_missing(
|
||||
email: str,
|
||||
full_name: str,
|
||||
password_hash: str,
|
||||
is_admin: bool,
|
||||
) -> None:
|
||||
"""Create matching auth user if one does not already exist."""
|
||||
if not email:
|
||||
return
|
||||
|
||||
auth_db = get_auth_db_optional()
|
||||
if auth_db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
check_result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
|
||||
existing_auth = check_result.first()
|
||||
if existing_auth:
|
||||
return
|
||||
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
|
||||
supports_returning = dialect == "postgresql"
|
||||
has_write_access = is_admin
|
||||
|
||||
if supports_returning:
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
else:
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
except Exception as e: # pragma: no cover - logging helper
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
|
||||
print(
|
||||
f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
finally:
|
||||
auth_db.close()
|
||||
|
||||
|
||||
def get_current_admin_user(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get current user and verify admin status from main database.
|
||||
|
||||
Raises HTTPException if user is not an admin.
|
||||
If no admin users exist, allows the current user to bootstrap as admin.
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if any admin users exist
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
|
||||
# If no admins exist, allow current user to bootstrap as admin
|
||||
if admin_count == 0:
|
||||
# Check if user already exists in main database
|
||||
main_user = db.query(User).filter(User.username == username).first()
|
||||
if not main_user:
|
||||
# Create the user as admin for bootstrap
|
||||
# Use a default password hash (user should change password after first login)
|
||||
# In production, this should be handled differently
|
||||
default_password_hash = hash_password("changeme")
|
||||
main_user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
db.refresh(main_user)
|
||||
elif not main_user.is_admin:
|
||||
# User exists but is not admin - make them admin for bootstrap
|
||||
main_user.is_admin = True
|
||||
main_user.role = DEFAULT_ADMIN_ROLE
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
db.refresh(main_user)
|
||||
|
||||
return {"username": username, "user_id": main_user.id}
|
||||
|
||||
# Normal admin check - user must exist and be admin
|
||||
main_user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not main_user or not main_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
return {"username": username, "user_id": main_user.id}
|
||||
|
||||
|
||||
def require_feature_permission(feature_key: str):
|
||||
"""Return a dependency that enforces feature-level access via role permissions."""
|
||||
|
||||
def dependency(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
username = current_user["username"]
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
_ensure_role_set(user)
|
||||
|
||||
has_access = user.is_admin or is_admin_role(user.role)
|
||||
if not has_access:
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
role_permissions = permissions_map.get(user.role, {})
|
||||
has_access = bool(role_permissions.get(feature_key))
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied for this feature",
|
||||
)
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"user_id": user.id,
|
||||
"role": user.role,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@router.get("", response_model=UsersListResponse)
|
||||
def list_users(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
is_active: bool | None = Query(None, description="Filter by active status"),
|
||||
is_admin: bool | None = Query(None, description="Filter by admin status"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UsersListResponse:
|
||||
"""List all users - admin only.
|
||||
|
||||
Optionally filter by is_active and/or is_admin status.
|
||||
"""
|
||||
query = db.query(User)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(User.is_active == is_active)
|
||||
|
||||
if is_admin is not None:
|
||||
query = query.filter(User.is_admin == is_admin)
|
||||
|
||||
users = query.order_by(User.username.asc()).all()
|
||||
for user in users:
|
||||
_ensure_role_set(user)
|
||||
items = [UserResponse.model_validate(u) for u in users]
|
||||
return UsersListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: UserCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Create a new user - admin only.
|
||||
|
||||
If give_frontend_permission is True, also creates the user in the auth database
|
||||
for frontend access.
|
||||
"""
|
||||
# Check if username already exists
|
||||
existing_user = db.query(User).filter(User.username == request.username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Username '{request.username}' already exists",
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_email = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{request.email}' is already in use",
|
||||
)
|
||||
|
||||
# Hash the password before storing
|
||||
password_hash = hash_password(request.password)
|
||||
if request.role is None:
|
||||
requested_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
requested_role = request.role.value
|
||||
else:
|
||||
requested_role = str(request.role)
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
requested_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=request.username,
|
||||
password_hash=password_hash,
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
is_active=request.is_active,
|
||||
is_admin=normalized_is_admin,
|
||||
role=normalized_role,
|
||||
password_change_required=True, # Force password change on first login
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if request.give_frontend_permission:
|
||||
create_auth_user_if_missing(
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
password_hash=password_hash,
|
||||
is_admin=normalized_is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def get_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Get a specific user by ID - admin only."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
_ensure_role_set(user)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
request: UserUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Update a user - admin only."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
|
||||
if request.role is None:
|
||||
desired_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
desired_role = request.role.value
|
||||
else:
|
||||
desired_role = str(request.role)
|
||||
if desired_role is None:
|
||||
if request.is_admin is not None:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if request.is_admin else DEFAULT_USER_ROLE
|
||||
elif user.role:
|
||||
desired_role = user.role
|
||||
else:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
desired_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
# Prevent admin from removing their own admin status
|
||||
if current_admin["username"] == user.username and not normalized_is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove your own admin status",
|
||||
)
|
||||
|
||||
# Check if email is being changed and if the new email already exists
|
||||
if request.email is not None and request.email != user.email:
|
||||
existing_email = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{request.email}' is already in use",
|
||||
)
|
||||
|
||||
# Update fields if provided
|
||||
if request.password is not None:
|
||||
user.password_hash = hash_password(request.password)
|
||||
if request.email is not None:
|
||||
user.email = request.email
|
||||
if request.full_name is not None:
|
||||
user.full_name = request.full_name
|
||||
if request.is_active is not None:
|
||||
user.is_active = request.is_active
|
||||
user.is_admin = normalized_is_admin
|
||||
user.role = normalized_role
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if request.give_frontend_permission:
|
||||
create_auth_user_if_missing(
|
||||
email=user.email,
|
||||
full_name=user.full_name or user.username,
|
||||
password_hash=user.password_hash,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Delete a user - admin only.
|
||||
|
||||
If the user has linked data (faces identified, video person linkages),
|
||||
the user will be set to inactive instead of deleted, and favorites will
|
||||
be removed. Admins will be notified via logging.
|
||||
|
||||
Prevents admin from deleting themselves.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
|
||||
# Prevent admin from deleting themselves
|
||||
if current_admin["username"] == user.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account",
|
||||
)
|
||||
|
||||
# Check for linked data (faces or photo_person_linkages identified by this user)
|
||||
faces_count = db.query(Face).filter(Face.identified_by_user_id == user_id).count()
|
||||
linkages_count = db.query(PhotoPersonLinkage).filter(
|
||||
PhotoPersonLinkage.identified_by_user_id == user_id
|
||||
).count()
|
||||
|
||||
has_linked_data = faces_count > 0 or linkages_count > 0
|
||||
|
||||
# Always delete favorites (they use username, not user_id)
|
||||
favorites_deleted = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == user.username
|
||||
).delete()
|
||||
|
||||
if has_linked_data:
|
||||
# Set user inactive instead of deleting
|
||||
user.is_active = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
# Notify admins via logging
|
||||
logger.warning(
|
||||
f"User '{user.username}' (ID: {user_id}) was set to inactive instead of deleted "
|
||||
f"because they have linked data: {faces_count} face(s) and {linkages_count} "
|
||||
f"video person linkage(s). {favorites_deleted} favorite(s) were deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"faces_count": faces_count,
|
||||
"linkages_count": linkages_count,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
# Return success but indicate user was deactivated
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"message": (
|
||||
f"User '{user.username}' has been set to inactive because they have "
|
||||
f"linked data ({faces_count} face(s), {linkages_count} linkage(s)). "
|
||||
f"{favorites_deleted} favorite(s) were deleted."
|
||||
),
|
||||
"deactivated": True,
|
||||
"faces_count": faces_count,
|
||||
"linkages_count": linkages_count,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No linked data - safe to delete
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User '{user.username}' (ID: {user_id}) was deleted. "
|
||||
f"{favorites_deleted} favorite(s) were deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.settings import APP_VERSION
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def version() -> dict[str, str]:
|
||||
"""Return API version information."""
|
||||
return {"version": APP_VERSION}
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
"""Video person identification endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import Photo, User
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
from backend.schemas.videos import (
|
||||
ListVideosResponse,
|
||||
VideoListItem,
|
||||
PersonInfo,
|
||||
VideoPeopleResponse,
|
||||
VideoPersonInfo,
|
||||
IdentifyVideoRequest,
|
||||
IdentifyVideoResponse,
|
||||
RemoveVideoPersonResponse,
|
||||
)
|
||||
from backend.services.video_service import (
|
||||
list_videos_for_identification,
|
||||
get_video_people,
|
||||
identify_person_in_video,
|
||||
remove_person_from_video,
|
||||
get_video_people_count,
|
||||
)
|
||||
from backend.services.thumbnail_service import get_video_thumbnail_path
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
@router.get("", response_model=ListVideosResponse)
|
||||
def list_videos(
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
||||
date_from: Optional[str] = Query(None, description="Filter by date taken (from, YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by date taken (to, YYYY-MM-DD)"),
|
||||
has_people: Optional[bool] = Query(None, description="Filter videos with/without identified people"),
|
||||
person_name: Optional[str] = Query(None, description="Filter videos containing person with this name"),
|
||||
sort_by: str = Query("filename", description="Sort field: filename, date_taken, date_added"),
|
||||
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ListVideosResponse:
|
||||
"""List videos for person identification."""
|
||||
# Parse date filters
|
||||
date_from_parsed = None
|
||||
date_to_parsed = None
|
||||
if date_from:
|
||||
try:
|
||||
date_from_parsed = date.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date_from format: {date_from}. Use YYYY-MM-DD",
|
||||
)
|
||||
if date_to:
|
||||
try:
|
||||
date_to_parsed = date.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date_to format: {date_to}. Use YYYY-MM-DD",
|
||||
)
|
||||
|
||||
# Validate sort parameters
|
||||
if sort_by not in ["filename", "date_taken", "date_added"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid sort_by: {sort_by}. Must be filename, date_taken, or date_added",
|
||||
)
|
||||
if sort_dir not in ["asc", "desc"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid sort_dir: {sort_dir}. Must be asc or desc",
|
||||
)
|
||||
|
||||
# Get videos
|
||||
videos, total = list_videos_for_identification(
|
||||
db=db,
|
||||
folder_path=folder_path,
|
||||
date_from=date_from_parsed,
|
||||
date_to=date_to_parsed,
|
||||
has_people=has_people,
|
||||
person_name=person_name,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Build response items
|
||||
items = []
|
||||
for video in videos:
|
||||
# Get people for this video
|
||||
people_data = get_video_people(db, video.id)
|
||||
identified_people = []
|
||||
for person, linkage in people_data:
|
||||
identified_people.append(
|
||||
PersonInfo(
|
||||
id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert date_added to date if it's datetime
|
||||
date_added = video.date_added
|
||||
if hasattr(date_added, "date"):
|
||||
date_added = date_added.date()
|
||||
|
||||
items.append(
|
||||
VideoListItem(
|
||||
id=video.id,
|
||||
filename=video.filename,
|
||||
path=video.path,
|
||||
date_taken=video.date_taken,
|
||||
date_added=date_added,
|
||||
identified_people=identified_people,
|
||||
identified_people_count=len(identified_people),
|
||||
)
|
||||
)
|
||||
|
||||
return ListVideosResponse(items=items, page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
@router.get("/{video_id}/people", response_model=VideoPeopleResponse)
|
||||
def get_video_people_endpoint(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> VideoPeopleResponse:
|
||||
"""Get all people identified in a video."""
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
# Get people
|
||||
people_data = get_video_people(db, video_id)
|
||||
|
||||
people = []
|
||||
for person, linkage in people_data:
|
||||
# Get username if identified_by_user_id exists
|
||||
username = None
|
||||
if linkage.identified_by_user_id:
|
||||
user = db.query(User).filter(User.id == linkage.identified_by_user_id).first()
|
||||
if user:
|
||||
username = user.username
|
||||
|
||||
people.append(
|
||||
VideoPersonInfo(
|
||||
person_id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
identified_by=username,
|
||||
identified_date=linkage.created_date,
|
||||
)
|
||||
)
|
||||
|
||||
return VideoPeopleResponse(video_id=video_id, people=people)
|
||||
|
||||
|
||||
@router.post("/{video_id}/identify", response_model=IdentifyVideoResponse)
|
||||
def identify_person_in_video_endpoint(
|
||||
video_id: int,
|
||||
request: IdentifyVideoRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyVideoResponse:
|
||||
"""Identify a person in a video."""
|
||||
user_id = current_user.get("id")
|
||||
|
||||
try:
|
||||
person, created_person = identify_person_in_video(
|
||||
db=db,
|
||||
video_id=video_id,
|
||||
person_id=request.person_id,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
middle_name=request.middle_name,
|
||||
maiden_name=request.maiden_name,
|
||||
date_of_birth=request.date_of_birth,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Person '{person.first_name} {person.last_name}' identified in video"
|
||||
if not created_person
|
||||
else f"Created new person '{person.first_name} {person.last_name}' and identified in video"
|
||||
)
|
||||
|
||||
return IdentifyVideoResponse(
|
||||
video_id=video_id,
|
||||
person_id=person.id,
|
||||
created_person=created_person,
|
||||
message=message,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{video_id}/people/{person_id}", response_model=RemoveVideoPersonResponse)
|
||||
def remove_person_from_video_endpoint(
|
||||
video_id: int,
|
||||
person_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> RemoveVideoPersonResponse:
|
||||
"""Remove person identification from video."""
|
||||
try:
|
||||
removed = remove_person_from_video(
|
||||
db=db,
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
)
|
||||
|
||||
if removed:
|
||||
return RemoveVideoPersonResponse(
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
removed=True,
|
||||
message=f"Person {person_id} removed from video {video_id}",
|
||||
)
|
||||
else:
|
||||
return RemoveVideoPersonResponse(
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
removed=False,
|
||||
message=f"Person {person_id} not found in video {video_id}",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}/thumbnail")
|
||||
def get_video_thumbnail(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Get video thumbnail (generated on-demand and cached)."""
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
# Generate or get cached thumbnail
|
||||
thumbnail_path = get_video_thumbnail_path(video.path)
|
||||
|
||||
if not thumbnail_path or not thumbnail_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate video thumbnail",
|
||||
)
|
||||
|
||||
# Return thumbnail with caching headers
|
||||
response = FileResponse(
|
||||
str(thumbnail_path),
|
||||
media_type="image/jpeg",
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400" # Cache for 1 day
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{video_id}/video")
|
||||
def get_video_file(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Serve video file for playback."""
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
if not os.path.exists(video.path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video file not found: {video.path}",
|
||||
)
|
||||
|
||||
# Determine media type from file extension
|
||||
media_type, _ = mimetypes.guess_type(video.path)
|
||||
if not media_type or not media_type.startswith('video/'):
|
||||
media_type = "video/mp4"
|
||||
|
||||
# Use FileResponse with range request support for video streaming
|
||||
response = FileResponse(
|
||||
video.path,
|
||||
media_type=media_type,
|
||||
)
|
||||
response.headers["Content-Disposition"] = "inline"
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from backend.api.auth import router as auth_router
|
||||
from backend.api.faces import router as faces_router
|
||||
from backend.api.health import router as health_router
|
||||
from backend.api.jobs import router as jobs_router
|
||||
from backend.api.metrics import router as metrics_router
|
||||
from backend.api.people import router as people_router
|
||||
from backend.api.pending_identifications import router as pending_identifications_router
|
||||
from backend.api.pending_linkages import router as pending_linkages_router
|
||||
from backend.api.photos import router as photos_router
|
||||
from backend.api.reported_photos import router as reported_photos_router
|
||||
from backend.api.pending_photos import router as pending_photos_router
|
||||
from backend.api.tags import router as tags_router
|
||||
from backend.api.users import router as users_router
|
||||
from backend.api.auth_users import router as auth_users_router
|
||||
from backend.api.role_permissions import router as role_permissions_router
|
||||
from backend.api.videos import router as videos_router
|
||||
from backend.api.version import router as version_router
|
||||
from backend.settings import APP_TITLE, APP_VERSION
|
||||
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
|
||||
from backend.db.base import Base, engine
|
||||
from backend.db.session import auth_engine, database_url, get_auth_database_url
|
||||
# Import models to ensure they're registered with Base.metadata
|
||||
from backend.db import models # noqa: F401
|
||||
from backend.db.models import RolePermission
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Global worker process (will be set in lifespan)
|
||||
_worker_process: subprocess.Popen | None = None
|
||||
|
||||
|
||||
def start_worker() -> None:
|
||||
"""Start RQ worker in background subprocess."""
|
||||
global _worker_process
|
||||
|
||||
try:
|
||||
from redis import Redis
|
||||
|
||||
# Check Redis connection first
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
redis_conn.ping()
|
||||
|
||||
# Start worker as a subprocess (avoids signal handler issues)
|
||||
# __file__ is backend/app.py, so parent.parent is the project root (punimtag/)
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
# Use explicit Python path to avoid Cursor interception
|
||||
# Check if sys.executable is Cursor, if so use /usr/bin/python3
|
||||
python_executable = sys.executable
|
||||
if "cursor" in python_executable.lower() or not python_executable.startswith("/usr"):
|
||||
python_executable = "/usr/bin/python3"
|
||||
|
||||
# Ensure PYTHONPATH is set correctly
|
||||
worker_env = {
|
||||
**{k: v for k, v in os.environ.items()},
|
||||
"PYTHONPATH": str(project_root),
|
||||
}
|
||||
|
||||
_worker_process = subprocess.Popen(
|
||||
[
|
||||
python_executable,
|
||||
"-m",
|
||||
"backend.worker",
|
||||
],
|
||||
cwd=str(project_root),
|
||||
stdout=None, # Don't capture - let output go to console
|
||||
stderr=None, # Don't capture - let errors go to console
|
||||
env=worker_env
|
||||
)
|
||||
# Give it a moment to start, then check if it's still running
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
if _worker_process.poll() is not None:
|
||||
# Process already exited - there was an error
|
||||
print(f"❌ Worker process exited immediately with code {_worker_process.returncode}")
|
||||
print(" Check worker errors above")
|
||||
else:
|
||||
print(f"✅ RQ worker started in background subprocess (PID: {_worker_process.pid})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to start RQ worker: {e}")
|
||||
print(" Background jobs will not be processed. Ensure Redis is running.")
|
||||
|
||||
|
||||
def stop_worker() -> None:
|
||||
"""Stop RQ worker gracefully."""
|
||||
global _worker_process
|
||||
|
||||
if _worker_process:
|
||||
try:
|
||||
_worker_process.terminate()
|
||||
try:
|
||||
_worker_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_worker_process.kill()
|
||||
print("✅ RQ worker stopped")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_user_password_hash_column(inspector) -> None:
|
||||
"""Ensure users table contains password_hash column."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
print("ℹ️ Users table does not exist yet - will be created with password_hash column")
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "password_hash" in columns:
|
||||
print("ℹ️ password_hash column already exists in users table")
|
||||
return
|
||||
|
||||
print("🔄 Adding password_hash column to users table...")
|
||||
|
||||
default_hash = hash_password("changeme")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
# PostgreSQL: Add column as nullable first, then update, then set NOT NULL
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE users SET password_hash = :default_hash "
|
||||
"WHERE password_hash IS NULL OR password_hash = ''"
|
||||
),
|
||||
{"default_hash": default_hash},
|
||||
)
|
||||
# Set NOT NULL constraint
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL")
|
||||
)
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN password_hash TEXT")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE users SET password_hash = :default_hash "
|
||||
"WHERE password_hash IS NULL OR password_hash = ''"
|
||||
),
|
||||
{"default_hash": default_hash},
|
||||
)
|
||||
print("✅ Added password_hash column to users table (default password: changeme)")
|
||||
|
||||
|
||||
def ensure_user_password_change_required_column(inspector) -> None:
|
||||
"""Ensure users table contains password_change_required column."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "password_change_required" in columns:
|
||||
print("ℹ️ password_change_required column already exists in users table")
|
||||
return
|
||||
|
||||
print("🔄 Adding password_change_required column to users table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_change_required BOOLEAN NOT NULL DEFAULT true")
|
||||
)
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN password_change_required BOOLEAN DEFAULT 1")
|
||||
)
|
||||
connection.execute(
|
||||
text("UPDATE users SET password_change_required = 1 WHERE password_change_required IS NULL")
|
||||
)
|
||||
print("✅ Added password_change_required column to users table")
|
||||
|
||||
|
||||
def ensure_user_email_unique_constraint(inspector) -> None:
|
||||
"""Ensure users table email column has a unique constraint."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
# Check if email column exists
|
||||
columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "email" not in columns:
|
||||
print("ℹ️ email column does not exist in users table yet")
|
||||
return
|
||||
|
||||
# Check if unique constraint already exists on email
|
||||
dialect = engine.dialect.name
|
||||
with engine.connect() as connection:
|
||||
if dialect == "postgresql":
|
||||
# Check if unique constraint exists
|
||||
result = connection.execute(text("""
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_name = 'users'
|
||||
AND constraint_type = 'UNIQUE'
|
||||
AND constraint_name LIKE '%email%'
|
||||
"""))
|
||||
if result.first():
|
||||
print("ℹ️ Unique constraint on email column already exists")
|
||||
return
|
||||
|
||||
# Try to add unique constraint (will fail if duplicates exist)
|
||||
try:
|
||||
print("🔄 Adding unique constraint to email column...")
|
||||
connection.execute(text("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)"))
|
||||
connection.commit()
|
||||
print("✅ Added unique constraint to email column")
|
||||
except Exception as e:
|
||||
# If constraint already exists or duplicates exist, that's okay
|
||||
# API validation will prevent new duplicates
|
||||
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
||||
print(f"ℹ️ Could not add unique constraint (may have duplicates): {e}")
|
||||
else:
|
||||
print(f"⚠️ Could not add unique constraint: {e}")
|
||||
else:
|
||||
# SQLite - unique constraint is handled at column level
|
||||
# Check if column already has unique constraint
|
||||
# SQLite doesn't easily support adding unique constraints to existing columns
|
||||
# The model definition will handle it for new tables
|
||||
print("ℹ️ SQLite: Unique constraint on email will be enforced by model definition for new tables")
|
||||
|
||||
|
||||
def ensure_face_identified_by_user_id_column(inspector) -> None:
|
||||
"""Ensure faces table contains identified_by_user_id column."""
|
||||
if "faces" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("faces")}
|
||||
if "identified_by_user_id" in columns:
|
||||
print("ℹ️ identified_by_user_id column already exists in faces table")
|
||||
return
|
||||
|
||||
print("🔄 Adding identified_by_user_id column to faces table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS identified_by_user_id INTEGER REFERENCES users(id)")
|
||||
)
|
||||
# Add index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_faces_identified_by ON faces(identified_by_user_id)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN identified_by_user_id INTEGER REFERENCES users(id)")
|
||||
)
|
||||
# SQLite doesn't support IF NOT EXISTS for indexes, so we'll try to create it
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX idx_faces_identified_by ON faces(identified_by_user_id)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added identified_by_user_id column to faces table")
|
||||
|
||||
|
||||
def ensure_user_role_column(inspector) -> None:
|
||||
"""Ensure users table has a role column with valid values."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
dialect = engine.dialect.name
|
||||
role_values = sorted(ROLE_VALUES)
|
||||
placeholder_parts = ", ".join(
|
||||
f":role_value_{index}" for index, _ in enumerate(role_values)
|
||||
)
|
||||
where_clause = (
|
||||
"role IS NULL OR role = ''"
|
||||
if not placeholder_parts
|
||||
else f"role IS NULL OR role = '' OR role NOT IN ({placeholder_parts})"
|
||||
)
|
||||
params = {
|
||||
f"role_value_{index}": value for index, value in enumerate(role_values)
|
||||
}
|
||||
params["admin_role"] = DEFAULT_ADMIN_ROLE
|
||||
params["default_role"] = DEFAULT_USER_ROLE
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if "role" not in columns:
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT "
|
||||
f"NOT NULL DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN role TEXT "
|
||||
f"DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET role = CASE
|
||||
WHEN is_admin THEN :admin_role
|
||||
ELSE :default_role
|
||||
END
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)")
|
||||
)
|
||||
print("✅ Ensured users.role column exists and is populated")
|
||||
|
||||
|
||||
def ensure_photo_media_type_column(inspector) -> None:
|
||||
"""Ensure photos table contains media_type column."""
|
||||
if "photos" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("photos")}
|
||||
if "media_type" in columns:
|
||||
print("ℹ️ media_type column already exists in photos table")
|
||||
return
|
||||
|
||||
print("🔄 Adding media_type column to photos table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN IF NOT EXISTS media_type TEXT NOT NULL DEFAULT 'image'")
|
||||
)
|
||||
# Add index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN media_type TEXT DEFAULT 'image'")
|
||||
)
|
||||
# Update existing rows to have 'image' as default
|
||||
connection.execute(
|
||||
text("UPDATE photos SET media_type = 'image' WHERE media_type IS NULL")
|
||||
)
|
||||
# SQLite doesn't support IF NOT EXISTS for indexes, so we'll try to create it
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX idx_photos_media_type ON photos(media_type)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added media_type column to photos table")
|
||||
|
||||
|
||||
def ensure_face_excluded_column(inspector) -> None:
|
||||
"""Ensure faces table contains excluded column."""
|
||||
if "faces" not in inspector.get_table_names():
|
||||
print("ℹ️ Faces table does not exist yet - will be created with excluded column")
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("faces")}
|
||||
if "excluded" in columns:
|
||||
# Column already exists, no need to print or do anything
|
||||
return
|
||||
|
||||
print("🔄 Adding excluded column to faces table...")
|
||||
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
# PostgreSQL: Add column with default value
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT FALSE NOT NULL")
|
||||
)
|
||||
# Create index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_faces_excluded ON faces(excluded)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN excluded BOOLEAN DEFAULT 0 NOT NULL")
|
||||
)
|
||||
# Create index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX idx_faces_excluded ON faces(excluded)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added excluded column to faces table")
|
||||
|
||||
|
||||
def ensure_photo_person_linkage_table(inspector) -> None:
|
||||
"""Ensure photo_person_linkage table exists for direct video-person associations."""
|
||||
if "photo_person_linkage" in inspector.get_table_names():
|
||||
print("ℹ️ photo_person_linkage table already exists")
|
||||
return
|
||||
|
||||
print("🔄 Creating photo_person_linkage table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS photo_person_linkage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
identified_by_user_id INTEGER REFERENCES users(id),
|
||||
created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(photo_id, person_id)
|
||||
)
|
||||
"""))
|
||||
# Create indexes
|
||||
for idx_name, idx_col in [
|
||||
("idx_photo_person_photo", "photo_id"),
|
||||
("idx_photo_person_person", "person_id"),
|
||||
("idx_photo_person_user", "identified_by_user_id"),
|
||||
]:
|
||||
try:
|
||||
connection.execute(
|
||||
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON photo_person_linkage({idx_col})")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS photo_person_linkage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
identified_by_user_id INTEGER REFERENCES users(id),
|
||||
created_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(photo_id, person_id)
|
||||
)
|
||||
"""))
|
||||
# Create indexes
|
||||
for idx_name, idx_col in [
|
||||
("idx_photo_person_photo", "photo_id"),
|
||||
("idx_photo_person_person", "person_id"),
|
||||
("idx_photo_person_user", "identified_by_user_id"),
|
||||
]:
|
||||
try:
|
||||
connection.execute(
|
||||
text(f"CREATE INDEX {idx_name} ON photo_person_linkage({idx_col})")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Created photo_person_linkage table")
|
||||
|
||||
|
||||
def ensure_auth_user_is_active_column() -> None:
|
||||
"""Ensure auth database users table contains is_active column.
|
||||
|
||||
NOTE: Auth database is managed by the frontend. This function only checks/updates
|
||||
if the database and table already exist. It will not fail if they don't exist.
|
||||
"""
|
||||
if auth_engine is None:
|
||||
# Auth database not configured
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
|
||||
# Try to get inspector - gracefully handle if database doesn't exist
|
||||
try:
|
||||
auth_inspector = sqlalchemy_inspect(auth_engine)
|
||||
except Exception as inspect_exc:
|
||||
error_str = str(inspect_exc).lower()
|
||||
if "does not exist" in error_str or "database" in error_str:
|
||||
# Database doesn't exist - that's okay, frontend will create it
|
||||
return
|
||||
# Some other error - log but don't fail
|
||||
print(f"ℹ️ Could not inspect auth database: {inspect_exc}")
|
||||
return
|
||||
|
||||
if "users" not in auth_inspector.get_table_names():
|
||||
# Table doesn't exist - that's okay, frontend will create it
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in auth_inspector.get_columns("users")}
|
||||
if "is_active" in columns:
|
||||
print("ℹ️ is_active column already exists in auth database users table")
|
||||
return
|
||||
|
||||
# Column doesn't exist - try to add it
|
||||
print("🔄 Adding is_active column to auth database users table...")
|
||||
|
||||
dialect = auth_engine.dialect.name
|
||||
|
||||
try:
|
||||
with auth_engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE")
|
||||
)
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT 1")
|
||||
)
|
||||
print("✅ Added is_active column to auth database users table")
|
||||
except Exception as alter_exc:
|
||||
# Check if it's a permission error
|
||||
error_str = str(alter_exc)
|
||||
if "permission" in error_str.lower() or "insufficient" in error_str.lower() or "owner" in error_str.lower():
|
||||
print("⚠️ Cannot add is_active column: insufficient database privileges")
|
||||
print(" The column will need to be added manually by a database administrator:")
|
||||
if dialect == "postgresql":
|
||||
print(" ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE;")
|
||||
else:
|
||||
print(" ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT 1;")
|
||||
print(" Until then, users with linked data cannot be deleted.")
|
||||
else:
|
||||
# Some other error
|
||||
print(f"⚠️ Failed to add is_active column to auth database users table: {alter_exc}")
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to check/add is_active column to auth database users table: {exc}")
|
||||
# Don't raise - auth database might not be available or have permission issues
|
||||
# The delete endpoint will handle this case gracefully
|
||||
|
||||
|
||||
def ensure_role_permissions_table(inspector) -> None:
|
||||
"""Ensure the role_permissions table exists for permission matrix."""
|
||||
if "role_permissions" in inspector.get_table_names():
|
||||
return
|
||||
|
||||
try:
|
||||
print("🔄 Creating role_permissions table...")
|
||||
RolePermission.__table__.create(bind=engine, checkfirst=True)
|
||||
print("✅ Created role_permissions table")
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to create role_permissions table: {exc}")
|
||||
|
||||
|
||||
def ensure_postgresql_database(db_url: str) -> None:
|
||||
"""Ensure PostgreSQL database exists, create it if it doesn't."""
|
||||
if not db_url.startswith("postgresql"):
|
||||
return # Not PostgreSQL, skip
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
|
||||
# Parse the database URL
|
||||
parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://"))
|
||||
db_name = parsed.path.lstrip("/")
|
||||
user = parsed.username
|
||||
password = parsed.password
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 5432
|
||||
|
||||
if not db_name:
|
||||
return # No database name specified
|
||||
|
||||
# Try to connect to the database
|
||||
try:
|
||||
test_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=db_name
|
||||
)
|
||||
test_conn.close()
|
||||
return # Database exists
|
||||
except psycopg2.OperationalError as e:
|
||||
if "does not exist" not in str(e):
|
||||
# Some other error (permissions, connection, etc.)
|
||||
print(f"⚠️ Cannot check if database '{db_name}' exists: {e}")
|
||||
return
|
||||
|
||||
# Database doesn't exist - try to create it
|
||||
print(f"🔄 Creating PostgreSQL database '{db_name}'...")
|
||||
|
||||
# Connect to postgres database to create the new database
|
||||
# Try with the configured user first (they might have CREATEDB privilege)
|
||||
admin_conn = None
|
||||
try:
|
||||
admin_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database="postgres"
|
||||
)
|
||||
except psycopg2.OperationalError:
|
||||
# Try postgres superuser (might need password from environment or .pgpass)
|
||||
try:
|
||||
import os
|
||||
postgres_password = os.getenv("POSTGRES_PASSWORD", "")
|
||||
admin_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user="postgres",
|
||||
password=postgres_password if postgres_password else None,
|
||||
database="postgres"
|
||||
)
|
||||
except psycopg2.OperationalError as e:
|
||||
print(f"⚠️ Cannot create database '{db_name}': insufficient privileges")
|
||||
print(f" Error: {e}")
|
||||
print(f" Please create it manually:")
|
||||
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
|
||||
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
|
||||
return
|
||||
|
||||
if admin_conn is None:
|
||||
return
|
||||
|
||||
admin_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cursor = admin_conn.cursor()
|
||||
|
||||
# Check if database exists
|
||||
cursor.execute(
|
||||
"SELECT 1 FROM pg_database WHERE datname = %s",
|
||||
(db_name,)
|
||||
)
|
||||
exists = cursor.fetchone()
|
||||
|
||||
if not exists:
|
||||
# Create the database
|
||||
try:
|
||||
cursor.execute(f'CREATE DATABASE "{db_name}"')
|
||||
if user != "postgres" and admin_conn.info.user == "postgres":
|
||||
# Grant privileges to the user if we're connected as postgres
|
||||
try:
|
||||
cursor.execute(f'GRANT ALL PRIVILEGES ON DATABASE "{db_name}" TO "{user}"')
|
||||
except Exception as grant_exc:
|
||||
print(f"⚠️ Created database '{db_name}' but could not grant privileges: {grant_exc}")
|
||||
|
||||
# Grant schema permissions (needed for creating tables)
|
||||
if admin_conn.info.user == "postgres":
|
||||
try:
|
||||
# Connect to the new database to grant schema permissions
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
schema_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user="postgres",
|
||||
password=os.getenv("POSTGRES_PASSWORD", "") if os.getenv("POSTGRES_PASSWORD") else None,
|
||||
database=db_name
|
||||
)
|
||||
schema_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
schema_cursor = schema_conn.cursor()
|
||||
schema_cursor.execute(f'GRANT ALL ON SCHEMA public TO "{user}"')
|
||||
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "{user}"')
|
||||
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "{user}"')
|
||||
schema_cursor.close()
|
||||
schema_conn.close()
|
||||
print(f"✅ Granted schema permissions to user '{user}'")
|
||||
except Exception as schema_exc:
|
||||
print(f"⚠️ Created database '{db_name}' but could not grant schema permissions: {schema_exc}")
|
||||
print(f" Please run manually:")
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"GRANT ALL ON SCHEMA public TO {user};\"")
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {user};\"")
|
||||
|
||||
print(f"✅ Created database '{db_name}'")
|
||||
except Exception as create_exc:
|
||||
print(f"⚠️ Failed to create database '{db_name}': {create_exc}")
|
||||
print(f" Please create it manually:")
|
||||
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
|
||||
if user != "postgres":
|
||||
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
return
|
||||
else:
|
||||
print(f"ℹ️ Database '{db_name}' already exists")
|
||||
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to ensure database exists: {exc}")
|
||||
import traceback
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
# Don't raise - let the connection attempt fail naturally with a clearer error
|
||||
|
||||
|
||||
def ensure_auth_database_tables() -> None:
|
||||
"""Ensure auth database tables exist, create them if they don't.
|
||||
|
||||
NOTE: This function is deprecated. Auth database is now managed by the frontend.
|
||||
This function is kept for backward compatibility but will not create tables.
|
||||
"""
|
||||
# Auth database is managed by the frontend - do not create tables here
|
||||
return
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown events."""
|
||||
# Ensure database exists and tables are created on first run
|
||||
try:
|
||||
# Ensure main PostgreSQL database exists
|
||||
# This must happen BEFORE we try to use the engine
|
||||
ensure_postgresql_database(database_url)
|
||||
|
||||
# Note: Auth database is managed by the frontend, not created here
|
||||
|
||||
if database_url.startswith("sqlite"):
|
||||
db_path = database_url.replace("sqlite:///", "")
|
||||
db_file = Path(db_path)
|
||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Only create tables if they don't already exist (safety check)
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Check if required application tables exist (not just alembic_version)
|
||||
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users", "photo_person_linkage"}
|
||||
missing_tables = required_tables - existing_tables
|
||||
|
||||
if missing_tables:
|
||||
# Some required tables are missing - create all tables
|
||||
# create_all() only creates missing tables, won't drop existing ones
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if len(missing_tables) == len(required_tables):
|
||||
print("✅ Database initialized (first run - tables created)")
|
||||
else:
|
||||
print(f"✅ Database tables created (missing tables: {', '.join(missing_tables)})")
|
||||
else:
|
||||
# All required tables exist - don't recreate (prevents data loss)
|
||||
print(f"✅ Database already initialized ({len(existing_tables)} tables exist)")
|
||||
|
||||
# Ensure new columns exist (backward compatibility without migrations)
|
||||
ensure_user_password_hash_column(inspector)
|
||||
ensure_user_password_change_required_column(inspector)
|
||||
ensure_user_email_unique_constraint(inspector)
|
||||
ensure_face_identified_by_user_id_column(inspector)
|
||||
ensure_user_role_column(inspector)
|
||||
ensure_photo_media_type_column(inspector)
|
||||
ensure_photo_person_linkage_table(inspector)
|
||||
ensure_face_excluded_column(inspector)
|
||||
ensure_role_permissions_table(inspector)
|
||||
|
||||
# Note: Auth database schema and tables are managed by the frontend
|
||||
# Only check/update if the database exists (don't create it)
|
||||
if auth_engine is not None:
|
||||
try:
|
||||
ensure_auth_user_is_active_column()
|
||||
except Exception as auth_exc:
|
||||
# Auth database might not exist yet - that's okay, frontend will handle it
|
||||
print(f"ℹ️ Auth database not available: {auth_exc}")
|
||||
print(" Frontend will manage auth database setup")
|
||||
except Exception as exc:
|
||||
print(f"❌ Database initialization failed: {exc}")
|
||||
raise
|
||||
# Startup
|
||||
start_worker()
|
||||
yield
|
||||
# Shutdown
|
||||
stop_worker()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application instance."""
|
||||
app = FastAPI(
|
||||
title=APP_TITLE,
|
||||
version=APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router, tags=["health"])
|
||||
app.include_router(version_router, tags=["meta"])
|
||||
app.include_router(metrics_router, tags=["metrics"])
|
||||
app.include_router(auth_router, prefix="/api/v1")
|
||||
app.include_router(jobs_router, prefix="/api/v1")
|
||||
app.include_router(photos_router, prefix="/api/v1")
|
||||
app.include_router(faces_router, prefix="/api/v1")
|
||||
app.include_router(people_router, prefix="/api/v1")
|
||||
app.include_router(videos_router, prefix="/api/v1")
|
||||
app.include_router(pending_identifications_router, prefix="/api/v1")
|
||||
app.include_router(pending_linkages_router, prefix="/api/v1")
|
||||
app.include_router(reported_photos_router, prefix="/api/v1")
|
||||
app.include_router(pending_photos_router, prefix="/api/v1")
|
||||
app.include_router(tags_router, prefix="/api/v1")
|
||||
app.include_router(users_router, prefix="/api/v1")
|
||||
app.include_router(auth_users_router, prefix="/api/v1")
|
||||
app.include_router(role_permissions_router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Configuration values used by the PunimTag web services.
|
||||
|
||||
This module replaces the legacy desktop configuration to keep the web
|
||||
application self-contained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Supported image formats for uploads/imports
|
||||
SUPPORTED_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"}
|
||||
|
||||
# Supported video formats for scanning (not processed for faces)
|
||||
SUPPORTED_VIDEO_FORMATS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg"}
|
||||
|
||||
# DeepFace behavior
|
||||
DEEPFACE_ENFORCE_DETECTION = False
|
||||
DEEPFACE_ALIGN_FACES = True
|
||||
|
||||
# Face filtering thresholds
|
||||
MIN_FACE_CONFIDENCE = 0.4
|
||||
MIN_FACE_SIZE = 40
|
||||
MAX_FACE_SIZE = 1500
|
||||
|
||||
# Matching tolerance and calibration options
|
||||
DEFAULT_FACE_TOLERANCE = 0.6
|
||||
USE_CALIBRATED_CONFIDENCE = True
|
||||
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Feature definitions and default role permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Final, List, Set
|
||||
|
||||
from backend.constants.roles import UserRole
|
||||
|
||||
ROLE_FEATURES: Final[List[dict[str, str]]] = [
|
||||
{"key": "scan", "label": "Scan"},
|
||||
{"key": "process", "label": "Process"},
|
||||
{"key": "search_photos", "label": "Search Photos"},
|
||||
{"key": "identify_people", "label": "Identify People"},
|
||||
{"key": "auto_match", "label": "Auto-Match"},
|
||||
{"key": "modify_people", "label": "Modify People"},
|
||||
{"key": "tag_photos", "label": "Tag Photos"},
|
||||
{"key": "faces_maintenance", "label": "Faces Maintenance"},
|
||||
{"key": "user_identified", "label": "User Identified"},
|
||||
{"key": "user_reported", "label": "User Reported"},
|
||||
{"key": "user_tagged", "label": "User Tagged Photos"},
|
||||
{"key": "user_uploaded", "label": "User Uploaded"},
|
||||
{"key": "manage_users", "label": "Manage Users"},
|
||||
{"key": "manage_roles", "label": "Manage Roles"},
|
||||
]
|
||||
|
||||
ROLE_FEATURE_KEYS: Final[List[str]] = [feature["key"] for feature in ROLE_FEATURES]
|
||||
|
||||
DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = {
|
||||
UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MODERATOR.value: {"scan", "process", "manage_users"},
|
||||
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded", "user_tagged"},
|
||||
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users", "user_tagged"},
|
||||
UserRole.IMPORTER.value: {"user_uploaded"},
|
||||
UserRole.VIEWER.value: {"user_identified", "user_reported", "user_tagged"},
|
||||
}
|
||||
|
||||
|
||||
def get_default_permission(role: str, feature_key: str) -> bool:
|
||||
"""Return the default allowed value for a role/feature pair."""
|
||||
allowed_features = DEFAULT_ROLE_FEATURE_MATRIX.get(role, set())
|
||||
return feature_key in allowed_features
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Shared role definitions for backend user management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Final, Set
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""Enumerated set of supported user roles."""
|
||||
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
MODERATOR = "moderator"
|
||||
REVIEWER = "reviewer"
|
||||
EDITOR = "editor"
|
||||
IMPORTER = "importer"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
ROLE_VALUES: Final[Set[str]] = {role.value for role in UserRole}
|
||||
ADMIN_ROLE_VALUES: Final[Set[str]] = {
|
||||
UserRole.ADMIN.value,
|
||||
}
|
||||
DEFAULT_ADMIN_ROLE: Final[str] = UserRole.ADMIN.value
|
||||
DEFAULT_USER_ROLE: Final[str] = UserRole.VIEWER.value
|
||||
|
||||
|
||||
def is_admin_role(role: str) -> bool:
|
||||
"""Return True when the provided role is considered an admin role."""
|
||||
return role in ADMIN_ROLE_VALUES
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Database package for PunimTag Web."""
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Database base configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.db.models import Base
|
||||
from backend.db.session import engine
|
||||
|
||||
__all__ = ["Base", "engine"]
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""SQLAlchemy models for PunimTag Web - matching desktop schema exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Photo(Base):
|
||||
"""Photo model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "photos"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
path = Column(Text, unique=True, nullable=False, index=True)
|
||||
filename = Column(Text, nullable=False)
|
||||
date_added = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
date_taken = Column(Date, nullable=True, index=True)
|
||||
processed = Column(Boolean, default=False, nullable=False, index=True)
|
||||
file_hash = Column(Text, nullable=False, index=True)
|
||||
media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video"
|
||||
|
||||
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
|
||||
photo_tags = relationship(
|
||||
"PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan"
|
||||
)
|
||||
favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan")
|
||||
video_people = relationship(
|
||||
"PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_photos_processed", "processed"),
|
||||
Index("idx_photos_date_taken", "date_taken"),
|
||||
Index("idx_photos_date_added", "date_added"),
|
||||
Index("idx_photos_file_hash", "file_hash"),
|
||||
)
|
||||
|
||||
|
||||
class Person(Base):
|
||||
"""Person model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "people"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
first_name = Column(Text, nullable=False)
|
||||
last_name = Column(Text, nullable=False)
|
||||
middle_name = Column(Text, nullable=True)
|
||||
maiden_name = Column(Text, nullable=True)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
faces = relationship("Face", back_populates="person")
|
||||
person_encodings = relationship(
|
||||
"PersonEncoding", back_populates="person", cascade="all, delete-orphan"
|
||||
)
|
||||
video_photos = relationship(
|
||||
"PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"first_name", "last_name", "middle_name", "maiden_name", "date_of_birth",
|
||||
name="uq_people_names_dob"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Face(Base):
|
||||
"""Face detection model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "faces"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=True, index=True)
|
||||
encoding = Column(LargeBinary, nullable=False)
|
||||
location = Column(Text, nullable=False)
|
||||
confidence = Column(Numeric, default=0.0, nullable=False)
|
||||
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
|
||||
is_primary_encoding = Column(Boolean, default=False, nullable=False)
|
||||
detector_backend = Column(Text, default="retinaface", nullable=False)
|
||||
model_name = Column(Text, default="ArcFace", nullable=False)
|
||||
face_confidence = Column(Numeric, default=0.0, nullable=False)
|
||||
exif_orientation = Column(Integer, nullable=True)
|
||||
pose_mode = Column(Text, default="frontal", nullable=False, index=True)
|
||||
yaw_angle = Column(Numeric, nullable=True)
|
||||
pitch_angle = Column(Numeric, nullable=True)
|
||||
roll_angle = Column(Numeric, nullable=True)
|
||||
landmarks = Column(Text, nullable=True) # JSON string of facial landmarks
|
||||
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
excluded = Column(Boolean, default=False, nullable=False, index=True) # Exclude from identification
|
||||
|
||||
photo = relationship("Photo", back_populates="faces")
|
||||
person = relationship("Person", back_populates="faces")
|
||||
person_encodings = relationship(
|
||||
"PersonEncoding", back_populates="face", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_faces_person_id", "person_id"),
|
||||
Index("idx_faces_photo_id", "photo_id"),
|
||||
Index("idx_faces_quality", "quality_score"),
|
||||
Index("idx_faces_pose_mode", "pose_mode"),
|
||||
Index("idx_faces_identified_by", "identified_by_user_id"),
|
||||
Index("idx_faces_excluded", "excluded"),
|
||||
)
|
||||
|
||||
|
||||
class PersonEncoding(Base):
|
||||
"""Person encoding model - matches desktop schema exactly (was person_encodings)."""
|
||||
|
||||
__tablename__ = "person_encodings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
|
||||
face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True)
|
||||
encoding = Column(LargeBinary, nullable=False)
|
||||
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
|
||||
detector_backend = Column(Text, default="retinaface", nullable=False)
|
||||
model_name = Column(Text, default="ArcFace", nullable=False)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
person = relationship("Person", back_populates="person_encodings")
|
||||
face = relationship("Face", back_populates="person_encodings")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_person_encodings_person_id", "person_id"),
|
||||
Index("idx_person_encodings_quality", "quality_score"),
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""Tag model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
tag_name = Column(Text, unique=True, nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo_tags = relationship(
|
||||
"PhotoTagLinkage", back_populates="tag", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PhotoTagLinkage(Base):
|
||||
"""Photo-Tag linkage model - matches desktop schema exactly (was phototaglinkage)."""
|
||||
|
||||
__tablename__ = "phototaglinkage"
|
||||
|
||||
linkage_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
tag_id = Column(Integer, ForeignKey("tags.id"), nullable=False, index=True)
|
||||
linkage_type = Column(
|
||||
Integer, default=0, nullable=False,
|
||||
server_default="0"
|
||||
)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="photo_tags")
|
||||
tag = relationship("Tag", back_populates="photo_tags")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("photo_id", "tag_id", name="uq_photo_tag"),
|
||||
CheckConstraint("linkage_type IN (0, 1)", name="ck_linkage_type"),
|
||||
Index("idx_photo_tags_tag", "tag_id"),
|
||||
Index("idx_photo_tags_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
class PhotoFavorite(Base):
|
||||
"""Photo favorites model - user-specific favorites."""
|
||||
|
||||
__tablename__ = "photo_favorites"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(Text, nullable=False, index=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="favorites")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"),
|
||||
Index("idx_favorites_username", "username"),
|
||||
Index("idx_favorites_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for main database - separate from auth database users."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
username = Column(Text, unique=True, nullable=False, index=True)
|
||||
password_hash = Column(Text, nullable=False) # Hashed password
|
||||
email = Column(Text, unique=True, nullable=False, index=True)
|
||||
full_name = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False, index=True)
|
||||
role = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default=DEFAULT_USER_ROLE,
|
||||
server_default=DEFAULT_USER_ROLE,
|
||||
index=True,
|
||||
)
|
||||
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_users_username", "username"),
|
||||
Index("idx_users_email", "email"),
|
||||
Index("idx_users_is_admin", "is_admin"),
|
||||
Index("idx_users_password_change_required", "password_change_required"),
|
||||
Index("idx_users_role", "role"),
|
||||
)
|
||||
|
||||
|
||||
class PhotoPersonLinkage(Base):
|
||||
"""Direct linkage between Video (Photo with media_type='video') and Person.
|
||||
|
||||
This allows identifying people in videos without requiring face detection.
|
||||
Only used for videos, not photos (photos use Face model for identification).
|
||||
"""
|
||||
|
||||
__tablename__ = "photo_person_linkage"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
|
||||
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="video_people")
|
||||
person = relationship("Person", back_populates="video_photos")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("photo_id", "person_id", name="uq_photo_person"),
|
||||
Index("idx_photo_person_photo", "photo_id"),
|
||||
Index("idx_photo_person_person", "person_id"),
|
||||
Index("idx_photo_person_user", "identified_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Role-to-feature permission matrix."""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
role = Column(Text, nullable=False, index=True)
|
||||
feature_key = Column(Text, nullable=False, index=True)
|
||||
allowed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role", "feature_key", name="uq_role_feature"),
|
||||
Index("idx_role_permissions_role_feature", "role", "feature_key"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Load environment variables from .env file if it exists
|
||||
env_path = Path(__file__).parent.parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""Fetch database URL from environment or defaults."""
|
||||
import os
|
||||
# Check for environment variable first
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if db_url:
|
||||
return db_url
|
||||
# Default to SQLite for development
|
||||
return "sqlite:///data/punimtag.db"
|
||||
|
||||
|
||||
def get_auth_database_url() -> str:
|
||||
"""Fetch auth database URL from environment."""
|
||||
import os
|
||||
db_url = os.getenv("DATABASE_URL_AUTH")
|
||||
if not db_url:
|
||||
raise ValueError("DATABASE_URL_AUTH environment variable not set")
|
||||
return db_url
|
||||
|
||||
|
||||
database_url = get_database_url()
|
||||
# SQLite-specific configuration
|
||||
connect_args = {}
|
||||
if database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
|
||||
# PostgreSQL connection pool settings
|
||||
pool_kwargs = {"pool_pre_ping": True}
|
||||
if database_url.startswith("postgresql"):
|
||||
pool_kwargs.update({
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
})
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
future=True,
|
||||
connect_args=connect_args,
|
||||
**pool_kwargs
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Generator:
|
||||
"""Yield a DB session for request lifecycle."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Auth database setup
|
||||
try:
|
||||
auth_database_url = get_auth_database_url()
|
||||
auth_connect_args = {}
|
||||
if auth_database_url.startswith("sqlite"):
|
||||
auth_connect_args = {"check_same_thread": False}
|
||||
|
||||
auth_pool_kwargs = {"pool_pre_ping": True}
|
||||
if auth_database_url.startswith("postgresql"):
|
||||
auth_pool_kwargs.update({
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
})
|
||||
|
||||
auth_engine = create_engine(
|
||||
auth_database_url,
|
||||
future=True,
|
||||
connect_args=auth_connect_args,
|
||||
**auth_pool_kwargs
|
||||
)
|
||||
AuthSessionLocal = sessionmaker(bind=auth_engine, autoflush=False, autocommit=False, future=True)
|
||||
except ValueError:
|
||||
# DATABASE_URL_AUTH not set - auth database not available
|
||||
auth_engine = None
|
||||
AuthSessionLocal = None
|
||||
|
||||
|
||||
def get_auth_db() -> Generator:
|
||||
"""Yield a DB session for auth database request lifecycle."""
|
||||
if AuthSessionLocal is None:
|
||||
raise ValueError("Auth database not configured. Set DATABASE_URL_AUTH environment variable.")
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Pydantic schemas for PunimTag Web."""
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Authentication schemas for web API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE, UserRole
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""Refresh token request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
password_change_required: bool = False
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
role: UserRole = DEFAULT_USER_ROLE
|
||||
permissions: Dict[str, bool] = {}
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
"""Password change request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeResponse(BaseModel):
|
||||
"""Password change response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Auth database user management schemas for web API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class AuthUserResponse(BaseModel):
|
||||
"""Auth user DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
email: str
|
||||
is_admin: Optional[bool] = None
|
||||
has_write_access: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AuthUserCreateRequest(BaseModel):
|
||||
"""Request payload to create a new auth user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
email: EmailStr = Field(..., description="Email address (unique, required)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
|
||||
password: str = Field(..., min_length=6, description="Password (minimum 6 characters, required)")
|
||||
is_admin: bool = Field(..., description="Admin role (required)")
|
||||
has_write_access: bool = Field(..., description="Write access (required)")
|
||||
|
||||
|
||||
class AuthUserUpdateRequest(BaseModel):
|
||||
"""Request payload to update an auth user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
email: EmailStr = Field(..., description="Email address (required)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
|
||||
is_admin: bool = Field(..., description="Admin role (required)")
|
||||
has_write_access: bool = Field(..., description="Write access (required)")
|
||||
is_active: Optional[bool] = Field(None, description="Active status (optional)")
|
||||
role: Optional[str] = Field(None, description="Role: 'Admin' or 'User' (optional)")
|
||||
password: Optional[str] = Field(None, min_length=6, description="New password (optional, minimum 6 characters, leave empty to keep current)")
|
||||
|
||||
|
||||
class AuthUsersListResponse(BaseModel):
|
||||
"""List of auth users."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[AuthUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""Face processing and identify workflow schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ProcessFacesRequest(BaseModel):
|
||||
"""Request to process faces in photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
batch_size: Optional[int] = Field(
|
||||
None,
|
||||
ge=1,
|
||||
description="Maximum number of photos to process (None = all unprocessed)",
|
||||
)
|
||||
detector_backend: str = Field(
|
||||
"retinaface",
|
||||
description="DeepFace detector backend (retinaface, mtcnn, opencv, ssd)",
|
||||
)
|
||||
model_name: str = Field(
|
||||
"ArcFace",
|
||||
description="DeepFace model name (ArcFace, Facenet, Facenet512, VGG-Face)",
|
||||
)
|
||||
|
||||
|
||||
class ProcessFacesResponse(BaseModel):
|
||||
"""Response after initiating face processing."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
job_id: str
|
||||
message: str
|
||||
batch_size: Optional[int] = None
|
||||
detector_backend: str
|
||||
model_name: str
|
||||
|
||||
|
||||
class FaceItem(BaseModel):
|
||||
"""Minimal face item for list views."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
quality_score: float
|
||||
face_confidence: float
|
||||
location: str
|
||||
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
excluded: bool = Field(False, description="Whether this face is excluded from identification")
|
||||
|
||||
|
||||
class UnidentifiedFacesQuery(BaseModel):
|
||||
"""Query params for listing unidentified faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
page: int = 1
|
||||
page_size: int = 50
|
||||
min_quality: float = 0.0
|
||||
date_from: Optional[date] = None
|
||||
date_to: Optional[date] = None
|
||||
sort_by: str = Field("quality", description="quality|date_taken|date_added")
|
||||
sort_dir: str = Field("desc", description="asc|desc")
|
||||
|
||||
|
||||
class UnidentifiedFacesResponse(BaseModel):
|
||||
"""Paginated unidentified faces list."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[FaceItem]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
|
||||
|
||||
class SimilarFaceItem(BaseModel):
|
||||
"""Similar face with similarity score (0-1)."""
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
similarity: float
|
||||
location: str
|
||||
quality_score: float
|
||||
filename: str
|
||||
pose_mode: Optional[str] = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
|
||||
|
||||
class SimilarFacesResponse(BaseModel):
|
||||
"""Response containing similar faces for a given face."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
base_face_id: int
|
||||
items: list[SimilarFaceItem]
|
||||
|
||||
|
||||
class BatchSimilarityRequest(BaseModel):
|
||||
"""Request to get similarities between multiple faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., description="List of face IDs to calculate similarities for")
|
||||
min_confidence: float = Field(60.0, ge=0.0, le=100.0, description="Minimum confidence percentage (0-100)")
|
||||
|
||||
|
||||
class FaceSimilarityPair(BaseModel):
|
||||
"""A pair of similar faces with their similarity score."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_id_1: int
|
||||
face_id_2: int
|
||||
similarity: float # 0-1 range
|
||||
confidence_pct: float # 0-100 range
|
||||
|
||||
|
||||
class BatchSimilarityResponse(BaseModel):
|
||||
"""Response containing similarities between face pairs."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
pairs: list[FaceSimilarityPair] = Field(..., description="List of similar face pairs")
|
||||
|
||||
|
||||
class IdentifyFaceRequest(BaseModel):
|
||||
"""Identify a face by selecting existing or creating new person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
# Either provide person_id or the fields to create new person
|
||||
person_id: Optional[int] = None
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
# Optionally identify a batch of face IDs along with this one
|
||||
additional_face_ids: Optional[list[int]] = None
|
||||
|
||||
|
||||
class IdentifyFaceResponse(BaseModel):
|
||||
"""Result of identify operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
identified_face_ids: list[int]
|
||||
person_id: int
|
||||
created_person: bool
|
||||
|
||||
|
||||
class FaceUnmatchResponse(BaseModel):
|
||||
"""Result of unmatch operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_id: int
|
||||
message: str
|
||||
|
||||
|
||||
class BatchUnmatchRequest(BaseModel):
|
||||
"""Request to batch unmatch multiple faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., min_items=1)
|
||||
|
||||
|
||||
class BatchUnmatchResponse(BaseModel):
|
||||
"""Result of batch unmatch operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
unmatched_face_ids: list[int]
|
||||
count: int
|
||||
message: str
|
||||
|
||||
|
||||
class PersonFaceItem(BaseModel):
|
||||
"""Face item for person's faces list (includes photo info)."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
photo_path: str
|
||||
photo_filename: str
|
||||
location: str
|
||||
face_confidence: float
|
||||
quality_score: float
|
||||
detector_backend: str
|
||||
model_name: str
|
||||
|
||||
|
||||
class PersonFacesResponse(BaseModel):
|
||||
"""Response containing all faces for a person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
person_id: int
|
||||
items: list[PersonFaceItem]
|
||||
total: int
|
||||
|
||||
|
||||
class AutoMatchRequest(BaseModel):
|
||||
"""Request to start auto-match process."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
tolerance: float = Field(0.6, ge=0.0, le=1.0, description="Tolerance threshold (lower = stricter matching)")
|
||||
auto_accept: bool = Field(False, description="Enable automatic acceptance of matching faces")
|
||||
auto_accept_threshold: float = Field(70.0, ge=0.0, le=100.0, description="Similarity threshold for auto-acceptance (0-100%)")
|
||||
|
||||
|
||||
class AutoMatchFaceItem(BaseModel):
|
||||
"""Unidentified face match for a person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
photo_filename: str
|
||||
location: str
|
||||
quality_score: float
|
||||
similarity: float # Confidence percentage (0-100)
|
||||
distance: float
|
||||
pose_mode: str = Field("frontal", description="Pose classification (frontal, profile_left, etc.)")
|
||||
|
||||
|
||||
class AutoMatchPersonItem(BaseModel):
|
||||
"""Person with matches for auto-match workflow."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
person_id: int
|
||||
person_name: str
|
||||
reference_face_id: int
|
||||
reference_photo_id: int
|
||||
reference_photo_filename: str
|
||||
reference_location: str
|
||||
reference_pose_mode: str = Field("frontal", description="Reference face pose classification")
|
||||
face_count: int # Number of faces already identified for this person
|
||||
matches: list[AutoMatchFaceItem]
|
||||
total_matches: int
|
||||
|
||||
|
||||
class AutoMatchPersonSummary(BaseModel):
|
||||
"""Person summary without matches (for fast initial load)."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
person_id: int
|
||||
person_name: str
|
||||
reference_face_id: int
|
||||
reference_photo_id: int
|
||||
reference_photo_filename: str
|
||||
reference_location: str
|
||||
reference_pose_mode: str = Field("frontal", description="Reference face pose classification")
|
||||
face_count: int # Number of faces already identified for this person
|
||||
total_matches: int = Field(0, description="Total matches (loaded separately)")
|
||||
|
||||
|
||||
class AutoMatchPeopleResponse(BaseModel):
|
||||
"""Response containing people list without matches (for fast initial load)."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
people: list[AutoMatchPersonSummary]
|
||||
total_people: int
|
||||
|
||||
|
||||
class AutoMatchPersonMatchesResponse(BaseModel):
|
||||
"""Response containing matches for a specific person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
person_id: int
|
||||
matches: list[AutoMatchFaceItem]
|
||||
total_matches: int
|
||||
|
||||
|
||||
class AutoMatchResponse(BaseModel):
|
||||
"""Response from auto-match start operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
people: list[AutoMatchPersonItem]
|
||||
total_people: int
|
||||
total_matches: int
|
||||
auto_accepted: bool = Field(False, description="Whether auto-acceptance was performed")
|
||||
auto_accepted_faces: int = Field(0, description="Number of faces automatically accepted")
|
||||
skipped_persons: int = Field(0, description="Number of persons skipped (non-frontal reference)")
|
||||
skipped_matches: int = Field(0, description="Number of matches skipped (didn't meet criteria)")
|
||||
|
||||
|
||||
class AcceptMatchesRequest(BaseModel):
|
||||
"""Request to accept matches for a person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., min_items=0, description="Face IDs to identify with this person")
|
||||
|
||||
|
||||
class MaintenanceFaceItem(BaseModel):
|
||||
"""Face item for maintenance view with person info and file path."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
photo_path: str
|
||||
photo_filename: str
|
||||
quality_score: float
|
||||
person_id: Optional[int] = None
|
||||
person_name: Optional[str] = None # Full name if identified
|
||||
excluded: bool
|
||||
|
||||
|
||||
class MaintenanceFacesResponse(BaseModel):
|
||||
"""Response containing all faces for maintenance."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[MaintenanceFaceItem]
|
||||
total: int
|
||||
|
||||
|
||||
class DeleteFacesRequest(BaseModel):
|
||||
"""Request to delete multiple faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
face_ids: list[int] = Field(..., min_items=1, description="Face IDs to delete")
|
||||
|
||||
|
||||
class DeleteFacesResponse(BaseModel):
|
||||
"""Response after deleting faces."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_face_ids: list[int]
|
||||
count: int
|
||||
message: str
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Job schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class JobStatus(str, Enum):
|
||||
"""Job status enum."""
|
||||
|
||||
PENDING = "pending"
|
||||
STARTED = "started"
|
||||
PROGRESS = "progress"
|
||||
SUCCESS = "success"
|
||||
FAILURE = "failure"
|
||||
|
||||
|
||||
class JobResponse(BaseModel):
|
||||
"""Job response schema."""
|
||||
|
||||
id: str
|
||||
status: JobStatus
|
||||
progress: int = 0
|
||||
message: str = ""
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""People schemas for web API (Phase 3)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PersonResponse(BaseModel):
|
||||
"""Person DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
|
||||
|
||||
class PersonCreateRequest(BaseModel):
|
||||
"""Request payload to create a new person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
first_name: str = Field(..., min_length=1)
|
||||
last_name: str = Field(..., min_length=1)
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
|
||||
|
||||
class PeopleListResponse(BaseModel):
|
||||
"""List of people for selection dropdowns."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PersonResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PersonUpdateRequest(BaseModel):
|
||||
"""Request payload to update a person."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
first_name: str = Field(..., min_length=1)
|
||||
last_name: str = Field(..., min_length=1)
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
|
||||
|
||||
class PersonWithFacesResponse(BaseModel):
|
||||
"""Person with face count for modify identified workflow."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
face_count: int
|
||||
video_count: int
|
||||
|
||||
|
||||
class PeopleWithFacesListResponse(BaseModel):
|
||||
"""List of people with face counts."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PersonWithFacesResponse]
|
||||
total: int
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Photo schemas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class PhotoImportRequest(BaseModel):
|
||||
"""Request to import photos from a folder or upload files."""
|
||||
|
||||
folder_path: Optional[str] = Field(
|
||||
None, description="Path to folder to scan for photos"
|
||||
)
|
||||
recursive: bool = Field(
|
||||
True, description="Whether to scan subdirectories recursively"
|
||||
)
|
||||
|
||||
|
||||
class PhotoResponse(BaseModel):
|
||||
"""Photo response schema."""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
filename: str
|
||||
checksum: Optional[str] = None
|
||||
date_added: datetime
|
||||
date_taken: Optional[datetime] = None
|
||||
width: Optional[int] = None
|
||||
height: Optional[int] = None
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PhotoImportResponse(BaseModel):
|
||||
"""Response after initiating photo import."""
|
||||
|
||||
job_id: str
|
||||
message: str
|
||||
folder_path: Optional[str] = None
|
||||
estimated_photos: Optional[int] = None
|
||||
|
||||
|
||||
class BulkAddFavoritesRequest(BaseModel):
|
||||
"""Request to add multiple photos to favorites."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to add to favorites")
|
||||
|
||||
|
||||
class BulkAddFavoritesResponse(BaseModel):
|
||||
"""Response for bulk add favorites operation."""
|
||||
|
||||
message: str
|
||||
added_count: int
|
||||
already_favorite_count: int
|
||||
total_requested: int
|
||||
|
||||
|
||||
class BulkRemoveFavoritesRequest(BaseModel):
|
||||
"""Request to remove multiple photos from favorites."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to remove from favorites")
|
||||
|
||||
|
||||
class BulkRemoveFavoritesResponse(BaseModel):
|
||||
"""Response for bulk remove favorites operation."""
|
||||
|
||||
message: str
|
||||
removed_count: int
|
||||
not_favorite_count: int
|
||||
total_requested: int
|
||||
|
||||
|
||||
class BulkDeletePhotosRequest(BaseModel):
|
||||
"""Request to delete multiple photos permanently."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="List of photo IDs to delete")
|
||||
|
||||
|
||||
class BulkDeletePhotosResponse(BaseModel):
|
||||
"""Response for bulk delete photos operation."""
|
||||
|
||||
message: str
|
||||
deleted_count: int
|
||||
missing_photo_ids: List[int] = Field(
|
||||
default_factory=list,
|
||||
description="Photo IDs that were requested but not found",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Schemas for role permissions management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from backend.constants.role_features import ROLE_FEATURES
|
||||
from backend.constants.roles import UserRole
|
||||
|
||||
|
||||
class RoleFeatureSchema(BaseModel):
|
||||
"""Feature metadata visible in the UI."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
|
||||
|
||||
class RolePermissionsResponse(BaseModel):
|
||||
"""Payload returned when listing role permissions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
features: list[RoleFeatureSchema]
|
||||
permissions: Dict[UserRole, Dict[str, bool]]
|
||||
|
||||
|
||||
class RolePermissionsUpdateRequest(BaseModel):
|
||||
"""Payload for updating role permissions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
permissions: Dict[UserRole, Dict[str, bool]] = Field(
|
||||
...,
|
||||
description="Map of role -> {feature_key: allowed}",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_feature_list() -> list[RoleFeatureSchema]:
|
||||
return [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Search schemas for Phase 5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SearchPhotosQuery(BaseModel):
|
||||
"""Query parameters for searching photos."""
|
||||
|
||||
person_ids: Optional[List[int]] = Field(None, description="Filter by person IDs")
|
||||
tag_ids: Optional[List[int]] = Field(None, description="Filter by tag IDs")
|
||||
date_from: Optional[date] = Field(None, description="Filter by date taken (from)")
|
||||
date_to: Optional[date] = Field(None, description="Filter by date taken (to)")
|
||||
min_quality: Optional[float] = Field(None, ge=0.0, le=1.0, description="Minimum face quality score")
|
||||
folder_path: Optional[str] = Field(None, description="Filter by folder path prefix")
|
||||
sort_by: str = Field("date_taken", description="Sort column: date_taken, date_added, filename, path")
|
||||
sort_dir: str = Field("desc", description="Sort direction: asc|desc")
|
||||
page: int = Field(1, ge=1, description="Page number")
|
||||
page_size: int = Field(50, ge=1, le=200, description="Page size")
|
||||
|
||||
|
||||
class PhotoSearchResult(BaseModel):
|
||||
"""Photo search result item."""
|
||||
|
||||
id: int
|
||||
path: str
|
||||
filename: str
|
||||
date_taken: Optional[date] = None
|
||||
date_added: date
|
||||
processed: bool
|
||||
person_name: Optional[str] = None # For name search
|
||||
tags: List[str] = Field(default_factory=list) # All tags for the photo
|
||||
has_faces: bool = False
|
||||
face_count: int = 0
|
||||
is_favorite: bool = False # Whether photo is favorited by current user
|
||||
|
||||
|
||||
class SearchPhotosResponse(BaseModel):
|
||||
"""Response for photo search."""
|
||||
|
||||
items: List[PhotoSearchResult]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tag schemas for Phase 5."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
"""Tag response schema."""
|
||||
|
||||
id: int
|
||||
tag_name: str
|
||||
created_date: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TagCreateRequest(BaseModel):
|
||||
"""Request to create a tag."""
|
||||
|
||||
tag_name: str = Field(..., description="Tag name")
|
||||
|
||||
|
||||
class TagsResponse(BaseModel):
|
||||
"""Response for listing tags."""
|
||||
|
||||
items: List[TagResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class PhotoTagsRequest(BaseModel):
|
||||
"""Request to add/remove tags from photos."""
|
||||
|
||||
photo_ids: List[int] = Field(..., description="Photo IDs")
|
||||
tag_names: List[str] = Field(..., description="Tag names to add/remove")
|
||||
|
||||
|
||||
class PhotoTagsResponse(BaseModel):
|
||||
"""Response for photo tagging operations."""
|
||||
|
||||
message: str
|
||||
photos_updated: int
|
||||
tags_added: int
|
||||
tags_removed: int
|
||||
|
||||
|
||||
class TagUpdateRequest(BaseModel):
|
||||
"""Request to update a tag name."""
|
||||
|
||||
tag_name: str = Field(..., description="New tag name")
|
||||
|
||||
|
||||
class TagDeleteRequest(BaseModel):
|
||||
"""Request to delete tags."""
|
||||
|
||||
tag_ids: List[int] = Field(..., description="Tag IDs to delete")
|
||||
|
||||
|
||||
class PhotoTagItem(BaseModel):
|
||||
"""Tag item for a photo."""
|
||||
|
||||
tag_id: int
|
||||
tag_name: str
|
||||
|
||||
|
||||
class PhotoTagsListResponse(BaseModel):
|
||||
"""Response for listing tags on a photo."""
|
||||
|
||||
photo_id: int
|
||||
tags: List[PhotoTagItem]
|
||||
total: int
|
||||
|
||||
|
||||
class PhotoWithTagsItem(BaseModel):
|
||||
"""Photo item with tags for tag manager."""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
path: str
|
||||
processed: bool
|
||||
date_taken: Optional[str] = None
|
||||
date_added: Optional[str] = None
|
||||
face_count: int
|
||||
unidentified_face_count: int # Count of faces with person_id IS NULL
|
||||
tags: str # Comma-separated tags string (matching desktop)
|
||||
people_names: str = "" # Comma-separated people names string
|
||||
|
||||
|
||||
class PhotosWithTagsResponse(BaseModel):
|
||||
"""Response for listing photos with tags."""
|
||||
|
||||
items: List[PhotoWithTagsItem]
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""User management schemas for web API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE, UserRole
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
username: str
|
||||
email: Optional[str] = None
|
||||
full_name: Optional[str] = None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
role: UserRole
|
||||
password_change_required: bool
|
||||
created_date: datetime
|
||||
last_login: Optional[datetime] = None
|
||||
|
||||
|
||||
class UserCreateRequest(BaseModel):
|
||||
"""Request payload to create a new user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
username: str = Field(..., min_length=1, max_length=100)
|
||||
password: str = Field(..., min_length=6, description="Password (minimum 6 characters)")
|
||||
email: EmailStr = Field(..., description="Email address (required)")
|
||||
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
|
||||
is_active: bool = True
|
||||
is_admin: bool = False
|
||||
role: UserRole = Field(
|
||||
DEFAULT_USER_ROLE,
|
||||
description="Role for feature-level access; also controls admin status where applicable",
|
||||
)
|
||||
give_frontend_permission: bool = Field(False, description="Create user in auth database for frontend access")
|
||||
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
"""Request payload to update a user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
password: Optional[str] = Field(None, min_length=6, description="New password (minimum 6 characters, leave empty to keep current)")
|
||||
email: EmailStr = Field(..., description="Email address (required)")
|
||||
full_name: str = Field(..., min_length=1, max_length=200, description="Full name (required)")
|
||||
is_active: Optional[bool] = None
|
||||
is_admin: Optional[bool] = None
|
||||
role: Optional[UserRole] = Field(
|
||||
None,
|
||||
description="Updated role; determines admin status when provided",
|
||||
)
|
||||
give_frontend_permission: Optional[bool] = Field(
|
||||
None,
|
||||
description="Create user in auth database for frontend access if True",
|
||||
)
|
||||
|
||||
|
||||
class UsersListResponse(BaseModel):
|
||||
"""List of users."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[UserResponse]
|
||||
total: int
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Video schemas for person identification."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PersonInfo(BaseModel):
|
||||
"""Person information for video listings."""
|
||||
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
|
||||
|
||||
class VideoListItem(BaseModel):
|
||||
"""Video item in list response."""
|
||||
|
||||
id: int
|
||||
filename: str
|
||||
path: str
|
||||
date_taken: Optional[date] = None
|
||||
date_added: date
|
||||
identified_people: List[PersonInfo]
|
||||
identified_people_count: int
|
||||
|
||||
|
||||
class ListVideosResponse(BaseModel):
|
||||
"""Response for listing videos."""
|
||||
|
||||
items: List[VideoListItem]
|
||||
page: int
|
||||
page_size: int
|
||||
total: int
|
||||
|
||||
|
||||
class VideoPersonInfo(BaseModel):
|
||||
"""Person information with identification metadata."""
|
||||
|
||||
person_id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
identified_by: Optional[str] = None # Username
|
||||
identified_date: datetime
|
||||
|
||||
|
||||
class VideoPeopleResponse(BaseModel):
|
||||
"""Response for getting people in a video."""
|
||||
|
||||
video_id: int
|
||||
people: List[VideoPersonInfo]
|
||||
|
||||
|
||||
class IdentifyVideoRequest(BaseModel):
|
||||
"""Request to identify a person in a video."""
|
||||
|
||||
person_id: Optional[int] = None # Use existing person
|
||||
first_name: Optional[str] = None # Create new person
|
||||
last_name: Optional[str] = None
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
|
||||
|
||||
class IdentifyVideoResponse(BaseModel):
|
||||
"""Response for identifying a person in a video."""
|
||||
|
||||
video_id: int
|
||||
person_id: int
|
||||
created_person: bool
|
||||
message: str
|
||||
|
||||
|
||||
class RemoveVideoPersonResponse(BaseModel):
|
||||
"""Response for removing a person from a video."""
|
||||
|
||||
video_id: int
|
||||
person_id: int
|
||||
removed: bool
|
||||
message: str
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Service layer for PunimTag Web (application orchestration)."""
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,397 @@
|
||||
"""Photo import and management services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime, date
|
||||
from typing import Callable, Optional, Tuple
|
||||
|
||||
from PIL import Image
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.config import SUPPORTED_IMAGE_FORMATS, SUPPORTED_VIDEO_FORMATS
|
||||
from backend.db.models import Photo
|
||||
|
||||
|
||||
def extract_exif_date(image_path: str) -> Optional[date]:
|
||||
"""Extract date taken from photo EXIF data - returns Date (not DateTime) to match desktop schema.
|
||||
|
||||
Tries multiple methods to extract EXIF date:
|
||||
1. PIL's getexif() (modern method)
|
||||
2. PIL's _getexif() (deprecated but sometimes more reliable)
|
||||
3. Access EXIF IFD directly if available
|
||||
"""
|
||||
try:
|
||||
with Image.open(image_path) as image:
|
||||
exifdata = None
|
||||
|
||||
# Try modern getexif() first
|
||||
try:
|
||||
exifdata = image.getexif()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If getexif() didn't work or returned empty, try deprecated _getexif()
|
||||
if not exifdata or len(exifdata) == 0:
|
||||
try:
|
||||
if hasattr(image, '_getexif'):
|
||||
exifdata = image._getexif()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not exifdata:
|
||||
return None
|
||||
|
||||
# Look for date taken in EXIF tags
|
||||
# Priority: DateTimeOriginal (when photo was taken) > DateTimeDigitized > DateTime (file modification)
|
||||
date_tags = [
|
||||
36867, # DateTimeOriginal - when photo was actually taken (highest priority)
|
||||
36868, # DateTimeDigitized - when photo was digitized
|
||||
306, # DateTime - file modification date (lowest priority)
|
||||
]
|
||||
|
||||
# Try direct access first
|
||||
for tag_id in date_tags:
|
||||
try:
|
||||
if tag_id in exifdata:
|
||||
date_str = exifdata[tag_id]
|
||||
if date_str:
|
||||
# Parse EXIF date format (YYYY:MM:DD HH:MM:SS)
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
|
||||
return dt.date()
|
||||
except ValueError:
|
||||
# Try alternative format
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.date()
|
||||
except ValueError:
|
||||
continue
|
||||
except (KeyError, TypeError):
|
||||
continue
|
||||
|
||||
# Try accessing EXIF IFD directly if available (for tags in EXIF IFD like DateTimeOriginal)
|
||||
try:
|
||||
if hasattr(exifdata, 'get_ifd'):
|
||||
# EXIF IFD is at offset 0x8769
|
||||
exif_ifd = exifdata.get_ifd(0x8769)
|
||||
if exif_ifd:
|
||||
for tag_id in date_tags:
|
||||
if tag_id in exif_ifd:
|
||||
date_str = exif_ifd[tag_id]
|
||||
if date_str:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y:%m:%d %H:%M:%S")
|
||||
return dt.date()
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.date()
|
||||
except ValueError:
|
||||
continue
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# Log error for debugging (but don't fail the import)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Failed to extract EXIF date from {image_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def calculate_file_hash(file_path: str) -> str:
|
||||
"""Calculate SHA256 hash of file content.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to hash
|
||||
|
||||
Returns:
|
||||
Hexadecimal string representation of SHA256 hash
|
||||
"""
|
||||
sha256_hash = hashlib.sha256()
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
# Read file in chunks to handle large files efficiently
|
||||
for byte_block in iter(lambda: f.read(4096), b""):
|
||||
sha256_hash.update(byte_block)
|
||||
return sha256_hash.hexdigest()
|
||||
except Exception as e:
|
||||
# Log error for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to calculate hash for {file_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def extract_video_date(video_path: str) -> Optional[date]:
|
||||
"""Extract date taken from video metadata.
|
||||
|
||||
Tries in order:
|
||||
1. Video metadata date (using ffprobe if available)
|
||||
2. File modification time (as fallback)
|
||||
|
||||
Returns:
|
||||
Date object or None if no date can be determined
|
||||
"""
|
||||
# Try to extract date from video metadata using ffprobe
|
||||
try:
|
||||
import subprocess
|
||||
import json
|
||||
|
||||
# Use ffprobe to get video metadata
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffprobe",
|
||||
"-v", "quiet",
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
video_path
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
metadata = json.loads(result.stdout)
|
||||
format_info = metadata.get("format", {})
|
||||
|
||||
# Try common date tags in video metadata
|
||||
date_tags = [
|
||||
"creation_time", # Common in MP4/MOV
|
||||
"date", # Alternative tag
|
||||
"com.apple.quicktime.creationdate", # QuickTime specific
|
||||
]
|
||||
|
||||
for tag in date_tags:
|
||||
date_str = format_info.get("tags", {}).get(tag)
|
||||
if date_str:
|
||||
try:
|
||||
# Try ISO format first (2023-12-25T10:30:00)
|
||||
if "T" in date_str:
|
||||
dt = datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
else:
|
||||
# Try other common formats
|
||||
dt = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
return dt.date()
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, json.JSONDecodeError, Exception) as e:
|
||||
# ffprobe not available or failed - fall through to file modification time
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Failed to extract video metadata from {video_path}: {e}")
|
||||
|
||||
# Fallback to file modification time
|
||||
try:
|
||||
if os.path.exists(video_path):
|
||||
mtime = os.path.getmtime(video_path)
|
||||
mtime_date = datetime.fromtimestamp(mtime).date()
|
||||
return mtime_date
|
||||
except Exception as e:
|
||||
# Log error for debugging (but don't fail the import)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Failed to get file modification time from {video_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_photo_date(image_path: str) -> Optional[date]:
|
||||
"""Extract date taken from photo with fallback to file modification time.
|
||||
|
||||
Tries in order:
|
||||
1. EXIF date tags (DateTimeOriginal, DateTimeDigitized, DateTime)
|
||||
2. File modification time (as fallback)
|
||||
|
||||
Returns:
|
||||
Date object or None if no date can be determined
|
||||
"""
|
||||
# First try EXIF date extraction
|
||||
date_taken = extract_exif_date(image_path)
|
||||
if date_taken:
|
||||
return date_taken
|
||||
|
||||
# Fallback to file modification time
|
||||
try:
|
||||
if os.path.exists(image_path):
|
||||
mtime = os.path.getmtime(image_path)
|
||||
mtime_date = datetime.fromtimestamp(mtime).date()
|
||||
return mtime_date
|
||||
except Exception as e:
|
||||
# Log error for debugging (but don't fail the import)
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.debug(f"Failed to get file modification time from {image_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_photos_in_folder(folder_path: str, recursive: bool = True) -> list[str]:
|
||||
"""Find all photo and video files in a folder.
|
||||
|
||||
Returns both image and video files. Videos are scanned but not processed for faces.
|
||||
"""
|
||||
folder_path = os.path.abspath(folder_path)
|
||||
if not os.path.isdir(folder_path):
|
||||
return []
|
||||
|
||||
found_photos = []
|
||||
# Combine image and video formats
|
||||
supported_formats = SUPPORTED_IMAGE_FORMATS | SUPPORTED_VIDEO_FORMATS
|
||||
|
||||
if recursive:
|
||||
for root, _dirs, files in os.walk(folder_path):
|
||||
for file in files:
|
||||
file_ext = Path(file).suffix.lower()
|
||||
if file_ext in supported_formats:
|
||||
photo_path = os.path.join(root, file)
|
||||
found_photos.append(photo_path)
|
||||
else:
|
||||
for file in os.listdir(folder_path):
|
||||
file_ext = Path(file).suffix.lower()
|
||||
if file_ext in supported_formats:
|
||||
photo_path = os.path.join(folder_path, file)
|
||||
if os.path.isfile(photo_path):
|
||||
found_photos.append(photo_path)
|
||||
|
||||
return found_photos
|
||||
|
||||
|
||||
def import_photo_from_path(
|
||||
db: Session, photo_path: str, update_progress: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> Tuple[Optional[Photo], bool]:
|
||||
"""Import a single photo or video from file path into database.
|
||||
|
||||
Returns:
|
||||
Tuple of (Photo instance or None, is_new: bool)
|
||||
"""
|
||||
photo_path = os.path.abspath(photo_path)
|
||||
filename = os.path.basename(photo_path)
|
||||
file_ext = Path(photo_path).suffix.lower()
|
||||
|
||||
# Determine media type
|
||||
if file_ext in SUPPORTED_VIDEO_FORMATS:
|
||||
media_type = "video"
|
||||
else:
|
||||
media_type = "image"
|
||||
|
||||
# Calculate file hash for duplicate detection
|
||||
try:
|
||||
file_hash = calculate_file_hash(photo_path)
|
||||
except Exception as e:
|
||||
# If hash calculation fails, we can't proceed
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Failed to calculate hash for {photo_path}: {e}")
|
||||
raise
|
||||
|
||||
# Check if photo already exists by hash (primary duplicate check)
|
||||
existing = db.query(Photo).filter(Photo.file_hash == file_hash).first()
|
||||
if existing:
|
||||
# If existing photo doesn't have date_taken, try to update it
|
||||
if existing.date_taken is None:
|
||||
if media_type == "video":
|
||||
date_taken = extract_video_date(photo_path)
|
||||
else:
|
||||
date_taken = extract_photo_date(photo_path)
|
||||
if date_taken:
|
||||
existing.date_taken = date_taken
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing, False
|
||||
|
||||
# Also check by path as fallback (in case hash wasn't set for some reason)
|
||||
existing_by_path = db.query(Photo).filter(Photo.path == photo_path).first()
|
||||
if existing_by_path:
|
||||
# Update hash if missing
|
||||
if not existing_by_path.file_hash:
|
||||
existing_by_path.file_hash = file_hash
|
||||
db.commit()
|
||||
db.refresh(existing_by_path)
|
||||
# If existing photo doesn't have date_taken, try to update it
|
||||
if existing_by_path.date_taken is None:
|
||||
if media_type == "video":
|
||||
date_taken = extract_video_date(photo_path)
|
||||
else:
|
||||
date_taken = extract_photo_date(photo_path)
|
||||
if date_taken:
|
||||
existing_by_path.date_taken = date_taken
|
||||
db.commit()
|
||||
db.refresh(existing_by_path)
|
||||
return existing_by_path, False
|
||||
|
||||
# Extract date taken with fallback to file modification time
|
||||
if media_type == "video":
|
||||
date_taken = extract_video_date(photo_path)
|
||||
else:
|
||||
date_taken = extract_photo_date(photo_path)
|
||||
|
||||
# For videos, mark as processed immediately (we don't process videos for faces)
|
||||
# For images, start as unprocessed
|
||||
processed = media_type == "video"
|
||||
|
||||
# Create new photo record with file_hash and media_type
|
||||
photo = Photo(
|
||||
path=photo_path,
|
||||
filename=filename,
|
||||
date_taken=date_taken,
|
||||
processed=processed,
|
||||
file_hash=file_hash,
|
||||
media_type=media_type,
|
||||
)
|
||||
|
||||
db.add(photo)
|
||||
db.commit()
|
||||
db.refresh(photo)
|
||||
|
||||
return photo, True
|
||||
|
||||
|
||||
def import_photos_from_folder(
|
||||
db: Session,
|
||||
folder_path: str,
|
||||
recursive: bool = True,
|
||||
update_progress: Optional[Callable[[int, int, str], None]] = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Import all photos from a folder.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
folder_path: Path to folder to scan
|
||||
recursive: Whether to scan subdirectories
|
||||
update_progress: Optional callback(processed, total, current_file)
|
||||
|
||||
Returns:
|
||||
Tuple of (added_count, existing_count)
|
||||
"""
|
||||
found_photos = find_photos_in_folder(folder_path, recursive)
|
||||
total = len(found_photos)
|
||||
|
||||
if total == 0:
|
||||
return 0, 0
|
||||
|
||||
added_count = 0
|
||||
existing_count = 0
|
||||
|
||||
for idx, photo_path in enumerate(found_photos, 1):
|
||||
try:
|
||||
photo, is_new = import_photo_from_path(db, photo_path)
|
||||
if is_new:
|
||||
added_count += 1
|
||||
else:
|
||||
existing_count += 1
|
||||
|
||||
if update_progress:
|
||||
update_progress(idx, total, os.path.basename(photo_path))
|
||||
except Exception:
|
||||
# Log error but continue
|
||||
if update_progress:
|
||||
update_progress(idx, total, f"Error: {os.path.basename(photo_path)}")
|
||||
|
||||
return added_count, existing_count
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Role permission helpers for ensuring and updating access matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Dict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.constants.role_features import (
|
||||
ROLE_FEATURE_KEYS,
|
||||
get_default_permission,
|
||||
)
|
||||
from backend.constants.roles import ROLE_VALUES
|
||||
from backend.db.models import RolePermission
|
||||
|
||||
|
||||
def ensure_role_permissions_initialized(session: Session) -> None:
|
||||
"""Seed permissions table once using default matrix if table is empty."""
|
||||
|
||||
has_permissions = session.execute(select(RolePermission.id)).first()
|
||||
if has_permissions:
|
||||
return
|
||||
|
||||
for role in ROLE_VALUES:
|
||||
for feature_key in ROLE_FEATURE_KEYS:
|
||||
permission = RolePermission(
|
||||
role=role,
|
||||
feature_key=feature_key,
|
||||
allowed=get_default_permission(role, feature_key),
|
||||
)
|
||||
session.add(permission)
|
||||
|
||||
session.commit()
|
||||
|
||||
|
||||
def fetch_role_permissions_map(session: Session) -> Dict[str, Dict[str, bool]]:
|
||||
"""Return permissions map keyed by role then feature."""
|
||||
|
||||
ensure_role_permissions_initialized(session)
|
||||
permissions = defaultdict(dict)
|
||||
results = session.execute(select(RolePermission)).scalars().all()
|
||||
for perm in results:
|
||||
permissions[perm.role][perm.feature_key] = bool(perm.allowed)
|
||||
return dict(permissions)
|
||||
|
||||
|
||||
def set_role_permissions(session: Session, permissions: Dict[str, Dict[str, bool]]) -> None:
|
||||
"""Update permissions based on provided map."""
|
||||
|
||||
ensure_role_permissions_initialized(session)
|
||||
existing = {
|
||||
(perm.role, perm.feature_key): perm
|
||||
for perm in session.execute(select(RolePermission)).scalars().all()
|
||||
}
|
||||
|
||||
updated = False
|
||||
for role, feature_map in permissions.items():
|
||||
for feature_key, allowed in feature_map.items():
|
||||
key = (role, feature_key)
|
||||
perm = existing.get(key)
|
||||
if perm is None:
|
||||
perm = RolePermission(role=role, feature_key=feature_key)
|
||||
session.add(perm)
|
||||
existing[key] = perm
|
||||
if perm.allowed != bool(allowed):
|
||||
perm.allowed = bool(allowed)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
session.commit()
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
"""Search service for photos - matches desktop search functionality exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.models import Face, Photo, Person, PhotoTagLinkage, Tag
|
||||
|
||||
|
||||
def search_photos_by_name(
|
||||
db: Session,
|
||||
person_name: str,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
tag_names: Optional[List[str]] = None,
|
||||
match_all: bool = False,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Tuple[Photo, str]], int]:
|
||||
"""Search photos by person name(s) (partial, case-insensitive).
|
||||
|
||||
Supports multiple names separated by commas (OR logic - photos matching any name).
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- Searches first_name, last_name, middle_name, maiden_name
|
||||
- Returns (photo, full_name) tuples
|
||||
- Filters by folder_path if provided
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
- Multiple names: comma-separated, searches for photos with ANY matching person
|
||||
"""
|
||||
search_name = (person_name or "").strip()
|
||||
if not search_name:
|
||||
return [], 0
|
||||
|
||||
# Split by comma and clean up names
|
||||
search_names = [name.strip().lower() for name in search_name.split(',') if name.strip()]
|
||||
if not search_names:
|
||||
return [], 0
|
||||
|
||||
# Build OR conditions for each search name
|
||||
name_conditions = []
|
||||
for search_name_lower in search_names:
|
||||
name_conditions.append(
|
||||
or_(
|
||||
func.lower(Person.first_name).contains(search_name_lower),
|
||||
func.lower(Person.last_name).contains(search_name_lower),
|
||||
func.lower(Person.middle_name).contains(search_name_lower),
|
||||
func.lower(Person.maiden_name).contains(search_name_lower),
|
||||
)
|
||||
)
|
||||
|
||||
# Find matching people (any of the search names)
|
||||
matching_people = (
|
||||
db.query(Person)
|
||||
.filter(or_(*name_conditions))
|
||||
.all()
|
||||
)
|
||||
|
||||
if not matching_people:
|
||||
return [], 0
|
||||
|
||||
person_ids = [p.id for p in matching_people]
|
||||
|
||||
# Query photos with faces linked to matching people
|
||||
query = (
|
||||
db.query(Photo, Person)
|
||||
.join(Face, Photo.id == Face.photo_id)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.filter(Face.person_id.in_(person_ids))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Apply tag filter if provided
|
||||
if tag_names:
|
||||
# Find tag IDs (case-insensitive)
|
||||
tag_ids = (
|
||||
db.query(Tag.id)
|
||||
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
|
||||
.all()
|
||||
)
|
||||
tag_ids = [tid[0] for tid in tag_ids]
|
||||
|
||||
if tag_ids:
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
query = (
|
||||
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.group_by(Photo.id, Person.id)
|
||||
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
|
||||
)
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
tagged_photo_ids_subquery = (
|
||||
db.query(PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
)
|
||||
query = query.filter(Photo.id.in_(tagged_photo_ids_subquery))
|
||||
else:
|
||||
# No matching tags found - return empty result
|
||||
return [], 0
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination
|
||||
results = query.order_by(Person.last_name, Person.first_name, Photo.path).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# Format results: (photo, full_name)
|
||||
formatted = []
|
||||
for photo, person in results:
|
||||
full_name = f"{person.first_name or ''} {person.last_name or ''}".strip() or "Unknown"
|
||||
formatted.append((photo, full_name))
|
||||
|
||||
return formatted, total
|
||||
|
||||
|
||||
def search_photos_by_date(
|
||||
db: Session,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
tag_names: Optional[List[str]] = None,
|
||||
match_all: bool = False,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Search photos by date range.
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- Filters by date_taken
|
||||
- Requires at least one date
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
- Returns photos ordered by date_taken DESC
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.date_taken.is_not(None))
|
||||
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply tag filter if provided
|
||||
if tag_names:
|
||||
# Find tag IDs (case-insensitive)
|
||||
tag_ids = (
|
||||
db.query(Tag.id)
|
||||
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
|
||||
.all()
|
||||
)
|
||||
tag_ids = [tid[0] for tid in tag_ids]
|
||||
|
||||
if tag_ids:
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
query = (
|
||||
query.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.group_by(Photo.id)
|
||||
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
|
||||
)
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
tagged_photo_ids_subquery = (
|
||||
db.query(PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
)
|
||||
query = query.filter(Photo.id.in_(tagged_photo_ids_subquery))
|
||||
else:
|
||||
# No matching tags found - return empty result
|
||||
return [], 0
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.date_taken.desc().nullslast(), Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def search_photos_by_tags(
|
||||
db: Session,
|
||||
tag_names: List[str],
|
||||
match_all: bool = False,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Search photos by tags.
|
||||
|
||||
Matches desktop behavior exactly:
|
||||
- match_all=True: photos must have ALL tags
|
||||
- match_all=False: photos with ANY tag
|
||||
- Case-insensitive tag matching
|
||||
- Filters by media_type if provided ("image" or "video", None/"all" for all)
|
||||
"""
|
||||
if not tag_names:
|
||||
return [], 0
|
||||
|
||||
# Find tag IDs (case-insensitive)
|
||||
tag_ids = (
|
||||
db.query(Tag.id)
|
||||
.filter(func.lower(Tag.tag_name).in_([t.lower().strip() for t in tag_names]))
|
||||
.all()
|
||||
)
|
||||
tag_ids = [tid[0] for tid in tag_ids]
|
||||
|
||||
if not tag_ids:
|
||||
return [], 0
|
||||
|
||||
if match_all:
|
||||
# Photos that have ALL specified tags
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.group_by(Photo.id)
|
||||
.having(func.count(func.distinct(PhotoTagLinkage.tag_id)) == len(tag_ids))
|
||||
)
|
||||
else:
|
||||
# Photos that have ANY of the specified tags
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.join(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.tag_id.in_(tag_ids))
|
||||
.distinct()
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.path).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photos_without_faces(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no detected faces.
|
||||
|
||||
Only includes processed photos (photos that have been processed for face detection).
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.outerjoin(Face, Photo.id == Face.photo_id)
|
||||
.filter(Face.photo_id.is_(None))
|
||||
.filter(Photo.processed == True) # Only include processed photos
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photos_without_tags(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have no tags.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.outerjoin(PhotoTagLinkage, Photo.id == PhotoTagLinkage.photo_id)
|
||||
.filter(PhotoTagLinkage.photo_id.is_(None))
|
||||
)
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_photo_tags(db: Session, photo_id: int) -> List[str]:
|
||||
"""Get all tags for a photo."""
|
||||
tags = (
|
||||
db.query(Tag.tag_name)
|
||||
.join(PhotoTagLinkage, Tag.id == PhotoTagLinkage.tag_id)
|
||||
.filter(PhotoTagLinkage.photo_id == photo_id)
|
||||
.all()
|
||||
)
|
||||
return [t[0] for t in tags]
|
||||
|
||||
|
||||
def get_photo_person(db: Session, photo_id: int) -> Optional[str]:
|
||||
"""Get person name for a photo (first face found)."""
|
||||
person = (
|
||||
db.query(Person)
|
||||
.join(Face, Person.id == Face.person_id)
|
||||
.filter(Face.photo_id == photo_id)
|
||||
.first()
|
||||
)
|
||||
if person:
|
||||
return f"{person.first_name or ''} {person.last_name or ''}".strip() or "Unknown"
|
||||
return None
|
||||
|
||||
|
||||
def get_photo_face_count(db: Session, photo_id: int) -> int:
|
||||
"""Get face count for a photo."""
|
||||
return db.query(Face).filter(Face.photo_id == photo_id).count()
|
||||
|
||||
|
||||
def get_processed_photos(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have been processed for face detection.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.processed == True) # noqa: E712
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_favorite_photos(
|
||||
db: Session,
|
||||
username: str,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get all favorite photos for a user with pagination.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
"""
|
||||
from backend.db.models import PhotoFavorite
|
||||
|
||||
# Join favorites with photos
|
||||
query = (
|
||||
db.query(Photo)
|
||||
.join(PhotoFavorite, Photo.id == PhotoFavorite.photo_id)
|
||||
.filter(PhotoFavorite.username == username)
|
||||
)
|
||||
|
||||
if folder_path:
|
||||
query = query.filter(Photo.path.like(f"{folder_path}%"))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
total = query.count()
|
||||
|
||||
# Order by favorite date (most recent first), then date_taken
|
||||
results = (
|
||||
query.order_by(PhotoFavorite.created_date.desc())
|
||||
.order_by(Photo.date_taken.desc().nulls_last())
|
||||
.order_by(Photo.date_added.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
.all()
|
||||
)
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_unprocessed_photos(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
media_type: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""Get photos that have not been processed for face detection.
|
||||
|
||||
Filters by media_type if provided ("image" or "video", None/"all" for all).
|
||||
Matches desktop behavior exactly.
|
||||
"""
|
||||
query = db.query(Photo).filter(Photo.processed == False) # noqa: E712
|
||||
|
||||
# Apply folder filter if provided
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply media type filter if provided
|
||||
if media_type and media_type.lower() != "all":
|
||||
query = query.filter(Photo.media_type == media_type.lower())
|
||||
|
||||
# Apply date taken filter if provided
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Total count
|
||||
total = query.count()
|
||||
|
||||
# Pagination and sorting
|
||||
results = query.order_by(Photo.filename).offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Tag service for managing tags - matches desktop functionality exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.models import Photo, PhotoTagLinkage, Tag, Face, Person
|
||||
|
||||
|
||||
def list_tags(db: Session) -> List[Tag]:
|
||||
"""Get all tags."""
|
||||
return db.query(Tag).order_by(Tag.tag_name).all()
|
||||
|
||||
|
||||
def get_or_create_tag(db: Session, tag_name: str) -> Tag:
|
||||
"""Get existing tag or create new one (case-insensitive)."""
|
||||
# Check if tag exists (case-insensitive)
|
||||
existing = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(tag_name.strip()))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
# Create new tag
|
||||
tag = Tag(tag_name=tag_name.strip())
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def add_tags_to_photos(
|
||||
db: Session, photo_ids: List[int], tag_names: List[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Add tags to photos.
|
||||
|
||||
Returns:
|
||||
Tuple of (photos_updated, tags_added)
|
||||
"""
|
||||
photos_updated = 0
|
||||
tags_added = 0
|
||||
|
||||
# Deduplicate tag names (case-insensitive) - matching desktop deduplicate_tags
|
||||
seen_tags = set()
|
||||
unique_tags = []
|
||||
for tag_name in tag_names:
|
||||
normalized = tag_name.strip().lower()
|
||||
if normalized and normalized not in seen_tags:
|
||||
seen_tags.add(normalized)
|
||||
unique_tags.append(tag_name.strip())
|
||||
|
||||
if not unique_tags:
|
||||
return 0, 0
|
||||
|
||||
# Get or create tags (matching desktop add_tag)
|
||||
tag_objs = []
|
||||
for tag_name in unique_tags:
|
||||
tag = get_or_create_tag(db, tag_name)
|
||||
tag_objs.append(tag)
|
||||
|
||||
# Add tags to photos
|
||||
for photo_id in photo_ids:
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
continue
|
||||
|
||||
for tag in tag_objs:
|
||||
# Check if linkage already exists
|
||||
existing = (
|
||||
db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == photo_id,
|
||||
PhotoTagLinkage.tag_id == tag.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
# Linkage already exists, skip
|
||||
continue
|
||||
else:
|
||||
# Create new linkage
|
||||
linkage = PhotoTagLinkage(
|
||||
photo_id=photo_id,
|
||||
tag_id=tag.id,
|
||||
)
|
||||
db.add(linkage)
|
||||
tags_added += 1
|
||||
|
||||
photos_updated += 1
|
||||
|
||||
db.commit()
|
||||
return photos_updated, tags_added
|
||||
|
||||
|
||||
def remove_tags_from_photos(
|
||||
db: Session, photo_ids: List[int], tag_names: List[str]
|
||||
) -> tuple[int, int]:
|
||||
"""Remove tags from photos.
|
||||
|
||||
Returns:
|
||||
Tuple of (photos_updated, tags_removed)
|
||||
"""
|
||||
photos_updated = 0
|
||||
tags_removed = 0
|
||||
|
||||
# Find tag IDs (case-insensitive)
|
||||
tag_ids = []
|
||||
for tag_name in tag_names:
|
||||
tag = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(tag_name.strip()))
|
||||
.first()
|
||||
)
|
||||
if tag:
|
||||
tag_ids.append(tag.id)
|
||||
|
||||
if not tag_ids:
|
||||
return 0, 0
|
||||
|
||||
# Remove linkages
|
||||
for photo_id in photo_ids:
|
||||
linkages = (
|
||||
db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == photo_id,
|
||||
PhotoTagLinkage.tag_id.in_(tag_ids),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for linkage in linkages:
|
||||
db.delete(linkage)
|
||||
tags_removed += 1
|
||||
|
||||
if linkages:
|
||||
photos_updated += 1
|
||||
|
||||
db.commit()
|
||||
return photos_updated, tags_removed
|
||||
|
||||
|
||||
def get_photo_tags(db: Session, photo_id: int) -> List[tuple[int, str]]:
|
||||
"""Get all tags for a photo.
|
||||
|
||||
Returns:
|
||||
List of (tag_id, tag_name) tuples
|
||||
"""
|
||||
linkages = (
|
||||
db.query(PhotoTagLinkage, Tag)
|
||||
.join(Tag, PhotoTagLinkage.tag_id == Tag.id)
|
||||
.filter(PhotoTagLinkage.photo_id == photo_id)
|
||||
.order_by(Tag.tag_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [
|
||||
(linkage.tag_id, tag.tag_name)
|
||||
for linkage, tag in linkages
|
||||
]
|
||||
|
||||
|
||||
def update_tag(db: Session, tag_id: int, new_tag_name: str) -> Tag:
|
||||
"""Update a tag name, matching desktop logic exactly.
|
||||
|
||||
Desktop logic:
|
||||
- Updates tag_name in tags table
|
||||
- Case-insensitive check for duplicates
|
||||
"""
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise ValueError(f"Tag {tag_id} not found")
|
||||
|
||||
# Check if new name already exists (case-insensitive)
|
||||
existing = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(new_tag_name.strip()))
|
||||
.filter(Tag.id != tag_id)
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
raise ValueError(f"Tag name '{new_tag_name}' already exists")
|
||||
|
||||
tag.tag_name = new_tag_name.strip()
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return tag
|
||||
|
||||
|
||||
def delete_tags(db: Session, tag_ids: List[int]) -> int:
|
||||
"""Delete tags and all their linkages, matching desktop logic exactly.
|
||||
|
||||
Desktop logic:
|
||||
- Deletes all phototaglinkage entries for these tags
|
||||
- Deletes the tags themselves
|
||||
|
||||
Returns:
|
||||
Number of tags deleted
|
||||
"""
|
||||
if not tag_ids:
|
||||
return 0
|
||||
|
||||
# Delete all linkages for these tags (matching desktop)
|
||||
db.query(PhotoTagLinkage).filter(PhotoTagLinkage.tag_id.in_(tag_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
# Delete the tags themselves
|
||||
deleted_count = db.query(Tag).filter(Tag.id.in_(tag_ids)).delete(
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return deleted_count
|
||||
|
||||
|
||||
def get_photos_with_tags(db: Session) -> List[dict]:
|
||||
"""Get all photos with tags and face counts, optimized with JOINs and aggregations.
|
||||
|
||||
This function uses efficient JOINs and aggregations instead of N+1 queries,
|
||||
reducing database queries from 4N+1 to just 3 queries total.
|
||||
|
||||
Returns:
|
||||
List of dicts with photo info, face_count, and tags (comma-separated string)
|
||||
"""
|
||||
from sqlalchemy import func, case, distinct
|
||||
|
||||
# Query 1: Get all photos with face counts using LEFT JOIN and GROUP BY
|
||||
# This gets face_count and unidentified_face_count in one query
|
||||
# Note: Excludes excluded faces (Face.excluded == False) to match identify UI behavior
|
||||
photos_with_counts = (
|
||||
db.query(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.path,
|
||||
Photo.processed,
|
||||
Photo.date_taken,
|
||||
Photo.date_added,
|
||||
Photo.media_type,
|
||||
# Face count (non-excluded faces only)
|
||||
func.count(distinct(Face.id)).label('face_count'),
|
||||
# Unidentified face count (non-excluded faces with person_id IS NULL)
|
||||
func.sum(
|
||||
case((Face.person_id.is_(None), 1), else_=0)
|
||||
).label('unidentified_face_count'),
|
||||
)
|
||||
.outerjoin(Face, (Photo.id == Face.photo_id) & (Face.excluded == False))
|
||||
.group_by(
|
||||
Photo.id,
|
||||
Photo.filename,
|
||||
Photo.path,
|
||||
Photo.processed,
|
||||
Photo.date_taken,
|
||||
Photo.date_added,
|
||||
Photo.media_type,
|
||||
)
|
||||
.order_by(Photo.date_taken.desc().nullslast(), Photo.filename)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Create a map of photo_id -> photo data
|
||||
photo_map = {row.id: row for row in photos_with_counts}
|
||||
photo_ids = list(photo_map.keys())
|
||||
|
||||
# If no photos, return empty list
|
||||
if not photo_ids:
|
||||
return []
|
||||
|
||||
# Query 2: Get all tags for all photos in one query
|
||||
# Fetch all tag linkages and aggregate in Python (more reliable across databases)
|
||||
tags_data = (
|
||||
db.query(
|
||||
PhotoTagLinkage.photo_id,
|
||||
Tag.tag_name,
|
||||
)
|
||||
.join(Tag, PhotoTagLinkage.tag_id == Tag.id)
|
||||
.filter(PhotoTagLinkage.photo_id.in_(photo_ids))
|
||||
.order_by(PhotoTagLinkage.photo_id, Tag.tag_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Group tags by photo_id and join with comma
|
||||
tags_map = {}
|
||||
for row in tags_data:
|
||||
if row.photo_id not in tags_map:
|
||||
tags_map[row.photo_id] = []
|
||||
tags_map[row.photo_id].append(row.tag_name)
|
||||
|
||||
# Convert lists to comma-separated strings
|
||||
tags_map = {photo_id: ", ".join(tags) for photo_id, tags in tags_map.items()}
|
||||
|
||||
# Query 3: Get all people for all photos in one query
|
||||
# Get distinct people per photo, then format names in Python
|
||||
# Note: Excludes excluded faces to match face count behavior
|
||||
people_data = (
|
||||
db.query(
|
||||
Face.photo_id,
|
||||
Person.id,
|
||||
Person.first_name,
|
||||
Person.middle_name,
|
||||
Person.last_name,
|
||||
Person.maiden_name,
|
||||
)
|
||||
.join(Person, Face.person_id == Person.id)
|
||||
.filter(Face.photo_id.in_(photo_ids))
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.filter(Face.excluded == False)
|
||||
.distinct()
|
||||
.order_by(Face.photo_id, Person.last_name, Person.first_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Group people by photo_id and format names
|
||||
people_map = {}
|
||||
for row in people_data:
|
||||
if row.photo_id not in people_map:
|
||||
people_map[row.photo_id] = []
|
||||
|
||||
# Format person name
|
||||
name_parts = []
|
||||
if row.first_name:
|
||||
name_parts.append(row.first_name)
|
||||
if row.middle_name:
|
||||
name_parts.append(row.middle_name)
|
||||
if row.last_name:
|
||||
name_parts.append(row.last_name)
|
||||
if row.maiden_name:
|
||||
name_parts.append(f"({row.maiden_name})")
|
||||
full_name = " ".join(name_parts) if name_parts else "Unknown"
|
||||
people_map[row.photo_id].append(full_name)
|
||||
|
||||
# Build result list
|
||||
result_list = []
|
||||
for photo_id, photo_row in photo_map.items():
|
||||
# Format date_taken
|
||||
date_taken = None
|
||||
if photo_row.date_taken:
|
||||
if isinstance(photo_row.date_taken, str):
|
||||
date_taken = photo_row.date_taken
|
||||
else:
|
||||
date_taken = photo_row.date_taken.isoformat()
|
||||
|
||||
# Format date_added
|
||||
date_added = None
|
||||
if photo_row.date_added:
|
||||
if isinstance(photo_row.date_added, str):
|
||||
date_added = photo_row.date_added
|
||||
else:
|
||||
date_added = photo_row.date_added.isoformat()
|
||||
|
||||
# Get tags for this photo
|
||||
tags = tags_map.get(photo_id, "")
|
||||
|
||||
# Get people names for this photo
|
||||
people_names = people_map.get(photo_id, [])
|
||||
people_names_str = ", ".join(people_names) if people_names else ""
|
||||
|
||||
result_list.append({
|
||||
'id': photo_row.id,
|
||||
'filename': photo_row.filename,
|
||||
'path': photo_row.path,
|
||||
'processed': photo_row.processed,
|
||||
'date_taken': date_taken,
|
||||
'date_added': date_added,
|
||||
'face_count': photo_row.face_count or 0,
|
||||
'unidentified_face_count': int(photo_row.unidentified_face_count or 0),
|
||||
'tags': tags,
|
||||
'people_names': people_names_str,
|
||||
'media_type': photo_row.media_type or 'image',
|
||||
})
|
||||
|
||||
return result_list
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""RQ worker tasks for PunimTag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from rq import get_current_job
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import SessionLocal
|
||||
from backend.services.photo_service import import_photos_from_folder
|
||||
from backend.services.face_service import process_unprocessed_photos
|
||||
|
||||
|
||||
def import_photos_task(folder_path: str, recursive: bool = True) -> dict:
|
||||
"""RQ task to import photos from a folder.
|
||||
|
||||
Updates job metadata with progress:
|
||||
- progress: 0-100
|
||||
- message: status message
|
||||
- processed: number of photos processed
|
||||
- total: total photos found
|
||||
- added: number of new photos added
|
||||
- existing: number of photos that already existed
|
||||
"""
|
||||
job = get_current_job()
|
||||
if not job:
|
||||
raise RuntimeError("Not running in RQ job context")
|
||||
|
||||
db: Session = SessionLocal()
|
||||
|
||||
try:
|
||||
def update_progress(processed: int, total: int, current_file: str) -> None:
|
||||
"""Update job progress."""
|
||||
if job:
|
||||
progress = int((processed / total) * 100) if total > 0 else 0
|
||||
job.meta = {
|
||||
"progress": progress,
|
||||
"message": f"Processing {current_file}... ({processed}/{total})",
|
||||
"processed": processed,
|
||||
"total": total,
|
||||
}
|
||||
job.save_meta()
|
||||
|
||||
# Import photos
|
||||
added, existing = import_photos_from_folder(
|
||||
db, folder_path, recursive, update_progress
|
||||
)
|
||||
|
||||
# Final update
|
||||
total_processed = added + existing
|
||||
result = {
|
||||
"folder_path": folder_path,
|
||||
"recursive": recursive,
|
||||
"added": added,
|
||||
"existing": existing,
|
||||
"total": total_processed,
|
||||
}
|
||||
|
||||
if job:
|
||||
job.meta = {
|
||||
"progress": 100,
|
||||
"message": f"Completed: {added} new, {existing} existing",
|
||||
"processed": total_processed,
|
||||
"total": total_processed,
|
||||
"added": added,
|
||||
"existing": existing,
|
||||
}
|
||||
job.save_meta()
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def process_faces_task(
|
||||
batch_size: Optional[int] = None,
|
||||
detector_backend: str = "retinaface",
|
||||
model_name: str = "ArcFace",
|
||||
) -> dict:
|
||||
"""RQ task to process faces in unprocessed photos.
|
||||
|
||||
Updates job metadata with progress:
|
||||
- progress: 0-100
|
||||
- message: status message
|
||||
- processed: number of photos processed
|
||||
- total: total photos to process
|
||||
- faces_detected: total faces detected
|
||||
- faces_stored: total faces stored
|
||||
"""
|
||||
import traceback
|
||||
|
||||
job = get_current_job()
|
||||
if not job:
|
||||
raise RuntimeError("Not running in RQ job context")
|
||||
|
||||
print(f"[Task] Starting face processing task: job_id={job.id}, batch_size={batch_size}, detector={detector_backend}, model={model_name}")
|
||||
|
||||
# Update progress immediately - job started
|
||||
try:
|
||||
if job:
|
||||
job.meta = {
|
||||
"progress": 0,
|
||||
"message": "Initializing face processing...",
|
||||
"processed": 0,
|
||||
"total": 0,
|
||||
"faces_detected": 0,
|
||||
"faces_stored": 0,
|
||||
}
|
||||
job.save_meta()
|
||||
except Exception as e:
|
||||
print(f"[Task] Error setting initial job metadata: {e}")
|
||||
|
||||
db: Session = SessionLocal()
|
||||
|
||||
# Initialize result variables
|
||||
photos_processed = 0
|
||||
total_faces_detected = 0
|
||||
total_faces_stored = 0
|
||||
|
||||
try:
|
||||
def update_progress(
|
||||
processed: int,
|
||||
total: int,
|
||||
current_file: str,
|
||||
faces_detected: int,
|
||||
faces_stored: int,
|
||||
) -> None:
|
||||
"""Update job progress and check for cancellation."""
|
||||
if job:
|
||||
# Check if job was cancelled
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
return # Don't update if cancelled
|
||||
|
||||
# Calculate progress: 10% for setup, 90% for processing
|
||||
if total == 0:
|
||||
# Setup phase
|
||||
progress = min(10, processed * 2) # 0-10% during setup
|
||||
else:
|
||||
# Processing phase
|
||||
progress = 10 + int((processed / total) * 90) if total > 0 else 10
|
||||
|
||||
job.meta = {
|
||||
"progress": progress,
|
||||
"message": f"Processing {current_file}... ({processed}/{total})" if total > 0 else current_file,
|
||||
"processed": processed,
|
||||
"total": total,
|
||||
"faces_detected": faces_detected,
|
||||
"faces_stored": faces_stored,
|
||||
}
|
||||
job.save_meta()
|
||||
|
||||
# Check for cancellation after updating
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
print(f"[Task] Job {job.id} cancellation detected")
|
||||
raise KeyboardInterrupt("Job cancelled by user")
|
||||
|
||||
# Update progress - finding photos
|
||||
if job:
|
||||
job.meta = {
|
||||
"progress": 5,
|
||||
"message": "Finding photos to process...",
|
||||
"processed": 0,
|
||||
"total": 0,
|
||||
"faces_detected": 0,
|
||||
"faces_stored": 0,
|
||||
}
|
||||
job.save_meta()
|
||||
|
||||
# Process faces
|
||||
photos_processed, total_faces_detected, total_faces_stored = (
|
||||
process_unprocessed_photos(
|
||||
db,
|
||||
batch_size=batch_size,
|
||||
detector_backend=detector_backend,
|
||||
model_name=model_name,
|
||||
update_progress=update_progress,
|
||||
)
|
||||
)
|
||||
|
||||
# Final update
|
||||
result = {
|
||||
"photos_processed": photos_processed,
|
||||
"faces_detected": total_faces_detected,
|
||||
"faces_stored": total_faces_stored,
|
||||
"detector_backend": detector_backend,
|
||||
"model_name": model_name,
|
||||
}
|
||||
|
||||
if job:
|
||||
job.meta = {
|
||||
"progress": 100,
|
||||
"message": (
|
||||
f"Completed: {photos_processed} photos, "
|
||||
f"{total_faces_stored} faces stored"
|
||||
),
|
||||
"processed": photos_processed,
|
||||
"total": photos_processed,
|
||||
"faces_detected": total_faces_detected,
|
||||
"faces_stored": total_faces_stored,
|
||||
}
|
||||
job.save_meta()
|
||||
|
||||
return result
|
||||
|
||||
except KeyboardInterrupt as e:
|
||||
# Job was cancelled - exit gracefully
|
||||
print(f"[Task] Job {job.id if job else 'unknown'} cancelled by user")
|
||||
if job:
|
||||
try:
|
||||
job.meta = job.meta or {}
|
||||
job.meta.update({
|
||||
"progress": job.meta.get("progress", 0),
|
||||
"message": "Cancelled by user - finished current photo",
|
||||
"cancelled": True,
|
||||
"processed": job.meta.get("processed", photos_processed),
|
||||
"total": job.meta.get("total", 0),
|
||||
"faces_detected": job.meta.get("faces_detected", total_faces_detected),
|
||||
"faces_stored": job.meta.get("faces_stored", total_faces_stored),
|
||||
})
|
||||
job.save_meta()
|
||||
except Exception:
|
||||
pass
|
||||
# Don't re-raise - job cancellation is not a failure
|
||||
return {
|
||||
"photos_processed": photos_processed,
|
||||
"faces_detected": total_faces_detected,
|
||||
"faces_stored": total_faces_stored,
|
||||
"detector_backend": detector_backend,
|
||||
"model_name": model_name,
|
||||
"cancelled": True,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# Log error and update job metadata
|
||||
error_msg = f"Task failed: {str(e)}"
|
||||
print(f"[Task] ❌ {error_msg}")
|
||||
traceback.print_exc()
|
||||
|
||||
if job:
|
||||
try:
|
||||
job.meta = {
|
||||
"progress": 0,
|
||||
"message": error_msg,
|
||||
"processed": 0,
|
||||
"total": 0,
|
||||
"faces_detected": 0,
|
||||
"faces_stored": 0,
|
||||
}
|
||||
job.save_meta()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Re-raise so RQ marks job as failed
|
||||
raise
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Video thumbnail generation service with caching."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
# Cache directory for thumbnails (relative to project root)
|
||||
# Will be created in the same directory as the database
|
||||
THUMBNAIL_CACHE_DIR = Path(__file__).parent.parent.parent.parent / "data" / "thumbnails"
|
||||
THUMBNAIL_SIZE = (320, 240) # Width, Height
|
||||
THUMBNAIL_QUALITY = 85 # JPEG quality
|
||||
|
||||
|
||||
def get_thumbnail_cache_path(video_path: str) -> Path:
|
||||
"""Get cache path for a video thumbnail.
|
||||
|
||||
Args:
|
||||
video_path: Full path to video file
|
||||
|
||||
Returns:
|
||||
Path to cached thumbnail file
|
||||
"""
|
||||
# Create hash of video path for cache filename
|
||||
path_hash = hashlib.md5(video_path.encode()).hexdigest()
|
||||
# Use original filename (without extension) + hash for uniqueness
|
||||
video_file = Path(video_path)
|
||||
cache_filename = f"{video_file.stem}_{path_hash[:8]}.jpg"
|
||||
|
||||
# Ensure cache directory exists
|
||||
THUMBNAIL_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return THUMBNAIL_CACHE_DIR / cache_filename
|
||||
|
||||
|
||||
def generate_video_thumbnail(
|
||||
video_path: str,
|
||||
force_regenerate: bool = False,
|
||||
) -> Optional[Path]:
|
||||
"""Generate thumbnail for a video file.
|
||||
|
||||
Extracts first frame and creates a cached thumbnail.
|
||||
|
||||
Args:
|
||||
video_path: Full path to video file
|
||||
force_regenerate: If True, regenerate even if cached thumbnail exists
|
||||
|
||||
Returns:
|
||||
Path to thumbnail file, or None if generation failed
|
||||
"""
|
||||
if not os.path.exists(video_path):
|
||||
return None
|
||||
|
||||
cache_path = get_thumbnail_cache_path(video_path)
|
||||
|
||||
# Return cached thumbnail if it exists and we're not forcing regeneration
|
||||
if cache_path.exists() and not force_regenerate:
|
||||
return cache_path
|
||||
|
||||
try:
|
||||
# Try to use OpenCV first (faster, more reliable)
|
||||
try:
|
||||
import cv2
|
||||
|
||||
# Open video file
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
return None
|
||||
|
||||
# Read first frame
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if not ret or frame is None:
|
||||
return None
|
||||
|
||||
# Convert BGR to RGB (OpenCV uses BGR)
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
|
||||
# Convert to PIL Image
|
||||
image = Image.fromarray(frame_rgb)
|
||||
|
||||
except ImportError:
|
||||
# Fallback to ffmpeg if OpenCV not available
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
# Extract first frame using ffmpeg
|
||||
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as tmp_file:
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
try:
|
||||
# Use ffmpeg to extract first frame
|
||||
result = subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i", video_path,
|
||||
"-vframes", "1",
|
||||
"-q:v", "2", # High quality
|
||||
"-y", # Overwrite output
|
||||
tmp_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30, # 30 second timeout
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
if not os.path.exists(tmp_path):
|
||||
return None
|
||||
|
||||
# Load with PIL
|
||||
image = Image.open(tmp_path)
|
||||
os.unlink(tmp_path) # Clean up temp file
|
||||
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
|
||||
# ffmpeg not available or failed
|
||||
if os.path.exists(tmp_path):
|
||||
os.unlink(tmp_path)
|
||||
return None
|
||||
|
||||
# Resize to thumbnail size (maintain aspect ratio)
|
||||
image.thumbnail(THUMBNAIL_SIZE, Image.Resampling.LANCZOS)
|
||||
|
||||
# Convert to RGB if needed (for JPEG)
|
||||
if image.mode != "RGB":
|
||||
image = image.convert("RGB")
|
||||
|
||||
# Save to cache
|
||||
image.save(cache_path, "JPEG", quality=THUMBNAIL_QUALITY, optimize=True)
|
||||
|
||||
return cache_path
|
||||
|
||||
except Exception as e:
|
||||
# Log error but don't fail
|
||||
print(f"⚠️ Failed to generate thumbnail for {video_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def get_video_thumbnail_path(video_path: str) -> Optional[Path]:
|
||||
"""Get thumbnail path for a video, generating if needed.
|
||||
|
||||
Args:
|
||||
video_path: Full path to video file
|
||||
|
||||
Returns:
|
||||
Path to thumbnail file, or None if generation failed
|
||||
"""
|
||||
return generate_video_thumbnail(video_path, force_regenerate=False)
|
||||
|
||||
|
||||
def clear_thumbnail_cache() -> int:
|
||||
"""Clear all cached thumbnails.
|
||||
|
||||
Returns:
|
||||
Number of files deleted
|
||||
"""
|
||||
if not THUMBNAIL_CACHE_DIR.exists():
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for file in THUMBNAIL_CACHE_DIR.glob("*.jpg"):
|
||||
try:
|
||||
file.unlink()
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return count
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Video service for managing video-person identifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.models import Photo, Person, PhotoPersonLinkage, User
|
||||
|
||||
|
||||
def list_videos_for_identification(
|
||||
db: Session,
|
||||
folder_path: Optional[str] = None,
|
||||
date_from: Optional[date] = None,
|
||||
date_to: Optional[date] = None,
|
||||
has_people: Optional[bool] = None,
|
||||
person_name: Optional[str] = None,
|
||||
sort_by: str = "filename",
|
||||
sort_dir: str = "asc",
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> Tuple[List[Photo], int]:
|
||||
"""List videos for person identification.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
folder_path: Filter by folder path (starts with)
|
||||
date_from: Filter by date taken (from)
|
||||
date_to: Filter by date taken (to)
|
||||
has_people: Filter videos with/without identified people (True/False/None)
|
||||
person_name: Filter videos containing person with this name
|
||||
sort_by: Sort field ("filename", "date_taken", "date_added")
|
||||
sort_dir: Sort direction ("asc" or "desc")
|
||||
page: Page number (1-based)
|
||||
page_size: Items per page
|
||||
|
||||
Returns:
|
||||
Tuple of (videos list, total count)
|
||||
"""
|
||||
# Base query: only videos
|
||||
query = db.query(Photo).filter(Photo.media_type == "video")
|
||||
|
||||
# Apply folder filter
|
||||
if folder_path:
|
||||
folder_path = folder_path.strip()
|
||||
query = query.filter(Photo.path.startswith(folder_path))
|
||||
|
||||
# Apply date filters
|
||||
if date_from:
|
||||
query = query.filter(Photo.date_taken >= date_from)
|
||||
if date_to:
|
||||
query = query.filter(Photo.date_taken <= date_to)
|
||||
|
||||
# Apply person name filter
|
||||
if person_name:
|
||||
person_name_lower = person_name.lower().strip()
|
||||
# Search in first_name, last_name, middle_name, maiden_name
|
||||
matching_people = (
|
||||
db.query(Person.id)
|
||||
.filter(
|
||||
or_(
|
||||
func.lower(Person.first_name).contains(person_name_lower),
|
||||
func.lower(Person.last_name).contains(person_name_lower),
|
||||
func.lower(Person.middle_name).contains(person_name_lower),
|
||||
func.lower(Person.maiden_name).contains(person_name_lower),
|
||||
)
|
||||
)
|
||||
.subquery()
|
||||
)
|
||||
# Get videos linked to these people
|
||||
video_ids_with_person = (
|
||||
db.query(PhotoPersonLinkage.photo_id)
|
||||
.filter(PhotoPersonLinkage.person_id.in_(db.query(matching_people.c.id)))
|
||||
.subquery()
|
||||
)
|
||||
query = query.filter(Photo.id.in_(db.query(video_ids_with_person.c.photo_id)))
|
||||
|
||||
# Apply has_people filter
|
||||
if has_people is not None:
|
||||
# Subquery to get video IDs with people
|
||||
videos_with_people = (
|
||||
db.query(PhotoPersonLinkage.photo_id)
|
||||
.distinct()
|
||||
.subquery()
|
||||
)
|
||||
if has_people:
|
||||
query = query.filter(Photo.id.in_(db.query(videos_with_people.c.photo_id)))
|
||||
else:
|
||||
query = query.filter(~Photo.id.in_(db.query(videos_with_people.c.photo_id)))
|
||||
|
||||
# Get total count before pagination
|
||||
total = query.count()
|
||||
|
||||
# Apply sorting
|
||||
if sort_by == "filename":
|
||||
order_col = Photo.filename
|
||||
elif sort_by == "date_taken":
|
||||
order_col = Photo.date_taken
|
||||
elif sort_by == "date_added":
|
||||
order_col = Photo.date_added
|
||||
else:
|
||||
order_col = Photo.filename
|
||||
|
||||
if sort_dir.lower() == "desc":
|
||||
query = query.order_by(order_col.desc())
|
||||
else:
|
||||
query = query.order_by(order_col.asc())
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * page_size
|
||||
results = query.offset(offset).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
def get_video_people(
|
||||
db: Session,
|
||||
video_id: int,
|
||||
) -> List[Tuple[Person, PhotoPersonLinkage]]:
|
||||
"""Get all people identified in a video.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
video_id: Video (Photo) ID
|
||||
|
||||
Returns:
|
||||
List of (Person, PhotoPersonLinkage) tuples
|
||||
"""
|
||||
# Verify it's a video
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
return []
|
||||
|
||||
# Get all linkages for this video
|
||||
linkages = (
|
||||
db.query(PhotoPersonLinkage, Person)
|
||||
.join(Person, PhotoPersonLinkage.person_id == Person.id)
|
||||
.filter(PhotoPersonLinkage.photo_id == video_id)
|
||||
.order_by(Person.last_name, Person.first_name)
|
||||
.all()
|
||||
)
|
||||
|
||||
return [(person, linkage) for linkage, person in linkages]
|
||||
|
||||
|
||||
def identify_person_in_video(
|
||||
db: Session,
|
||||
video_id: int,
|
||||
person_id: Optional[int] = None,
|
||||
first_name: Optional[str] = None,
|
||||
last_name: Optional[str] = None,
|
||||
middle_name: Optional[str] = None,
|
||||
maiden_name: Optional[str] = None,
|
||||
date_of_birth: Optional[date] = None,
|
||||
user_id: Optional[int] = None,
|
||||
) -> Tuple[Person, bool]:
|
||||
"""Identify a person in a video.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
video_id: Video (Photo) ID
|
||||
person_id: Existing person ID (if using existing person)
|
||||
first_name: First name (if creating new person)
|
||||
last_name: Last name (if creating new person)
|
||||
middle_name: Middle name (optional)
|
||||
maiden_name: Maiden name (optional)
|
||||
date_of_birth: Date of birth (optional)
|
||||
user_id: User ID who is identifying (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (Person, created_person: bool)
|
||||
|
||||
Raises:
|
||||
ValueError: If video doesn't exist or is not a video, or if person data is invalid
|
||||
"""
|
||||
# Verify video exists and is actually a video
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise ValueError(f"Video {video_id} not found or is not a video")
|
||||
|
||||
# Get or create person
|
||||
person: Optional[Person] = None
|
||||
created_person = False
|
||||
|
||||
if person_id:
|
||||
# Use existing person
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise ValueError(f"Person {person_id} not found")
|
||||
else:
|
||||
# Create new person
|
||||
if not first_name or not last_name:
|
||||
raise ValueError("first_name and last_name are required to create a person")
|
||||
|
||||
first_name = first_name.strip()
|
||||
last_name = last_name.strip()
|
||||
middle_name = middle_name.strip() if middle_name else None
|
||||
maiden_name = maiden_name.strip() if maiden_name else None
|
||||
|
||||
# Check if person already exists (unique constraint)
|
||||
existing_person = (
|
||||
db.query(Person)
|
||||
.filter(
|
||||
Person.first_name == first_name,
|
||||
Person.last_name == last_name,
|
||||
Person.middle_name == middle_name,
|
||||
Person.maiden_name == maiden_name,
|
||||
Person.date_of_birth == date_of_birth,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if existing_person:
|
||||
person = existing_person
|
||||
else:
|
||||
person = Person(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
middle_name=middle_name,
|
||||
maiden_name=maiden_name,
|
||||
date_of_birth=date_of_birth,
|
||||
)
|
||||
db.add(person)
|
||||
db.flush() # Get person.id
|
||||
created_person = True
|
||||
# Commit the person creation immediately to ensure it's saved
|
||||
db.commit()
|
||||
|
||||
# Check if linkage already exists
|
||||
existing_linkage = (
|
||||
db.query(PhotoPersonLinkage)
|
||||
.filter(
|
||||
PhotoPersonLinkage.photo_id == video_id,
|
||||
PhotoPersonLinkage.person_id == person.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing_linkage:
|
||||
# Create new linkage
|
||||
linkage = PhotoPersonLinkage(
|
||||
photo_id=video_id,
|
||||
person_id=person.id,
|
||||
identified_by_user_id=user_id,
|
||||
)
|
||||
db.add(linkage)
|
||||
db.commit()
|
||||
|
||||
# If person was already committed above, this commit is just for the linkage
|
||||
# If person already existed, this commit does nothing (no pending changes)
|
||||
# This ensures the linkage is saved
|
||||
return person, created_person
|
||||
|
||||
|
||||
def remove_person_from_video(
|
||||
db: Session,
|
||||
video_id: int,
|
||||
person_id: int,
|
||||
) -> bool:
|
||||
"""Remove person identification from video.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
video_id: Video (Photo) ID
|
||||
person_id: Person ID to remove
|
||||
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If video doesn't exist or is not a video
|
||||
"""
|
||||
# Verify video exists and is actually a video
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise ValueError(f"Video {video_id} not found or is not a video")
|
||||
|
||||
# Find and delete linkage
|
||||
linkage = (
|
||||
db.query(PhotoPersonLinkage)
|
||||
.filter(
|
||||
PhotoPersonLinkage.photo_id == video_id,
|
||||
PhotoPersonLinkage.person_id == person_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if linkage:
|
||||
db.delete(linkage)
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_video_people_count(db: Session, video_id: int) -> int:
|
||||
"""Get count of people identified in a video.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
video_id: Video (Photo) ID
|
||||
|
||||
Returns:
|
||||
Count of people
|
||||
"""
|
||||
return (
|
||||
db.query(PhotoPersonLinkage)
|
||||
.filter(PhotoPersonLinkage.photo_id == video_id)
|
||||
.count()
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Application settings for PunimTag Web."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
APP_TITLE = "PunimTag Web API"
|
||||
APP_VERSION = "0.1.0"
|
||||
|
||||
# Photo storage settings
|
||||
PHOTO_STORAGE_DIR = os.getenv("PHOTO_STORAGE_DIR", "data/uploads")
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Utility functions for PunimTag Web."""
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Password hashing utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password as string
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
password_hash: Hashed password to compare against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(
|
||||
password.encode('utf-8'),
|
||||
password_hash.encode('utf-8')
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""RQ worker entrypoint for PunimTag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import signal
|
||||
import sys
|
||||
from typing import NoReturn
|
||||
|
||||
import uuid
|
||||
|
||||
from rq import Worker
|
||||
from redis import Redis
|
||||
|
||||
from backend.services.tasks import import_photos_task, process_faces_task
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
|
||||
|
||||
def main() -> NoReturn:
|
||||
"""Worker entrypoint - starts RQ worker to process background jobs."""
|
||||
def _handle_sigterm(_signum, _frame):
|
||||
sys.exit(0)
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_sigterm)
|
||||
signal.signal(signal.SIGINT, _handle_sigterm)
|
||||
|
||||
# Generate unique worker name to avoid conflicts
|
||||
worker_name = f"punimtag-worker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
print(f"[Worker] Starting worker: {worker_name}")
|
||||
print(f"[Worker] Listening on queue: default")
|
||||
|
||||
# Check if Redis is accessible
|
||||
try:
|
||||
redis_conn.ping()
|
||||
print(f"[Worker] Redis connection successful")
|
||||
except Exception as e:
|
||||
print(f"[Worker] ❌ Redis connection failed: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Register tasks with worker
|
||||
# Tasks are imported from services.tasks
|
||||
worker = Worker(
|
||||
["default"],
|
||||
connection=redis_conn,
|
||||
name=worker_name,
|
||||
)
|
||||
|
||||
print(f"[Worker] ✅ Worker ready, waiting for jobs...")
|
||||
|
||||
# Start worker
|
||||
worker.work()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user