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:
+111
-6
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Utility functions for PunimTag Web."""
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Password hashing utilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import bcrypt
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""Hash a password using bcrypt.
|
||||
|
||||
Args:
|
||||
password: Plain text password
|
||||
|
||||
Returns:
|
||||
Hashed password as string
|
||||
"""
|
||||
salt = bcrypt.gensalt()
|
||||
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
|
||||
return hashed.decode('utf-8')
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""Verify a password against a hash.
|
||||
|
||||
Args:
|
||||
password: Plain text password to verify
|
||||
password_hash: Hashed password to compare against
|
||||
|
||||
Returns:
|
||||
True if password matches, False otherwise
|
||||
"""
|
||||
return bcrypt.checkpw(
|
||||
password.encode('utf-8'),
|
||||
password_hash.encode('utf-8')
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user