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:
Tanya
2025-12-30 15:04:32 -05:00
parent 12c62f1deb
commit 68d280e8f5
140 changed files with 5101 additions and 933 deletions
View File
+3
View File
@@ -0,0 +1,3 @@
"""API routers package for PunimTag Web."""
+357
View File
@@ -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",
)
+699
View File
@@ -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}",
)
+1022
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"}
+221
View File
@@ -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)}",
)
+17
View File
@@ -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",
}
+540
View File
@@ -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
)
+450
View File
@@ -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}",
)
+686
View File
@@ -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
)
+317
View File
@@ -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)}",
)
+971
View File
@@ -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)}",
)
+375
View File
@@ -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}",
)
+74
View File
@@ -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)
+191
View File
@@ -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))
+534
View File
@@ -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)
+16
View File
@@ -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}
+343
View File
@@ -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
View File
@@ -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()
+29
View File
@@ -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"
+43
View File
@@ -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
+32
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Database package for PunimTag Web."""
+9
View File
@@ -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"]
+286
View File
@@ -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"),
)
+106
View File
@@ -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()
+2
View File
@@ -0,0 +1,2 @@
"""Pydantic schemas for PunimTag Web."""
+65
View File
@@ -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
+61
View File
@@ -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
+348
View File
@@ -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
+30
View File
@@ -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
+81
View File
@@ -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
+93
View File
@@ -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",
)
+48
View File
@@ -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]
+49
View File
@@ -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
+98
View File
@@ -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
+75
View File
@@ -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
+96
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Service layer for PunimTag Web (application orchestration)."""
File diff suppressed because it is too large Load Diff
+397
View File
@@ -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
+73
View File
@@ -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()
+536
View File
@@ -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
+375
View File
@@ -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
+260
View File
@@ -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()
+176
View File
@@ -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
+326
View File
@@ -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()
)
+13
View File
@@ -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")
+2
View File
@@ -0,0 +1,2 @@
"""Utility functions for PunimTag Web."""
+36
View File
@@ -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')
)
+59
View File
@@ -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()