Merge pull request 'Finish IP scrub repo-wide, make Python lint gate real (ruff)' (#72) from dev into master
Some checks failed
CI / skip-ci-check (push) Successful in 27s
CI / docker-ci (push) Successful in 34s
CI / secret-scan (push) Successful in 43s
CI / python-lint (push) Successful in 22s
CI / admin-unit (push) Successful in 1m27s
CI / viewer-unit (push) Successful in 2m6s
CI / e2e (push) Failing after 8m6s

This commit is contained in:
ilia 2026-07-26 15:15:23 -05:00
commit 2b6065e486
42 changed files with 293 additions and 262 deletions

View File

@ -71,6 +71,26 @@ jobs:
docker run --rm -v "$PWD:/repo" ghcr.io/gitleaks/gitleaks:latest \
detect --source /repo --no-banner --redact ${extra}
# Ruff lint on the FastAPI backend (config in ruff.toml). Replaces the old
# flake8 `|| true` npm script that never actually gated anything.
# actions/checkout@v4 needs node inside the job container and python images
# don't ship it, so clone manually (public repo, no token needed).
python-lint:
needs: skip-ci-check
if: needs.skip-ci-check.outputs.should-skip != '1'
runs-on: [homelab, self-hosted, linux]
container:
image: python:3.12-bookworm
steps:
- name: Checkout and ruff check
run: |
BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF#refs/heads/}}"
git clone --branch "$BRANCH" --depth 1 \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /tmp/src
pip install "ruff==0.16.0"
cd /tmp/src
ruff check backend
# Vitest unit tests (viewer-frontend) — landed in PR #63, wired here.
viewer-unit:
needs: skip-ci-check

1
.gitignore vendored
View File

@ -100,3 +100,4 @@ e2e/.env
.notes/
logs/
.vite/
e2e/env-defaults.local.json

View File

@ -7,8 +7,8 @@ After pulling from Git, configure the following server-specific settings:
### Root `.env`
```bash
# Database connections
DATABASE_URL=postgresql+psycopg2://user:password@10.0.10.179:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://user:password@10.0.10.179:5432/punimtag_auth
DATABASE_URL=postgresql+psycopg2://user:password@<db-host>:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://user:password@<db-host>:5432/punimtag_auth
# JWT Secrets
SECRET_KEY=your-secret-key-here
@ -21,16 +21,16 @@ PHOTO_STORAGE_DIR=/opt/punimtag/data/uploads
### `admin-frontend/.env`
```bash
VITE_API_URL=http://10.0.10.121:8000
VITE_API_URL=http://<backend-host>:8000
```
### `viewer-frontend/.env`
```bash
DATABASE_URL=postgresql://user:password@10.0.10.179:5432/punimtag
DATABASE_URL_AUTH=postgresql://user:password@10.0.10.179:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
DATABASE_URL=postgresql://user:password@<db-host>:5432/punimtag
DATABASE_URL_AUTH=postgresql://user:password@<db-host>:5432/punimtag_auth
NEXTAUTH_URL=http://<backend-host>:3001
NEXTAUTH_SECRET=your-secret-key-here
AUTH_URL=http://10.0.10.121:3001
AUTH_URL=http://<backend-host>:3001
# Password reset / email verification emails (lib/email.ts). Without these,
# "Forgot password" and "Confirm your email" never actually send mail — see

View File

@ -35,7 +35,7 @@ Living plan for product quality, auth/email reliability, and automation.
### Ops / docs debt
- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) — confirmed via `pct list` on `10.0.10.201`: only DEV (`9101`) exists today, so this (and PROD smoke actually running, and the PROD `NEXTAUTH_URL` hostname) stays dormant-but-ready until QA/PROD LXCs are provisioned
- [ ] **Wire QA/PROD SMTP on live guests** when LXCs 9102/9103 exist (`make punimtag-sync-smtp ENV=qa|prod`) — confirmed via `pct list` on the Proxmox node: only DEV (`9101`) exists today, so this (and PROD smoke actually running, and the PROD `NEXTAUTH_URL` hostname) stays dormant-but-ready until QA/PROD LXCs are provisioned
- [ ] **Stop seeding `admin@admin.com` in docs** as the day-to-day login; keep bootstrap scripts but point operators at Vaultwarden `PunimTag e2e`
- [ ] Set the new `E2E_VIEWER_EMAIL`/`E2E_VIEWER_PASSWORD` Gitea secrets from Infisical/Vaultwarden if CI doesn't already have them synced (script sets Gitea directly, so likely already OK — verify on next CI run)
- [x] ~~**pve10 RAM overcommit / CI OOM under bursts**~~ — dropped from backlog 2026-07-15 (user); git-ci-01 already at 8 GB (ansible PR #128). Revisit only if OOM storms return.

View File

@ -17,18 +17,18 @@ from backend.constants.roles import (
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.db.session import get_db
from backend.schemas.auth import (
LoginRequest,
PasswordChangeRequest,
PasswordChangeResponse,
RefreshRequest,
TokenResponse,
UserResponse,
PasswordChangeRequest,
PasswordChangeResponse,
)
from backend.services.role_permissions import fetch_role_permissions_map
from backend.utils.password import hash_password, verify_password
router = APIRouter(prefix="/auth", tags=["auth"])
@ -315,7 +315,7 @@ def get_current_user_info(
# 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()
admin_count = db.query(User).filter(User.is_admin.is_(True)).count()
# If no admins exist, bootstrap current user as admin
if admin_count == 0:

View File

@ -5,19 +5,18 @@ from __future__ import annotations
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi import APIRouter, Depends, HTTPException, 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.db.session import get_auth_db
from backend.schemas.auth_users import (
AuthUserCreateRequest,
AuthUserResponse,
AuthUserUpdateRequest,
AuthUsersListResponse,
AuthUserUpdateRequest,
)
from backend.utils.password import hash_password
@ -593,17 +592,14 @@ def delete_auth_user(
)
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
# Check if is_active column exists by trying to query it
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

View File

@ -3,7 +3,8 @@
from __future__ import annotations
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from backend.api.auth import get_current_user

View File

@ -3,57 +3,57 @@
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse, Response
from rq import Queue
from redis import Redis
from sqlalchemy import func
from sqlalchemy.orm import Session
from typing import Annotated
from backend.db.session import get_db
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import Response
from redis import Redis
from rq import Queue
from sqlalchemy import func
from sqlalchemy.orm import Session
from backend.api.auth import get_current_user_with_id
from backend.db.models import Face, Person, PersonEncoding, Photo
from backend.db.session import get_db
from backend.schemas.faces import (
ProcessFacesRequest,
ProcessFacesResponse,
UnidentifiedFacesQuery,
UnidentifiedFacesResponse,
FaceItem,
SimilarFacesResponse,
SimilarFaceItem,
BatchSimilarityRequest,
BatchSimilarityResponse,
FaceSimilarityPair,
IdentifyFaceRequest,
IdentifyFaceResponse,
FaceUnmatchResponse,
BatchUnmatchRequest,
BatchUnmatchResponse,
AutoMatchRequest,
AutoMatchResponse,
AutoMatchPersonItem,
AutoMatchFaceItem,
AutoMatchPeopleResponse,
AutoMatchPersonSummary,
AutoMatchPersonItem,
AutoMatchPersonMatchesResponse,
AcceptMatchesRequest,
MaintenanceFacesResponse,
MaintenanceFaceItem,
AutoMatchPersonSummary,
AutoMatchRequest,
AutoMatchResponse,
BatchSimilarityRequest,
BatchSimilarityResponse,
BatchUnmatchRequest,
BatchUnmatchResponse,
DeleteFacesRequest,
DeleteFacesResponse,
FaceItem,
FaceSimilarityPair,
FaceUnmatchResponse,
IdentifyFaceRequest,
IdentifyFaceResponse,
MaintenanceFaceItem,
MaintenanceFacesResponse,
ProcessFacesRequest,
ProcessFacesResponse,
SimilarFaceItem,
SimilarFacesResponse,
UnidentifiedFacesResponse,
)
from backend.schemas.people import PersonCreateRequest, PersonResponse
from backend.db.models import Face, Person, PersonEncoding, Photo
from backend.services.face_service import (
list_unidentified_faces,
find_similar_faces,
accept_auto_match_matches,
calculate_batch_similarities,
find_auto_match_matches,
accept_auto_match_matches,
find_similar_faces,
get_auto_match_people_list,
list_unidentified_faces,
)
from backend.services.face_service import (
get_auto_match_person_matches as get_person_matches_service,
)
# Note: Function passed as string path to avoid RQ serialization issues
router = APIRouter(prefix="/faces", tags=["faces"])
@ -202,6 +202,7 @@ def get_similar_faces(
) -> SimilarFacesResponse:
"""Return similar unidentified faces for a given face."""
import logging
import numpy as np
logger = logging.getLogger(__name__)
logger.info(f"API: get_similar_faces called for face_id={face_id}, include_excluded={include_excluded}, debug={debug}")
@ -369,11 +370,12 @@ def identify_face(
@router.get("/{face_id}/crop")
def get_face_crop(face_id: int, db: Session = Depends(get_db)) -> Response:
"""Serve face crop image extracted from photo using face location."""
import os
import json
import ast
import tempfile
import json
import os
from PIL import Image
from backend.db.models import Face, Photo
from src.utils.exif_utils import EXIFOrientationHandler
@ -549,8 +551,6 @@ def batch_unmatch_faces(request: BatchUnmatchRequest, db: Session = Depends(get_
# Unmatch all matched faces
face_ids_to_unmatch = [f.id for f in matched_faces]
# Collect person_ids that will be affected (before unlinking)
affected_person_ids = {f.person_id for f in matched_faces if f.person_id is not None}
for face in matched_faces:
face.person_id = None
@ -620,7 +620,6 @@ def auto_match_faces(
- Only auto-accepts faces with quality > 50% (quality_score > 0.5)
"""
from backend.db.models import Person, Photo
from sqlalchemy import func
# Track statistics for auto-accept
auto_accepted_faces = 0
@ -997,10 +996,6 @@ def delete_faces(
detail=f"Faces not found: {sorted(missing_ids)}",
)
# Collect person_ids that will be affected (before deletion)
# Only include faces that are identified (have a person_id)
affected_person_ids = {f.person_id for f in faces if f.person_id is not None}
# Delete associated person_encodings for these faces
db.query(PersonEncoding).filter(PersonEncoding.face_id.in_(request.face_ids)).delete(synchronize_session=False)

View File

@ -2,7 +2,6 @@ from __future__ import annotations
from fastapi import APIRouter
router = APIRouter()

View File

@ -2,19 +2,19 @@
from __future__ import annotations
import json
import time
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, status
from fastapi.responses import StreamingResponse
from redis import Redis
from rq import Queue
from rq.job import Job
from redis import Redis
import json
import time
from typing import Optional
from backend.schemas.jobs import JobResponse, JobStatus
from backend.api.auth import get_current_user_from_token
from backend.schemas.jobs import JobResponse, JobStatus
router = APIRouter(prefix="/jobs", tags=["jobs"])

View File

@ -7,13 +7,13 @@ 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 import func, text
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.constants.roles import DEFAULT_USER_ROLE
from backend.db.models import Face, Person, PersonEncoding, User
from backend.db.session import get_auth_db, get_db
from backend.utils.password import hash_password
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
@ -328,7 +328,6 @@ def approve_deny_pending_identifications(
person = query.first()
# Create person if doesn't exist
created_person = False
if not person:
# Explicitly set created_date to ensure it's a valid datetime object
person = Person(
@ -341,7 +340,6 @@ def approve_deny_pending_identifications(
)
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

View File

@ -4,8 +4,8 @@ from __future__ import annotations
import os
from datetime import datetime
from typing import Annotated, Optional
from pathlib import Path
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from fastapi.responses import FileResponse
@ -13,10 +13,10 @@ 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.api.users import get_current_admin_user, require_feature_permission
from backend.db.session import get_auth_db, get_db
from backend.services.photo_service import calculate_file_hash, import_photo_from_path
from backend.settings import PHOTO_STORAGE_DIR
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
@ -264,10 +264,10 @@ def review_pending_photos(
- Updates status in auth database to 'rejected'
- Photo file remains in place (can be deleted later if needed)
"""
import shutil
import uuid
import traceback
import logging
import shutil
import traceback
import uuid
logger = logging.getLogger(__name__)
@ -418,7 +418,7 @@ def review_pending_photos(
if potential_path.exists():
source_path = potential_path
break
except (PermissionError, OSError) as e:
except (PermissionError, OSError):
# Can't read directory, skip this search
pass
@ -495,7 +495,7 @@ def review_pending_photos(
if dest_path.exists():
try:
dest_path.unlink()
except:
except Exception:
pass
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
continue
@ -726,8 +726,8 @@ def cleanup_pending_photos_database(
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
import subprocess
from urllib.parse import urlparse
try:
@ -790,7 +790,7 @@ def cleanup_pending_photos_database(
"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:
except Exception:
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;\""

View File

@ -9,18 +9,18 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy import func, or_
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.db.models import Face, Person, PersonEncoding, Photo, PhotoPersonLinkage
from backend.db.session import get_db
from backend.schemas.faces import AcceptMatchesRequest, IdentifyFaceResponse, PersonFaceItem, PersonFacesResponse
from backend.schemas.people import (
PeopleListResponse,
PeopleWithFacesListResponse,
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"])
@ -267,7 +267,6 @@ def accept_matches(
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"]
try:

View File

@ -3,34 +3,29 @@
from __future__ import annotations
from datetime import date, datetime
from typing import List, Optional
from typing import Annotated, List, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status, Request
from fastapi.responses import JSONResponse, FileResponse, Response
from typing import Annotated
from rq import Queue
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import Response
from redis import Redis
from rq import Queue
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.db.session import get_db
from backend.schemas.photos import (
PhotoImportRequest,
PhotoImportResponse,
PhotoResponse,
BrowseDirectoryResponse,
BulkAddFavoritesRequest,
BulkAddFavoritesResponse,
BulkDeletePhotosRequest,
BulkDeletePhotosResponse,
BulkRemoveFavoritesRequest,
BulkRemoveFavoritesResponse,
BrowseDirectoryResponse,
DirectoryItem,
PhotoImportRequest,
PhotoImportResponse,
PhotoResponse,
)
from backend.schemas.search import (
PhotoSearchResult,
@ -52,6 +47,11 @@ from backend.services.search_service import (
search_photos_by_name,
search_photos_by_tags,
)
# Redis connection for RQ
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
queue = Queue(connection=redis_conn)
# Note: Function passed as string path to avoid RQ serialization issues
router = APIRouter(prefix="/photos", tags=["photos"])
@ -415,9 +415,8 @@ async def upload_photos(
For large batches, prefer the /import endpoint with folder_path.
"""
import os
import shutil
from datetime import date, datetime
from pathlib import Path
from datetime import datetime, date
from backend.settings import PHOTO_STORAGE_DIR
@ -466,7 +465,7 @@ async def upload_photos(
if hasattr(request, '_form'):
form = await request.form()
files = form.getlist('files')
except:
except Exception:
pass
if not files:
@ -581,7 +580,6 @@ def browse_directory(
HTTPException: If path doesn't exist, is not a directory, or access is denied
"""
import os
from pathlib import Path
try:
# Convert to absolute path
@ -678,7 +676,6 @@ def browse_folder() -> dict:
dict with 'path' (str) and 'success' (bool) keys
"""
import os
import sys
try:
import tkinter as tk
@ -801,11 +798,13 @@ def get_photo_image(
db: Session = Depends(get_db)
):
"""Serve photo image or video file for display (not download)."""
import os
import mimetypes
from backend.db.models import Photo
import os
from starlette.responses import FileResponse
from backend.db.models import Photo
photo = db.query(Photo).filter(Photo.id == photo_id).first()
if not photo:
raise HTTPException(
@ -879,7 +878,7 @@ def get_photo_image(
},
media_type=media_type,
)
except (ValueError, IndexError) as e:
except (ValueError, IndexError):
# If range parsing fails, fall through to serve full file
pass
@ -1110,9 +1109,10 @@ def bulk_delete_photos(
If a photo's file is in the uploads folder, it will also be deleted from the filesystem
to prevent duplicate uploads.
"""
import os
import logging
import os
from pathlib import Path
from backend.db.models import Photo, PhotoTagLinkage
from backend.settings import PHOTO_STORAGE_DIR
@ -1198,8 +1198,9 @@ def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
- Linux: tries file manager-specific commands (nautilus, dolphin, etc.) or opens folder
"""
import os
import sys
import subprocess
import sys
from backend.db.models import Photo
photo = db.query(Photo).filter(Photo.id == photo_id).first()
@ -1238,7 +1239,7 @@ def open_photo_folder(photo_id: int, db: Session = Depends(get_db)) -> dict:
except ImportError:
# showinfm not installed, fall back to manual commands
pass
except Exception as e:
except Exception:
# showinfm failed, fall back to manual commands
pass

View File

@ -10,9 +10,9 @@ 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
from backend.db.models import Photo, PhotoTagLinkage
from backend.db.session import get_auth_db, get_db
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])

View File

@ -8,7 +8,7 @@ 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.role_features import ROLE_FEATURE_KEYS, ROLE_FEATURES
from backend.constants.roles import ROLE_VALUES
from backend.db.session import get_db
from backend.schemas.role_permissions import (

View File

@ -2,36 +2,34 @@
from __future__ import annotations
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.db.models import Photo
from backend.db.session import get_db
from backend.schemas.tags import (
PhotosWithTagsResponse,
PhotoTagItem,
PhotoTagsListResponse,
PhotoTagsRequest,
PhotoTagsResponse,
PhotoWithTagsItem,
TagCreateRequest,
TagDeleteRequest,
TagResponse,
TagsResponse,
TagUpdateRequest,
TagDeleteRequest,
PhotoTagsListResponse,
PhotoTagItem,
PhotosWithTagsResponse,
PhotoWithTagsItem,
)
from backend.services.tag_service import (
add_tags_to_photos,
delete_tags,
get_or_create_tag,
get_photo_tags,
get_photos_with_tags,
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"])

View File

@ -8,7 +8,6 @@ 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
@ -19,16 +18,16 @@ from backend.constants.roles import (
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.db.session import get_auth_db, get_db
from backend.schemas.users import (
UserCreateRequest,
UserResponse,
UserUpdateRequest,
UsersListResponse,
UserUpdateRequest,
)
from backend.utils.password import hash_password
from backend.services.role_permissions import fetch_role_permissions_map
from backend.utils.password import hash_password
router = APIRouter(prefix="/users", tags=["users"])
logger = logging.getLogger(__name__)
@ -161,7 +160,7 @@ def get_current_admin_user(
username = current_user["username"]
# Check if any admin users exist
admin_count = db.query(User).filter(User.is_admin == True).count()
admin_count = db.query(User).filter(User.is_admin.is_(True)).count()
# If no admins exist, allow current user to bootstrap as admin
if admin_count == 0:

View File

@ -4,7 +4,6 @@ from fastapi import APIRouter
from backend.settings import APP_VERSION
router = APIRouter()

View File

@ -6,34 +6,33 @@ from datetime import date
from typing import Annotated, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse, Response, StreamingResponse
from fastapi.responses import FileResponse, Response
from redis import Redis
from rq import Queue
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.db.models import Photo, User
from backend.db.session import get_db
from backend.schemas.videos import (
ListVideosResponse,
VideoListItem,
PersonInfo,
VideoPeopleResponse,
VideoPersonInfo,
IdentifyVideoRequest,
IdentifyVideoResponse,
ListVideosResponse,
PersonInfo,
RemoveVideoPersonResponse,
VideoListItem,
VideoPeopleResponse,
VideoPersonInfo,
WebPlaybackPrepareResponse,
WebPlaybackStatusResponse,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
from backend.services.video_service import (
list_videos_for_identification,
get_video_people,
identify_person_in_video,
list_videos_for_identification,
remove_person_from_video,
get_video_people_count,
)
from backend.services.thumbnail_service import get_video_thumbnail_path
from backend.services.web_video_service import (
expire_web_playable_if_stale,
get_web_playback_status_dict,
@ -314,8 +313,9 @@ def get_video_file(
db: Session = Depends(get_db),
):
"""Serve video file for playback with range request support."""
import os
import mimetypes
import os
from starlette.responses import FileResponse
# Verify video exists

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import os
import subprocess
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
@ -11,30 +10,31 @@ from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import inspect, text
from backend.api.auth import router as auth_router
from backend.api.auth_users import router as auth_users_router
from backend.api.click_log import router as click_log_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.pending_photos import router as pending_photos_router
from backend.api.people import router as people_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.role_permissions import router as role_permissions_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.click_log import router as click_log_router
from backend.api.version import router as version_router
from backend.settings import APP_TITLE, APP_VERSION
from backend.api.videos import router as videos_router
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.base import Base, engine
from backend.db.models import RolePermission
from backend.db.session import auth_engine, database_url, get_auth_database_url
from backend.settings import APP_TITLE, APP_VERSION
from backend.utils.password import hash_password
# Global worker process (will be set in lifespan)
@ -443,8 +443,6 @@ def ensure_auth_user_is_active_column() -> None:
# 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():
@ -488,8 +486,9 @@ def ensure_postgresql_database(db_url: str) -> None:
return # Not PostgreSQL, skip
try:
from urllib.parse import urlparse, parse_qs
import os
from urllib.parse import urlparse
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
@ -550,7 +549,7 @@ def ensure_postgresql_database(db_url: str) -> None:
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(" 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
@ -602,14 +601,14 @@ def ensure_postgresql_database(db_url: str) -> None:
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(" 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(" 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};\"")

View File

@ -2,11 +2,12 @@
from __future__ import annotations
from datetime import datetime, date
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
CheckConstraint,
Column,
Date,
DateTime,
@ -17,7 +18,6 @@ from sqlalchemy import (
Numeric,
Text,
UniqueConstraint,
CheckConstraint,
)
from sqlalchemy.orm import declarative_base, relationship

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import date
from typing import Optional
from pydantic import BaseModel, Field, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class ProcessFacesRequest(BaseModel):

View File

@ -6,14 +6,14 @@ import json
import os
import tempfile
import time
from pathlib import Path
from typing import Callable, Optional, Tuple, List, Dict
from datetime import date
from pathlib import Path
from typing import Callable, Dict, List, Optional, Tuple
import numpy as np
from PIL import Image
from sqlalchemy import case, func
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import and_, func, case
# Skip DeepFace import during tests to avoid illegal instruction errors
if os.getenv("SKIP_DEEPFACE_IN_TESTS") == "1":
@ -29,18 +29,18 @@ else:
from backend.config import (
CONFIDENCE_CALIBRATION_METHOD,
DEFAULT_FACE_TOLERANCE,
DEEPFACE_ALIGN_FACES,
DEEPFACE_ENFORCE_DETECTION,
DEFAULT_FACE_TOLERANCE,
MAX_FACE_SIZE,
MIN_AUTO_MATCH_FACE_SIZE_RATIO,
MIN_FACE_CONFIDENCE,
MIN_FACE_SIZE,
MIN_AUTO_MATCH_FACE_SIZE_RATIO,
USE_CALIBRATED_CONFIDENCE,
)
from backend.db.models import Face, Person, Photo, PhotoTagLinkage, Tag
from src.utils.exif_utils import EXIFOrientationHandler
from src.utils.pose_detection import PoseDetector, RETINAFACE_AVAILABLE
from backend.db.models import Face, Photo, Person, Tag, PhotoTagLinkage
from src.utils.pose_detection import RETINAFACE_AVAILABLE, PoseDetector
def _pre_warm_deepface(
@ -75,8 +75,8 @@ def _pre_warm_deepface(
dummy_array = np.array(dummy_img)
# Suppress stderr to prevent broken pipe errors from gdown
import sys
import contextlib
import sys
from io import StringIO
# Suppress stdout/stderr during model loading
@ -328,8 +328,6 @@ def process_photo_faces(
# Suppress stderr globally for this function to prevent broken pipe errors
# This must happen BEFORE any DeepFace/RetinaFace initialization
import sys
from io import StringIO
import contextlib
# Save original stderr and redirect to devnull for the entire function
# This prevents gdown (used by RetinaFace/DeepFace) from causing broken pipe errors
@ -426,7 +424,7 @@ def process_photo_faces(
# Check if model weights exist before calling DeepFace
# This prevents automatic download attempts that can cause broken pipe errors
weights_path = os.path.expanduser(f"~/.deepface/weights/arcface_weights.h5")
weights_path = os.path.expanduser("~/.deepface/weights/arcface_weights.h5")
if model_name == "ArcFace" and not os.path.exists(weights_path):
sys.stderr = original_stderr
raise Exception(
@ -579,7 +577,8 @@ def process_photo_faces(
# Recalculate pose_mode using classify_pose_mode if we have face_width but no yaw
# This ensures profile faces are detected even when yaw calculation fails
from src.utils.pose_detection import PoseDetector
# (PoseDetector comes from the module-level import; a local re-import here
# would shadow it and break the fallback path earlier in this function)
if yaw_angle is None:
# Try to get yaw from the matched pose_face if available
# This helps determine direction (left vs right) when yaw calculation failed
@ -761,7 +760,7 @@ def process_photo_faces(
try:
_print_with_stderr(f"[FaceService] Failed to commit {faces_stored} faces for {photo.filename}: {error_msg}")
if is_connection_error:
_print_with_stderr(f"[FaceService] ⚠️ Database connection error detected - session may need refresh")
_print_with_stderr("[FaceService] ⚠️ Database connection error detected - session may need refresh")
import traceback
traceback.print_exc()
except (BrokenPipeError, OSError):
@ -1130,7 +1129,6 @@ def process_unprocessed_photos(
try:
print(f"[FaceService] Starting face processing: detector={detector_backend}, model={model_name}, batch_size={batch_size}")
overall_start = time.time()
# Update progress - querying unprocessed photos
if update_progress:
@ -1192,8 +1190,8 @@ def process_unprocessed_photos(
Also checks RQ's built-in cancellation status.
"""
try:
from rq import get_current_job
from redis import Redis
from rq import get_current_job
from rq.job import Job
job = get_current_job()
@ -1268,7 +1266,7 @@ def process_unprocessed_photos(
# This moves the model loading delay to initialization phase (with progress updates)
# instead of causing delay during first photo processing
if total > 0:
print(f"[FaceService] Pre-warming DeepFace models...")
print("[FaceService] Pre-warming DeepFace models...")
_pre_warm_deepface(detector_backend, model_name, update_progress)
# Check cancellation after pre-warming
@ -1281,9 +1279,9 @@ def process_unprocessed_photos(
pose_detector = None
if RETINAFACE_AVAILABLE:
try:
print(f"[FaceService] Initializing RetinaFace pose detector...")
print("[FaceService] Initializing RetinaFace pose detector...")
pose_detector = PoseDetector()
print(f"[FaceService] Pose detector initialized successfully")
print("[FaceService] Pose detector initialized successfully")
except Exception as e:
print(f"[FaceService] ⚠️ Pose detection not available: {e}, will skip pose detection")
pose_detector = None
@ -1309,7 +1307,7 @@ def process_unprocessed_photos(
if is_connection_error:
try:
print(f"[FaceService] ⚠️ Database health check failed at photo {idx}/{total}: {health_check_error}")
print(f"[FaceService] Session may need refresh - will be handled by error handler")
print("[FaceService] Session may need refresh - will be handled by error handler")
except (BrokenPipeError, OSError):
pass
@ -1345,7 +1343,7 @@ def process_unprocessed_photos(
# Time the first photo to see if there's still delay after pre-warming
if idx == 1:
first_photo_start = time.time()
print(f"[FaceService] Starting first photo processing...")
print("[FaceService] Starting first photo processing...")
# Process photo fully (including pose detection, DeepFace, and database commit)
# CRITICAL: This photo will complete FULLY even if cancellation is requested
@ -1468,7 +1466,7 @@ def process_unprocessed_photos(
# Session is dead - need to get a new one from the caller
# We can't create a new SessionLocal here, so we'll raise a special exception
try:
print(f"[FaceService] ⚠️ Database session is dead after connection error - caller should refresh session")
print("[FaceService] ⚠️ Database session is dead after connection error - caller should refresh session")
except (BrokenPipeError, OSError):
pass
# Re-raise with a flag that indicates session needs refresh
@ -1576,7 +1574,7 @@ def list_unidentified_faces(
# Filter by excluded status (exclude excluded faces by default)
if not include_excluded:
query = query.filter(Face.excluded == False)
query = query.filter(Face.excluded.is_(False))
# Tag filtering
if tag_names:
@ -1708,7 +1706,7 @@ def load_face_encoding(encoding_bytes: bytes) -> np.ndarray:
encoding = np.frombuffer(encoding_bytes, dtype=np.float64)
if len(encoding) == 512:
return encoding
except:
except Exception:
pass
# Fallback to float32
encoding = np.frombuffer(encoding_bytes, dtype=np.float32)
@ -1768,7 +1766,7 @@ def calculate_cosine_distance(encoding1: np.ndarray, encoding2: np.ndarray) -> f
return float(distance)
except Exception as e:
except Exception:
return 2.0 # Maximum distance on error
@ -1893,6 +1891,7 @@ def _calculate_face_size_ratio(face: Face, photo: Photo) -> float:
"""
try:
import json
from PIL import Image
# Parse location
@ -1985,7 +1984,6 @@ def find_similar_faces(
filter_frontal_only: Only return frontal or tilted faces (not profile)
include_excluded: Include excluded faces in results (default: False)
"""
from backend.db.models import Photo
if tolerance is None:
tolerance = DEFAULT_FACE_TOLERANCE
@ -2483,7 +2481,7 @@ def get_auto_match_person_matches(
Returns:
List of (face, distance, confidence_pct) tuples
"""
from backend.db.models import Person, Face
from backend.db.models import Face
# Get reference face for this person (best quality >= 0.3)
reference_face = (

View File

@ -4,8 +4,8 @@ from __future__ import annotations
import hashlib
import os
from datetime import date, datetime
from pathlib import Path
from datetime import datetime, date
from typing import Callable, Optional, Tuple, Union
from PIL import Image
@ -415,8 +415,8 @@ def extract_video_date(video_path: str) -> Optional[date]:
"""
# Try to extract date from video metadata using ffprobe
try:
import subprocess
import json
import subprocess
# Use ffprobe to get video metadata
result = subprocess.run(
@ -501,7 +501,6 @@ def extract_photo_date(image_path: str, is_uploaded_file: bool = False) -> Optio
Date object or None if no date can be determined
"""
import logging
import stat
logger = logging.getLogger(__name__)
# First try EXIF date extraction

View File

@ -8,7 +8,7 @@ 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
from backend.db.models import Face, Person, Photo, PhotoTagLinkage, Tag
def search_photos_by_name(
@ -325,7 +325,7 @@ def get_photos_without_faces(
db.query(Photo)
.outerjoin(Face, Photo.id == Face.photo_id)
.filter(Face.photo_id.is_(None))
.filter(Photo.processed == True) # Only include processed photos
.filter(Photo.processed.is_(True)) # Only include processed photos
)
# Apply folder filter if provided

View File

@ -2,12 +2,11 @@
from __future__ import annotations
from typing import List, Optional
from datetime import datetime
from typing import List
from sqlalchemy.orm import Session
from backend.db.models import Photo, PhotoTagLinkage, Tag, Face, Person
from backend.db.models import Face, Person, Photo, PhotoTagLinkage, Tag
def list_tags(db: Session) -> List[Tag]:
@ -225,7 +224,7 @@ def get_photos_with_tags(db: Session) -> List[dict]:
Returns:
List of dicts with photo info, face_count, and tags (comma-separated string)
"""
from sqlalchemy import func, case, distinct
from sqlalchemy import case, distinct, func
# 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
@ -246,7 +245,7 @@ def get_photos_with_tags(db: Session) -> List[dict]:
case((Face.person_id.is_(None), 1), else_=0)
).label('unidentified_face_count'),
)
.outerjoin(Face, (Photo.id == Face.photo_id) & (Face.excluded == False))
.outerjoin(Face, (Photo.id == Face.photo_id) & (Face.excluded.is_(False)))
.group_by(
Photo.id,
Photo.filename,
@ -306,7 +305,7 @@ def get_photos_with_tags(db: Session) -> List[dict]:
.join(Person, Face.person_id == Person.id)
.filter(Face.photo_id.in_(photo_ids))
.filter(Face.person_id.isnot(None))
.filter(Face.excluded == False)
.filter(Face.excluded.is_(False))
.distinct()
.order_by(Face.photo_id, Person.last_name, Person.first_name)
.all()

View File

@ -8,8 +8,8 @@ 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
from backend.services.photo_service import import_photos_from_folder
def import_photos_task(folder_path: str, recursive: bool = True) -> dict:
@ -143,7 +143,7 @@ def process_faces_task(
pass
db = SessionLocal()
try:
print(f"[Task] Database session refreshed")
print("[Task] Database session refreshed")
except (BrokenPipeError, OSError):
pass
@ -236,7 +236,7 @@ def process_faces_task(
try:
print(f"[Task] Database error detected, attempting to refresh session: {e}")
refresh_db_session()
print(f"[Task] Session refreshed - job will fail gracefully. Restart job to continue processing remaining photos.")
print("[Task] Session refreshed - job will fail gracefully. Restart job to continue processing remaining photos.")
except Exception as refresh_error:
try:
print(f"[Task] Failed to refresh database session: {refresh_error}")
@ -277,7 +277,7 @@ def process_faces_task(
return result
except KeyboardInterrupt as e:
except KeyboardInterrupt:
# Job was cancelled - exit gracefully
print(f"[Task] Job {job.id if job else 'unknown'} cancelled by user")
if job:

View File

@ -9,7 +9,6 @@ from typing import Optional
from PIL import Image
# Cache directory for thumbnails (relative to project root).
# NOTE: This file lives at: <repo>/backend/services/thumbnail_service.py
# So project root is 2 levels up from: <repo>/backend/services/

View File

@ -5,10 +5,10 @@ from __future__ import annotations
from datetime import date, datetime
from typing import List, Optional, Tuple
from sqlalchemy import and_, func, or_
from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from backend.db.models import Photo, Person, PhotoPersonLinkage, User
from backend.db.models import Person, Photo, PhotoPersonLinkage
def list_videos_for_identification(

View File

@ -2,7 +2,6 @@
from __future__ import annotations
import os
import logging
from datetime import datetime
from pathlib import Path

View File

@ -4,10 +4,10 @@ from __future__ import annotations
import signal
import sys
import uuid
from pathlib import Path
from typing import NoReturn
import uuid
from dotenv import load_dotenv
# Load environment variables from .env file before importing anything that needs them
@ -17,12 +17,11 @@ env_path = env_path.resolve() # Make absolute path
if env_path.exists():
load_dotenv(dotenv_path=env_path, override=True)
from rq import Worker
from redis import Redis
from redis import Redis # noqa: E402
from rq import Worker # noqa: E402
from sqlalchemy import text # noqa: E402
from backend.services.tasks import import_photos_task, process_faces_task
from backend.db.session import auth_engine
from sqlalchemy import text
from backend.db.session import auth_engine # noqa: E402
# Redis connection for RQ
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
@ -216,7 +215,7 @@ def main() -> NoReturn:
worker_name = f"punimtag-worker-{uuid.uuid4().hex[:8]}"
print(f"[Worker] Starting worker: {worker_name}")
print(f"[Worker] Listening on queue: default")
print("[Worker] Listening on queue: default")
# Setup auth database tables for both frontends
setup_auth_database_tables()
@ -224,7 +223,7 @@ def main() -> NoReturn:
# Check if Redis is accessible
try:
redis_conn.ping()
print(f"[Worker] Redis connection successful")
print("[Worker] Redis connection successful")
except Exception as e:
print(f"[Worker] ❌ Redis connection failed: {e}")
sys.exit(1)
@ -237,7 +236,7 @@ def main() -> NoReturn:
name=worker_name,
)
print(f"[Worker] ✅ Worker ready, waiting for jobs...")
print("[Worker] ✅ Worker ready, waiting for jobs...")
# Start worker
worker.work()

View File

@ -32,12 +32,12 @@ This guide covers deployment of PunimTag to development and production environme
### Development Server Information
**Development Server:**
- **Host**: 10.0.10.121
- **Host**: <backend-host>
- **User**: appuser
- **Password**: [Contact administrator for password]
**Development Database:**
- **Host**: 10.0.10.179 (postgresQA / pve201 VM 109; legacy `.181` is dead)
- **Host**: `<db-host>`
- **Port**: 5432
- **User**: ladmin
- **Password**: [Contact administrator for password]
@ -70,18 +70,18 @@ This will:
```bash
# Create deployment directory structure
ssh appuser@10.0.10.121 "mkdir -p /opt/punimtag/{backend,admin-frontend,viewer-frontend,data/uploads,logs}"
ssh appuser@<backend-host> "mkdir -p /opt/punimtag/{backend,admin-frontend,viewer-frontend,data/uploads,logs}"
# Transfer backend
scp -r backend/* appuser@10.0.10.121:/opt/punimtag/backend/
scp requirements.txt appuser@10.0.10.121:/opt/punimtag/
scp -r backend/* appuser@<backend-host>:/opt/punimtag/backend/
scp requirements.txt appuser@<backend-host>:/opt/punimtag/
# Transfer built frontends
scp -r admin-frontend/dist/* appuser@10.0.10.121:/opt/punimtag/admin-frontend/
scp -r viewer-frontend/.next/* appuser@10.0.10.121:/opt/punimtag/viewer-frontend/
scp -r admin-frontend/dist/* appuser@<backend-host>:/opt/punimtag/admin-frontend/
scp -r viewer-frontend/.next/* appuser@<backend-host>:/opt/punimtag/viewer-frontend/
# Transfer configuration files
scp .env.example appuser@10.0.10.121:/opt/punimtag/.env
scp .env.example appuser@<backend-host>:/opt/punimtag/.env
```
### Step 3: Server Setup
@ -89,7 +89,7 @@ scp .env.example appuser@10.0.10.121:/opt/punimtag/.env
SSH into the development server:
```bash
ssh appuser@10.0.10.121
ssh appuser@<backend-host>
cd /opt/punimtag
```
@ -125,8 +125,8 @@ Set the following variables:
```bash
# Development Database
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag_auth
DATABASE_URL=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag
DATABASE_URL_AUTH=postgresql+psycopg2://ladmin:[PASSWORD]@<db-host>:5432/punimtag_auth
# JWT Secrets (change in production!)
SECRET_KEY=dev-secret-key-change-in-production
@ -150,16 +150,16 @@ API_PORT=8000
Create `admin-frontend/.env`:
```bash
VITE_API_URL=http://10.0.10.121:8000
VITE_API_URL=http://<backend-host>:8000
```
**Viewer Frontend:**
Create `viewer-frontend/.env`:
```bash
DATABASE_URL=postgresql://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag
DATABASE_URL_AUTH=postgresql://ladmin:[PASSWORD]@10.0.10.179:5432/punimtag_auth
NEXTAUTH_URL=http://10.0.10.121:3001
DATABASE_URL=postgresql://ladmin:[PASSWORD]@<db-host>:5432/punimtag
DATABASE_URL_AUTH=postgresql://ladmin:[PASSWORD]@<db-host>:5432/punimtag_auth
NEXTAUTH_URL=http://<backend-host>:3001
NEXTAUTH_SECRET=dev-secret-key-change-in-production
```
@ -263,17 +263,17 @@ sudo systemctl status punimtag-worker
1. **Check API Health:**
```bash
curl http://10.0.10.121:8000/api/v1/health
curl http://<backend-host>:8000/api/v1/health
```
2. **Check API Documentation:**
Open browser: `http://10.0.10.121:8000/docs`
Open browser: `http://<backend-host>:8000/docs`
3. **Access Admin Frontend:**
Open browser: `http://10.0.10.121:3000`
Open browser: `http://<backend-host>:3000`
4. **Access Viewer Frontend:**
Open browser: `http://10.0.10.121:3001`
Open browser: `http://<backend-host>:3001`
---
@ -501,7 +501,7 @@ curl http://localhost:8000/api/v1/health
redis-cli ping
# PostgreSQL
psql -h 10.0.10.179 -U ladmin -d punimtag -c "SELECT 1;"
psql -h <db-host> -U ladmin -d punimtag -c "SELECT 1;"
```
---

View File

@ -72,10 +72,10 @@ If your PostgreSQL database is on a **separate server** from the application, yo
Add a line allowing connections from your application server IP:
```bash
# Allow connections from application server
host all all 10.0.10.121/32 md5
host all all <app-server-ip>/32 md5
```
Replace `10.0.10.121` with your actual application server IP address.
Replace `<app-server-ip>` with your actual application server IP address.
Replace `md5` with `scram-sha-256` if your PostgreSQL version uses that (PostgreSQL 14+).
2. **Edit `postgresql.conf`** to listen on network interfaces:
@ -87,7 +87,7 @@ If your PostgreSQL database is on a **separate server** from the application, yo
```bash
listen_addresses = '*' # Listen on all interfaces
# OR for specific IP:
# listen_addresses = 'localhost,10.0.10.179' # Replace with your DB server IP
# listen_addresses = 'localhost,<db-server-ip>' # Replace with your DB server IP
```
3. **Restart PostgreSQL** to apply changes:
@ -97,17 +97,17 @@ If your PostgreSQL database is on a **separate server** from the application, yo
4. **Configure firewall** on the database server to allow PostgreSQL connections:
```bash
sudo ufw allow from 10.0.10.121 to any port 5432 # Replace with your app server IP
sudo ufw allow from <app-server-ip> to any port 5432 # Replace with your app server IP
# OR allow from all (less secure):
# sudo ufw allow 5432/tcp
```
5. **Test the connection** from the application server:
```bash
psql -h 10.0.10.179 -U punim_dev_user -d postgres
psql -h <db-server-ip> -U punim_dev_user -d postgres
```
Replace `10.0.10.179` with your database server IP and `punim_dev_user` with your database username.
Replace `<db-server-ip>` with your database server IP and `punim_dev_user` with your database username.
**Note:** If PostgreSQL is on the same server as the application, you can skip this step and use `localhost` in your connection strings.

View File

@ -8,9 +8,9 @@ Dev URLs: https://punimtagdev.levkin.ca · https://punimtagadmindev.levkin.ca
| Item | Detail |
|------|--------|
| Outage | App `.env` pointed at dead Postgres `10.0.10.181` |
| Fix | Retarget to live `postgresQA` **`10.0.10.179`** (pve201 VM 109) |
| Hosts | LXC **9101** `punimTagFE-dev` @ `10.0.10.121` |
| Outage | App `.env` pointed at dead Postgres `<old-db-host>` |
| Fix | Retarget to live `postgresQA` **`<db-host>`** (pve201 VM 109) |
| Hosts | LXC **9101** `punimTagFE-dev` @ `<backend-host>` |
| Monitoring | Kuma: PunimTag viewer/admin; Beszel: `punimtag-9101` |
## Ticket fixes (code) — closed

View File

@ -25,7 +25,7 @@ E2E_ADMIN_PASSWORD=
# Homelab Mailpit (preferred for DEV e2e)
PLAYKIT_MAIL_PROVIDER=mailpit
MAILPIT_BASE_URL=http://10.0.10.45:8025
MAILPIT_BASE_URL=http://<mailpit-host>:8025
MAILPIT_USER=
MAILPIT_PASSWORD=
# E2E_MAIL_TO= # defaults to E2E_ADMIN_EMAIL

View File

@ -1,7 +1,7 @@
# PunimTag e2e (Playwright + @levkin/playkit)
Smoke tests against the **public** DEV URL so we catch LAN-redirect bugs
(e.g. `NEXTAUTH_URL=http://10.0.10.121:3001` → Kolby #57).
(e.g. `NEXTAUTH_URL=http://<backend-host>:3001` → Kolby #57).
## Quick start
@ -29,7 +29,7 @@ Never commit lockfile URLs that embed tokens.
| `E2E_API_VIEWER_USERNAME` / `E2E_API_VIEWER_PASSWORD` | optional | FastAPI bearer login, viewer role — role-permission gate specs only |
| `E2E_VIEWER_EMAIL` / `E2E_VIEWER_PASSWORD` | optional | NextAuth auth-DB viewer, `hasWriteAccess=false` (third, independent store) — `viewer.write-gates.spec.ts` only |
| `PLAYKIT_MAIL_PROVIDER` | for mail specs | `mailpit` (homelab) |
| `MAILPIT_BASE_URL` | for mail specs | `http://10.0.10.45:8025` |
| `MAILPIT_BASE_URL` | for mail specs | `http://<mailpit-host>:8025` |
| `MAILPIT_USER` / `MAILPIT_PASSWORD` | for mail specs | Mailpit basic auth |
**Secrets:** Infisical `LevkinOps``dev``/playkit/punimtag` + Gitea Actions.
@ -69,4 +69,7 @@ not enabled in this CI job yet.
See repo root [`ROADMAP.md`](../ROADMAP.md) for gaps and next steps.
Defaults for base URLs live in `env-defaults.json` (imported by `env-defaults.ts`,
`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job).
`fixtures.ts`, `playwright.config.ts`, and the Gitea `e2e` job). The checked-in
file holds placeholder hosts; real targets come from `PLAYKIT_BASE_URL` /
`PLAYKIT_API_BASE_URL` env vars (Gitea Actions secrets in CI) or a gitignored
`env-defaults.local.json` alongside it for local runs.

View File

@ -1,6 +1,6 @@
{
"DEFAULT_BASE_URL": "https://punimtagdev.levkin.ca",
"DEFAULT_API_BASE_URL": "http://10.0.10.121:8000",
"DEFAULT_API_BASE_URL": "http://<backend-host>:8000",
"DEFAULT_PROJECT": "punimtag",
"DEFAULT_ENV": "dev"
}

View File

@ -4,8 +4,30 @@
*
* Values live in env-defaults.json so CI bash can read them without
* compiling TypeScript (see `.gitea/workflows/ci.yml`).
*
* env-defaults.json ships placeholder host values (this is a public repo).
* Real targets come from env vars (PLAYKIT_BASE_URL / PLAYKIT_API_BASE_URL
* set as Gitea Actions secrets in CI) or from a gitignored
* `env-defaults.local.json` next to this file for local runs.
*/
import defaults from './env-defaults.json' with { type: 'json' };
import * as fs from 'node:fs';
import * as path from 'node:path';
import checkedInDefaults from './env-defaults.json' with { type: 'json' };
function loadLocalOverrides(): Partial<typeof checkedInDefaults> {
try {
const raw = fs.readFileSync(
path.join(__dirname, 'env-defaults.local.json'),
'utf8',
);
return JSON.parse(raw);
} catch {
return {};
}
}
const defaults = { ...checkedInDefaults, ...loadLocalOverrides() };
export const DEFAULT_BASE_URL = defaults.DEFAULT_BASE_URL;
export const DEFAULT_API_BASE_URL = defaults.DEFAULT_API_BASE_URL;

View File

@ -16,14 +16,14 @@
"lint:viewer": "npm run lint --prefix viewer-frontend",
"lint:all": "npm run lint:admin && npm run lint:viewer",
"type-check:viewer": "npm run type-check --prefix viewer-frontend",
"lint:python": "flake8 backend --max-line-length=100 --ignore=E501,W503,W293,E305,F401,F811,W291,W391,E712,W504,F841,E402,F824,E128,E226,F402,F541,E302,E117,E722 || true",
"lint:python": "./venv/bin/ruff check backend",
"lint:python:syntax": "find backend -name '*.py' -exec python -m py_compile {} \\;",
"test:backend": "export PYTHONPATH=$(pwd) && export SKIP_DEEPFACE_IN_TESTS=1 && ./venv/bin/python3 -m pytest tests/ -v",
"test:e2e": "npm test --prefix e2e",
"test:all": "npm run test:backend",
"install:e2e": "npm install --prefix e2e",
"ci:local": "npm run lint:all && npm run type-check:viewer && npm run lint:python && npm run test:backend && npm run build:all",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to dev server (10.0.10.121)'",
"deploy:dev": "npm run build:all && echo '✅ Build complete. Ready for deployment to the dev server'",
"deploy:dev:prepare": "npm run build:all && mkdir -p deploy/package && cp -r backend deploy/package/ && cp -r admin-frontend/dist deploy/package/admin-frontend-dist && cp -r viewer-frontend/.next deploy/package/viewer-frontend-next && cp requirements.txt deploy/package/ && cp .env.example deploy/package/ && echo '✅ Deployment package prepared in deploy/package/'"
},
"engines": {

2
requirements-dev.txt Normal file
View File

@ -0,0 +1,2 @@
# Development-only tooling (not needed on deployment targets)
ruff==0.16.0

6
ruff.toml Normal file
View File

@ -0,0 +1,6 @@
# Pragmatic lint gate for the FastAPI backend (see package.json lint:python
# and the python-lint CI job). Legacy desktop code under src/ is out of scope.
line-length = 120
[lint]
select = ["E4", "E7", "E9", "F", "I"]