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
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:
commit
2b6065e486
@ -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
1
.gitignore
vendored
@ -100,3 +100,4 @@ e2e/.env
|
||||
.notes/
|
||||
logs/
|
||||
.vite/
|
||||
e2e/env-defaults.local.json
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;\""
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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"])
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -4,7 +4,6 @@ from fastapi import APIRouter
|
||||
|
||||
from backend.settings import APP_VERSION
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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};\"")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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/
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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;"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
2
requirements-dev.txt
Normal file
@ -0,0 +1,2 @@
|
||||
# Development-only tooling (not needed on deployment targets)
|
||||
ruff==0.16.0
|
||||
Loading…
x
Reference in New Issue
Block a user