feat: Add user management features with password change and reported photos handling

This commit introduces several user management functionalities, including the ability to create, update, and delete users through a new API. The frontend has been updated to include a Manage Users page, allowing admins to manage user accounts effectively. Additionally, a password change feature has been implemented, requiring users to change their passwords upon first login. The reported photos functionality has been added, enabling admins to review and manage reported content. Documentation has been updated to reflect these changes.
This commit is contained in:
tanyar09
2025-11-20 13:18:58 -05:00
parent 926e738a13
commit 87146b1356
23 changed files with 2148 additions and 71 deletions
+111 -6
View File
@@ -8,12 +8,18 @@ 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 src.web.db.session import get_db
from src.web.db.models import User
from src.web.utils.password import verify_password, hash_password
from src.web.schemas.auth import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
PasswordChangeRequest,
PasswordChangeResponse,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -69,8 +75,55 @@ def get_current_user(
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest) -> TokenResponse:
"""Authenticate user and return tokens."""
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
@@ -82,8 +135,11 @@ def login(credentials: LoginRequest) -> TokenResponse:
)
refresh_token = create_refresh_token(data={"sub": credentials.username})
return TokenResponse(
access_token=access_token, refresh_token=refresh_token
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",
@@ -125,8 +181,57 @@ def refresh_token(request: RefreshRequest) -> TokenResponse:
@router.get("/me", response_model=UserResponse)
def get_current_user_info(
current_user: Annotated[dict, Depends(get_current_user)]
current_user: Annotated[dict, Depends(get_current_user)],
db: Session = Depends(get_db),
) -> UserResponse:
"""Get current user information."""
return UserResponse(username=current_user["username"])
"""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()
is_admin = user.is_admin if user else False
return UserResponse(username=username, is_admin=is_admin)
@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",
)
+6 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from datetime import date, datetime
from typing import Optional
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
@@ -12,6 +12,7 @@ from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import Face, Person, PersonEncoding
from src.web.api.users import get_current_admin_user
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
@@ -75,6 +76,7 @@ class ApproveDenyResponse(BaseModel):
@router.get("", response_model=PendingIdentificationsListResponse)
def list_pending_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
include_denied: bool = False,
db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
@@ -170,6 +172,7 @@ def list_pending_identifications(
@router.post("/approve-deny", response_model=ApproveDenyResponse)
def approve_deny_pending_identifications(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
request: ApproveDenyRequest,
auth_db: Session = Depends(get_auth_db),
main_db: Session = Depends(get_db),
@@ -191,6 +194,7 @@ def approve_deny_pending_identifications(
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,
@@ -201,7 +205,7 @@ def approve_deny_pending_identifications(
pi.maiden_name,
pi.date_of_birth
FROM pending_identifications pi
WHERE pi.id = :id AND pi.status = 'pending'
WHERE pi.id = :id AND pi.status IN ('pending', 'denied')
"""), {"id": decision.id})
row = result.fetchone()
+281
View File
@@ -0,0 +1,281 @@
"""Reported photos endpoints - admin only."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import text
from sqlalchemy.orm import Session
from src.web.db.session import get_auth_db, get_db
from src.web.db.models import Photo
from src.web.api.users import get_current_admin_user
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
# Photo details from main database
photo_path: Optional[str] = None
photo_filename: 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]
@router.get("", response_model=ReportedPhotosListResponse)
def list_reported_photos(
current_admin: Annotated[dict, Depends(get_current_admin_user)],
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
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
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 = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
if photo:
photo_path = photo.path
photo_filename = photo.filename
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,
photo_path=photo_path,
photo_filename=photo_filename,
))
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_admin: Annotated[dict, Depends(get_current_admin_user)],
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_admin.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:
errors.append(f"Photo {row.photo_id} not found in main database")
# Still update status to reviewed since we can't process it
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 not found in database"
})
auth_db.commit()
kept_count += 1 # Count as kept since we couldn't remove it
continue
# Delete the photo (cascade will delete faces, tags, etc.)
main_db.delete(photo)
main_db.commit()
# Update status in auth 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 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
)
+224
View File
@@ -0,0 +1,224 @@
"""User management endpoints - admin only."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from src.web.api.auth import get_current_user
from src.web.db.session import get_db
from src.web.db.models import User
from src.web.schemas.users import (
UserCreateRequest,
UserResponse,
UserUpdateRequest,
UsersListResponse,
)
from src.web.utils.password import hash_password
router = APIRouter(prefix="/users", tags=["users"])
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,
)
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
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}
@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()
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."""
# 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",
)
# Hash the password before storing
password_hash = hash_password(request.password)
user = User(
username=request.username,
password_hash=password_hash,
email=request.email,
full_name=request.full_name,
is_active=request.is_active,
is_admin=request.is_admin,
password_change_required=True, # Force password change on first login
)
db.add(user)
db.commit()
db.refresh(user)
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",
)
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",
)
# Prevent admin from removing their own admin status
if (
current_admin["username"] == user.username
and request.is_admin is not None
and not request.is_admin
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot remove your own admin status",
)
# 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
if request.is_admin is not None:
user.is_admin = request.is_admin
db.add(user)
db.commit()
db.refresh(user)
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.
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",
)
db.delete(user)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+91 -3
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import inspect, text
from src.web.api.auth import router as auth_router
from src.web.api.faces import router as faces_router
@@ -17,13 +18,16 @@ from src.web.api.metrics import router as metrics_router
from src.web.api.people import router as people_router
from src.web.api.pending_identifications import router as pending_identifications_router
from src.web.api.photos import router as photos_router
from src.web.api.reported_photos import router as reported_photos_router
from src.web.api.tags import router as tags_router
from src.web.api.users import router as users_router
from src.web.api.version import router as version_router
from src.web.settings import APP_TITLE, APP_VERSION
from src.web.db.base import Base, engine
from src.web.db.session import database_url
# Import models to ensure they're registered with Base.metadata
from src.web.db import models # noqa: F401
from src.web.utils.password import hash_password
# Global worker process (will be set in lifespan)
_worker_process: subprocess.Popen | None = None
@@ -88,6 +92,85 @@ def stop_worker() -> None:
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")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Lifespan context manager for startup and shutdown events."""
@@ -99,12 +182,11 @@ async def lifespan(app: FastAPI):
db_file.parent.mkdir(parents=True, exist_ok=True)
# Only create tables if they don't already exist (safety check)
from sqlalchemy import inspect
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"}
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users"}
missing_tables = required_tables - existing_tables
if missing_tables:
@@ -118,6 +200,10 @@ async def lifespan(app: FastAPI):
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)
except Exception as exc:
print(f"❌ Database initialization failed: {exc}")
raise
@@ -153,7 +239,9 @@ def create_app() -> FastAPI:
app.include_router(faces_router, prefix="/api/v1")
app.include_router(people_router, prefix="/api/v1")
app.include_router(pending_identifications_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(reported_photos_router, prefix="/api/v1")
app.include_router(tags_router, prefix="/api/v1")
app.include_router(users_router, prefix="/api/v1")
return app
+23
View File
@@ -195,3 +195,26 @@ class PhotoFavorite(Base):
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, nullable=True)
full_name = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
is_admin = Column(Boolean, default=False, nullable=False, 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_is_admin", "is_admin"),
Index("idx_users_password_change_required", "password_change_required"),
)
+35 -9
View File
@@ -1,33 +1,59 @@
"""Authentication schemas."""
"""Authentication schemas for web API."""
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, ConfigDict
class LoginRequest(BaseModel):
"""Login request schema."""
"""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 schema."""
"""Token response payload."""
model_config = ConfigDict(protected_namespaces=())
access_token: str
refresh_token: str
token_type: str = "bearer"
password_change_required: bool = False
class UserResponse(BaseModel):
"""User response schema."""
"""User response payload."""
model_config = ConfigDict(protected_namespaces=())
username: str
is_admin: bool = False
class RefreshRequest(BaseModel):
"""Refresh token request schema."""
class PasswordChangeRequest(BaseModel):
"""Password change request payload."""
refresh_token: str
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
+59
View File
@@ -0,0 +1,59 @@
"""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 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
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: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
is_active: bool = True
is_admin: bool = False
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: Optional[EmailStr] = None
full_name: Optional[str] = Field(None, max_length=200)
is_active: Optional[bool] = None
is_admin: Optional[bool] = None
class UsersListResponse(BaseModel):
"""List of users."""
model_config = ConfigDict(protected_namespaces=())
items: list[UserResponse]
total: int
+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')
)