Compare commits
181 Commits
85d5c120d4
...
713584dc04
| Author | SHA1 | Date | |
|---|---|---|---|
| 713584dc04 | |||
| 845b3f3b87 | |||
| 3ec0da1573 | |||
| 906e2cbe19 | |||
| 1f3f35d535 | |||
| b104dcba71 | |||
| fe01ff51b8 | |||
| c69604573d | |||
| 0b95cd2492 | |||
| 03d3a28b21 | |||
| e624d203d5 | |||
| 32be5c7f23 | |||
| 68d280e8f5 | |||
| 12c62f1deb | |||
| a8fd0568c9 | |||
| bca01a5ac3 | |||
| 10f777f3cc | |||
| a9b4510d08 | |||
| 6e196ff859 | |||
| 0a109b198a | |||
| 8f31e1942f | |||
| e9e8fbf3f5 | |||
| 0e65eac206 | |||
| 30f8a36e57 | |||
| 7973dfadd2 | |||
| d2852fbf1e | |||
| 47505249ce | |||
| 2f2e44c933 | |||
| a41e30b101 | |||
| 6cc359f25a | |||
| e48b614b23 | |||
| 84c4f7ca73 | |||
| 0c212348f6 | |||
| e0e5aae2ff | |||
| 9d40f9772e | |||
| c6055737fb | |||
| 9c6a2ff05e | |||
| a888968a97 | |||
| d5d6dc82b1 | |||
| 999e79f859 | |||
| 709be7555a | |||
| 7c35e4d8ec | |||
| eed3b36dad | |||
| 638ed18033 | |||
| a0cc3a985a | |||
| f9e8c476bc | |||
| 51eaf6a52b | |||
| a036169b0f | |||
| dbffaef298 | |||
| 100d39c556 | |||
| 661e812193 | |||
| 93cb4eda5b | |||
| e6c66e564e | |||
| 2c3b2d7a08 | |||
| 87146b1356 | |||
| 926e738a13 | |||
| 1d8ca7e592 | |||
| 7f48d48b80 | |||
| 8caa9e192b | |||
| c661aeeda6 | |||
| 4f0b72ee5f | |||
| 5ca130f8bd | |||
| cd72913cd5 | |||
| 72d18ead8c | |||
| cfb94900ef | |||
| 89a63cbf57 | |||
| 842f588f19 | |||
| 049f9de4f8 | |||
| 52344febad | |||
| f7accb925d | |||
| 20f1a4207f | |||
| 85dd6a68b3 | |||
| 17aeb5b823 | |||
| 20a8e4df5d | |||
| 21c138a339 | |||
| 8d668a9658 | |||
| ac07932e14 | |||
| ea3d06a3d5 | |||
| 8d11ac415e | |||
| 3e78e90061 | |||
| b1cb9decb5 | |||
| 81b845c98f | |||
| e4a5ff8a57 | |||
| f4f6223cd0 | |||
| e74ade9278 | |||
| a70637feff | |||
| e2cadf3232 | |||
| 0dcfe327cd | |||
| 0e69677d54 | |||
| 7945b084a4 | |||
| 0a960a99ce | |||
| 91ee2ce8ab | |||
| bb42478c8f | |||
| c0f9d19368 | |||
| 59dc01118e | |||
| 817e95337f | |||
| 5174fe0d54 | |||
| dd92d1ec14 | |||
| 2f039a1d48 | |||
| 4c2148f7fc | |||
| 94385e3dcc | |||
| d6b1e85998 | |||
| f44cb8b777 | |||
| f8fefd2983 | |||
| 4816925a3d | |||
| 5db41b63ef | |||
| 2828b9966b | |||
| 68673ccdbe | |||
| d398b139f5 | |||
| ddb156520b | |||
| 986fc81005 | |||
| b2847a066e | |||
| ef7a296a9b | |||
| d300eb1122 | |||
| e49b567afa | |||
| ac5507c560 | |||
| 507b09b764 | |||
| 3e88e2cd2c | |||
| cbc29a9429 | |||
| f40b3db868 | |||
| 8ce538c508 | |||
| e5ec0e4aea | |||
| de23fccf6a | |||
| 34c7998ce9 | |||
| aa67f12a20 | |||
| 18e65e88fc | |||
| 36aaadca1d | |||
| 150ae5fd3f | |||
| 29a02ceae3 | |||
| 6fd5fe3e44 | |||
| 1c8856209a | |||
| 8a9834b056 | |||
| 40ffc0692e | |||
| d92f5750b7 | |||
| 1972a69685 | |||
| 69150b2025 | |||
| 9ec8b78b05 | |||
| 55cd82943a | |||
| 0883a47914 | |||
| d4504ee81a | |||
| b9a0637035 | |||
| 01404418f7 | |||
| 64c29f24de | |||
| 70cb11adbd | |||
| ac546a09e0 | |||
| b75e12816c | |||
| 38f931a7a7 | |||
| 5c1d5584a3 | |||
| a51ffcfaa0 | |||
| f410e60e66 | |||
| b910be9fe7 | |||
| a14a8a4231 | |||
| 31691d7c47 | |||
| 639b283c0c | |||
| 4602c252e8 | |||
| 7f89c2a825 | |||
| 6bfc44a6c9 | |||
| 0f599d3d16 | |||
| b6e6b38a76 | |||
| 68ec18b822 | |||
| 199a75098d | |||
| 15b7c10056 | |||
| 360fcb0881 | |||
| 0fb6a19624 | |||
| da6f810b5b | |||
| 4c0a1a3b38 | |||
| e1bed343b6 | |||
| 34aba85fc6 | |||
| 2394afb5ee | |||
| 347a597927 | |||
| 62bb0dc31f | |||
| 267519a034 | |||
| 8c9da7362b | |||
| 9f11a1a647 | |||
| f3338f0097 | |||
| 5ecfe1121e | |||
| ee3638b929 | |||
| 2fcd200cd0 | |||
| 52b9d37d8c | |||
| 6a5bafef50 | |||
| 2c67b2216d |
127
.cursorignore
Normal file
127
.cursorignore
Normal file
@ -0,0 +1,127 @@
|
||||
# Cursor AI Ignore File
|
||||
# Files and directories that Cursor should not index or analyze
|
||||
|
||||
# Virtual Environment
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Python Cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
|
||||
# Distribution / packaging
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Database Files
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/photos.db
|
||||
photos.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Temporary Files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Test Coverage
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.tox/
|
||||
|
||||
# Archives
|
||||
archive/
|
||||
*.backup
|
||||
*_backup.py
|
||||
*_original.py
|
||||
|
||||
# Demo/Sample Files
|
||||
demo_photos/
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.png
|
||||
*.gif
|
||||
*.bmp
|
||||
*.tiff
|
||||
*.tif
|
||||
|
||||
# Compiled Files
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.so
|
||||
|
||||
# Documentation Build
|
||||
docs/_build/
|
||||
docs/_static/
|
||||
docs/_templates/
|
||||
|
||||
# OS Files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Large Files
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
|
||||
# Config Files (may contain sensitive data)
|
||||
gui_config.json
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Scripts output
|
||||
*.out
|
||||
*.err
|
||||
|
||||
# Jupyter Notebooks
|
||||
.ipynb_checkpoints/
|
||||
*.ipynb
|
||||
|
||||
# MyPy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyenv
|
||||
.python-version
|
||||
|
||||
# Package Manager
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
240
.cursorrules
Normal file
240
.cursorrules
Normal file
@ -0,0 +1,240 @@
|
||||
# Cursor AI Rules for PunimTag
|
||||
|
||||
## Project Context
|
||||
This is a modern web-based photo management application with facial recognition capabilities. The project uses a monorepo structure with FastAPI backend, React admin frontend, and Next.js viewer frontend.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python (Backend)
|
||||
- Follow PEP 8 strictly
|
||||
- Use type hints for all function signatures
|
||||
- Maximum line length: 100 characters
|
||||
- Use 4 spaces for indentation (never tabs)
|
||||
- Add docstrings to all public classes and methods
|
||||
|
||||
### TypeScript/JavaScript (Frontend)
|
||||
- Use TypeScript for all new code
|
||||
- Follow ESLint rules
|
||||
- Use functional components with hooks
|
||||
- Prefer named exports over default exports
|
||||
- Maximum line length: 100 characters
|
||||
|
||||
## Import Organization
|
||||
|
||||
### Python
|
||||
```python
|
||||
# Standard library imports
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Third-party imports
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from fastapi import APIRouter
|
||||
|
||||
# Local imports
|
||||
from backend.db.session import get_db
|
||||
from backend.services.face_service import FaceService
|
||||
```
|
||||
|
||||
### TypeScript
|
||||
```typescript
|
||||
// Third-party imports
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// Local imports
|
||||
import { apiClient } from '@/api/client';
|
||||
import { Layout } from '@/components/Layout';
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Python Classes**: PascalCase (e.g., `FaceProcessor`)
|
||||
- **Python Functions/methods**: snake_case (e.g., `process_faces`)
|
||||
- **Python Constants**: UPPER_SNAKE_CASE (e.g., `DEFAULT_TOLERANCE`)
|
||||
- **Python Private members**: prefix with underscore (e.g., `_internal_method`)
|
||||
- **TypeScript/React Components**: PascalCase (e.g., `PhotoViewer`)
|
||||
- **TypeScript Functions**: camelCase (e.g., `processFaces`)
|
||||
- **TypeScript Constants**: UPPER_SNAKE_CASE or camelCase (e.g., `API_URL` or `defaultTolerance`)
|
||||
|
||||
## Project Structure (Monorepo)
|
||||
```
|
||||
punimtag/
|
||||
├── backend/ # FastAPI backend
|
||||
│ ├── api/ # API routers
|
||||
│ ├── db/ # Database models and session
|
||||
│ ├── schemas/ # Pydantic models
|
||||
│ ├── services/ # Business logic services
|
||||
│ ├── constants/ # Constants and configuration
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── app.py # FastAPI application
|
||||
│ └── worker.py # RQ worker for background jobs
|
||||
├── admin-frontend/ # React admin interface
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # API client
|
||||
│ │ ├── components/ # React components
|
||||
│ │ ├── context/ # React contexts (Auth)
|
||||
│ │ ├── hooks/ # Custom hooks
|
||||
│ │ └── pages/ # Page components
|
||||
│ └── package.json
|
||||
├── viewer-frontend/ # Next.js viewer interface
|
||||
│ ├── app/ # Next.js app router
|
||||
│ ├── components/ # React components
|
||||
│ ├── lib/ # Utilities and database
|
||||
│ └── prisma/ # Prisma schemas
|
||||
├── src/ # Legacy utilities (if any)
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
└── deploy/ # Deployment configurations
|
||||
```
|
||||
|
||||
## Key Technologies
|
||||
|
||||
### Backend
|
||||
- **Python 3.12+**
|
||||
- **FastAPI** - Web framework
|
||||
- **PostgreSQL** - Database (required, not SQLite)
|
||||
- **SQLAlchemy 2.0** - ORM
|
||||
- **DeepFace** - Face recognition (migrated from face_recognition)
|
||||
- **Redis + RQ** - Background job processing
|
||||
- **JWT** - Authentication
|
||||
|
||||
### Frontend
|
||||
- **React 18 + TypeScript** - Admin interface
|
||||
- **Next.js 16** - Viewer interface
|
||||
- **Vite** - Build tool for admin
|
||||
- **Tailwind CSS** - Styling
|
||||
- **React Query** - Data fetching
|
||||
- **Prisma** - Database client for viewer
|
||||
|
||||
## Import Paths
|
||||
|
||||
### Python Backend
|
||||
Always use absolute imports from backend:
|
||||
```python
|
||||
from backend.db.session import get_db
|
||||
from backend.services.face_service import FaceService
|
||||
from backend.api.photos import router as photos_router
|
||||
```
|
||||
|
||||
### TypeScript Frontend
|
||||
Use path aliases configured in tsconfig.json:
|
||||
```typescript
|
||||
import { apiClient } from '@/api/client';
|
||||
import { Layout } from '@/components/Layout';
|
||||
```
|
||||
|
||||
## Database
|
||||
- **PostgreSQL** is required (not SQLite)
|
||||
- Use SQLAlchemy ORM for all database operations
|
||||
- Use context managers for database sessions
|
||||
- Always use prepared statements (SQLAlchemy handles this)
|
||||
- Two databases: `punimtag` (main) and `punimtag_auth` (auth)
|
||||
|
||||
## Error Handling
|
||||
- Use specific exception types
|
||||
- Log errors appropriately
|
||||
- Provide user-friendly error messages in API responses
|
||||
- Never silently catch exceptions
|
||||
- Use FastAPI HTTPException for API errors
|
||||
- Use try-catch in React components with proper error boundaries
|
||||
|
||||
## Testing
|
||||
- Write tests for all new features
|
||||
- Use pytest for Python backend tests
|
||||
- Use Vitest/Jest for frontend tests
|
||||
- Place tests in `tests/` directory
|
||||
- Aim for >80% code coverage
|
||||
|
||||
## Documentation
|
||||
- Update docstrings when changing code
|
||||
- Keep README.md current
|
||||
- Update docs/ARCHITECTURE.md for architectural changes
|
||||
- Document complex algorithms inline
|
||||
- Update API documentation (FastAPI auto-generates from docstrings)
|
||||
|
||||
## Git Commit Messages
|
||||
Format: `<type>: <subject>`
|
||||
|
||||
Types:
|
||||
- feat: New feature
|
||||
- fix: Bug fix
|
||||
- docs: Documentation
|
||||
- style: Formatting
|
||||
- refactor: Code restructuring
|
||||
- test: Tests
|
||||
- chore: Maintenance
|
||||
|
||||
## Current Focus
|
||||
- Web-based architecture (migrated from desktop)
|
||||
- DeepFace integration (completed)
|
||||
- PostgreSQL migration (completed)
|
||||
- Monorepo structure (completed)
|
||||
- Production deployment preparation
|
||||
|
||||
## Avoid
|
||||
- Circular imports
|
||||
- Global state (except configuration)
|
||||
- Hard-coded file paths
|
||||
- SQL injection vulnerabilities (use SQLAlchemy ORM)
|
||||
- Mixing business logic with API/UI code
|
||||
- Storing sensitive data in code (use environment variables)
|
||||
|
||||
## Prefer
|
||||
- Type hints (Python) and TypeScript
|
||||
- List/dict comprehensions over loops
|
||||
- Context managers for resources
|
||||
- f-strings for formatting (Python)
|
||||
- Template literals for formatting (TypeScript)
|
||||
- Pathlib over os.path (Python)
|
||||
- Environment variables for configuration
|
||||
|
||||
## When Adding Features
|
||||
1. **Design**: Plan the feature and update architecture docs
|
||||
2. **Database**: Update schema if needed (SQLAlchemy models)
|
||||
3. **Backend**: Implement API endpoints and services
|
||||
4. **Frontend**: Add UI components and API integration
|
||||
5. **Tests**: Write test cases for backend and frontend
|
||||
6. **Docs**: Update documentation
|
||||
|
||||
## Performance Considerations
|
||||
- Cache face encodings in database
|
||||
- Use database indices
|
||||
- Batch database operations
|
||||
- Lazy load large datasets
|
||||
- Use React Query caching for frontend
|
||||
- Profile before optimizing
|
||||
- Use background jobs (RQ) for long-running tasks
|
||||
|
||||
## Security
|
||||
- Validate all user inputs (Pydantic schemas for API)
|
||||
- Sanitize file paths
|
||||
- Use SQLAlchemy ORM (prevents SQL injection)
|
||||
- Don't store sensitive data in plain text
|
||||
- Use bcrypt for password hashing
|
||||
- JWT tokens for authentication
|
||||
- CORS configuration for production
|
||||
- Environment variables for secrets
|
||||
|
||||
## Development Environment
|
||||
|
||||
### Dev Server
|
||||
- **Host**: 10.0.10.121
|
||||
- **User**: appuser
|
||||
- **Password**: C0caC0la
|
||||
|
||||
### Dev PostgreSQL
|
||||
- **Host**: 10.0.10.181
|
||||
- **Port**: 5432
|
||||
- **User**: ladmin
|
||||
- **Password**: C0caC0la
|
||||
|
||||
## Deployment
|
||||
- Use deployment scripts in package.json
|
||||
- Build frontends before deployment
|
||||
- Set up environment variables on server
|
||||
- Configure PostgreSQL connection strings
|
||||
- Set up Redis for background jobs
|
||||
- Use process managers (systemd, PM2) for production
|
||||
|
||||
16
.env.example
Normal file
16
.env.example
Normal file
@ -0,0 +1,16 @@
|
||||
# Database Configuration
|
||||
# PostgreSQL (for network database)
|
||||
DATABASE_URL=postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag
|
||||
|
||||
# Or use SQLite for local development (default if DATABASE_URL not set)
|
||||
# DATABASE_URL=sqlite:///data/punimtag.db
|
||||
|
||||
# Photo Storage
|
||||
PHOTO_STORAGE_DIR=data/uploads
|
||||
|
||||
# JWT Secrets (change in production!)
|
||||
SECRET_KEY=your-secret-key-here-change-in-production
|
||||
|
||||
# Single-user credentials (change in production!)
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=admin
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@ -24,7 +24,7 @@ wheels/
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
``````````
|
||||
# Database files (keep structure, ignore content)
|
||||
*.db
|
||||
*.sqlite
|
||||
@ -37,7 +37,7 @@ data/*.sqlite
|
||||
*.temp
|
||||
temp_face_crop_*.jpg
|
||||
|
||||
# IDE
|
||||
# IDE``````````
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
@ -67,4 +67,14 @@ photos/
|
||||
*.webp
|
||||
dlib/
|
||||
*.dat
|
||||
*.model
|
||||
*.model
|
||||
# Node.js
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/.parcel-cache/
|
||||
|
||||
# Archive and demo files
|
||||
archive/
|
||||
demo_photos/
|
||||
data/uploads/
|
||||
data/thumbnails/
|
||||
|
||||
805
.notes/deepface_migration_plan.md
Normal file
805
.notes/deepface_migration_plan.md
Normal file
@ -0,0 +1,805 @@
|
||||
# Migration Plan: Replace face_recognition with DeepFace in PunimTag
|
||||
|
||||
**Version:** 1.0
|
||||
**Created:** October 15, 2025
|
||||
**Status:** Planning Phase
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This plan outlines the complete migration from `face_recognition` library to `DeepFace` library for the PunimTag photo tagging application. Based on testing in `test_deepface_gui.py`, DeepFace provides superior accuracy using the ArcFace model with configurable detector backends.
|
||||
|
||||
**Key Changes:**
|
||||
- Face encoding dimensions: 128 → 512 (ArcFace model)
|
||||
- Detection method: HOG/CNN → RetinaFace/MTCNN/OpenCV/SSD (configurable)
|
||||
- Similarity metric: Euclidean distance → Cosine similarity
|
||||
- Face location format: (top, right, bottom, left) → {x, y, w, h}
|
||||
- No backward compatibility - fresh start with new database
|
||||
|
||||
---
|
||||
|
||||
## PHASE 1: Database Schema Updates
|
||||
|
||||
### Step 1.1: Update Database Schema
|
||||
**File:** `src/core/database.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Modify `faces` table** to add DeepFace-specific columns:
|
||||
```sql
|
||||
ALTER TABLE faces ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
|
||||
ALTER TABLE faces ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
|
||||
ALTER TABLE faces ADD COLUMN face_confidence REAL DEFAULT 0.0;
|
||||
```
|
||||
|
||||
2. **Update `person_encodings` table** similarly:
|
||||
```sql
|
||||
ALTER TABLE person_encodings ADD COLUMN detector_backend TEXT DEFAULT 'retinaface';
|
||||
ALTER TABLE person_encodings ADD COLUMN model_name TEXT DEFAULT 'ArcFace';
|
||||
```
|
||||
|
||||
3. **Update `init_database()` method** in `DatabaseManager` class:
|
||||
- Add new columns to CREATE TABLE statements for `faces` and `person_encodings`
|
||||
- Add indices for new columns if needed
|
||||
|
||||
**Expected encoding size change:**
|
||||
- face_recognition: 128 floats × 8 bytes = 1,024 bytes per encoding
|
||||
- DeepFace ArcFace: 512 floats × 8 bytes = 4,096 bytes per encoding
|
||||
|
||||
### Step 1.2: Update Database Methods
|
||||
**File:** `src/core/database.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Modify `add_face()` method signature:**
|
||||
```python
|
||||
def add_face(self, photo_id: int, encoding: bytes, location: str,
|
||||
confidence: float = 0.0, quality_score: float = 0.0,
|
||||
person_id: Optional[int] = None,
|
||||
detector_backend: str = 'retinaface',
|
||||
model_name: str = 'ArcFace',
|
||||
face_confidence: float = 0.0) -> int:
|
||||
```
|
||||
|
||||
2. **Modify `add_person_encoding()` method signature:**
|
||||
```python
|
||||
def add_person_encoding(self, person_id: int, face_id: int, encoding: bytes,
|
||||
quality_score: float,
|
||||
detector_backend: str = 'retinaface',
|
||||
model_name: str = 'ArcFace'):
|
||||
```
|
||||
|
||||
3. **Update all database queries** that insert/update faces to include new fields
|
||||
|
||||
---
|
||||
|
||||
## PHASE 2: Configuration Updates
|
||||
|
||||
### Step 2.1: Update Configuration Constants
|
||||
**File:** `src/core/config.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Replace face_recognition settings:**
|
||||
```python
|
||||
# OLD - Remove these:
|
||||
# DEFAULT_FACE_DETECTION_MODEL = "hog"
|
||||
# DEFAULT_FACE_TOLERANCE = 0.6
|
||||
|
||||
# NEW - Add these:
|
||||
DEEPFACE_DETECTOR_BACKEND = "retinaface" # Options: retinaface, mtcnn, opencv, ssd
|
||||
DEEPFACE_MODEL_NAME = "ArcFace" # Best accuracy model
|
||||
DEEPFACE_DISTANCE_METRIC = "cosine" # For similarity calculation
|
||||
DEEPFACE_ENFORCE_DETECTION = False # Don't fail if no faces found
|
||||
DEEPFACE_ALIGN_FACES = True # Face alignment for better accuracy
|
||||
|
||||
# Tolerance/threshold adjustments for DeepFace
|
||||
DEFAULT_FACE_TOLERANCE = 0.4 # Lower for DeepFace (was 0.6 for face_recognition)
|
||||
DEEPFACE_SIMILARITY_THRESHOLD = 60 # Minimum similarity percentage (0-100)
|
||||
|
||||
# Environment settings for TensorFlow (DeepFace uses TensorFlow backend)
|
||||
import os
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' # Suppress TensorFlow warnings
|
||||
```
|
||||
|
||||
2. **Add detector backend options:**
|
||||
```python
|
||||
DEEPFACE_DETECTOR_OPTIONS = ["retinaface", "mtcnn", "opencv", "ssd"]
|
||||
DEEPFACE_MODEL_OPTIONS = ["ArcFace", "Facenet", "Facenet512", "VGG-Face"]
|
||||
```
|
||||
|
||||
### Step 2.2: Add TensorFlow Suppression
|
||||
**File:** Add to all main entry points (dashboard_gui.py, photo_tagger.py, etc.)
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
# At the top of file, before other imports
|
||||
import os
|
||||
import warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
# Then import DeepFace
|
||||
from deepface import DeepFace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: Face Processing Core Migration
|
||||
|
||||
### Step 3.1: Replace Face Detection and Encoding
|
||||
**File:** `src/core/face_processing.py` → `FaceProcessor` class
|
||||
|
||||
**Method:** `process_faces()`
|
||||
|
||||
**Current Implementation (lines 59-144):**
|
||||
```python
|
||||
# OLD CODE:
|
||||
image = face_recognition.load_image_file(photo_path)
|
||||
face_locations = face_recognition.face_locations(image, model=model)
|
||||
face_encodings = face_recognition.face_encodings(image, face_locations)
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW CODE:
|
||||
try:
|
||||
# Use DeepFace.represent() to get face detection and encodings
|
||||
results = DeepFace.represent(
|
||||
img_path=photo_path,
|
||||
model_name=DEEPFACE_MODEL_NAME, # 'ArcFace'
|
||||
detector_backend=DEEPFACE_DETECTOR_BACKEND, # 'retinaface'
|
||||
enforce_detection=DEEPFACE_ENFORCE_DETECTION, # False
|
||||
align=DEEPFACE_ALIGN_FACES # True
|
||||
)
|
||||
|
||||
if not results:
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 No faces found")
|
||||
# Mark as processed even with no faces
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
continue
|
||||
|
||||
if self.verbose >= 1:
|
||||
print(f" 👤 Found {len(results)} faces")
|
||||
|
||||
# Process each detected face
|
||||
for i, result in enumerate(results):
|
||||
# Extract face region info from DeepFace result
|
||||
facial_area = result.get('facial_area', {})
|
||||
face_confidence = result.get('face_confidence', 0.0)
|
||||
embedding = np.array(result['embedding'])
|
||||
|
||||
# Convert DeepFace facial_area {x, y, w, h} to our location format
|
||||
# Store as dict for consistency
|
||||
location = {
|
||||
'x': facial_area.get('x', 0),
|
||||
'y': facial_area.get('y', 0),
|
||||
'w': facial_area.get('w', 0),
|
||||
'h': facial_area.get('h', 0)
|
||||
}
|
||||
|
||||
# Calculate face quality score (reuse existing method)
|
||||
# Convert facial_area to (top, right, bottom, left) for quality calculation
|
||||
face_location_tuple = (
|
||||
facial_area.get('y', 0), # top
|
||||
facial_area.get('x', 0) + facial_area.get('w', 0), # right
|
||||
facial_area.get('y', 0) + facial_area.get('h', 0), # bottom
|
||||
facial_area.get('x', 0) # left
|
||||
)
|
||||
|
||||
# Load image for quality calculation
|
||||
image = Image.open(photo_path)
|
||||
image_np = np.array(image)
|
||||
quality_score = self._calculate_face_quality_score(image_np, face_location_tuple)
|
||||
|
||||
# Store in database with new format
|
||||
self.db.add_face(
|
||||
photo_id=photo_id,
|
||||
encoding=embedding.tobytes(),
|
||||
location=str(location), # Store as string representation of dict
|
||||
confidence=0.0, # Legacy field, keep for compatibility
|
||||
quality_score=quality_score,
|
||||
person_id=None,
|
||||
detector_backend=DEEPFACE_DETECTOR_BACKEND,
|
||||
model_name=DEEPFACE_MODEL_NAME,
|
||||
face_confidence=face_confidence
|
||||
)
|
||||
|
||||
if self.verbose >= 3:
|
||||
print(f" Face {i+1}: {location} (quality: {quality_score:.2f}, confidence: {face_confidence:.2f})")
|
||||
|
||||
# Mark as processed
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
processed_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing {filename}: {e}")
|
||||
self.db.mark_photo_processed(photo_id)
|
||||
```
|
||||
|
||||
### Step 3.2: Update Face Location Handling
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Method:** `_extract_face_crop()` (appears twice, lines 212-271 and 541-600)
|
||||
|
||||
**Current Implementation:**
|
||||
```python
|
||||
# OLD: face_recognition format (top, right, bottom, left)
|
||||
top, right, bottom, left = location
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW: DeepFace format {x, y, w, h}
|
||||
# Parse location from string if needed
|
||||
if isinstance(location, str):
|
||||
location = eval(location) # Convert string to dict
|
||||
|
||||
# Handle both formats for compatibility during migration
|
||||
if isinstance(location, dict):
|
||||
# DeepFace format
|
||||
left = location.get('x', 0)
|
||||
top = location.get('y', 0)
|
||||
width = location.get('w', 0)
|
||||
height = location.get('h', 0)
|
||||
right = left + width
|
||||
bottom = top + height
|
||||
else:
|
||||
# Legacy face_recognition format (top, right, bottom, left)
|
||||
top, right, bottom, left = location
|
||||
|
||||
# Rest of the method remains the same
|
||||
face_width = right - left
|
||||
face_height = bottom - top
|
||||
# ... etc
|
||||
```
|
||||
|
||||
### Step 3.3: Replace Similarity Calculation
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Method:** `find_similar_faces()` and helper methods
|
||||
|
||||
**Current Implementation (line 457):**
|
||||
```python
|
||||
# OLD:
|
||||
distance = face_recognition.face_distance([target_encoding], other_enc)[0]
|
||||
```
|
||||
|
||||
**New Implementation:**
|
||||
```python
|
||||
# NEW: Use cosine similarity (same as test_deepface_gui.py)
|
||||
def _calculate_cosine_similarity(self, encoding1: np.ndarray, encoding2: np.ndarray) -> float:
|
||||
"""Calculate cosine similarity between two face encodings"""
|
||||
try:
|
||||
# Ensure encodings are numpy arrays
|
||||
enc1 = np.array(encoding1).flatten()
|
||||
enc2 = np.array(encoding2).flatten()
|
||||
|
||||
# Check if encodings have the same length
|
||||
if len(enc1) != len(enc2):
|
||||
print(f"Warning: Encoding length mismatch: {len(enc1)} vs {len(enc2)}")
|
||||
return 0.0
|
||||
|
||||
# Normalize encodings
|
||||
enc1_norm = enc1 / (np.linalg.norm(enc1) + 1e-8)
|
||||
enc2_norm = enc2 / (np.linalg.norm(enc2) + 1e-8)
|
||||
|
||||
# Calculate cosine similarity
|
||||
cosine_sim = np.dot(enc1_norm, enc2_norm)
|
||||
|
||||
# Clamp to valid range [-1, 1]
|
||||
cosine_sim = np.clip(cosine_sim, -1.0, 1.0)
|
||||
|
||||
# Convert to distance (0 = identical, 2 = opposite)
|
||||
# For consistency with face_recognition's distance metric
|
||||
distance = 1.0 - cosine_sim # Range [0, 2], where 0 is perfect match
|
||||
|
||||
return distance
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error calculating similarity: {e}")
|
||||
return 2.0 # Maximum distance on error
|
||||
|
||||
# Replace in find_similar_faces():
|
||||
distance = self._calculate_cosine_similarity(target_encoding, other_enc)
|
||||
```
|
||||
|
||||
**Update adaptive tolerance calculation (line 333-351):**
|
||||
```python
|
||||
def _calculate_adaptive_tolerance(self, base_tolerance: float, face_quality: float, match_confidence: float = None) -> float:
|
||||
"""Calculate adaptive tolerance based on face quality and match confidence
|
||||
|
||||
Note: For DeepFace, tolerance values are generally lower than face_recognition
|
||||
"""
|
||||
# Start with base tolerance (e.g., 0.4 instead of 0.6)
|
||||
tolerance = base_tolerance
|
||||
|
||||
# Adjust based on face quality
|
||||
quality_factor = 0.9 + (face_quality * 0.2) # Range: 0.9 to 1.1
|
||||
tolerance *= quality_factor
|
||||
|
||||
# Adjust based on match confidence if provided
|
||||
if match_confidence is not None:
|
||||
confidence_factor = 0.95 + (match_confidence * 0.1)
|
||||
tolerance *= confidence_factor
|
||||
|
||||
# Ensure tolerance stays within reasonable bounds for DeepFace
|
||||
return max(0.2, min(0.6, tolerance)) # Lower range for DeepFace
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 4: GUI Integration Updates
|
||||
|
||||
### Step 4.1: Add DeepFace Settings to Dashboard
|
||||
**File:** `src/gui/dashboard_gui.py`
|
||||
|
||||
**Actions:**
|
||||
1. **Add detector selection to menu or settings:**
|
||||
```python
|
||||
# Add to settings menu or control panel
|
||||
detector_frame = ttk.Frame(settings_panel)
|
||||
detector_frame.pack(fill=tk.X, padx=5, pady=5)
|
||||
|
||||
ttk.Label(detector_frame, text="Face Detector:").pack(side=tk.LEFT, padx=5)
|
||||
self.detector_var = tk.StringVar(value=DEEPFACE_DETECTOR_BACKEND)
|
||||
detector_combo = ttk.Combobox(detector_frame, textvariable=self.detector_var,
|
||||
values=DEEPFACE_DETECTOR_OPTIONS,
|
||||
state="readonly", width=12)
|
||||
detector_combo.pack(side=tk.LEFT, padx=5)
|
||||
|
||||
ttk.Label(detector_frame, text="Model:").pack(side=tk.LEFT, padx=5)
|
||||
self.model_var = tk.StringVar(value=DEEPFACE_MODEL_NAME)
|
||||
model_combo = ttk.Combobox(detector_frame, textvariable=self.model_var,
|
||||
values=DEEPFACE_MODEL_OPTIONS,
|
||||
state="readonly", width=12)
|
||||
model_combo.pack(side=tk.LEFT, padx=5)
|
||||
```
|
||||
|
||||
2. **Update process_faces calls to use selected settings:**
|
||||
```python
|
||||
# When calling face processor
|
||||
detector = self.detector_var.get()
|
||||
model = self.model_var.get()
|
||||
|
||||
# Pass to FaceProcessor (need to update FaceProcessor.__init__ to accept these)
|
||||
self.face_processor = FaceProcessor(
|
||||
db_manager=self.db,
|
||||
verbose=self.verbose,
|
||||
detector_backend=detector,
|
||||
model_name=model
|
||||
)
|
||||
```
|
||||
|
||||
### Step 4.2: Update Face Processor Initialization
|
||||
**File:** `src/core/face_processing.py`
|
||||
|
||||
**Class:** `FaceProcessor.__init__()`
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
def __init__(self, db_manager: DatabaseManager, verbose: int = 0,
|
||||
detector_backend: str = None, model_name: str = None):
|
||||
"""Initialize face processor with DeepFace settings"""
|
||||
self.db = db_manager
|
||||
self.verbose = verbose
|
||||
self.detector_backend = detector_backend or DEEPFACE_DETECTOR_BACKEND
|
||||
self.model_name = model_name or DEEPFACE_MODEL_NAME
|
||||
self._face_encoding_cache = {}
|
||||
self._image_cache = {}
|
||||
```
|
||||
|
||||
### Step 4.3: Update GUI Display Methods
|
||||
**File:** Multiple panel files (identify_panel.py, auto_match_panel.py, modify_panel.py)
|
||||
|
||||
**Actions:**
|
||||
1. **Update all face thumbnail extraction** to handle new location format
|
||||
2. **Update confidence display** to use cosine similarity percentages
|
||||
3. **Update any hardcoded face_recognition references**
|
||||
|
||||
**Example for identify_panel.py:**
|
||||
```python
|
||||
# In display methods, convert distance to percentage
|
||||
confidence_pct = (1 - distance) * 100 # Already done correctly
|
||||
# But ensure distance calculation uses cosine similarity
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 5: Dependencies and Installation
|
||||
|
||||
### Step 5.1: Update requirements.txt
|
||||
**File:** `requirements.txt`
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
# REMOVE these:
|
||||
# face-recognition==1.3.0
|
||||
# face-recognition-models==0.3.0
|
||||
# dlib>=20.0.0
|
||||
|
||||
# ADD these:
|
||||
deepface>=0.0.79
|
||||
tensorflow>=2.13.0 # Required by DeepFace
|
||||
opencv-python>=4.8.0 # Required by DeepFace
|
||||
retina-face>=0.0.13 # For RetinaFace detector (best accuracy)
|
||||
|
||||
# KEEP these:
|
||||
numpy>=1.21.0
|
||||
pillow>=8.0.0
|
||||
click>=8.0.0
|
||||
setuptools>=40.0.0
|
||||
```
|
||||
|
||||
### Step 5.2: Create Migration Script
|
||||
**File:** `scripts/migrate_to_deepface.py` (new file)
|
||||
|
||||
**Purpose:** Drop all tables and reinitialize database for fresh start
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to prepare database for DeepFace
|
||||
Drops all existing tables and recreates with new schema
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
|
||||
def migrate_database():
|
||||
"""Drop all tables and reinitialize with DeepFace schema"""
|
||||
print("⚠️ WARNING: This will delete all existing data!")
|
||||
response = input("Type 'DELETE ALL DATA' to confirm: ")
|
||||
|
||||
if response != "DELETE ALL DATA":
|
||||
print("Migration cancelled.")
|
||||
return
|
||||
|
||||
print("\n🗑️ Dropping all existing tables...")
|
||||
|
||||
# Connect directly to database
|
||||
conn = sqlite3.connect(DEFAULT_DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Drop all tables
|
||||
tables = ['phototaglinkage', 'person_encodings', 'faces', 'tags', 'people', 'photos']
|
||||
for table in tables:
|
||||
cursor.execute(f'DROP TABLE IF EXISTS {table}')
|
||||
print(f" Dropped table: {table}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n✅ All tables dropped successfully")
|
||||
print("\n🔄 Reinitializing database with DeepFace schema...")
|
||||
|
||||
# Reinitialize with new schema
|
||||
db = DatabaseManager(DEFAULT_DB_PATH, verbose=1)
|
||||
|
||||
print("\n✅ Database migration complete!")
|
||||
print("\nNext steps:")
|
||||
print("1. Add photos using the dashboard")
|
||||
print("2. Process faces with DeepFace")
|
||||
print("3. Identify people")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 6: Testing and Validation
|
||||
|
||||
### Step 6.1: Create Test Suite
|
||||
**File:** `tests/test_deepface_integration.py` (new file)
|
||||
|
||||
**Actions:**
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test DeepFace integration in PunimTag
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Suppress TensorFlow warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
import warnings
|
||||
warnings.filterwarnings('ignore')
|
||||
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.config import DEEPFACE_DETECTOR_BACKEND, DEEPFACE_MODEL_NAME
|
||||
|
||||
def test_face_detection():
|
||||
"""Test face detection with DeepFace"""
|
||||
print("🧪 Testing DeepFace face detection...")
|
||||
|
||||
db = DatabaseManager(":memory:", verbose=0) # In-memory database for testing
|
||||
processor = FaceProcessor(db, verbose=1)
|
||||
|
||||
# Test with a sample image
|
||||
test_image = "demo_photos/2019-11-22_0011.jpg"
|
||||
if not os.path.exists(test_image):
|
||||
print(f"❌ Test image not found: {test_image}")
|
||||
return False
|
||||
|
||||
# Add photo to database
|
||||
photo_id = db.add_photo(test_image, Path(test_image).name, None)
|
||||
|
||||
# Process faces
|
||||
count = processor.process_faces(limit=1)
|
||||
|
||||
# Verify results
|
||||
stats = db.get_statistics()
|
||||
print(f"✅ Processed {count} photos, found {stats['total_faces']} faces")
|
||||
|
||||
return stats['total_faces'] > 0
|
||||
|
||||
def test_face_matching():
|
||||
"""Test face matching with DeepFace"""
|
||||
print("\n🧪 Testing DeepFace face matching...")
|
||||
|
||||
db = DatabaseManager(":memory:", verbose=0)
|
||||
processor = FaceProcessor(db, verbose=1)
|
||||
|
||||
# Test with multiple images
|
||||
test_images = [
|
||||
"demo_photos/2019-11-22_0011.jpg",
|
||||
"demo_photos/2019-11-22_0012.jpg"
|
||||
]
|
||||
|
||||
for img in test_images:
|
||||
if os.path.exists(img):
|
||||
photo_id = db.add_photo(img, Path(img).name, None)
|
||||
|
||||
# Process all faces
|
||||
processor.process_faces(limit=10)
|
||||
|
||||
# Find similar faces
|
||||
faces = db.get_all_face_encodings()
|
||||
if len(faces) >= 2:
|
||||
matches = processor.find_similar_faces(faces[0][0])
|
||||
print(f"✅ Found {len(matches)} similar faces")
|
||||
return len(matches) >= 0
|
||||
|
||||
return False
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all tests"""
|
||||
print("=" * 60)
|
||||
print("DeepFace Integration Test Suite")
|
||||
print("=" * 60)
|
||||
|
||||
tests = [
|
||||
test_face_detection,
|
||||
test_face_matching
|
||||
]
|
||||
|
||||
results = []
|
||||
for test in tests:
|
||||
try:
|
||||
result = test()
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed with error: {e}")
|
||||
results.append(False)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(f"Tests passed: {sum(results)}/{len(results)}")
|
||||
print("=" * 60)
|
||||
|
||||
return all(results)
|
||||
|
||||
if __name__ == "__main__":
|
||||
success = run_all_tests()
|
||||
sys.exit(0 if success else 1)
|
||||
```
|
||||
|
||||
### Step 6.2: Validation Checklist
|
||||
1. **Face Detection:**
|
||||
- [ ] DeepFace successfully detects faces in test images
|
||||
- [ ] Face locations are correctly stored in new format
|
||||
- [ ] Face encodings are 512-dimensional (ArcFace)
|
||||
- [ ] Multiple detector backends work (retinaface, mtcnn, etc.)
|
||||
|
||||
2. **Face Matching:**
|
||||
- [ ] Similar faces are correctly identified
|
||||
- [ ] Cosine similarity produces reasonable confidence scores
|
||||
- [ ] Adaptive tolerance works with new metric
|
||||
- [ ] No false positives at default threshold
|
||||
|
||||
3. **GUI Integration:**
|
||||
- [ ] All panels display faces correctly
|
||||
- [ ] Face thumbnails extract properly with new location format
|
||||
- [ ] Confidence scores display correctly
|
||||
- [ ] Detector/model selection works in settings
|
||||
|
||||
4. **Database:**
|
||||
- [ ] New columns are created correctly
|
||||
- [ ] Encodings are stored as 4096-byte BLOBs
|
||||
- [ ] Queries work with new schema
|
||||
- [ ] Indices improve performance
|
||||
|
||||
---
|
||||
|
||||
## PHASE 7: Implementation Order
|
||||
|
||||
**Execute in this order to minimize issues:**
|
||||
|
||||
1. **Day 1: Database & Configuration**
|
||||
- Update `requirements.txt`
|
||||
- Install DeepFace: `pip install deepface tensorflow opencv-python retina-face`
|
||||
- Update `src/core/config.py` with DeepFace settings
|
||||
- Update `src/core/database.py` schema and methods
|
||||
- Create and run `scripts/migrate_to_deepface.py`
|
||||
|
||||
2. **Day 2: Core Face Processing**
|
||||
- Update `src/core/face_processing.py` `process_faces()` method
|
||||
- Update `_extract_face_crop()` to handle new location format
|
||||
- Implement `_calculate_cosine_similarity()` method
|
||||
- Update `find_similar_faces()` to use new similarity
|
||||
- Update `_calculate_adaptive_tolerance()` for DeepFace ranges
|
||||
|
||||
3. **Day 3: GUI Updates**
|
||||
- Add detector/model selection to dashboard
|
||||
- Update `FaceProcessor.__init__()` to accept settings
|
||||
- Test face processing with GUI
|
||||
|
||||
4. **Day 4: Panel Updates**
|
||||
- Update `src/gui/identify_panel.py` for new format
|
||||
- Update `src/gui/auto_match_panel.py` for new format
|
||||
- Update `src/gui/modify_panel.py` for new format
|
||||
- Verify all face displays work correctly
|
||||
|
||||
5. **Day 5: Testing & Refinement**
|
||||
- Create `tests/test_deepface_integration.py`
|
||||
- Run all tests and fix issues
|
||||
- Process test photos from `demo_photos/testdeepface/`
|
||||
- Validate matching accuracy
|
||||
- Adjust thresholds if needed
|
||||
|
||||
6. **Day 6: Documentation & Cleanup**
|
||||
- Update README.md with DeepFace information
|
||||
- Document detector backend options
|
||||
- Document model options and trade-offs
|
||||
- Remove old face_recognition references
|
||||
- Final testing
|
||||
|
||||
---
|
||||
|
||||
## PHASE 8: Key Differences and Gotchas
|
||||
|
||||
### Encoding Size Change
|
||||
- **face_recognition:** 128 floats = 1,024 bytes
|
||||
- **DeepFace ArcFace:** 512 floats = 4,096 bytes
|
||||
- **Impact:** Database size will be ~4x larger for encodings
|
||||
- **Action:** Ensure sufficient disk space
|
||||
|
||||
### Location Format Change
|
||||
- **face_recognition:** tuple `(top, right, bottom, left)`
|
||||
- **DeepFace:** dict `{'x': x, 'y': y, 'w': w, 'h': h}`
|
||||
- **Impact:** All location parsing code must be updated
|
||||
- **Action:** Create helper function to convert between formats
|
||||
|
||||
### Tolerance/Threshold Adjustments
|
||||
- **face_recognition:** Default 0.6 works well
|
||||
- **DeepFace:** Lower tolerance needed (0.4 recommended)
|
||||
- **Impact:** Matching sensitivity changes
|
||||
- **Action:** Test and adjust `DEFAULT_FACE_TOLERANCE` in config
|
||||
|
||||
### Performance Considerations
|
||||
- **DeepFace:** Slower than face_recognition (uses deep learning)
|
||||
- **Mitigation:** Use GPU if available, cache results, process in batches
|
||||
- **Action:** Add progress indicators, allow cancellation
|
||||
|
||||
### Dependencies
|
||||
- **DeepFace requires:** TensorFlow, OpenCV, specific detectors
|
||||
- **Size:** ~500MB+ of additional packages and models
|
||||
- **Action:** Warn users about download size during installation
|
||||
|
||||
---
|
||||
|
||||
## PHASE 9: Rollback Plan (If Needed)
|
||||
|
||||
Since we're starting fresh (no backward compatibility), rollback is simple:
|
||||
|
||||
1. **Restore database:**
|
||||
```bash
|
||||
rm data/photos.db
|
||||
cp data/photos.db.backup data/photos.db # If backup exists
|
||||
```
|
||||
|
||||
2. **Restore code:**
|
||||
```bash
|
||||
git checkout HEAD -- src/core/face_processing.py src/core/database.py src/core/config.py requirements.txt
|
||||
```
|
||||
|
||||
3. **Reinstall dependencies:**
|
||||
```bash
|
||||
pip uninstall deepface tensorflow
|
||||
pip install face-recognition
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## PHASE 10: Success Criteria
|
||||
|
||||
Migration is complete when:
|
||||
|
||||
- [ ] All face_recognition imports removed
|
||||
- [ ] DeepFace successfully detects faces in test images
|
||||
- [ ] Face matching produces accurate results
|
||||
- [ ] All GUI panels work with new format
|
||||
- [ ] Database stores DeepFace encodings correctly
|
||||
- [ ] Test suite passes all tests
|
||||
- [ ] Documentation updated
|
||||
- [ ] No regression in core functionality
|
||||
- [ ] Performance is acceptable (may be slower but accurate)
|
||||
|
||||
---
|
||||
|
||||
## Additional Notes for Agent
|
||||
|
||||
1. **Import Order Matters:**
|
||||
```python
|
||||
# ALWAYS import in this order:
|
||||
import os
|
||||
import warnings
|
||||
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
|
||||
warnings.filterwarnings('ignore')
|
||||
# THEN import DeepFace
|
||||
from deepface import DeepFace
|
||||
```
|
||||
|
||||
2. **Error Handling:**
|
||||
- DeepFace can throw various TensorFlow errors
|
||||
- Always wrap DeepFace calls in try-except
|
||||
- Use `enforce_detection=False` to avoid crashes on no-face images
|
||||
|
||||
3. **Model Downloads:**
|
||||
- First run will download models (~100MB+)
|
||||
- Store in `~/.deepface/weights/`
|
||||
- Plan for initial download time
|
||||
|
||||
4. **Testing Strategy:**
|
||||
- Use `demo_photos/testdeepface/` for initial testing
|
||||
- These were already tested in `test_deepface_gui.py`
|
||||
- Known good results for comparison
|
||||
|
||||
5. **Code Quality:**
|
||||
- Maintain existing code style
|
||||
- Keep verbose levels consistent
|
||||
- Preserve all existing functionality
|
||||
- Add comments explaining DeepFace-specific code
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify (Summary)
|
||||
|
||||
1. **`requirements.txt`** - Update dependencies
|
||||
2. **`src/core/config.py`** - Add DeepFace configuration
|
||||
3. **`src/core/database.py`** - Update schema and methods
|
||||
4. **`src/core/face_processing.py`** - Replace all face_recognition code
|
||||
5. **`src/gui/dashboard_gui.py`** - Add detector/model selection
|
||||
6. **`src/gui/identify_panel.py`** - Update for new formats
|
||||
7. **`src/gui/auto_match_panel.py`** - Update for new formats
|
||||
8. **`src/gui/modify_panel.py`** - Update for new formats
|
||||
9. **`scripts/migrate_to_deepface.py`** - New migration script
|
||||
10. **`tests/test_deepface_integration.py`** - New test suite
|
||||
|
||||
---
|
||||
|
||||
**END OF MIGRATION PLAN**
|
||||
|
||||
This plan provides a complete, step-by-step guide for migrating from face_recognition to DeepFace. Execute phases in order, test thoroughly, and refer to `tests/test_deepface_gui.py` for working DeepFace implementation examples.
|
||||
|
||||
127
.notes/directory_structure.md
Normal file
127
.notes/directory_structure.md
Normal file
@ -0,0 +1,127 @@
|
||||
# Directory Structure
|
||||
|
||||
## Overview
|
||||
```
|
||||
punimtag/
|
||||
├── .notes/ # Project notes and planning
|
||||
│ ├── project_overview.md # High-level project info
|
||||
│ ├── task_list.md # Task tracking
|
||||
│ ├── directory_structure.md # This file
|
||||
│ └── meeting_notes.md # Meeting records
|
||||
│
|
||||
├── src/ # Source code
|
||||
│ ├── __init__.py
|
||||
│ ├── photo_tagger.py # CLI entry point
|
||||
│ ├── setup.py # Package setup
|
||||
│ │
|
||||
│ ├── core/ # Business logic
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── config.py # Configuration
|
||||
│ │ ├── database.py # Database manager
|
||||
│ │ ├── face_processing.py # Face recognition
|
||||
│ │ ├── photo_management.py # Photo operations
|
||||
│ │ ├── tag_management.py # Tag operations
|
||||
│ │ └── search_stats.py # Search & analytics
|
||||
│ │
|
||||
│ ├── gui/ # GUI components
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── dashboard_gui.py # Main dashboard
|
||||
│ │ ├── gui_core.py # Common utilities
|
||||
│ │ ├── identify_panel.py # Identification UI
|
||||
│ │ ├── auto_match_panel.py # Auto-matching UI
|
||||
│ │ ├── modify_panel.py # Person editing UI
|
||||
│ │ └── tag_manager_panel.py # Tag management UI
|
||||
│ │
|
||||
│ └── utils/ # Utility functions
|
||||
│ ├── __init__.py
|
||||
│ └── path_utils.py # Path operations
|
||||
│
|
||||
├── tests/ # Test suite
|
||||
│ ├── __init__.py
|
||||
│ ├── test_deepface_gui.py # DeepFace testing
|
||||
│ ├── test_face_recognition.py # Face rec tests
|
||||
│ ├── test_simple_gui.py # GUI tests
|
||||
│ ├── test_thumbnail_sizes.py # UI tests
|
||||
│ ├── debug_face_detection.py # Debug tools
|
||||
│ └── show_large_thumbnails.py # Debug tools
|
||||
│
|
||||
├── docs/ # Documentation
|
||||
│ ├── README.md # Main documentation
|
||||
│ ├── ARCHITECTURE.md # System architecture
|
||||
│ ├── DEMO.md # Demo guide
|
||||
│ └── README_UNIFIED_DASHBOARD.md
|
||||
│
|
||||
├── data/ # Application data
|
||||
│ └── photos.db # SQLite database
|
||||
│
|
||||
├── demo_photos/ # Sample photos for testing
|
||||
│ ├── events/
|
||||
│ ├── more_photos/
|
||||
│ └── testdeepface/
|
||||
│
|
||||
├── scripts/ # Utility scripts
|
||||
│ └── drop_all_tables.py # Database utilities
|
||||
│
|
||||
├── archive/ # Legacy/backup files
|
||||
│ ├── *_backup.py # Old versions
|
||||
│ └── *_gui.py # Legacy GUIs
|
||||
│
|
||||
├── logs/ # Application logs
|
||||
│
|
||||
├── venv/ # Virtual environment
|
||||
│
|
||||
├── .git/ # Git repository
|
||||
├── .gitignore # Git ignore rules
|
||||
├── .cursorrules # Cursor AI rules
|
||||
├── .cursorignore # Cursor ignore rules
|
||||
├── requirements.txt # Python dependencies
|
||||
├── gui_config.json # GUI preferences
|
||||
├── demo.sh # Demo script
|
||||
└── run_deepface_gui.sh # Run script
|
||||
```
|
||||
|
||||
## Import Path Examples
|
||||
|
||||
### From core modules:
|
||||
```python
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
```
|
||||
|
||||
### From GUI modules:
|
||||
```python
|
||||
from src.gui.dashboard_gui import DashboardGUI
|
||||
from src.gui.gui_core import GUICore
|
||||
```
|
||||
|
||||
### From utils:
|
||||
```python
|
||||
from src.utils.path_utils import normalize_path
|
||||
```
|
||||
|
||||
## Entry Points
|
||||
|
||||
### GUI Application
|
||||
```bash
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
### CLI Application
|
||||
```bash
|
||||
python src/photo_tagger.py
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
python -m pytest tests/
|
||||
|
||||
```
|
||||
|
||||
## Notes
|
||||
- All source code in `src/` directory
|
||||
- Tests separate from source code
|
||||
- Documentation in `docs/`
|
||||
- Project notes in `.notes/`
|
||||
- Legacy code archived in `archive/`
|
||||
|
||||
73
.notes/meeting_notes.md
Normal file
73
.notes/meeting_notes.md
Normal file
@ -0,0 +1,73 @@
|
||||
# Meeting Notes
|
||||
|
||||
## 2025-10-15: Project Restructuring
|
||||
|
||||
### Attendees
|
||||
- Development Team
|
||||
|
||||
### Discussion
|
||||
- Agreed to restructure project for better organization
|
||||
- Adopted standard Python project layout
|
||||
- Separated concerns: core, gui, utils, tests
|
||||
- Created .notes directory for project management
|
||||
|
||||
### Decisions
|
||||
1. Move all business logic to `src/core/`
|
||||
2. Move all GUI components to `src/gui/`
|
||||
3. Move utilities to `src/utils/`
|
||||
4. Consolidate tests in `tests/`
|
||||
5. Move documentation to `docs/`
|
||||
6. Archive legacy code instead of deleting
|
||||
|
||||
### Action Items
|
||||
- [x] Create new directory structure
|
||||
- [x] Move files to appropriate locations
|
||||
- [x] Create __init__.py files for packages
|
||||
- [x] Create project notes
|
||||
- [ ] Update import statements
|
||||
- [ ] Test all functionality
|
||||
- [ ] Update documentation
|
||||
|
||||
---
|
||||
|
||||
## 2025-10-15: DeepFace Migration Planning
|
||||
|
||||
### Attendees
|
||||
- Development Team
|
||||
|
||||
### Discussion
|
||||
- Analyzed test_deepface_gui.py results
|
||||
- DeepFace shows better accuracy than face_recognition
|
||||
- ArcFace model recommended for best results
|
||||
- RetinaFace detector provides best face detection
|
||||
|
||||
### Decisions
|
||||
1. Migrate from face_recognition to DeepFace
|
||||
2. Use ArcFace model (512-dim encodings)
|
||||
3. Use RetinaFace detector as default
|
||||
4. Support multiple detector backends
|
||||
5. No backward compatibility - fresh start
|
||||
|
||||
### Action Items
|
||||
- [x] Document migration plan
|
||||
- [x] Create architecture document
|
||||
- [ ] Update database schema
|
||||
- [ ] Implement DeepFace integration
|
||||
- [ ] Create migration script
|
||||
- [ ] Test with demo photos
|
||||
|
||||
### Technical Notes
|
||||
- Encoding size: 128 → 512 dimensions
|
||||
- Similarity metric: Euclidean → Cosine
|
||||
- Location format: tuple → dict
|
||||
- Tolerance adjustment: 0.6 → 0.4
|
||||
|
||||
---
|
||||
|
||||
## Future Topics
|
||||
- Web interface design
|
||||
- Cloud storage integration
|
||||
- Performance optimization
|
||||
- Multi-user support
|
||||
- Mobile app development
|
||||
|
||||
88
.notes/phase1_quickstart.md
Normal file
88
.notes/phase1_quickstart.md
Normal file
@ -0,0 +1,88 @@
|
||||
# Phase 1 Quick Start Guide
|
||||
|
||||
## What Was Done
|
||||
|
||||
Phase 1: Database Schema Updates ✅ COMPLETE
|
||||
|
||||
All database tables and methods updated to support DeepFace:
|
||||
- New columns for detector backend and model name
|
||||
- Support for 512-dimensional encodings (ArcFace)
|
||||
- Enhanced face confidence tracking
|
||||
- Migration script ready to use
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 tests/test_phase1_schema.py
|
||||
```
|
||||
|
||||
### Migrate Existing Database (⚠️ DELETES ALL DATA)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 scripts/migrate_to_deepface.py
|
||||
```
|
||||
|
||||
### Install New Dependencies (for Phase 2+)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **requirements.txt** - DeepFace dependencies
|
||||
2. **src/core/config.py** - DeepFace configuration
|
||||
3. **src/core/database.py** - Schema + method updates
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **scripts/migrate_to_deepface.py** - Migration script
|
||||
2. **tests/test_phase1_schema.py** - Test suite
|
||||
3. **PHASE1_COMPLETE.md** - Full documentation
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready to proceed to **Phase 2** or **Phase 3**:
|
||||
|
||||
### Phase 2: Configuration Updates
|
||||
- Add TensorFlow suppression to entry points
|
||||
- Update GUI with detector/model selection
|
||||
|
||||
### Phase 3: Core Face Processing
|
||||
- Replace face_recognition with DeepFace
|
||||
- Update process_faces() method
|
||||
- Implement cosine similarity
|
||||
|
||||
## Quick Verification
|
||||
|
||||
```bash
|
||||
# Check schema has new columns
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
from src.core.database import DatabaseManager
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.db') as tmp:
|
||||
db = DatabaseManager(tmp.name, verbose=0)
|
||||
print('✅ Database initialized with DeepFace schema')
|
||||
"
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Tests passed: 4/4
|
||||
✅ PASS: Schema Columns
|
||||
✅ PASS: add_face() Method
|
||||
✅ PASS: add_person_encoding() Method
|
||||
✅ PASS: Config Constants
|
||||
```
|
||||
|
||||
All systems ready for DeepFace implementation!
|
||||
|
||||
|
||||
132
.notes/phase2_quickstart.md
Normal file
132
.notes/phase2_quickstart.md
Normal file
@ -0,0 +1,132 @@
|
||||
# Phase 2 Quick Start Guide
|
||||
|
||||
## What Was Done
|
||||
|
||||
Phase 2: Configuration Updates ✅ COMPLETE
|
||||
|
||||
Added GUI controls for DeepFace settings and updated FaceProcessor to accept them.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 tests/test_phase2_config.py
|
||||
```
|
||||
|
||||
Expected: **5/5 tests passing**
|
||||
|
||||
### Test GUI (Visual Check)
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 run_dashboard.py
|
||||
```
|
||||
|
||||
Then:
|
||||
1. Click "🔍 Process" button
|
||||
2. Look for "DeepFace Settings" section
|
||||
3. Verify two dropdowns:
|
||||
- Face Detector: [retinaface ▼]
|
||||
- Recognition Model: [ArcFace ▼]
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **run_dashboard.py** - Callback passes detector/model
|
||||
2. **src/gui/dashboard_gui.py** - GUI controls added
|
||||
3. **src/photo_tagger.py** - TF suppression
|
||||
4. **src/core/face_processing.py** - Accepts detector/model params
|
||||
|
||||
## Files Created
|
||||
|
||||
1. **tests/test_phase2_config.py** - 5 tests
|
||||
2. **PHASE2_COMPLETE.md** - Full documentation
|
||||
|
||||
## What Changed
|
||||
|
||||
### Before Phase 2:
|
||||
```python
|
||||
processor = FaceProcessor(db_manager)
|
||||
```
|
||||
|
||||
### After Phase 2:
|
||||
```python
|
||||
processor = FaceProcessor(db_manager,
|
||||
detector_backend='retinaface',
|
||||
model_name='ArcFace')
|
||||
```
|
||||
|
||||
### GUI Process Panel:
|
||||
Now includes DeepFace Settings section with:
|
||||
- Detector selection dropdown (4 options)
|
||||
- Model selection dropdown (4 options)
|
||||
- Help text for each option
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
✅ PASS: TensorFlow Suppression
|
||||
✅ PASS: FaceProcessor Initialization
|
||||
✅ PASS: Config Imports
|
||||
✅ PASS: Entry Point Imports
|
||||
✅ PASS: GUI Config Constants
|
||||
|
||||
Tests passed: 5/5
|
||||
```
|
||||
|
||||
## Available Options
|
||||
|
||||
### Detectors:
|
||||
- retinaface (default, best accuracy)
|
||||
- mtcnn
|
||||
- opencv
|
||||
- ssd
|
||||
|
||||
### Models:
|
||||
- ArcFace (default, 512-dim, best accuracy)
|
||||
- Facenet (128-dim)
|
||||
- Facenet512 (512-dim)
|
||||
- VGG-Face (2622-dim)
|
||||
|
||||
## Important Note
|
||||
|
||||
⚠️ **Phase 2 adds UI/config only**
|
||||
|
||||
The GUI captures settings, but actual DeepFace processing happens in **Phase 3**. Currently still using face_recognition for processing.
|
||||
|
||||
## Next Steps
|
||||
|
||||
Ready for **Phase 3: Core Face Processing**
|
||||
- Replace face_recognition with DeepFace
|
||||
- Implement actual detector/model usage
|
||||
- Update face location handling
|
||||
- Implement cosine similarity
|
||||
|
||||
## Quick Verification
|
||||
|
||||
```bash
|
||||
# Test FaceProcessor accepts params
|
||||
cd /home/ladmin/Code/punimtag
|
||||
source venv/bin/activate
|
||||
python3 -c "
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
db = DatabaseManager(':memory:', verbose=0)
|
||||
p = FaceProcessor(db, verbose=0, detector_backend='mtcnn', model_name='Facenet')
|
||||
print(f'Detector: {p.detector_backend}')
|
||||
print(f'Model: {p.model_name}')
|
||||
print('✅ Phase 2 working!')
|
||||
"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
Detector: mtcnn
|
||||
Model: Facenet
|
||||
✅ Phase 2 working!
|
||||
```
|
||||
|
||||
All systems ready for Phase 3!
|
||||
|
||||
|
||||
43
.notes/project_overview.md
Normal file
43
.notes/project_overview.md
Normal file
@ -0,0 +1,43 @@
|
||||
# PunimTag - Project Overview
|
||||
|
||||
## Mission Statement
|
||||
PunimTag is a desktop photo management application that leverages facial recognition AI to help users organize, tag, and search their photo collections efficiently.
|
||||
|
||||
## Core Capabilities
|
||||
- Automated face detection and recognition
|
||||
- Person identification and management
|
||||
- Custom tagging system
|
||||
- Advanced search functionality
|
||||
- Batch processing
|
||||
|
||||
## Current Status
|
||||
- **Version**: 1.0 (Development)
|
||||
- **Stage**: Active Development
|
||||
- **Next Major Feature**: DeepFace Migration
|
||||
|
||||
## Key Technologies
|
||||
- Python 3.12+
|
||||
- Tkinter (GUI)
|
||||
- SQLite (Database)
|
||||
- face_recognition (Current - to be replaced)
|
||||
- DeepFace (Planned migration)
|
||||
|
||||
## Project Goals
|
||||
1. Make photo organization effortless
|
||||
2. Provide accurate face recognition
|
||||
3. Enable powerful search capabilities
|
||||
4. Maintain user privacy (local-only by default)
|
||||
5. Scale to large photo collections (50K+ photos)
|
||||
|
||||
## Success Metrics
|
||||
- Face recognition accuracy > 95%
|
||||
- Process 1000+ photos per hour
|
||||
- Search response time < 1 second
|
||||
- Zero data loss
|
||||
- User-friendly interface
|
||||
|
||||
## Links
|
||||
- Architecture: `docs/ARCHITECTURE.md`
|
||||
- Main README: `docs/README.md`
|
||||
- Demo Guide: `docs/DEMO.md`
|
||||
|
||||
342
.notes/restructure_migration.md
Normal file
342
.notes/restructure_migration.md
Normal file
@ -0,0 +1,342 @@
|
||||
# Project Restructure Migration Guide
|
||||
|
||||
## Overview
|
||||
The project has been restructured to follow Python best practices with a clean separation of concerns.
|
||||
|
||||
---
|
||||
|
||||
## Directory Changes
|
||||
|
||||
### Before → After
|
||||
|
||||
```
|
||||
Root Directory Files → Organized Structure
|
||||
```
|
||||
|
||||
| Old Location | New Location | Type |
|
||||
|-------------|--------------|------|
|
||||
| `config.py` | `src/core/config.py` | Core |
|
||||
| `database.py` | `src/core/database.py` | Core |
|
||||
| `face_processing.py` | `src/core/face_processing.py` | Core |
|
||||
| `photo_management.py` | `src/core/photo_management.py` | Core |
|
||||
| `tag_management.py` | `src/core/tag_management.py` | Core |
|
||||
| `search_stats.py` | `src/core/search_stats.py` | Core |
|
||||
| `dashboard_gui.py` | `src/gui/dashboard_gui.py` | GUI |
|
||||
| `gui_core.py` | `src/gui/gui_core.py` | GUI |
|
||||
| `identify_panel.py` | `src/gui/identify_panel.py` | GUI |
|
||||
| `auto_match_panel.py` | `src/gui/auto_match_panel.py` | GUI |
|
||||
| `modify_panel.py` | `src/gui/modify_panel.py` | GUI |
|
||||
| `tag_manager_panel.py` | `src/gui/tag_manager_panel.py` | GUI |
|
||||
| `path_utils.py` | `src/utils/path_utils.py` | Utils |
|
||||
| `photo_tagger.py` | `src/photo_tagger.py` | Entry |
|
||||
| `test_*.py` | `tests/test_*.py` | Tests |
|
||||
| `README.md` | `docs/README.md` | Docs |
|
||||
| `ARCHITECTURE.md` | `docs/ARCHITECTURE.md` | Docs |
|
||||
|
||||
---
|
||||
|
||||
## Import Path Changes
|
||||
|
||||
### Core Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from config import DEFAULT_DB_PATH
|
||||
from database import DatabaseManager
|
||||
from face_processing import FaceProcessor
|
||||
from photo_management import PhotoManager
|
||||
from tag_management import TagManager
|
||||
from search_stats import SearchStats
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.core.config import DEFAULT_DB_PATH
|
||||
from src.core.database import DatabaseManager
|
||||
from src.core.face_processing import FaceProcessor
|
||||
from src.core.photo_management import PhotoManager
|
||||
from src.core.tag_management import TagManager
|
||||
from src.core.search_stats import SearchStats
|
||||
```
|
||||
|
||||
### GUI Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from gui_core import GUICore
|
||||
from identify_panel import IdentifyPanel
|
||||
from auto_match_panel import AutoMatchPanel
|
||||
from modify_panel import ModifyPanel
|
||||
from tag_manager_panel import TagManagerPanel
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.gui.gui_core import GUICore
|
||||
from src.gui.identify_panel import IdentifyPanel
|
||||
from src.gui.auto_match_panel import AutoMatchPanel
|
||||
from src.gui.modify_panel import ModifyPanel
|
||||
from src.gui.tag_manager_panel import TagManagerPanel
|
||||
```
|
||||
|
||||
### Utility Modules
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
from path_utils import normalize_path, validate_path_exists
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
from src.utils.path_utils import normalize_path, validate_path_exists
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Requiring Import Updates
|
||||
|
||||
### Priority 1 - Core Files
|
||||
- [ ] `src/core/face_processing.py`
|
||||
- [ ] `src/core/photo_management.py`
|
||||
- [ ] `src/core/tag_management.py`
|
||||
- [ ] `src/core/search_stats.py`
|
||||
- [ ] `src/core/database.py`
|
||||
|
||||
### Priority 2 - GUI Files
|
||||
- [ ] `src/gui/dashboard_gui.py`
|
||||
- [ ] `src/gui/identify_panel.py`
|
||||
- [ ] `src/gui/auto_match_panel.py`
|
||||
- [ ] `src/gui/modify_panel.py`
|
||||
- [ ] `src/gui/tag_manager_panel.py`
|
||||
- [ ] `src/gui/gui_core.py`
|
||||
|
||||
### Priority 3 - Entry Points
|
||||
- [ ] `src/photo_tagger.py`
|
||||
- [ ] `src/setup.py`
|
||||
|
||||
### Priority 4 - Tests
|
||||
- [ ] `tests/test_deepface_gui.py`
|
||||
- [ ] `tests/test_face_recognition.py`
|
||||
- [ ] `tests/test_simple_gui.py`
|
||||
|
||||
---
|
||||
|
||||
## Search & Replace Patterns
|
||||
|
||||
Use these patterns to update imports systematically:
|
||||
|
||||
### Pattern 1: Core imports
|
||||
```bash
|
||||
# Find
|
||||
from config import
|
||||
from database import
|
||||
from face_processing import
|
||||
from photo_management import
|
||||
from tag_management import
|
||||
from search_stats import
|
||||
|
||||
# Replace with
|
||||
from src.core.config import
|
||||
from src.core.database import
|
||||
from src.core.face_processing import
|
||||
from src.core.photo_management import
|
||||
from src.core.tag_management import
|
||||
from src.core.search_stats import
|
||||
```
|
||||
|
||||
### Pattern 2: GUI imports
|
||||
```bash
|
||||
# Find
|
||||
from gui_core import
|
||||
from identify_panel import
|
||||
from auto_match_panel import
|
||||
from modify_panel import
|
||||
from tag_manager_panel import
|
||||
|
||||
# Replace with
|
||||
from src.gui.gui_core import
|
||||
from src.gui.identify_panel import
|
||||
from src.gui.auto_match_panel import
|
||||
from src.gui.modify_panel import
|
||||
from src.gui.tag_manager_panel import
|
||||
```
|
||||
|
||||
### Pattern 3: Utils imports
|
||||
```bash
|
||||
# Find
|
||||
from path_utils import
|
||||
|
||||
# Replace with
|
||||
from src.utils.path_utils import
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running the Application After Restructure
|
||||
|
||||
### GUI Dashboard
|
||||
```bash
|
||||
# Old
|
||||
python dashboard_gui.py
|
||||
|
||||
# New
|
||||
python src/gui/dashboard_gui.py
|
||||
# OR
|
||||
python -m src.gui.dashboard_gui
|
||||
```
|
||||
|
||||
### CLI Tool
|
||||
```bash
|
||||
# Old
|
||||
python photo_tagger.py
|
||||
|
||||
# New
|
||||
python src/photo_tagger.py
|
||||
# OR
|
||||
python -m src.photo_tagger
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
# Old
|
||||
python test_deepface_gui.py
|
||||
|
||||
# New
|
||||
python tests/test_deepface_gui.py
|
||||
# OR
|
||||
python -m pytest tests/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Steps
|
||||
|
||||
### 1. Check Import Errors
|
||||
```bash
|
||||
cd /home/ladmin/Code/punimtag
|
||||
python -c "from src.core import DatabaseManager; print('Core imports OK')"
|
||||
python -c "from src.gui import GUICore; print('GUI imports OK')"
|
||||
python -c "from src.utils import normalize_path; print('Utils imports OK')"
|
||||
```
|
||||
|
||||
### 2. Test Each Module
|
||||
```bash
|
||||
# Test core modules
|
||||
python -c "from src.core.database import DatabaseManager; db = DatabaseManager(':memory:'); print('Database OK')"
|
||||
|
||||
# Test GUI modules (may need display)
|
||||
python -c "from src.gui.gui_core import GUICore; print('GUI Core OK')"
|
||||
```
|
||||
|
||||
### 3. Run Application
|
||||
```bash
|
||||
# Try to launch dashboard
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
### 4. Run Tests
|
||||
```bash
|
||||
# Run test suite
|
||||
python -m pytest tests/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue 1: ModuleNotFoundError
|
||||
```
|
||||
ModuleNotFoundError: No module named 'config'
|
||||
```
|
||||
|
||||
**Solution:** Update import from `from config import` to `from src.core.config import`
|
||||
|
||||
### Issue 2: Relative Import Error
|
||||
```
|
||||
ImportError: attempted relative import with no known parent package
|
||||
```
|
||||
|
||||
**Solution:** Use absolute imports with `src.` prefix
|
||||
|
||||
### Issue 3: Circular Import
|
||||
```
|
||||
ImportError: cannot import name 'X' from partially initialized module
|
||||
```
|
||||
|
||||
**Solution:** Check for circular dependencies, may need to refactor
|
||||
|
||||
### Issue 4: sys.path Issues
|
||||
If imports still fail, add to top of file:
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
project_root = Path(__file__).parent.parent # Adjust based on file location
|
||||
sys.path.insert(0, str(project_root))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, you can rollback:
|
||||
|
||||
```bash
|
||||
# Revert git changes
|
||||
git checkout HEAD -- .
|
||||
|
||||
# Or manually move files back
|
||||
mv src/core/*.py .
|
||||
mv src/gui/*.py .
|
||||
mv src/utils/*.py .
|
||||
mv tests/*.py .
|
||||
mv docs/*.md .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Benefits of New Structure
|
||||
|
||||
✅ **Better Organization**: Clear separation of concerns
|
||||
✅ **Easier Navigation**: Files grouped by function
|
||||
✅ **Professional**: Follows Python community standards
|
||||
✅ **Scalable**: Easy to add new modules
|
||||
✅ **Testable**: Tests separate from source
|
||||
✅ **Maintainable**: Clear dependencies
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Directory structure created
|
||||
2. ✅ Files moved to new locations
|
||||
3. ✅ __init__.py files created
|
||||
4. ✅ Documentation updated
|
||||
5. ⏳ Update import statements (NEXT)
|
||||
6. ⏳ Test all functionality
|
||||
7. ⏳ Update scripts and launchers
|
||||
8. ⏳ Commit changes
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After updating imports, verify:
|
||||
|
||||
- [ ] Dashboard GUI launches
|
||||
- [ ] Can scan for photos
|
||||
- [ ] Face processing works
|
||||
- [ ] Face identification works
|
||||
- [ ] Auto-matching works
|
||||
- [ ] Tag management works
|
||||
- [ ] Search functionality works
|
||||
- [ ] Database operations work
|
||||
- [ ] All tests pass
|
||||
|
||||
---
|
||||
|
||||
**Status**: Files moved, imports need updating
|
||||
**Last Updated**: 2025-10-15
|
||||
**Next Action**: Update import statements in all files
|
||||
|
||||
65
.notes/task_list.md
Normal file
65
.notes/task_list.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Task List
|
||||
|
||||
## High Priority
|
||||
|
||||
### DeepFace Migration
|
||||
- [ ] Update requirements.txt with DeepFace dependencies
|
||||
- [ ] Modify database schema for DeepFace support
|
||||
- [ ] Implement DeepFace face detection
|
||||
- [ ] Implement cosine similarity matching
|
||||
- [ ] Update GUI for detector selection
|
||||
- [ ] Create migration script
|
||||
- [ ] Test with demo photos
|
||||
- [ ] Update documentation
|
||||
|
||||
### Bug Fixes
|
||||
- [ ] Fix import paths after restructuring
|
||||
- [ ] Update all relative imports to absolute imports
|
||||
- [ ] Test all GUI panels after restructure
|
||||
- [ ] Verify database connections work
|
||||
|
||||
## Medium Priority
|
||||
|
||||
### Code Quality
|
||||
- [ ] Add type hints throughout codebase
|
||||
- [ ] Improve error handling consistency
|
||||
- [ ] Add logging framework
|
||||
- [ ] Increase test coverage
|
||||
- [ ] Document all public APIs
|
||||
|
||||
### Features
|
||||
- [ ] Add batch face identification
|
||||
- [ ] Implement face clustering
|
||||
- [ ] Add photo timeline view
|
||||
- [ ] Implement advanced filters
|
||||
- [ ] Add keyboard shortcuts
|
||||
|
||||
## Low Priority
|
||||
|
||||
### Performance
|
||||
- [ ] Optimize database queries
|
||||
- [ ] Implement result caching
|
||||
- [ ] Add lazy loading for large datasets
|
||||
- [ ] Profile and optimize slow operations
|
||||
|
||||
### Documentation
|
||||
- [ ] Create developer guide
|
||||
- [ ] Add API documentation
|
||||
- [ ] Create video tutorials
|
||||
- [ ] Write troubleshooting guide
|
||||
|
||||
## Completed
|
||||
- [x] Restructure project to organized layout
|
||||
- [x] Create architecture documentation
|
||||
- [x] Unified dashboard interface
|
||||
- [x] Auto-matching functionality
|
||||
- [x] Tag management system
|
||||
- [x] Search and statistics
|
||||
|
||||
## Backlog
|
||||
- Web interface migration
|
||||
- Cloud storage integration
|
||||
- Mobile app
|
||||
- Video face detection
|
||||
- Multi-user support
|
||||
|
||||
522
CONTRIBUTING.md
Normal file
522
CONTRIBUTING.md
Normal file
@ -0,0 +1,522 @@
|
||||
# Contributing to PunimTag
|
||||
|
||||
Thank you for your interest in contributing to PunimTag! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Table of Contents
|
||||
|
||||
1. [Code of Conduct](#code-of-conduct)
|
||||
2. [Getting Started](#getting-started)
|
||||
3. [Development Workflow](#development-workflow)
|
||||
4. [Coding Standards](#coding-standards)
|
||||
5. [Testing](#testing)
|
||||
6. [Documentation](#documentation)
|
||||
7. [Pull Request Process](#pull-request-process)
|
||||
8. [Project Structure](#project-structure)
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Code of Conduct
|
||||
|
||||
### Our Pledge
|
||||
We are committed to providing a welcoming and inclusive environment for all contributors.
|
||||
|
||||
### Expected Behavior
|
||||
- Be respectful and considerate
|
||||
- Welcome newcomers and help them learn
|
||||
- Accept constructive criticism gracefully
|
||||
- Focus on what's best for the project
|
||||
- Show empathy towards other contributors
|
||||
|
||||
### Unacceptable Behavior
|
||||
- Harassment or discriminatory language
|
||||
- Trolling or insulting comments
|
||||
- Public or private harassment
|
||||
- Publishing others' private information
|
||||
- Other unprofessional conduct
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.12+
|
||||
- Git
|
||||
- Basic understanding of Python and Tkinter
|
||||
- Familiarity with face recognition concepts (helpful)
|
||||
|
||||
### Setting Up Development Environment
|
||||
|
||||
1. **Fork and Clone**
|
||||
```bash
|
||||
git fork <repository-url>
|
||||
git clone <your-fork-url>
|
||||
cd punimtag
|
||||
```
|
||||
|
||||
2. **Create Virtual Environment**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install Dependencies**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt # If available
|
||||
```
|
||||
|
||||
4. **Verify Installation**
|
||||
```bash
|
||||
python src/gui/dashboard_gui.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Development Workflow
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
```
|
||||
main/master - Stable releases
|
||||
develop - Integration branch
|
||||
feature/* - New features
|
||||
bugfix/* - Bug fixes
|
||||
hotfix/* - Urgent fixes
|
||||
release/* - Release preparation
|
||||
```
|
||||
|
||||
### Creating a Feature Branch
|
||||
|
||||
```bash
|
||||
git checkout develop
|
||||
git pull origin develop
|
||||
git checkout -b feature/your-feature-name
|
||||
```
|
||||
|
||||
### Making Changes
|
||||
|
||||
1. Make your changes in the appropriate directory:
|
||||
- Business logic: `src/core/`
|
||||
- GUI components: `src/gui/`
|
||||
- Utilities: `src/utils/`
|
||||
- Tests: `tests/`
|
||||
|
||||
2. Follow coding standards (see below)
|
||||
|
||||
3. Add/update tests
|
||||
|
||||
4. Update documentation
|
||||
|
||||
5. Test your changes thoroughly
|
||||
|
||||
### Committing Changes
|
||||
|
||||
Use clear, descriptive commit messages:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add face clustering algorithm"
|
||||
```
|
||||
|
||||
**Commit Message Format:**
|
||||
```
|
||||
<type>: <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
**Types:**
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `style`: Code style changes (formatting)
|
||||
- `refactor`: Code refactoring
|
||||
- `test`: Adding or updating tests
|
||||
- `chore`: Maintenance tasks
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
feat: add DeepFace integration
|
||||
|
||||
- Replace face_recognition with DeepFace
|
||||
- Implement ArcFace model
|
||||
- Add cosine similarity matching
|
||||
- Update database schema
|
||||
|
||||
Closes #123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 Coding Standards
|
||||
|
||||
### Python Style Guide
|
||||
|
||||
Follow **PEP 8** with these specifics:
|
||||
|
||||
#### Formatting
|
||||
- **Indentation**: 4 spaces (no tabs)
|
||||
- **Line Length**: 100 characters max (120 for comments)
|
||||
- **Imports**: Grouped and sorted
|
||||
```python
|
||||
# Standard library
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Third-party
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Local
|
||||
from src.core.database import DatabaseManager
|
||||
```
|
||||
|
||||
#### Naming Conventions
|
||||
- **Classes**: `PascalCase` (e.g., `FaceProcessor`)
|
||||
- **Functions/Methods**: `snake_case` (e.g., `process_faces`)
|
||||
- **Constants**: `UPPER_SNAKE_CASE` (e.g., `DEFAULT_TOLERANCE`)
|
||||
- **Private**: Prefix with `_` (e.g., `_internal_method`)
|
||||
|
||||
#### Documentation
|
||||
All public classes and functions must have docstrings:
|
||||
|
||||
```python
|
||||
def process_faces(self, limit: int = 50) -> int:
|
||||
"""Process unprocessed photos for faces.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of photos to process
|
||||
|
||||
Returns:
|
||||
Number of photos successfully processed
|
||||
|
||||
Raises:
|
||||
DatabaseError: If database connection fails
|
||||
"""
|
||||
pass
|
||||
```
|
||||
|
||||
#### Type Hints
|
||||
Use type hints for all function signatures:
|
||||
|
||||
```python
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
def get_similar_faces(
|
||||
self,
|
||||
face_id: int,
|
||||
tolerance: float = 0.6
|
||||
) -> List[Dict[str, Any]]:
|
||||
pass
|
||||
```
|
||||
|
||||
### Code Organization
|
||||
|
||||
#### File Structure
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Module description
|
||||
"""
|
||||
|
||||
# Imports
|
||||
import os
|
||||
from typing import List
|
||||
|
||||
# Constants
|
||||
DEFAULT_VALUE = 42
|
||||
|
||||
# Classes
|
||||
class MyClass:
|
||||
"""Class description"""
|
||||
pass
|
||||
|
||||
# Functions
|
||||
def my_function():
|
||||
"""Function description"""
|
||||
pass
|
||||
|
||||
# Main execution
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
#### Error Handling
|
||||
Always use specific exception types:
|
||||
|
||||
```python
|
||||
try:
|
||||
result = risky_operation()
|
||||
except FileNotFoundError as e:
|
||||
logger.error(f"File not found: {e}")
|
||||
raise
|
||||
except ValueError as e:
|
||||
logger.warning(f"Invalid value: {e}")
|
||||
return default_value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
### Writing Tests
|
||||
|
||||
Create tests in `tests/` directory:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from src.core.face_processing import FaceProcessor
|
||||
|
||||
def test_face_detection():
|
||||
"""Test face detection on sample image"""
|
||||
processor = FaceProcessor(db_manager, verbose=0)
|
||||
result = processor.process_faces(limit=1)
|
||||
assert result > 0
|
||||
|
||||
def test_similarity_calculation():
|
||||
"""Test face similarity metric"""
|
||||
processor = FaceProcessor(db_manager, verbose=0)
|
||||
similarity = processor._calculate_cosine_similarity(enc1, enc2)
|
||||
assert 0.0 <= similarity <= 1.0
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
python -m pytest tests/
|
||||
|
||||
# Specific test file
|
||||
python tests/test_face_recognition.py
|
||||
|
||||
# With coverage
|
||||
pytest --cov=src tests/
|
||||
|
||||
# Verbose output
|
||||
pytest -v tests/
|
||||
```
|
||||
|
||||
### Test Guidelines
|
||||
|
||||
1. **Test Coverage**: Aim for >80% code coverage
|
||||
2. **Test Names**: Descriptive names starting with `test_`
|
||||
3. **Assertions**: Use clear assertion messages
|
||||
4. **Fixtures**: Use pytest fixtures for setup/teardown
|
||||
5. **Isolation**: Tests should not depend on each other
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
### What to Document
|
||||
|
||||
1. **Code Changes**: Update docstrings
|
||||
2. **API Changes**: Update API documentation
|
||||
3. **New Features**: Add to README and docs
|
||||
4. **Breaking Changes**: Clearly mark in changelog
|
||||
5. **Architecture**: Update ARCHITECTURE.md if needed
|
||||
|
||||
### Documentation Style
|
||||
|
||||
- Use Markdown for all documentation
|
||||
- Include code examples
|
||||
- Add diagrams where helpful
|
||||
- Keep language clear and concise
|
||||
- Update table of contents
|
||||
|
||||
### Files to Update
|
||||
|
||||
- `README.md`: User-facing documentation
|
||||
- `docs/ARCHITECTURE.md`: Technical architecture
|
||||
- `.notes/task_list.md`: Task tracking
|
||||
- Inline comments: Complex logic explanation
|
||||
|
||||
---
|
||||
|
||||
## 🔀 Pull Request Process
|
||||
|
||||
### Before Submitting
|
||||
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] All tests pass
|
||||
- [ ] New tests added for new features
|
||||
- [ ] Documentation updated
|
||||
- [ ] No linting errors
|
||||
- [ ] Commit messages are clear
|
||||
- [ ] Branch is up to date with develop
|
||||
|
||||
### Submitting PR
|
||||
|
||||
1. **Push your branch**
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
2. **Create Pull Request**
|
||||
- Go to GitHub/GitLab
|
||||
- Click "New Pull Request"
|
||||
- Select your feature branch
|
||||
- Fill out PR template
|
||||
|
||||
3. **PR Title Format**
|
||||
```
|
||||
[Type] Short description
|
||||
|
||||
Examples:
|
||||
[Feature] Add DeepFace integration
|
||||
[Bug Fix] Fix face detection on rotated images
|
||||
[Docs] Update architecture documentation
|
||||
```
|
||||
|
||||
4. **PR Description Template**
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix
|
||||
- [ ] New feature
|
||||
- [ ] Breaking change
|
||||
- [ ] Documentation update
|
||||
|
||||
## Related Issues
|
||||
Closes #123
|
||||
|
||||
## Testing
|
||||
Describe testing performed
|
||||
|
||||
## Screenshots (if applicable)
|
||||
Add screenshots
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Tests added/updated
|
||||
- [ ] Documentation updated
|
||||
- [ ] All tests pass
|
||||
```
|
||||
|
||||
### Review Process
|
||||
|
||||
1. **Automated Checks**: Must pass all CI/CD checks
|
||||
2. **Code Review**: At least one approval required
|
||||
3. **Discussion**: Address all review comments
|
||||
4. **Updates**: Make requested changes
|
||||
5. **Approval**: Merge after approval
|
||||
|
||||
### After Merge
|
||||
|
||||
1. Delete feature branch
|
||||
2. Pull latest develop
|
||||
3. Update local repository
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Structure
|
||||
|
||||
### Key Directories
|
||||
|
||||
```
|
||||
src/
|
||||
├── core/ # Business logic - most changes here
|
||||
├── gui/ # GUI components - UI changes here
|
||||
└── utils/ # Utilities - helper functions
|
||||
|
||||
tests/ # All tests go here
|
||||
|
||||
docs/ # User documentation
|
||||
.notes/ # Developer notes
|
||||
```
|
||||
|
||||
### Module Dependencies
|
||||
|
||||
```
|
||||
gui → core → database
|
||||
gui → utils
|
||||
core → utils
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Core modules should not import GUI modules
|
||||
- Utils should not import core or GUI
|
||||
- Avoid circular dependencies
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips for Contributors
|
||||
|
||||
### Finding Issues to Work On
|
||||
|
||||
- Look for `good first issue` label
|
||||
- Check `.notes/task_list.md`
|
||||
- Ask in discussions
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Read documentation first
|
||||
- Check existing issues
|
||||
- Ask in discussions
|
||||
- Contact maintainers
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Start Small**: Begin with small changes
|
||||
2. **One Feature**: One PR = one feature
|
||||
3. **Test Early**: Write tests as you code
|
||||
4. **Ask Questions**: Better to ask than assume
|
||||
5. **Be Patient**: Reviews take time
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Areas Needing Contribution
|
||||
|
||||
### High Priority
|
||||
- DeepFace integration
|
||||
- Test coverage improvement
|
||||
- Performance optimization
|
||||
- Documentation updates
|
||||
|
||||
### Medium Priority
|
||||
- GUI improvements
|
||||
- Additional search filters
|
||||
- Export functionality
|
||||
- Backup/restore features
|
||||
|
||||
### Low Priority
|
||||
- Code refactoring
|
||||
- Style improvements
|
||||
- Additional themes
|
||||
- Internationalization
|
||||
|
||||
---
|
||||
|
||||
## 📞 Contact
|
||||
|
||||
- **Issues**: GitHub Issues
|
||||
- **Discussions**: GitHub Discussions
|
||||
- **Email**: [Add email]
|
||||
|
||||
---
|
||||
|
||||
## 🙏 Recognition
|
||||
|
||||
Contributors will be:
|
||||
- Listed in AUTHORS file
|
||||
- Mentioned in release notes
|
||||
- Thanked in documentation
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
By contributing, you agree that your contributions will be licensed under the same license as the project.
|
||||
|
||||
---
|
||||
|
||||
**Thank you for contributing to PunimTag! 🎉**
|
||||
|
||||
Every contribution, no matter how small, makes a difference!
|
||||
|
||||
374
MERGE_REQUEST.md
Normal file
374
MERGE_REQUEST.md
Normal file
@ -0,0 +1,374 @@
|
||||
`1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111# Merge Request: PunimTag Web Application - Major Feature Release
|
||||
|
||||
## Overview
|
||||
|
||||
This merge request contains a comprehensive set of changes that transform PunimTag from a desktop GUI application into a modern web-based photo management system with advanced facial recognition capabilities. The changes span from September 2025 to January 2026 and include migration to DeepFace, PostgreSQL support, web frontend implementation, and extensive feature additions.
|
||||
|
||||
## Summary Statistics
|
||||
|
||||
- **Total Commits**: 200+ commits
|
||||
- **Files Changed**: 226 files
|
||||
- **Lines Added**: ~71,189 insertions
|
||||
- **Lines Removed**: ~1,670 deletions
|
||||
- **Net Change**: +69,519 lines
|
||||
- **Date Range**: September 19, 2025 - January 6, 2026
|
||||
|
||||
## Key Changes
|
||||
|
||||
### 1. Architecture Migration
|
||||
|
||||
#### Desktop to Web Migration
|
||||
- **Removed**: Complete desktop GUI application (Tkinter-based)
|
||||
- Archive folder with 22+ desktop GUI files removed
|
||||
- Old photo_tagger.py desktop application removed
|
||||
- All desktop-specific components archived
|
||||
- **Added**: Modern web application architecture
|
||||
- FastAPI backend with RESTful API
|
||||
- React-based admin frontend
|
||||
- Next.js-based viewer frontend
|
||||
- Monorepo structure for unified development
|
||||
|
||||
#### Database Migration
|
||||
- **From**: SQLite database
|
||||
- **To**: PostgreSQL database
|
||||
- Dual database architecture (main + auth databases)
|
||||
- Comprehensive migration scripts
|
||||
- Database architecture review documentation
|
||||
- Enhanced data validation and type safety
|
||||
|
||||
### 2. Face Recognition Engine Upgrade
|
||||
|
||||
#### DeepFace Integration
|
||||
- **Replaced**: face_recognition library
|
||||
- **New**: DeepFace with ArcFace model
|
||||
- 512-dimensional embeddings (4x more detailed)
|
||||
- Multiple detector options (RetinaFace, MTCNN, OpenCV, SSD)
|
||||
- Multiple recognition models (ArcFace, Facenet, Facenet512, VGG-Face)
|
||||
- Improved accuracy and performance
|
||||
- Pose detection using RetinaFace
|
||||
- Face quality scoring and filtering
|
||||
|
||||
#### Face Processing Enhancements
|
||||
- EXIF orientation handling`
|
||||
- Face width detection for profile classification
|
||||
- Landmarks column for pose detection
|
||||
- Quality filtering in identification process
|
||||
- Batch similarity endpoint for efficient face comparison
|
||||
- Unique faces filter to hide duplicates
|
||||
- Confidence calibration for realistic match probabilities
|
||||
|
||||
### 3. Backend API Development
|
||||
|
||||
#### Core API Endpoints
|
||||
- **Authentication & Authorization**
|
||||
- JWT-based authentication
|
||||
- Role-based access control (RBAC)
|
||||
- User management API
|
||||
- Password change functionality
|
||||
- Session management
|
||||
|
||||
- **Photo Management**
|
||||
- Photo upload and import
|
||||
- Photo search with advanced filters
|
||||
- Photo tagging and organization
|
||||
- Bulk operations (delete, tag)
|
||||
- Favorites functionality
|
||||
- Media type support (images and videos)
|
||||
- Date validation and EXIF extraction
|
||||
|
||||
- **Face Management**
|
||||
- Face processing with job queue
|
||||
- Face identification workflow
|
||||
- Face similarity matching
|
||||
- Excluded faces management
|
||||
- Face quality filtering
|
||||
- Batch processing support
|
||||
|
||||
- **People Management**
|
||||
- Person creation and identification
|
||||
- Person search and filtering
|
||||
- Person modification
|
||||
- Auto-match functionality
|
||||
- Pending identifications workflow
|
||||
- Person statistics and counts
|
||||
|
||||
- **Tag Management**
|
||||
- Tag creation and management
|
||||
- Photo-tag linkages
|
||||
- Tag filtering and search
|
||||
- Bulk tagging operations
|
||||
|
||||
- **Video Support**
|
||||
- Video upload and processing
|
||||
- Video player modal
|
||||
- Video metadata extraction
|
||||
- Video person identification
|
||||
|
||||
- **Job Management**
|
||||
- Background job processing with RQ
|
||||
- Job status tracking
|
||||
- Job cancellation support
|
||||
- Progress updates
|
||||
|
||||
- **User Management**
|
||||
- Admin user management
|
||||
- Role and permission management
|
||||
- User activity tracking
|
||||
- Inactivity timeout
|
||||
|
||||
- **Reporting & Moderation**
|
||||
- Reported photos management
|
||||
- Pending photos review
|
||||
- Pending linkages approval
|
||||
- Identification statistics
|
||||
|
||||
### 4. Frontend Development
|
||||
|
||||
#### Admin Frontend (React)
|
||||
- **Scan Page**: Photo import and processing
|
||||
- Native folder picker integration
|
||||
- Network path support
|
||||
- Progress tracking
|
||||
- Job management
|
||||
|
||||
- **Search Page**: Advanced photo search
|
||||
- Multiple search types (name, date, tags, no_faces, no_tags, processed, unprocessed, favorites)
|
||||
- Person autocomplete
|
||||
- Date range filters
|
||||
- Tag filtering
|
||||
- Media type filtering
|
||||
- Pagination
|
||||
- Session state management
|
||||
|
||||
- **Identify Page**: Face identification
|
||||
- Unidentified faces display
|
||||
- Person creation and matching
|
||||
- Quality filtering
|
||||
- Date filters
|
||||
- Excluded faces management
|
||||
- Pagination and navigation
|
||||
- Setup area toggle
|
||||
|
||||
- **AutoMatch Page**: Automated face matching
|
||||
- Auto-start on mount
|
||||
- Tolerance configuration
|
||||
- Quality criteria
|
||||
- Tag filtering
|
||||
- Developer mode options
|
||||
|
||||
- **Modify Page**: Person modification
|
||||
- Face selection and unselection
|
||||
- Person information editing
|
||||
- Video player modal
|
||||
- Search filters
|
||||
|
||||
- **Tags Page**: Tag management
|
||||
- Tag creation and editing
|
||||
- People names integration
|
||||
- Sorting and filtering
|
||||
- Tag statistics
|
||||
|
||||
- **Faces Maintenance Page**: Face management
|
||||
- Excluded and identified filters
|
||||
- Face quality display
|
||||
- Face deletion
|
||||
|
||||
- **User Management Pages**
|
||||
- User creation and editing
|
||||
- Role assignment
|
||||
- Permission management
|
||||
- Password management
|
||||
- User activity tracking
|
||||
|
||||
- **Reporting & Moderation Pages**
|
||||
- Pending identifications approval
|
||||
- Reported photos review
|
||||
- Pending photos management
|
||||
- Pending linkages approval
|
||||
|
||||
- **UI Enhancements**
|
||||
- Logo integration
|
||||
- Emoji page titles
|
||||
- Password visibility toggle
|
||||
- Loading progress indicators
|
||||
- Confirmation dialogs
|
||||
- Responsive design
|
||||
- Developer mode features
|
||||
|
||||
#### Viewer Frontend (Next.js)
|
||||
- Photo viewer component with zoom and slideshow
|
||||
- Photo browsing and navigation
|
||||
- Tag management interface
|
||||
- Person identification display
|
||||
- Favorites functionality
|
||||
|
||||
### 5. Infrastructure & DevOps
|
||||
|
||||
#### Installation & Setup
|
||||
- Comprehensive installation script (`install.sh`)
|
||||
- Automated system dependency installation
|
||||
- PostgreSQL and Redis setup
|
||||
- Python virtual environment creation
|
||||
- Frontend dependency installation
|
||||
- Environment configuration
|
||||
- Database initialization
|
||||
|
||||
#### Scripts & Utilities
|
||||
- Database management scripts
|
||||
- Table creation and migration
|
||||
- Database backup and restore
|
||||
- SQLite to PostgreSQL migration
|
||||
- Auth database setup
|
||||
|
||||
- Development utilities
|
||||
- Face detection debugging
|
||||
- Pose analysis scripts
|
||||
- Database diagnostics
|
||||
- Frontend issue diagnosis
|
||||
|
||||
#### Deployment
|
||||
- Docker Compose configuration
|
||||
- Backend startup scripts
|
||||
- Worker process management
|
||||
- Health check endpoints
|
||||
|
||||
### 6. Documentation
|
||||
|
||||
#### Technical Documentation
|
||||
- Architecture documentation
|
||||
- Database architecture review
|
||||
- API documentation
|
||||
- Phase completion summaries
|
||||
- Migration guides
|
||||
|
||||
#### User Documentation
|
||||
- Comprehensive user guide
|
||||
- Quick start guides
|
||||
- Feature documentation
|
||||
- Installation instructions
|
||||
|
||||
#### Analysis Documents
|
||||
- Video support analysis
|
||||
- Portrait detection plan
|
||||
- Auto-match automation plan
|
||||
- Resource requirements
|
||||
- Performance analysis
|
||||
- Client deployment questions
|
||||
|
||||
### 7. Testing & Quality Assurance
|
||||
|
||||
#### Test Suite
|
||||
- Face recognition tests
|
||||
- EXIF extraction tests
|
||||
- API endpoint tests
|
||||
- Database migration tests
|
||||
- Integration tests
|
||||
|
||||
#### Code Quality
|
||||
- Type hints throughout codebase
|
||||
- Comprehensive error handling
|
||||
- Input validation
|
||||
- Security best practices
|
||||
- Code organization and structure
|
||||
|
||||
### 8. Cleanup & Maintenance
|
||||
|
||||
#### Repository Cleanup
|
||||
- Removed archived desktop GUI files (22 files)
|
||||
- Removed demo photos and resources
|
||||
- Removed uploaded test files
|
||||
- Updated .gitignore to prevent re-adding unnecessary files
|
||||
- Removed obsolete migration files
|
||||
|
||||
#### Code Refactoring
|
||||
- Improved database connection management
|
||||
- Enhanced error handling
|
||||
- Better code organization
|
||||
- Improved type safety
|
||||
- Performance optimizations
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
1. **Database**: Migration from SQLite to PostgreSQL is required
|
||||
2. **API**: New RESTful API replaces desktop GUI
|
||||
3. **Dependencies**: New system requirements (PostgreSQL, Redis, Node.js)
|
||||
4. **Configuration**: New environment variables and configuration files
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Database Migration**
|
||||
- Run PostgreSQL setup script
|
||||
- Execute SQLite to PostgreSQL migration script
|
||||
- Verify data integrity
|
||||
|
||||
2. **Environment Setup**
|
||||
- Install system dependencies (PostgreSQL, Redis)
|
||||
- Run installation script
|
||||
- Configure environment variables
|
||||
- Generate Prisma clients
|
||||
|
||||
3. **Application Deployment**
|
||||
- Start PostgreSQL and Redis services
|
||||
- Run database migrations
|
||||
- Start backend API
|
||||
- Start frontend applications
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Database migration scripts tested
|
||||
- [x] API endpoints functional
|
||||
- [x] Face recognition accuracy verified
|
||||
- [x] Frontend components working
|
||||
- [x] Authentication and authorization tested
|
||||
- [x] Job processing verified
|
||||
- [x] Video support tested
|
||||
- [x] Search functionality validated
|
||||
- [x] Tag management verified
|
||||
- [x] User management tested
|
||||
|
||||
## Known Issues & Limitations
|
||||
|
||||
1. **Performance**: Large photo collections may require optimization
|
||||
2. **Memory**: DeepFace models require significant memory
|
||||
3. **Network**: Network path support may vary by OS
|
||||
4. **Browser**: Some features require modern browsers
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Enhanced video processing
|
||||
- Advanced analytics and reporting
|
||||
- Mobile app support
|
||||
- Cloud storage integration
|
||||
- Advanced AI features
|
||||
- Performance optimizations
|
||||
|
||||
## Contributors
|
||||
|
||||
- Tanya (tatiana.romlit@gmail.com) - Primary developer
|
||||
- tanyar09 - Initial development
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- `README.md` - Main project documentation
|
||||
- `docs/ARCHITECTURE.md` - System architecture
|
||||
- `docs/DATABASE_ARCHITECTURE_REVIEW.md` - Database design
|
||||
- `docs/USER_GUIDE.md` - User documentation
|
||||
- `MONOREPO_MIGRATION.md` - Migration details
|
||||
|
||||
## Approval Checklist
|
||||
|
||||
- [ ] Code review completed
|
||||
- [ ] Tests passing
|
||||
- [ ] Documentation updated
|
||||
- [ ] Migration scripts tested
|
||||
- [ ] Performance validated
|
||||
- [ ] Security review completed
|
||||
- [ ] Deployment plan reviewed
|
||||
|
||||
---
|
||||
|
||||
**Merge Request Created**: January 6, 2026
|
||||
**Base Branch**: `origin/master`
|
||||
**Target Branch**: `master`
|
||||
**Status**: Ready for Review
|
||||
|
||||
55
admin-frontend/.eslintrc.cjs
Normal file
55
admin-frontend/.eslintrc.cjs
Normal file
@ -0,0 +1,55 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
plugins: ['@typescript-eslint', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'max-len': [
|
||||
'error',
|
||||
{
|
||||
code: 100,
|
||||
tabWidth: 2,
|
||||
ignoreUrls: true,
|
||||
ignoreStrings: true,
|
||||
ignoreTemplateLiterals: true,
|
||||
},
|
||||
],
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
57
admin-frontend/README.md
Normal file
57
admin-frontend/README.md
Normal file
@ -0,0 +1,57 @@
|
||||
# PunimTag Frontend
|
||||
|
||||
React + Vite + TypeScript frontend for PunimTag.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The frontend will run on http://localhost:3000
|
||||
|
||||
Make sure the backend API is running on http://127.0.0.1:8000
|
||||
|
||||
## Default Login
|
||||
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## Features (Phase 1)
|
||||
|
||||
- ✅ Login page with JWT authentication
|
||||
- ✅ Protected routes with auth check
|
||||
- ✅ Navigation layout (left sidebar + top bar)
|
||||
- ✅ Dashboard page (placeholder)
|
||||
- ✅ Search page (placeholder)
|
||||
- ✅ Identify page (placeholder)
|
||||
- ✅ Auto-Match page (placeholder)
|
||||
- ✅ Tags page (placeholder)
|
||||
- ✅ Settings page (placeholder)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API client and endpoints
|
||||
│ ├── components/ # React components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── pages/ # Page components
|
||||
│ ├── App.tsx # Main app component
|
||||
│ ├── main.tsx # Entry point
|
||||
│ └── index.css # Tailwind CSS
|
||||
├── index.html
|
||||
├── package.json
|
||||
├── vite.config.ts
|
||||
└── tailwind.config.js
|
||||
```
|
||||
14
admin-frontend/index.html
Normal file
14
admin-frontend/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PunimTag</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
6182
admin-frontend/package-lock.json
generated
Normal file
6182
admin-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
admin-frontend/package.json
Normal file
35
admin-frontend/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "punimtag-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.8.4",
|
||||
"axios": "^1.6.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@typescript-eslint/eslint-plugin": "^6.10.0",
|
||||
"@typescript-eslint/parser": "^6.10.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
7
admin-frontend/postcss.config.js
Normal file
7
admin-frontend/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
141
admin-frontend/src/App.tsx
Normal file
141
admin-frontend/src/App.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider, useAuth } from './context/AuthContext'
|
||||
import { DeveloperModeProvider } from './context/DeveloperModeContext'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Search from './pages/Search'
|
||||
import Scan from './pages/Scan'
|
||||
import Process from './pages/Process'
|
||||
import Identify from './pages/Identify'
|
||||
import AutoMatch from './pages/AutoMatch'
|
||||
import Modify from './pages/Modify'
|
||||
import Tags from './pages/Tags'
|
||||
import FacesMaintenance from './pages/FacesMaintenance'
|
||||
import ApproveIdentified from './pages/ApproveIdentified'
|
||||
import ManageUsers from './pages/ManageUsers'
|
||||
import ReportedPhotos from './pages/ReportedPhotos'
|
||||
import PendingPhotos from './pages/PendingPhotos'
|
||||
import UserTaggedPhotos from './pages/UserTaggedPhotos'
|
||||
import ManagePhotos from './pages/ManagePhotos'
|
||||
import Settings from './pages/Settings'
|
||||
import Help from './pages/Help'
|
||||
import Layout from './components/Layout'
|
||||
import PasswordChangeModal from './components/PasswordChangeModal'
|
||||
import AdminRoute from './components/AdminRoute'
|
||||
|
||||
function PrivateRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading, passwordChangeRequired } = useAuth()
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && passwordChangeRequired) {
|
||||
setShowPasswordModal(true)
|
||||
}
|
||||
}, [isAuthenticated, passwordChangeRequired])
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showPasswordModal && (
|
||||
<PasswordChangeModal
|
||||
onSuccess={() => {
|
||||
setShowPasswordModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="scan" element={<Scan />} />
|
||||
<Route path="process" element={<Process />} />
|
||||
<Route path="search" element={<Search />} />
|
||||
<Route path="identify" element={<Identify />} />
|
||||
<Route path="auto-match" element={<AutoMatch />} />
|
||||
<Route path="modify" element={<Modify />} />
|
||||
<Route path="tags" element={<Tags />} />
|
||||
<Route path="manage-photos" element={<ManagePhotos />} />
|
||||
<Route path="faces-maintenance" element={<FacesMaintenance />} />
|
||||
<Route
|
||||
path="approve-identified"
|
||||
element={
|
||||
<AdminRoute featureKey="user_identified">
|
||||
<ApproveIdentified />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="manage-users"
|
||||
element={
|
||||
<AdminRoute featureKey="manage_users">
|
||||
<ManageUsers />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="reported-photos"
|
||||
element={
|
||||
<AdminRoute featureKey="user_reported">
|
||||
<ReportedPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="pending-linkages"
|
||||
element={
|
||||
<AdminRoute featureKey="user_tagged">
|
||||
<UserTaggedPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="pending-photos"
|
||||
element={
|
||||
<AdminRoute featureKey="user_uploaded">
|
||||
<PendingPhotos />
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="help" element={<Help />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<DeveloperModeProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</DeveloperModeProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
65
admin-frontend/src/api/auth.ts
Normal file
65
admin-frontend/src/api/auth.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import apiClient from './client'
|
||||
import { UserRoleValue } from './users'
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type?: string
|
||||
password_change_required?: boolean
|
||||
}
|
||||
|
||||
export interface PasswordChangeRequest {
|
||||
current_password: string
|
||||
new_password: string
|
||||
}
|
||||
|
||||
export interface PasswordChangeResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
username: string
|
||||
is_admin?: boolean
|
||||
role?: UserRoleValue
|
||||
permissions?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (credentials: LoginRequest): Promise<TokenResponse> => {
|
||||
const { data } = await apiClient.post<TokenResponse>(
|
||||
'/api/v1/auth/login',
|
||||
credentials
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
refresh: async (refreshToken: string): Promise<TokenResponse> => {
|
||||
const { data } = await apiClient.post<TokenResponse>(
|
||||
'/api/v1/auth/refresh',
|
||||
{ refresh_token: refreshToken }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
me: async (): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.get<UserResponse>('/api/v1/auth/me')
|
||||
return data
|
||||
},
|
||||
|
||||
changePassword: async (
|
||||
request: PasswordChangeRequest
|
||||
): Promise<PasswordChangeResponse> => {
|
||||
const { data } = await apiClient.post<PasswordChangeResponse>(
|
||||
'/api/v1/auth/change-password',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
72
admin-frontend/src/api/authUsers.ts
Normal file
72
admin-frontend/src/api/authUsers.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface AuthUserResponse {
|
||||
id: number
|
||||
name: string | null
|
||||
email: string
|
||||
is_admin: boolean | null
|
||||
has_write_access: boolean | null
|
||||
is_active: boolean | null
|
||||
role: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
|
||||
export interface AuthUserCreateRequest {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
is_admin: boolean
|
||||
has_write_access: boolean
|
||||
}
|
||||
|
||||
export interface AuthUserUpdateRequest {
|
||||
email: string
|
||||
name: string
|
||||
is_admin: boolean
|
||||
has_write_access: boolean
|
||||
is_active?: boolean
|
||||
role?: string
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface AuthUsersListResponse {
|
||||
items: AuthUserResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const authUsersApi = {
|
||||
listUsers: async (): Promise<AuthUsersListResponse> => {
|
||||
const { data } = await apiClient.get<AuthUsersListResponse>('/api/v1/auth-users')
|
||||
return data
|
||||
},
|
||||
|
||||
getUser: async (userId: number): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.get<AuthUserResponse>(`/api/v1/auth-users/${userId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
createUser: async (request: AuthUserCreateRequest): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.post<AuthUserResponse>('/api/v1/auth-users', request)
|
||||
return data
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
userId: number,
|
||||
request: AuthUserUpdateRequest
|
||||
): Promise<AuthUserResponse> => {
|
||||
const { data } = await apiClient.put<AuthUserResponse>(
|
||||
`/api/v1/auth-users/${userId}`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
|
||||
const response = await apiClient.delete(`/api/v1/auth-users/${userId}`)
|
||||
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
|
||||
return response.data || {}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
66
admin-frontend/src/api/client.ts
Normal file
66
admin-frontend/src/api/client.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import axios from 'axios'
|
||||
|
||||
// Get API base URL from environment variable or use default
|
||||
// The .env file should contain: VITE_API_URL=http://127.0.0.1:8000
|
||||
// Alternatively, Vite proxy can be used (configured in vite.config.ts) by setting VITE_API_URL to empty string
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Add token to requests
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 errors and network errors
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Handle network errors (no response from server)
|
||||
if (!error.response && (error.message === 'Network Error' || error.code === 'ERR_NETWORK')) {
|
||||
// Check if user is logged in
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
// Not logged in - redirect to login
|
||||
const isLoginPage = window.location.pathname === '/login'
|
||||
if (!isLoginPage) {
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
}
|
||||
// If logged in but network error, it's a connection issue
|
||||
console.error('Network Error:', error)
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized
|
||||
if (error.response?.status === 401) {
|
||||
// Don't redirect if we're already on the login page (prevents clearing error messages)
|
||||
const isLoginPage = window.location.pathname === '/login'
|
||||
|
||||
// Always clear tokens on 401, but only redirect if not already on login page
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Clear sessionStorage settings on authentication failure
|
||||
sessionStorage.removeItem('identify_settings')
|
||||
|
||||
// Only redirect if not already on login page
|
||||
if (!isLoginPage) {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
// If on login page, just reject the error so the login component can handle it
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default apiClient
|
||||
|
||||
293
admin-frontend/src/api/faces.ts
Normal file
293
admin-frontend/src/api/faces.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface ProcessFacesRequest {
|
||||
batch_size?: number
|
||||
detector_backend: string
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface ProcessFacesResponse {
|
||||
job_id: string
|
||||
message: string
|
||||
batch_size?: number
|
||||
detector_backend: string
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface FaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
quality_score: number
|
||||
face_confidence: number
|
||||
location: string
|
||||
pose_mode?: string
|
||||
excluded?: boolean
|
||||
}
|
||||
|
||||
export interface UnidentifiedFacesResponse {
|
||||
items: FaceItem[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SimilarFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
similarity: number
|
||||
location: string
|
||||
quality_score: number
|
||||
filename: string
|
||||
pose_mode?: string
|
||||
}
|
||||
|
||||
export interface SimilarFacesResponse {
|
||||
base_face_id: number
|
||||
items: SimilarFaceItem[]
|
||||
}
|
||||
|
||||
export interface FaceSimilarityPair {
|
||||
face_id_1: number
|
||||
face_id_2: number
|
||||
similarity: number // 0-1 range
|
||||
confidence_pct: number // 0-100 range
|
||||
}
|
||||
|
||||
export interface BatchSimilarityRequest {
|
||||
face_ids: number[]
|
||||
min_confidence?: number // 0-100, default 60
|
||||
}
|
||||
|
||||
export interface BatchSimilarityResponse {
|
||||
pairs: FaceSimilarityPair[]
|
||||
}
|
||||
|
||||
export interface IdentifyFaceRequest {
|
||||
person_id?: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string
|
||||
additional_face_ids?: number[]
|
||||
}
|
||||
|
||||
export interface IdentifyFaceResponse {
|
||||
identified_face_ids: number[]
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
}
|
||||
|
||||
export interface FaceUnmatchResponse {
|
||||
face_id: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface BatchUnmatchRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface BatchUnmatchResponse {
|
||||
unmatched_face_ids: number[]
|
||||
count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface AutoMatchRequest {
|
||||
tolerance: number
|
||||
auto_accept?: boolean
|
||||
auto_accept_threshold?: number
|
||||
}
|
||||
|
||||
export interface AutoMatchFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_filename: string
|
||||
location: string
|
||||
quality_score: number
|
||||
similarity: number // Confidence percentage (0-100)
|
||||
distance: number
|
||||
pose_mode?: string
|
||||
}
|
||||
|
||||
export interface AutoMatchPersonItem {
|
||||
person_id: number
|
||||
person_name: string
|
||||
reference_face_id: number
|
||||
reference_photo_id: number
|
||||
reference_photo_filename: string
|
||||
reference_location: string
|
||||
reference_pose_mode?: string
|
||||
face_count: number
|
||||
matches: AutoMatchFaceItem[]
|
||||
total_matches: number
|
||||
}
|
||||
|
||||
export interface AutoMatchPersonSummary {
|
||||
person_id: number
|
||||
person_name: string
|
||||
reference_face_id: number
|
||||
reference_photo_id: number
|
||||
reference_photo_filename: string
|
||||
reference_location: string
|
||||
reference_pose_mode?: string
|
||||
face_count: number
|
||||
total_matches: number
|
||||
}
|
||||
|
||||
export interface AutoMatchPeopleResponse {
|
||||
people: AutoMatchPersonSummary[]
|
||||
total_people: number
|
||||
}
|
||||
|
||||
export interface AutoMatchPersonMatchesResponse {
|
||||
person_id: number
|
||||
matches: AutoMatchFaceItem[]
|
||||
total_matches: number
|
||||
}
|
||||
|
||||
export interface AutoMatchResponse {
|
||||
people: AutoMatchPersonItem[]
|
||||
total_people: number
|
||||
total_matches: number
|
||||
auto_accepted?: boolean
|
||||
auto_accepted_faces?: number
|
||||
skipped_persons?: number
|
||||
skipped_matches?: number
|
||||
}
|
||||
|
||||
export interface AcceptMatchesRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface MaintenanceFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_path: string
|
||||
photo_filename: string
|
||||
quality_score: number
|
||||
person_id: number | null
|
||||
person_name: string | null
|
||||
excluded: boolean
|
||||
}
|
||||
|
||||
export interface MaintenanceFacesResponse {
|
||||
items: MaintenanceFaceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface DeleteFacesRequest {
|
||||
face_ids: number[]
|
||||
}
|
||||
|
||||
export interface DeleteFacesResponse {
|
||||
deleted_face_ids: number[]
|
||||
count: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export const facesApi = {
|
||||
/**
|
||||
* Start face processing job
|
||||
*/
|
||||
processFaces: async (request: ProcessFacesRequest): Promise<ProcessFacesResponse> => {
|
||||
const response = await apiClient.post<ProcessFacesResponse>('/api/v1/faces/process', request)
|
||||
return response.data
|
||||
},
|
||||
getUnidentified: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
min_quality?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
date_taken_from?: string
|
||||
date_taken_to?: string
|
||||
date_processed?: string
|
||||
date_processed_from?: string
|
||||
date_processed_to?: string
|
||||
sort_by?: 'quality' | 'date_taken' | 'date_added'
|
||||
sort_dir?: 'asc' | 'desc'
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
photo_ids?: string
|
||||
include_excluded?: boolean
|
||||
}): Promise<UnidentifiedFacesResponse> => {
|
||||
const response = await apiClient.get<UnidentifiedFacesResponse>('/api/v1/faces/unidentified', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getSimilar: async (faceId: number, includeExcluded?: boolean): Promise<SimilarFacesResponse> => {
|
||||
const response = await apiClient.get<SimilarFacesResponse>(`/api/v1/faces/${faceId}/similar`, {
|
||||
params: { include_excluded: includeExcluded || false },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
batchSimilarity: async (request: BatchSimilarityRequest): Promise<BatchSimilarityResponse> => {
|
||||
const response = await apiClient.post<BatchSimilarityResponse>('/api/v1/faces/batch-similarity', request)
|
||||
return response.data
|
||||
},
|
||||
identify: async (faceId: number, payload: IdentifyFaceRequest): Promise<IdentifyFaceResponse> => {
|
||||
const response = await apiClient.post<IdentifyFaceResponse>(`/api/v1/faces/${faceId}/identify`, payload)
|
||||
return response.data
|
||||
},
|
||||
setExcluded: async (faceId: number, excluded: boolean): Promise<{ face_id: number; excluded: boolean; message: string }> => {
|
||||
const response = await apiClient.put<{ face_id: number; excluded: boolean; message: string }>(
|
||||
`/api/v1/faces/${faceId}/excluded?excluded=${excluded}`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
unmatch: async (faceId: number): Promise<FaceUnmatchResponse> => {
|
||||
const response = await apiClient.post<FaceUnmatchResponse>(`/api/v1/faces/${faceId}/unmatch`)
|
||||
return response.data
|
||||
},
|
||||
batchUnmatch: async (payload: BatchUnmatchRequest): Promise<BatchUnmatchResponse> => {
|
||||
const response = await apiClient.post<BatchUnmatchResponse>('/api/v1/faces/batch-unmatch', payload)
|
||||
return response.data
|
||||
},
|
||||
autoMatch: async (request: AutoMatchRequest): Promise<AutoMatchResponse> => {
|
||||
const response = await apiClient.post<AutoMatchResponse>('/api/v1/faces/auto-match', request)
|
||||
return response.data
|
||||
},
|
||||
getAutoMatchPeople: async (params?: {
|
||||
filter_frontal_only?: boolean
|
||||
}): Promise<AutoMatchPeopleResponse> => {
|
||||
const response = await apiClient.get<AutoMatchPeopleResponse>('/api/v1/faces/auto-match/people', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
getAutoMatchPersonMatches: async (
|
||||
personId: number,
|
||||
params?: {
|
||||
tolerance?: number
|
||||
filter_frontal_only?: boolean
|
||||
}
|
||||
): Promise<AutoMatchPersonMatchesResponse> => {
|
||||
const response = await apiClient.get<AutoMatchPersonMatchesResponse>(
|
||||
`/api/v1/faces/auto-match/people/${personId}/matches`,
|
||||
{ params }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
getMaintenanceFaces: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
min_quality?: number
|
||||
max_quality?: number
|
||||
excluded_filter?: 'all' | 'excluded' | 'included'
|
||||
identified_filter?: 'all' | 'identified' | 'unidentified'
|
||||
}): Promise<MaintenanceFacesResponse> => {
|
||||
const response = await apiClient.get<MaintenanceFacesResponse>('/api/v1/faces/maintenance', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
deleteFaces: async (request: DeleteFacesRequest): Promise<DeleteFacesResponse> => {
|
||||
const response = await apiClient.post<DeleteFacesResponse>('/api/v1/faces/delete', request)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default facesApi
|
||||
|
||||
42
admin-frontend/src/api/jobs.ts
Normal file
42
admin-frontend/src/api/jobs.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export enum JobStatus {
|
||||
PENDING = 'pending',
|
||||
STARTED = 'started',
|
||||
PROGRESS = 'progress',
|
||||
SUCCESS = 'success',
|
||||
FAILURE = 'failure',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export interface JobResponse {
|
||||
id: string
|
||||
status: JobStatus
|
||||
progress: number
|
||||
message: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const jobsApi = {
|
||||
getJob: async (jobId: string): Promise<JobResponse> => {
|
||||
const { data } = await apiClient.get<JobResponse>(
|
||||
`/api/v1/jobs/${jobId}`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
streamJobProgress: (jobId: string): EventSource => {
|
||||
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
|
||||
},
|
||||
|
||||
cancelJob: async (jobId: string): Promise<{ message: string; status: string }> => {
|
||||
const { data } = await apiClient.delete<{ message: string; status: string }>(
|
||||
`/api/v1/jobs/${jobId}`
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
95
admin-frontend/src/api/pendingIdentifications.ts
Normal file
95
admin-frontend/src/api/pendingIdentifications.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PendingIdentification {
|
||||
id: number
|
||||
face_id: number
|
||||
photo_id?: number | null
|
||||
user_id: number
|
||||
user_name?: string | null
|
||||
user_email: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
status: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface PendingIdentificationsListResponse {
|
||||
items: PendingIdentification[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ApproveDenyDecision {
|
||||
id: number
|
||||
decision: 'approve' | 'deny'
|
||||
}
|
||||
|
||||
export interface ApproveDenyRequest {
|
||||
decisions: ApproveDenyDecision[]
|
||||
}
|
||||
|
||||
export interface ApproveDenyResponse {
|
||||
approved: number
|
||||
denied: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface UserIdentificationStats {
|
||||
user_id: number
|
||||
username: string
|
||||
full_name: string
|
||||
email: string
|
||||
face_count: number
|
||||
first_identification_date: string | null
|
||||
last_identification_date: string | null
|
||||
}
|
||||
|
||||
export interface IdentificationReportResponse {
|
||||
items: UserIdentificationStats[]
|
||||
total_faces: number
|
||||
total_users: number
|
||||
}
|
||||
|
||||
export interface ClearDatabaseResponse {
|
||||
deleted_records: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export const pendingIdentificationsApi = {
|
||||
list: async (includeDenied: boolean = false): Promise<PendingIdentificationsListResponse> => {
|
||||
const res = await apiClient.get<PendingIdentificationsListResponse>(
|
||||
'/api/v1/pending-identifications',
|
||||
{ params: { include_denied: includeDenied } }
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
approveDeny: async (request: ApproveDenyRequest): Promise<ApproveDenyResponse> => {
|
||||
const res = await apiClient.post<ApproveDenyResponse>(
|
||||
'/api/v1/pending-identifications/approve-deny',
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
getReport: async (dateFrom?: string, dateTo?: string): Promise<IdentificationReportResponse> => {
|
||||
const params: Record<string, string> = {}
|
||||
if (dateFrom) params.date_from = dateFrom
|
||||
if (dateTo) params.date_to = dateTo
|
||||
const res = await apiClient.get<IdentificationReportResponse>(
|
||||
'/api/v1/pending-identifications/report',
|
||||
{ params }
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
clearDenied: async (): Promise<ClearDatabaseResponse> => {
|
||||
const res = await apiClient.post<ClearDatabaseResponse>(
|
||||
'/api/v1/pending-identifications/clear-denied'
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
}
|
||||
|
||||
export default pendingIdentificationsApi
|
||||
|
||||
71
admin-frontend/src/api/pendingLinkages.ts
Normal file
71
admin-frontend/src/api/pendingLinkages.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PendingLinkageResponse {
|
||||
id: number
|
||||
photo_id: number
|
||||
tag_id: number | null
|
||||
proposed_tag_name: string | null
|
||||
resolved_tag_name: string | null
|
||||
user_id: number
|
||||
user_name: string | null
|
||||
user_email: string | null
|
||||
status: string
|
||||
notes: string | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
photo_filename: string | null
|
||||
photo_path: string | null
|
||||
photo_media_type: string | null
|
||||
photo_tags: string[]
|
||||
}
|
||||
|
||||
export interface PendingLinkagesListResponse {
|
||||
items: PendingLinkageResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ReviewDecision {
|
||||
id: number
|
||||
decision: 'approve' | 'deny'
|
||||
}
|
||||
|
||||
export interface ReviewRequest {
|
||||
decisions: ReviewDecision[]
|
||||
}
|
||||
|
||||
export interface ReviewResponse {
|
||||
approved: number
|
||||
denied: number
|
||||
tags_created: number
|
||||
linkages_created: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
deleted_records: number
|
||||
errors: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export const pendingLinkagesApi = {
|
||||
async listPendingLinkages(statusFilter?: string): Promise<PendingLinkagesListResponse> {
|
||||
const { data } = await apiClient.get<PendingLinkagesListResponse>('/api/v1/pending-linkages', {
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
async reviewPendingLinkages(request: ReviewRequest): Promise<ReviewResponse> {
|
||||
const { data } = await apiClient.post<ReviewResponse>('/api/v1/pending-linkages/review', request)
|
||||
return data
|
||||
},
|
||||
|
||||
async cleanupPendingLinkages(): Promise<CleanupResponse> {
|
||||
const { data } = await apiClient.post<CleanupResponse>('/api/v1/pending-linkages/cleanup', {})
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default pendingLinkagesApi
|
||||
|
||||
|
||||
106
admin-frontend/src/api/pendingPhotos.ts
Normal file
106
admin-frontend/src/api/pendingPhotos.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PendingPhotoResponse {
|
||||
id: number
|
||||
user_id: number
|
||||
user_name: string | null
|
||||
user_email: string | null
|
||||
filename: string
|
||||
original_filename: string
|
||||
file_path: string
|
||||
file_size: number
|
||||
mime_type: string
|
||||
status: string
|
||||
submitted_at: string
|
||||
reviewed_at: string | null
|
||||
reviewed_by: number | null
|
||||
rejection_reason: string | null
|
||||
}
|
||||
|
||||
export interface PendingPhotosListResponse {
|
||||
items: PendingPhotoResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ReviewDecision {
|
||||
id: number
|
||||
decision: 'approve' | 'reject'
|
||||
rejection_reason?: string | null
|
||||
}
|
||||
|
||||
export interface ReviewRequest {
|
||||
decisions: ReviewDecision[]
|
||||
}
|
||||
|
||||
export interface ReviewResponse {
|
||||
approved: number
|
||||
rejected: number
|
||||
errors: string[]
|
||||
warnings?: string[] // Informational messages (e.g., duplicates)
|
||||
}
|
||||
|
||||
export interface CleanupResponse {
|
||||
deleted_files: number
|
||||
deleted_records: number
|
||||
errors: string[]
|
||||
warnings?: string[] // Informational messages (e.g., files already deleted)
|
||||
}
|
||||
|
||||
export const pendingPhotosApi = {
|
||||
listPendingPhotos: async (statusFilter?: string): Promise<PendingPhotosListResponse> => {
|
||||
const { data } = await apiClient.get<PendingPhotosListResponse>(
|
||||
'/api/v1/pending-photos',
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
getPendingPhotoImage: (photoId: number): string => {
|
||||
return `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photoId}/image`
|
||||
},
|
||||
|
||||
getPendingPhotoImageBlob: async (photoId: number): Promise<string> => {
|
||||
// Fetch image as blob with authentication
|
||||
const response = await apiClient.get(
|
||||
`/api/v1/pending-photos/${photoId}/image`,
|
||||
{
|
||||
responseType: 'blob',
|
||||
}
|
||||
)
|
||||
// Create object URL from blob
|
||||
return URL.createObjectURL(response.data)
|
||||
},
|
||||
|
||||
reviewPendingPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
|
||||
const { data } = await apiClient.post<ReviewResponse>(
|
||||
'/api/v1/pending-photos/review',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
cleanupFiles: async (statusFilter?: string): Promise<CleanupResponse> => {
|
||||
const { data } = await apiClient.post<CleanupResponse>(
|
||||
'/api/v1/pending-photos/cleanup-files',
|
||||
{},
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
cleanupDatabase: async (statusFilter?: string): Promise<CleanupResponse> => {
|
||||
const { data } = await apiClient.post<CleanupResponse>(
|
||||
'/api/v1/pending-photos/cleanup-database',
|
||||
{},
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
121
admin-frontend/src/api/people.ts
Normal file
121
admin-frontend/src/api/people.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface Person {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface PeopleListResponse {
|
||||
items: Person[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonWithFaces extends Person {
|
||||
face_count: number
|
||||
video_count: number
|
||||
}
|
||||
|
||||
export interface PeopleWithFacesListResponse {
|
||||
items: PersonWithFaces[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonCreateRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface PersonUpdateRequest {
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export const peopleApi = {
|
||||
list: async (lastName?: string): Promise<PeopleListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleListResponse>('/api/v1/people', { params })
|
||||
return res.data
|
||||
},
|
||||
listWithFaces: async (lastName?: string): Promise<PeopleWithFacesListResponse> => {
|
||||
const params = lastName ? { last_name: lastName } : {}
|
||||
const res = await apiClient.get<PeopleWithFacesListResponse>('/api/v1/people/with-faces', { params })
|
||||
return res.data
|
||||
},
|
||||
create: async (payload: PersonCreateRequest): Promise<Person> => {
|
||||
const res = await apiClient.post<Person>('/api/v1/people', payload)
|
||||
return res.data
|
||||
},
|
||||
update: async (personId: number, payload: PersonUpdateRequest): Promise<Person> => {
|
||||
const res = await apiClient.put<Person>(`/api/v1/people/${personId}`, payload)
|
||||
return res.data
|
||||
},
|
||||
getFaces: async (personId: number): Promise<PersonFacesResponse> => {
|
||||
const res = await apiClient.get<PersonFacesResponse>(`/api/v1/people/${personId}/faces`)
|
||||
return res.data
|
||||
},
|
||||
getVideos: async (personId: number): Promise<PersonVideosResponse> => {
|
||||
const res = await apiClient.get<PersonVideosResponse>(`/api/v1/people/${personId}/videos`)
|
||||
return res.data
|
||||
},
|
||||
acceptMatches: async (personId: number, faceIds: number[]): Promise<IdentifyFaceResponse> => {
|
||||
const res = await apiClient.post<IdentifyFaceResponse>(`/api/v1/people/${personId}/accept-matches`, { face_ids: faceIds })
|
||||
return res.data
|
||||
},
|
||||
delete: async (personId: number): Promise<void> => {
|
||||
await apiClient.delete(`/api/v1/people/${personId}`)
|
||||
},
|
||||
}
|
||||
|
||||
export interface IdentifyFaceResponse {
|
||||
identified_face_ids: number[]
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
}
|
||||
|
||||
export interface PersonFaceItem {
|
||||
id: number
|
||||
photo_id: number
|
||||
photo_path: string
|
||||
photo_filename: string
|
||||
location: string
|
||||
face_confidence: number
|
||||
quality_score: number
|
||||
detector_backend: string
|
||||
model_name: string
|
||||
}
|
||||
|
||||
export interface PersonFacesResponse {
|
||||
person_id: number
|
||||
items: PersonFaceItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PersonVideoItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
date_taken: string | null
|
||||
date_added: string
|
||||
linkage_id: number
|
||||
}
|
||||
|
||||
export interface PersonVideosResponse {
|
||||
person_id: number
|
||||
items: PersonVideoItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export default peopleApi
|
||||
|
||||
|
||||
|
||||
168
admin-frontend/src/api/photos.ts
Normal file
168
admin-frontend/src/api/photos.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PhotoImportRequest {
|
||||
folder_path: string
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
export interface PhotoImportResponse {
|
||||
job_id: string
|
||||
message: string
|
||||
folder_path?: string
|
||||
estimated_photos?: number
|
||||
}
|
||||
|
||||
export interface PhotoResponse {
|
||||
id: number
|
||||
path: string
|
||||
filename: string
|
||||
checksum?: string
|
||||
date_added: string
|
||||
date_taken?: string
|
||||
width?: number
|
||||
height?: number
|
||||
mime_type?: string
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
message: string
|
||||
added: number
|
||||
existing: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface BulkDeletePhotosResponse {
|
||||
message: string
|
||||
deleted_count: number
|
||||
missing_photo_ids: number[]
|
||||
}
|
||||
|
||||
export const photosApi = {
|
||||
importPhotos: async (
|
||||
request: PhotoImportRequest
|
||||
): Promise<PhotoImportResponse> => {
|
||||
const { data } = await apiClient.post<PhotoImportResponse>(
|
||||
'/api/v1/photos/import',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
uploadPhotos: async (files: File[]): Promise<UploadResponse> => {
|
||||
const formData = new FormData()
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
// Don't set Content-Type header manually - let the browser set it with boundary
|
||||
const { data } = await apiClient.post<UploadResponse>(
|
||||
'/api/v1/photos/import/upload',
|
||||
formData
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
getPhoto: async (photoId: number): Promise<PhotoResponse> => {
|
||||
const { data } = await apiClient.get<PhotoResponse>(
|
||||
`/api/v1/photos/${photoId}`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
streamJobProgress: (jobId: string): EventSource => {
|
||||
// EventSource needs absolute URL - use VITE_API_URL or fallback to direct backend URL
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return new EventSource(`${baseURL}/api/v1/jobs/stream/${jobId}`)
|
||||
},
|
||||
|
||||
searchPhotos: async (params: {
|
||||
search_type: 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' | 'favorites'
|
||||
person_name?: string
|
||||
tag_names?: string
|
||||
match_all?: boolean
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
folder_path?: string
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<SearchPhotosResponse> => {
|
||||
const { data } = await apiClient.get<SearchPhotosResponse>('/api/v1/photos', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
toggleFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean; message: string }> => {
|
||||
const { data } = await apiClient.post(
|
||||
`/api/v1/photos/${photoId}/toggle-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
checkFavorite: async (photoId: number): Promise<{ photo_id: number; is_favorite: boolean }> => {
|
||||
const { data } = await apiClient.get(
|
||||
`/api/v1/photos/${photoId}/is-favorite`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkAddFavorites: async (photoIds: number[]): Promise<{ message: string; added_count: number; already_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-add-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkRemoveFavorites: async (photoIds: number[]): Promise<{ message: string; removed_count: number; not_favorite_count: number; total_requested: number }> => {
|
||||
const { data } = await apiClient.post(
|
||||
'/api/v1/photos/bulk-remove-favorites',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
bulkDeletePhotos: async (photoIds: number[]): Promise<BulkDeletePhotosResponse> => {
|
||||
const { data } = await apiClient.post<BulkDeletePhotosResponse>(
|
||||
'/api/v1/photos/bulk-delete',
|
||||
{ photo_ids: photoIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
openFolder: async (photoId: number): Promise<{ message: string; folder: string }> => {
|
||||
const { data } = await apiClient.post<{ message: string; folder: string }>(
|
||||
`/api/v1/photos/${photoId}/open-folder`
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
browseFolder: async (): Promise<{ path: string; success: boolean; message?: string }> => {
|
||||
const { data } = await apiClient.post<{ path: string; success: boolean; message?: string }>(
|
||||
'/api/v1/photos/browse-folder'
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export interface PhotoSearchResult {
|
||||
id: number
|
||||
path: string
|
||||
filename: string
|
||||
date_taken?: string
|
||||
date_added: string
|
||||
processed: boolean
|
||||
person_name?: string
|
||||
tags: string[]
|
||||
has_faces: boolean
|
||||
face_count: number
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface SearchPhotosResponse {
|
||||
items: PhotoSearchResult[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
74
admin-frontend/src/api/reportedPhotos.ts
Normal file
74
admin-frontend/src/api/reportedPhotos.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface ReportedPhotoResponse {
|
||||
id: number
|
||||
photo_id: number
|
||||
user_id: number
|
||||
user_name: string | null
|
||||
user_email: string | null
|
||||
status: string
|
||||
reported_at: string
|
||||
reviewed_at: string | null
|
||||
reviewed_by: number | null
|
||||
review_notes: string | null
|
||||
report_comment: string | null
|
||||
photo_path: string | null
|
||||
photo_filename: string | null
|
||||
photo_media_type: string | null
|
||||
}
|
||||
|
||||
export interface ReportedPhotosListResponse {
|
||||
items: ReportedPhotoResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface ReviewDecision {
|
||||
id: number
|
||||
decision: 'keep' | 'remove'
|
||||
review_notes?: string | null
|
||||
}
|
||||
|
||||
export interface ReviewRequest {
|
||||
decisions: ReviewDecision[]
|
||||
}
|
||||
|
||||
export interface ReviewResponse {
|
||||
kept: number
|
||||
removed: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface ReportedCleanupResponse {
|
||||
deleted_records: number
|
||||
errors: string[]
|
||||
warnings?: string[]
|
||||
}
|
||||
|
||||
export const reportedPhotosApi = {
|
||||
listReportedPhotos: async (statusFilter?: string): Promise<ReportedPhotosListResponse> => {
|
||||
const { data } = await apiClient.get<ReportedPhotosListResponse>(
|
||||
'/api/v1/reported-photos',
|
||||
{
|
||||
params: statusFilter ? { status_filter: statusFilter } : undefined,
|
||||
}
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
reviewReportedPhotos: async (request: ReviewRequest): Promise<ReviewResponse> => {
|
||||
const { data } = await apiClient.post<ReviewResponse>(
|
||||
'/api/v1/reported-photos/review',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
cleanupReportedPhotos: async (): Promise<ReportedCleanupResponse> => {
|
||||
const { data } = await apiClient.post<ReportedCleanupResponse>(
|
||||
'/api/v1/reported-photos/cleanup',
|
||||
{},
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
42
admin-frontend/src/api/rolePermissions.ts
Normal file
42
admin-frontend/src/api/rolePermissions.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import apiClient from './client'
|
||||
import { UserRoleValue } from './users'
|
||||
|
||||
export interface RoleFeature {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type RolePermissionsMap = Record<UserRoleValue, Record<string, boolean>>
|
||||
|
||||
export interface RolePermissionsResponse {
|
||||
features: RoleFeature[]
|
||||
permissions: RolePermissionsMap
|
||||
}
|
||||
|
||||
export interface RolePermissionsUpdateRequest {
|
||||
permissions: RolePermissionsMap
|
||||
}
|
||||
|
||||
export const rolePermissionsApi = {
|
||||
async listPermissions(): Promise<RolePermissionsResponse> {
|
||||
const { data } = await apiClient.get<RolePermissionsResponse>('/api/v1/role-permissions')
|
||||
return data
|
||||
},
|
||||
|
||||
async updatePermissions(
|
||||
request: RolePermissionsUpdateRequest
|
||||
): Promise<RolePermissionsResponse> {
|
||||
const { data } = await apiClient.put<RolePermissionsResponse>(
|
||||
'/api/v1/role-permissions',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
118
admin-frontend/src/api/tags.ts
Normal file
118
admin-frontend/src/api/tags.ts
Normal file
@ -0,0 +1,118 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface TagResponse {
|
||||
id: number
|
||||
tag_name: string
|
||||
created_date: string
|
||||
}
|
||||
|
||||
export interface TagsResponse {
|
||||
items: TagResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface PhotoTagsRequest {
|
||||
photo_ids: number[]
|
||||
tag_names: string[]
|
||||
}
|
||||
|
||||
export interface PhotoTagsResponse {
|
||||
message: string
|
||||
photos_updated: number
|
||||
tags_added: number
|
||||
tags_removed: number
|
||||
}
|
||||
|
||||
export interface PhotoTagItem {
|
||||
tag_id: number
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
export interface PhotoTagsListResponse {
|
||||
photo_id: number
|
||||
tags: PhotoTagItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface TagUpdateRequest {
|
||||
tag_name: string
|
||||
}
|
||||
|
||||
export interface TagDeleteRequest {
|
||||
tag_ids: number[]
|
||||
}
|
||||
|
||||
export interface PhotoWithTagsItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
processed: boolean
|
||||
date_taken?: string | null
|
||||
date_added?: string | null
|
||||
face_count: number
|
||||
unidentified_face_count: number // Count of faces with person_id IS NULL
|
||||
tags: string // Comma-separated tags string
|
||||
people_names: string // Comma-separated people names string
|
||||
media_type?: string | null // 'image' or 'video'
|
||||
}
|
||||
|
||||
export interface PhotosWithTagsResponse {
|
||||
items: PhotoWithTagsItem[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: async (): Promise<TagsResponse> => {
|
||||
const { data } = await apiClient.get<TagsResponse>('/api/v1/tags')
|
||||
return data
|
||||
},
|
||||
|
||||
create: async (tagName: string): Promise<TagResponse> => {
|
||||
const { data } = await apiClient.post<TagResponse>('/api/v1/tags', {
|
||||
tag_name: tagName,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
addToPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
|
||||
const { data } = await apiClient.post<PhotoTagsResponse>(
|
||||
'/api/v1/tags/photos/add',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
removeFromPhotos: async (request: PhotoTagsRequest): Promise<PhotoTagsResponse> => {
|
||||
const { data } = await apiClient.post<PhotoTagsResponse>(
|
||||
'/api/v1/tags/photos/remove',
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
getPhotoTags: async (photoId: number): Promise<PhotoTagsListResponse> => {
|
||||
const { data } = await apiClient.get<PhotoTagsListResponse>(
|
||||
`/api/v1/tags/photos/${photoId}`
|
||||
)
|
||||
return data
|
||||
},
|
||||
update: async (tagId: number, tagName: string): Promise<TagResponse> => {
|
||||
const { data } = await apiClient.put<TagResponse>(`/api/v1/tags/${tagId}`, {
|
||||
tag_name: tagName,
|
||||
})
|
||||
return data
|
||||
},
|
||||
delete: async (tagIds: number[]): Promise<{ message: string; deleted_count: number }> => {
|
||||
const { data } = await apiClient.post<{ message: string; deleted_count: number }>(
|
||||
'/api/v1/tags/delete',
|
||||
{ tag_ids: tagIds }
|
||||
)
|
||||
return data
|
||||
},
|
||||
getPhotosWithTags: async (): Promise<PhotosWithTagsResponse> => {
|
||||
const { data } = await apiClient.get<PhotosWithTagsResponse>('/api/v1/tags/photos')
|
||||
return data
|
||||
},
|
||||
}
|
||||
|
||||
export default tagsApi
|
||||
|
||||
88
admin-frontend/src/api/users.ts
Normal file
88
admin-frontend/src/api/users.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export type UserRoleValue =
|
||||
| 'admin'
|
||||
| 'manager'
|
||||
| 'moderator'
|
||||
| 'reviewer'
|
||||
| 'editor'
|
||||
| 'importer'
|
||||
| 'viewer'
|
||||
|
||||
export interface UserResponse {
|
||||
id: number
|
||||
username: string
|
||||
email: string | null
|
||||
full_name: string | null
|
||||
is_active: boolean
|
||||
is_admin: boolean
|
||||
role?: UserRoleValue | null
|
||||
created_date: string
|
||||
last_login: string | null
|
||||
}
|
||||
|
||||
export interface UserCreateRequest {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
role: UserRoleValue
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
export interface UserUpdateRequest {
|
||||
password?: string | null
|
||||
email: string
|
||||
full_name: string
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
role?: UserRoleValue
|
||||
give_frontend_permission?: boolean
|
||||
}
|
||||
|
||||
export interface UsersListResponse {
|
||||
items: UserResponse[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const usersApi = {
|
||||
listUsers: async (params?: {
|
||||
is_active?: boolean
|
||||
is_admin?: boolean
|
||||
}): Promise<UsersListResponse> => {
|
||||
const { data } = await apiClient.get<UsersListResponse>('/api/v1/users', {
|
||||
params,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
getUser: async (userId: number): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.get<UserResponse>(`/api/v1/users/${userId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
createUser: async (request: UserCreateRequest): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.post<UserResponse>('/api/v1/users', request)
|
||||
return data
|
||||
},
|
||||
|
||||
updateUser: async (
|
||||
userId: number,
|
||||
request: UserUpdateRequest
|
||||
): Promise<UserResponse> => {
|
||||
const { data } = await apiClient.put<UserResponse>(
|
||||
`/api/v1/users/${userId}`,
|
||||
request
|
||||
)
|
||||
return data
|
||||
},
|
||||
|
||||
deleteUser: async (userId: number): Promise<{ message?: string; deactivated?: boolean }> => {
|
||||
const response = await apiClient.delete(`/api/v1/users/${userId}`)
|
||||
// Return data if present (200 OK with deactivation message), otherwise empty object (204 No Content)
|
||||
return response.data || {}
|
||||
},
|
||||
}
|
||||
|
||||
128
admin-frontend/src/api/videos.ts
Normal file
128
admin-frontend/src/api/videos.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import apiClient from './client'
|
||||
|
||||
export interface PersonInfo {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface VideoListItem {
|
||||
id: number
|
||||
filename: string
|
||||
path: string
|
||||
date_taken: string | null
|
||||
date_added: string
|
||||
identified_people: PersonInfo[]
|
||||
identified_people_count: number
|
||||
}
|
||||
|
||||
export interface ListVideosResponse {
|
||||
items: VideoListItem[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface VideoPersonInfo {
|
||||
person_id: number
|
||||
first_name: string
|
||||
last_name: string
|
||||
middle_name?: string | null
|
||||
maiden_name?: string | null
|
||||
date_of_birth?: string | null
|
||||
identified_by: string | null
|
||||
identified_date: string
|
||||
}
|
||||
|
||||
export interface VideoPeopleResponse {
|
||||
video_id: number
|
||||
people: VideoPersonInfo[]
|
||||
}
|
||||
|
||||
export interface IdentifyVideoRequest {
|
||||
person_id?: number
|
||||
first_name?: string
|
||||
last_name?: string
|
||||
middle_name?: string
|
||||
maiden_name?: string
|
||||
date_of_birth?: string | null
|
||||
}
|
||||
|
||||
export interface IdentifyVideoResponse {
|
||||
video_id: number
|
||||
person_id: number
|
||||
created_person: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface RemoveVideoPersonResponse {
|
||||
video_id: number
|
||||
person_id: number
|
||||
removed: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
export const videosApi = {
|
||||
listVideos: async (params: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
folder_path?: string
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
has_people?: boolean
|
||||
person_name?: string
|
||||
sort_by?: string
|
||||
sort_dir?: string
|
||||
}): Promise<ListVideosResponse> => {
|
||||
const res = await apiClient.get<ListVideosResponse>('/api/v1/videos', { params })
|
||||
return res.data
|
||||
},
|
||||
|
||||
getVideoPeople: async (videoId: number): Promise<VideoPeopleResponse> => {
|
||||
const res = await apiClient.get<VideoPeopleResponse>(`/api/v1/videos/${videoId}/people`)
|
||||
return res.data
|
||||
},
|
||||
|
||||
identifyPerson: async (
|
||||
videoId: number,
|
||||
request: IdentifyVideoRequest
|
||||
): Promise<IdentifyVideoResponse> => {
|
||||
const res = await apiClient.post<IdentifyVideoResponse>(
|
||||
`/api/v1/videos/${videoId}/identify`,
|
||||
request
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
removePerson: async (
|
||||
videoId: number,
|
||||
personId: number
|
||||
): Promise<RemoveVideoPersonResponse> => {
|
||||
const res = await apiClient.delete<RemoveVideoPersonResponse>(
|
||||
`/api/v1/videos/${videoId}/people/${personId}`
|
||||
)
|
||||
return res.data
|
||||
},
|
||||
|
||||
getThumbnailUrl: (videoId: number): string => {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return `${baseURL}/api/v1/videos/${videoId}/thumbnail`
|
||||
},
|
||||
|
||||
getVideoUrl: (videoId: number): string => {
|
||||
const baseURL = import.meta.env.VITE_API_URL || 'http://127.0.0.1:8000'
|
||||
return `${baseURL}/api/v1/videos/${videoId}/video`
|
||||
},
|
||||
}
|
||||
|
||||
export default videosApi
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
30
admin-frontend/src/components/AdminRoute.tsx
Normal file
30
admin-frontend/src/components/AdminRoute.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
interface AdminRouteProps {
|
||||
children: React.ReactNode
|
||||
featureKey?: string
|
||||
}
|
||||
|
||||
export default function AdminRoute({ children, featureKey }: AdminRouteProps) {
|
||||
const { isAuthenticated, isLoading, isAdmin, hasPermission } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
if (featureKey) {
|
||||
if (!hasPermission(featureKey)) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
} else if (!isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
182
admin-frontend/src/components/Layout.tsx
Normal file
182
admin-frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { useInactivityTimeout } from '../hooks/useInactivityTimeout'
|
||||
|
||||
const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000
|
||||
|
||||
type NavItem = {
|
||||
path: string
|
||||
label: string
|
||||
icon: string
|
||||
featureKey?: string
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const location = useLocation()
|
||||
const { username, logout, isAuthenticated, hasPermission } = useAuth()
|
||||
const [maintenanceExpanded, setMaintenanceExpanded] = useState(true)
|
||||
|
||||
const handleInactivityLogout = useCallback(() => {
|
||||
logout()
|
||||
}, [logout])
|
||||
|
||||
useInactivityTimeout({
|
||||
timeoutMs: INACTIVITY_TIMEOUT_MS,
|
||||
onTimeout: handleInactivityLogout,
|
||||
isEnabled: isAuthenticated,
|
||||
})
|
||||
|
||||
const primaryNavItems: NavItem[] = [
|
||||
{ path: '/scan', label: 'Scan', icon: '🗂️', featureKey: 'scan' },
|
||||
{ path: '/process', label: 'Process', icon: '⚙️', featureKey: 'process' },
|
||||
{ path: '/search', label: 'Search Photos', icon: '🔍', featureKey: 'search_photos' },
|
||||
{ path: '/identify', label: 'Identify People', icon: '👤', featureKey: 'identify_people' },
|
||||
{ path: '/auto-match', label: 'Auto-Match', icon: '🤖', featureKey: 'auto_match' },
|
||||
{ path: '/modify', label: 'Modify People', icon: '✏️', featureKey: 'modify_people' },
|
||||
{ path: '/tags', label: 'Tag Photos', icon: '🏷️', featureKey: 'tag_photos' },
|
||||
]
|
||||
|
||||
const maintenanceNavItems: NavItem[] = [
|
||||
{ path: '/faces-maintenance', label: 'Faces', icon: '🔧', featureKey: 'faces_maintenance' },
|
||||
{ path: '/approve-identified', label: 'User Identified Faces', icon: '✅', featureKey: 'user_identified' },
|
||||
{ path: '/reported-photos', label: 'User Reported Photos', icon: '🚩', featureKey: 'user_reported' },
|
||||
{ path: '/pending-linkages', label: 'User Tagged Photos', icon: '🔖', featureKey: 'user_tagged' },
|
||||
{ path: '/pending-photos', label: 'User Uploaded Photos', icon: '📤', featureKey: 'user_uploaded' },
|
||||
{ path: '/manage-users', label: 'Users', icon: '👥', featureKey: 'manage_users' },
|
||||
]
|
||||
|
||||
const footerNavItems: NavItem[] = [{ path: '/help', label: 'Help', icon: '📚' }]
|
||||
|
||||
const filterNavItems = (items: NavItem[]) =>
|
||||
items.filter((item) => !item.featureKey || hasPermission(item.featureKey))
|
||||
|
||||
const renderNavLink = (
|
||||
item: { path: string; label: string; icon: string },
|
||||
extraClasses = ''
|
||||
) => {
|
||||
const isActive = location.pathname === item.path
|
||||
return (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'
|
||||
} ${extraClasses}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const visiblePrimary = filterNavItems(primaryNavItems)
|
||||
const visibleMaintenance = filterNavItems(maintenanceNavItems)
|
||||
const visibleFooter = filterNavItems(footerNavItems)
|
||||
|
||||
// Get page title based on route
|
||||
const getPageTitle = () => {
|
||||
const route = location.pathname
|
||||
if (route === '/') return '🏠 Home Page'
|
||||
if (route === '/scan') return '🗂️ Scan Photos'
|
||||
if (route === '/process') return '⚙️ Process Faces'
|
||||
if (route === '/search') return '🔍 Search Photos'
|
||||
if (route === '/identify') return '👤 Identify'
|
||||
if (route === '/auto-match') return '🤖 Auto-Match Faces'
|
||||
if (route === '/modify') return '✏️ Modify Identified'
|
||||
if (route === '/tags') return '🏷️ Photos tagging interface'
|
||||
if (route === '/manage-photos') return 'Manage Photos'
|
||||
if (route === '/faces-maintenance') return '🔧 Faces Maintenance'
|
||||
if (route === '/approve-identified') return '✅ Approve Identified'
|
||||
if (route === '/manage-users') return '👥 Manage Users'
|
||||
if (route === '/reported-photos') return '🚩 Reported Photos'
|
||||
if (route === '/pending-linkages') return '🔖 User Tagged Photos'
|
||||
if (route === '/pending-photos') return '📤 Manage User Uploaded Photos'
|
||||
if (route === '/settings') return 'Settings'
|
||||
if (route === '/help') return '📚 Help'
|
||||
return 'PunimTag'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Top bar */}
|
||||
<div className="bg-white border-b border-gray-200 shadow-sm">
|
||||
<div className="flex">
|
||||
{/* Left sidebar - fixed position with logo */}
|
||||
<div className="fixed left-0 top-0 w-64 bg-white border-r border-gray-200 h-20 flex items-center justify-center px-4 z-10">
|
||||
<Link to="/" className="flex items-center justify-center hover:opacity-80 transition-opacity">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="PunimTag"
|
||||
className="h-12 w-auto"
|
||||
onError={(e) => {
|
||||
// Fallback if logo.png doesn't exist, try logo.svg
|
||||
const target = e.target as HTMLImageElement
|
||||
if (target.src.endsWith('logo.png')) {
|
||||
target.src = '/logo.svg'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Header content - aligned with main content */}
|
||||
<div className="ml-64 flex-1 px-4">
|
||||
<div className="flex justify-between items-center h-20">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-lg font-bold text-gray-900">{getPageTitle()}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-gray-600">{username}</span>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-900"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex relative">
|
||||
{/* Left sidebar - fixed position */}
|
||||
<div className="fixed left-0 top-20 w-64 bg-white border-r border-gray-200 h-[calc(100vh-5rem)] overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{visiblePrimary.map((item) => renderNavLink(item))}
|
||||
|
||||
{visibleMaintenance.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMaintenanceExpanded((prev) => !prev)}
|
||||
className="w-full px-3 py-2 text-xs font-semibold uppercase tracking-wide text-gray-500 flex items-center justify-between hover:text-gray-700"
|
||||
>
|
||||
<span>Maintenance</span>
|
||||
<span>{maintenanceExpanded ? '▼' : '▶'}</span>
|
||||
</button>
|
||||
{maintenanceExpanded && (
|
||||
<div className="mt-1 space-y-1">
|
||||
{visibleMaintenance.map((item) => renderNavLink(item, 'ml-4'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleFooter.length > 0 && (
|
||||
<div className="mt-4 space-y-1">
|
||||
{visibleFooter.map((item) => renderNavLink(item))}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main content - with left margin to account for fixed sidebar */}
|
||||
<div className="flex-1 ml-64 p-4">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
129
admin-frontend/src/components/PasswordChangeModal.tsx
Normal file
129
admin-frontend/src/components/PasswordChangeModal.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState } from 'react'
|
||||
import { authApi } from '../api/auth'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
interface PasswordChangeModalProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export default function PasswordChangeModal({ onSuccess }: PasswordChangeModalProps) {
|
||||
const { clearPasswordChangeRequired } = useAuth()
|
||||
const [currentPassword, setCurrentPassword] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError(null)
|
||||
|
||||
// Validation
|
||||
if (!currentPassword || !newPassword || !confirmPassword) {
|
||||
setError('All fields are required')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
setError('New password must be at least 6 characters')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('New passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
if (currentPassword === newPassword) {
|
||||
setError('New password must be different from current password')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
await authApi.changePassword({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
})
|
||||
clearPasswordChangeRequired()
|
||||
onSuccess()
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Failed to change password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">Change Password Required</h2>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
You must change your password before continuing. Please enter your current password
|
||||
(provided by your administrator) and choose a new password.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Current Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
New Password * (min 6 characters)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Confirm New Password *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
430
admin-frontend/src/components/PhotoViewer.tsx
Normal file
430
admin-frontend/src/components/PhotoViewer.tsx
Normal file
@ -0,0 +1,430 @@
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { PhotoSearchResult, photosApi } from '../api/photos'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
interface PhotoViewerProps {
|
||||
photos: PhotoSearchResult[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const ZOOM_MIN = 0.5
|
||||
const ZOOM_MAX = 5
|
||||
const ZOOM_STEP = 0.25
|
||||
const SLIDESHOW_INTERVALS = [
|
||||
{ value: 1, label: '1s' },
|
||||
{ value: 2, label: '2s' },
|
||||
{ value: 3, label: '3s' },
|
||||
{ value: 5, label: '5s' },
|
||||
{ value: 10, label: '10s' },
|
||||
]
|
||||
|
||||
export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoViewerProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
const [imageLoading, setImageLoading] = useState(true)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
const preloadedImages = useRef<Set<number>>(new Set())
|
||||
|
||||
// Zoom state
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [panX, setPanX] = useState(0)
|
||||
const [panY, setPanY] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const imageContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Slideshow state
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds
|
||||
const slideshowTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Favorite state
|
||||
const [isFavorite, setIsFavorite] = useState(false)
|
||||
const [loadingFavorite, setLoadingFavorite] = useState(false)
|
||||
|
||||
const currentPhoto = photos[currentIndex]
|
||||
const canGoPrev = currentIndex > 0
|
||||
const canGoNext = currentIndex < photos.length - 1
|
||||
|
||||
// Get photo URL
|
||||
const getPhotoUrl = (photoId: number) => {
|
||||
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
}
|
||||
|
||||
// Preload adjacent images
|
||||
const preloadAdjacent = (index: number) => {
|
||||
// Preload next photo
|
||||
if (index + 1 < photos.length) {
|
||||
const nextPhotoId = photos[index + 1].id
|
||||
if (!preloadedImages.current.has(nextPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(nextPhotoId)
|
||||
preloadedImages.current.add(nextPhotoId)
|
||||
}
|
||||
}
|
||||
// Preload previous photo
|
||||
if (index - 1 >= 0) {
|
||||
const prevPhotoId = photos[index - 1].id
|
||||
if (!preloadedImages.current.has(prevPhotoId)) {
|
||||
const img = new Image()
|
||||
img.src = getPhotoUrl(prevPhotoId)
|
||||
preloadedImages.current.add(prevPhotoId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle navigation
|
||||
const goPrev = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
// Reset zoom when navigating
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
if (currentIndex < photos.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
// Reset zoom when navigating
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
}
|
||||
|
||||
// Zoom functions
|
||||
const zoomIn = () => {
|
||||
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
|
||||
}
|
||||
|
||||
const zoomOut = () => {
|
||||
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
|
||||
}
|
||||
|
||||
const resetZoom = () => {
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
// Zoom with Ctrl/Cmd + wheel
|
||||
e.preventDefault()
|
||||
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP
|
||||
setZoom(prev => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta)))
|
||||
}
|
||||
}
|
||||
|
||||
// Pan (drag) functionality
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (zoom > 1) {
|
||||
setIsDragging(true)
|
||||
setDragStart({ x: e.clientX - panX, y: e.clientY - panY })
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (isDragging && zoom > 1) {
|
||||
setPanX(e.clientX - dragStart.x)
|
||||
setPanY(e.clientY - dragStart.y)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
// Slideshow functions
|
||||
const toggleSlideshow = () => {
|
||||
setIsPlaying(prev => !prev)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying) {
|
||||
slideshowTimerRef.current = setInterval(() => {
|
||||
setCurrentIndex(prev => {
|
||||
if (prev < photos.length - 1) {
|
||||
return prev + 1
|
||||
} else {
|
||||
// Loop back to start or stop
|
||||
setIsPlaying(false)
|
||||
return prev
|
||||
}
|
||||
})
|
||||
// Reset zoom when slideshow advances
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}, slideshowInterval * 1000)
|
||||
} else {
|
||||
if (slideshowTimerRef.current) {
|
||||
clearInterval(slideshowTimerRef.current)
|
||||
slideshowTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (slideshowTimerRef.current) {
|
||||
clearInterval(slideshowTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [isPlaying, slideshowInterval, photos.length])
|
||||
|
||||
// Handle image load
|
||||
useEffect(() => {
|
||||
if (!currentPhoto) return
|
||||
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
// Reset zoom when photo changes
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
|
||||
// Load favorite status when photo changes
|
||||
photosApi.checkFavorite(currentPhoto.id)
|
||||
.then(result => setIsFavorite(result.is_favorite))
|
||||
.catch(err => {
|
||||
console.error('Error checking favorite:', err)
|
||||
setIsFavorite(false)
|
||||
})
|
||||
|
||||
// Preload adjacent images when current photo changes
|
||||
preloadAdjacent(currentIndex)
|
||||
}, [currentIndex, currentPhoto, photos.length])
|
||||
|
||||
// Toggle favorite
|
||||
const toggleFavorite = async () => {
|
||||
if (loadingFavorite || !currentPhoto) return
|
||||
|
||||
setLoadingFavorite(true)
|
||||
try {
|
||||
const result = await photosApi.toggleFavorite(currentPhoto.id)
|
||||
setIsFavorite(result.is_favorite)
|
||||
} catch (error) {
|
||||
console.error('Error toggling favorite:', error)
|
||||
alert('Error updating favorite status')
|
||||
} finally {
|
||||
setLoadingFavorite(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowLeft' && !isPlaying) {
|
||||
e.preventDefault()
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
} else if (e.key === 'ArrowRight' && !isPlaying) {
|
||||
e.preventDefault()
|
||||
if (currentIndex < photos.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
}
|
||||
} else if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault()
|
||||
setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX))
|
||||
} else if (e.key === '-' || e.key === '_') {
|
||||
e.preventDefault()
|
||||
setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN))
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
setZoom(1)
|
||||
setPanX(0)
|
||||
setPanY(0)
|
||||
} else if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
setIsPlaying(prev => !prev)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentIndex, photos.length, onClose, isPlaying])
|
||||
|
||||
if (!currentPhoto) {
|
||||
return null
|
||||
}
|
||||
|
||||
const photoUrl = getPhotoUrl(currentPhoto.id)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
|
||||
{/* Top Left Info Corner */}
|
||||
<div className="absolute top-0 left-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-br-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-2 py-1 bg-gray-700 hover:bg-gray-600 rounded text-xs"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div className="text-xs">
|
||||
{currentIndex + 1} / {photos.length}
|
||||
</div>
|
||||
{currentPhoto.filename && (
|
||||
<div className="text-xs text-gray-300 truncate max-w-xs">
|
||||
{currentPhoto.filename}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Controls */}
|
||||
<div className="absolute top-0 right-0 z-10 bg-black bg-opacity-70 text-white p-2 rounded-bl-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={toggleFavorite}
|
||||
disabled={loadingFavorite}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
isFavorite
|
||||
? 'bg-yellow-600 hover:bg-yellow-700'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
} disabled:opacity-50`}
|
||||
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite ? '⭐' : '☆'}
|
||||
</button>
|
||||
|
||||
{/* Slideshow controls */}
|
||||
{isPlaying && (
|
||||
<select
|
||||
value={slideshowInterval}
|
||||
onChange={(e) => setSlideshowInterval(Number(e.target.value))}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="px-2 py-1 bg-gray-700 rounded text-xs"
|
||||
title="Slideshow speed"
|
||||
>
|
||||
{SLIDESHOW_INTERVALS.map(interval => (
|
||||
<option key={interval.value} value={interval.value}>
|
||||
{interval.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleSlideshow}
|
||||
className={`px-3 py-1 rounded text-xs ${
|
||||
isPlaying
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
}`}
|
||||
title={isPlaying ? 'Pause slideshow (Space)' : 'Start slideshow (Space)'}
|
||||
>
|
||||
{isPlaying ? '⏸' : '▶'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Image Area */}
|
||||
<div
|
||||
ref={imageContainerRef}
|
||||
className="flex-1 flex items-center justify-center relative overflow-hidden"
|
||||
onWheel={handleWheel}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
style={{ cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }}
|
||||
>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
|
||||
<div className="text-white text-lg">Loading...</div>
|
||||
</div>
|
||||
)}
|
||||
{imageError ? (
|
||||
<div className="text-white text-center">
|
||||
<div className="text-lg mb-2">Failed to load image</div>
|
||||
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
transform: `translate(${panX}px, ${panY}px) scale(${zoom})`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={photoUrl}
|
||||
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ userSelect: 'none', pointerEvents: 'none' }}
|
||||
onLoad={() => setImageLoading(false)}
|
||||
onError={() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<div className="absolute top-12 right-4 z-30 flex flex-col gap-2">
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= ZOOM_MAX}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom in (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<div className="px-3 py-1 bg-black bg-opacity-70 text-white rounded text-center text-xs">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= ZOOM_MIN}
|
||||
className="px-3 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="Zoom out (Ctrl/Cmd + Wheel)"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
{zoom !== 1 && (
|
||||
<button
|
||||
onClick={resetZoom}
|
||||
className="px-3 py-1 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded text-xs"
|
||||
title="Reset zoom"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<button
|
||||
onClick={goPrev}
|
||||
disabled={!canGoPrev || isPlaying}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
|
||||
title="Previous (←)"
|
||||
>
|
||||
← Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext || isPlaying}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
|
||||
title="Next (→)"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
152
admin-frontend/src/context/AuthContext.tsx
Normal file
152
admin-frontend/src/context/AuthContext.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode, useCallback } from 'react'
|
||||
import { authApi, TokenResponse } from '../api/auth'
|
||||
import { UserRoleValue } from '../api/users'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
username: string | null
|
||||
isLoading: boolean
|
||||
passwordChangeRequired: boolean
|
||||
isAdmin: boolean
|
||||
role: UserRoleValue | null
|
||||
permissions: Record<string, boolean>
|
||||
}
|
||||
|
||||
interface AuthContextType extends AuthState {
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; passwordChangeRequired?: boolean }>
|
||||
logout: () => void
|
||||
clearPasswordChangeRequired: () => void
|
||||
hasPermission: (featureKey: string) => boolean
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: true,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
authApi
|
||||
.me()
|
||||
.then((user) => {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: user.is_admin || false,
|
||||
role: (user.role as UserRoleValue) || null,
|
||||
permissions: user.permissions || {},
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
})
|
||||
} else {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const tokens: TokenResponse = await authApi.login({ username, password })
|
||||
localStorage.setItem('access_token', tokens.access_token)
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token)
|
||||
const user = await authApi.me()
|
||||
const passwordChangeRequired = tokens.password_change_required || false
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
passwordChangeRequired,
|
||||
isAdmin: user.is_admin || false,
|
||||
role: (user.role as UserRoleValue) || null,
|
||||
permissions: user.permissions || {},
|
||||
})
|
||||
return { success: true, passwordChangeRequired }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Login failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clearPasswordChangeRequired = () => {
|
||||
setAuthState((prev) => ({
|
||||
...prev,
|
||||
passwordChangeRequired: false,
|
||||
}))
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// Clear sessionStorage settings on logout
|
||||
sessionStorage.removeItem('identify_settings')
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
passwordChangeRequired: false,
|
||||
isAdmin: false,
|
||||
role: null,
|
||||
permissions: {},
|
||||
})
|
||||
}
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(featureKey: string): boolean => {
|
||||
if (!featureKey) {
|
||||
return authState.isAdmin
|
||||
}
|
||||
if (authState.isAdmin) {
|
||||
return true
|
||||
}
|
||||
return Boolean(authState.permissions[featureKey])
|
||||
},
|
||||
[authState.isAdmin, authState.permissions]
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ ...authState, login, logout, clearPasswordChangeRequired, hasPermission }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
42
admin-frontend/src/context/DeveloperModeContext.tsx
Normal file
42
admin-frontend/src/context/DeveloperModeContext.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
interface DeveloperModeContextType {
|
||||
isDeveloperMode: boolean
|
||||
setDeveloperMode: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const DeveloperModeContext = createContext<DeveloperModeContextType | undefined>(undefined)
|
||||
|
||||
const STORAGE_KEY = 'punimtag_developer_mode'
|
||||
|
||||
export function DeveloperModeProvider({ children }: { children: ReactNode }) {
|
||||
const [isDeveloperMode, setIsDeveloperMode] = useState<boolean>(false)
|
||||
|
||||
// Load from localStorage on mount
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null) {
|
||||
setIsDeveloperMode(stored === 'true')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const setDeveloperMode = (enabled: boolean) => {
|
||||
setIsDeveloperMode(enabled)
|
||||
localStorage.setItem(STORAGE_KEY, enabled.toString())
|
||||
}
|
||||
|
||||
return (
|
||||
<DeveloperModeContext.Provider value={{ isDeveloperMode, setDeveloperMode }}>
|
||||
{children}
|
||||
</DeveloperModeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useDeveloperMode() {
|
||||
const context = useContext(DeveloperModeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useDeveloperMode must be used within a DeveloperModeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
84
admin-frontend/src/hooks/useAuth.ts
Normal file
84
admin-frontend/src/hooks/useAuth.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { authApi, TokenResponse } from '../api/auth'
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean
|
||||
username: string | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [authState, setAuthState] = useState<AuthState>({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: true,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
// Verify token by fetching user info
|
||||
authApi
|
||||
.me()
|
||||
.then((user) => {
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
try {
|
||||
const tokens: TokenResponse = await authApi.login({ username, password })
|
||||
localStorage.setItem('access_token', tokens.access_token)
|
||||
localStorage.setItem('refresh_token', tokens.refresh_token)
|
||||
const user = await authApi.me()
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
username: user.username,
|
||||
isLoading: false,
|
||||
})
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.detail || 'Login failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
username: null,
|
||||
isLoading: false,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...authState,
|
||||
login,
|
||||
logout,
|
||||
}
|
||||
}
|
||||
|
||||
68
admin-frontend/src/hooks/useInactivityTimeout.ts
Normal file
68
admin-frontend/src/hooks/useInactivityTimeout.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface UseInactivityTimeoutOptions {
|
||||
timeoutMs: number
|
||||
onTimeout: () => void
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
const ACTIVITY_EVENTS: Array<keyof WindowEventMap> = [
|
||||
'mousemove',
|
||||
'mousedown',
|
||||
'keydown',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'focus',
|
||||
]
|
||||
|
||||
export function useInactivityTimeout({
|
||||
timeoutMs,
|
||||
onTimeout,
|
||||
isEnabled = true,
|
||||
}: UseInactivityTimeoutOptions) {
|
||||
const timeoutRef = useRef<number | null>(null)
|
||||
const callbackRef = useRef(onTimeout)
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = onTimeout
|
||||
}, [onTimeout])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnabled) {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const resetTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
timeoutRef.current = window.setTimeout(() => {
|
||||
callbackRef.current()
|
||||
}, timeoutMs)
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
resetTimer()
|
||||
}
|
||||
}
|
||||
|
||||
resetTimer()
|
||||
ACTIVITY_EVENTS.forEach((event) => window.addEventListener(event, resetTimer))
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
window.clearTimeout(timeoutRef.current)
|
||||
}
|
||||
ACTIVITY_EVENTS.forEach((event) => window.removeEventListener(event, resetTimer))
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [timeoutMs, isEnabled])
|
||||
}
|
||||
|
||||
|
||||
65
admin-frontend/src/index.css
Normal file
65
admin-frontend/src/index.css
Normal file
@ -0,0 +1,65 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling for similar faces container */
|
||||
.similar-faces-scrollable {
|
||||
/* Firefox */
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #4B5563 #F3F4F6;
|
||||
}
|
||||
|
||||
.similar-faces-scrollable::-webkit-scrollbar {
|
||||
/* Chrome, Safari, Edge */
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.similar-faces-scrollable::-webkit-scrollbar-track {
|
||||
background: #F3F4F6;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.similar-faces-scrollable::-webkit-scrollbar-thumb {
|
||||
background: #4B5563;
|
||||
border-radius: 6px;
|
||||
border: 2px solid #F3F4F6;
|
||||
}
|
||||
|
||||
.similar-faces-scrollable::-webkit-scrollbar-thumb:hover {
|
||||
background: #374151;
|
||||
}
|
||||
|
||||
.role-permissions-scroll {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: #1d4ed8 #e5e7eb;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar-track {
|
||||
background: #bfdbfe;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.role-permissions-scroll::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%);
|
||||
border-radius: 8px;
|
||||
border: 3px solid #bfdbfe;
|
||||
box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
23
admin-frontend/src/main.tsx
Normal file
23
admin-frontend/src/main.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
606
admin-frontend/src/pages/ApproveIdentified.tsx
Normal file
606
admin-frontend/src/pages/ApproveIdentified.tsx
Normal file
@ -0,0 +1,606 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import pendingIdentificationsApi, {
|
||||
PendingIdentification,
|
||||
IdentificationReportResponse,
|
||||
UserIdentificationStats
|
||||
} from '../api/pendingIdentifications'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function ApproveIdentified() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [pendingIdentifications, setPendingIdentifications] = useState<PendingIdentification[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'deny' | null>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [includeDenied, setIncludeDenied] = useState(false)
|
||||
const [showReport, setShowReport] = useState(false)
|
||||
const [reportData, setReportData] = useState<IdentificationReportResponse | null>(null)
|
||||
const [reportLoading, setReportLoading] = useState(false)
|
||||
const [reportError, setReportError] = useState<string | null>(null)
|
||||
const [dateFrom, setDateFrom] = useState<string>('')
|
||||
const [dateTo, setDateTo] = useState<string>('')
|
||||
const [clearing, setClearing] = useState(false)
|
||||
|
||||
const loadPendingIdentifications = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.list(includeDenied)
|
||||
setPendingIdentifications(response.items)
|
||||
} catch (err: any) {
|
||||
let errorMessage = 'Failed to load pending identifications'
|
||||
if (err.response?.data?.detail) {
|
||||
errorMessage = err.response.data.detail
|
||||
} else if (err.message) {
|
||||
errorMessage = err.message
|
||||
// Provide more context for network errors
|
||||
if (err.message === 'Network Error' || err.code === 'ERR_NETWORK') {
|
||||
errorMessage = `Network Error: Cannot connect to backend API (${apiClient.defaults.baseURL}). Please check:\n1. Backend is running\n2. You are logged in\n3. CORS is configured correctly`
|
||||
}
|
||||
}
|
||||
setError(errorMessage)
|
||||
console.error('Error loading pending identifications:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [includeDenied])
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingIdentifications()
|
||||
}, [loadPendingIdentifications])
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatName = (pending: PendingIdentification): string => {
|
||||
const parts = [
|
||||
pending.first_name,
|
||||
pending.middle_name,
|
||||
pending.last_name,
|
||||
].filter(Boolean)
|
||||
if (pending.maiden_name) {
|
||||
parts.push(`(${pending.maiden_name})`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'approve' | 'deny') => {
|
||||
setDecisions(prev => {
|
||||
const currentDecision = prev[id]
|
||||
// If clicking the same checkbox, deselect it
|
||||
if (currentDecision === decision) {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
}
|
||||
// Otherwise, set the new decision (this will automatically deselect the other)
|
||||
return {
|
||||
...prev,
|
||||
[id]: decision
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made, for pending or denied items (not approved)
|
||||
const decisionsList = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const pending = pendingIdentifications.find(p => p.id === parseInt(id))
|
||||
return decision !== null && pending && pending.status !== 'approved'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
decision: decision!
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Please select Approve or Deny for at least one identification.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Submit ${decisionsList.length} decision(s)?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.approveDeny({
|
||||
decisions: decisionsList
|
||||
})
|
||||
|
||||
const message = [
|
||||
`✅ Approved: ${response.approved}`,
|
||||
`❌ Denied: ${response.denied}`,
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
await loadPendingIdentifications()
|
||||
// Clear decisions
|
||||
setDecisions({})
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error submitting decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadReport = useCallback(async () => {
|
||||
setReportLoading(true)
|
||||
setReportError(null)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.getReport(
|
||||
dateFrom || undefined,
|
||||
dateTo || undefined
|
||||
)
|
||||
setReportData(response)
|
||||
} catch (err: any) {
|
||||
setReportError(err.response?.data?.detail || err.message || 'Failed to load report')
|
||||
console.error('Error loading report:', err)
|
||||
} finally {
|
||||
setReportLoading(false)
|
||||
}
|
||||
}, [dateFrom, dateTo])
|
||||
|
||||
const handleOpenReport = () => {
|
||||
setShowReport(true)
|
||||
loadReport()
|
||||
}
|
||||
|
||||
const handleCloseReport = () => {
|
||||
setShowReport(false)
|
||||
setReportData(null)
|
||||
setReportError(null)
|
||||
setDateFrom('')
|
||||
setDateTo('')
|
||||
}
|
||||
|
||||
const formatDateTime = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDenied = async () => {
|
||||
if (!confirm('Are you sure you want to delete all denied records? This action cannot be undone.')) {
|
||||
return
|
||||
}
|
||||
|
||||
setClearing(true)
|
||||
try {
|
||||
const response = await pendingIdentificationsApi.clearDenied()
|
||||
|
||||
const message = [
|
||||
`✅ Deleted ${response.deleted_records} denied record(s)`,
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : ''
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
alert('Errors:\n' + response.errors.join('\n'))
|
||||
}
|
||||
|
||||
// Reload the list to reflect changes
|
||||
await loadPendingIdentifications()
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to clear denied records'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error clearing denied records:', err)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading identified people...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
<button
|
||||
onClick={loadPendingIdentifications}
|
||||
className="mt-3 px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total pending identifications: <span className="font-semibold">{pendingIdentifications.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
return
|
||||
}
|
||||
handleClearDenied()
|
||||
}}
|
||||
disabled={clearing || !isAdmin}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
title={
|
||||
isAdmin
|
||||
? 'Delete all denied records from the database'
|
||||
: 'Only admins can clear denied records'
|
||||
}
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeDenied}
|
||||
onChange={(e) => setIncludeDenied(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Include denied</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || Object.values(decisions).filter(d => d !== null).length === 0}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pendingIdentifications.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No pending identifications found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date of Birth
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Face
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Approve
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{pendingIdentifications.map((pending) => {
|
||||
const isDenied = pending.status === 'denied'
|
||||
const isApproved = pending.status === 'approved'
|
||||
return (
|
||||
<tr key={pending.id} className={`hover:bg-gray-50 ${isDenied ? 'opacity-60 bg-gray-50' : ''}`}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatName(pending)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(pending.date_of_birth)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{pending.photo_id ? (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${pending.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/faces/${pending.face_id}/crop`}
|
||||
alt={`Face ${pending.face_id}`}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${pending.face_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={`/api/v1/faces/${pending.face_id}/crop`}
|
||||
alt={`Face ${pending.face_id}`}
|
||||
className="w-16 h-16 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${pending.face_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{pending.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{pending.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(pending.created_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{isApproved ? (
|
||||
<div className="text-sm text-green-600 font-medium">Approved</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{isDenied && (
|
||||
<span className="text-xs text-red-600 font-medium">(Denied)</span>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decisions[pending.id] === 'approve'}
|
||||
onChange={() => {
|
||||
const currentDecision = decisions[pending.id]
|
||||
if (currentDecision === 'approve') {
|
||||
// Deselect if already selected
|
||||
handleDecisionChange(pending.id, 'approve')
|
||||
} else {
|
||||
// Select approve (this will deselect deny if selected)
|
||||
handleDecisionChange(pending.id, 'approve')
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decisions[pending.id] === 'deny'}
|
||||
onChange={() => {
|
||||
const currentDecision = decisions[pending.id]
|
||||
if (currentDecision === 'deny') {
|
||||
// Deselect if already selected
|
||||
handleDecisionChange(pending.id, 'deny')
|
||||
} else {
|
||||
// Select deny (this will deselect approve if selected)
|
||||
handleDecisionChange(pending.id, 'deny')
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Report Modal */}
|
||||
{showReport && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">Identification Report</h2>
|
||||
<button
|
||||
onClick={handleCloseReport}
|
||||
className="text-gray-400 hover:text-gray-600 text-2xl font-bold"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Date To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
onClick={loadReport}
|
||||
disabled={reportLoading}
|
||||
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{reportLoading ? 'Loading...' : 'Apply Filter'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{reportLoading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading report...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reportError && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading report</p>
|
||||
<p className="text-sm mt-1">{reportError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!reportLoading && !reportError && reportData && (
|
||||
<>
|
||||
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded">
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Total Users:</span>{' '}
|
||||
<span className="text-gray-900">{reportData.total_users}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Total Faces:</span>{' '}
|
||||
<span className="text-gray-900">{reportData.total_faces}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold text-gray-700">Average per User:</span>{' '}
|
||||
<span className="text-gray-900">
|
||||
{reportData.total_users > 0
|
||||
? Math.round((reportData.total_faces / reportData.total_users) * 10) / 10
|
||||
: 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reportData.items.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No identifications found for the selected date range.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
User
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Faces Identified
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
First Identification
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Last Identification
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportData.items.map((stat: UserIdentificationStats) => (
|
||||
<tr key={stat.user_id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{stat.full_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{stat.username}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">{stat.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-semibold text-gray-900">
|
||||
{stat.face_count}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDateTime(stat.first_identification_date)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDateTime(stat.last_identification_date)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
936
admin-frontend/src/pages/AutoMatch.tsx
Normal file
936
admin-frontend/src/pages/AutoMatch.tsx
Normal file
@ -0,0 +1,936 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import facesApi, {
|
||||
AutoMatchPersonSummary,
|
||||
AutoMatchFaceItem
|
||||
} from '../api/faces'
|
||||
import peopleApi, { Person } from '../api/people'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
const DEFAULT_TOLERANCE = 0.6
|
||||
|
||||
export default function AutoMatch() {
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
const [tolerance, setTolerance] = useState(DEFAULT_TOLERANCE)
|
||||
const [autoAcceptThreshold, setAutoAcceptThreshold] = useState(70)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [people, setPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
const [filteredPeople, setFilteredPeople] = useState<AutoMatchPersonSummary[]>([])
|
||||
// Store matches separately, keyed by person_id
|
||||
const [matchesCache, setMatchesCache] = useState<Record<number, AutoMatchFaceItem[]>>({})
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [allPeople, setAllPeople] = useState<Person[]>([])
|
||||
const [loadingPeople, setLoadingPeople] = useState(false)
|
||||
const [showPeopleDropdown, setShowPeopleDropdown] = useState(false)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const [selectedFaces, setSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
const [originalSelectedFaces, setOriginalSelectedFaces] = useState<Record<number, boolean>>({})
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [hasNoResults, setHasNoResults] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
// SessionStorage keys for persisting state and settings
|
||||
const STATE_KEY = 'automatch_state'
|
||||
const SETTINGS_KEY = 'automatch_settings'
|
||||
|
||||
// Track if initial load has happened
|
||||
const initialLoadRef = useRef(false)
|
||||
// Track if settings have been loaded from sessionStorage
|
||||
const [settingsLoaded, setSettingsLoaded] = useState(false)
|
||||
// Track if state has been restored from sessionStorage
|
||||
const [stateRestored, setStateRestored] = useState(false)
|
||||
// Track if initial restoration is complete (prevents reload effects from firing during restoration)
|
||||
const restorationCompleteRef = useRef(false)
|
||||
|
||||
const currentPerson = useMemo(() => {
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
return activePeople[currentIndex]
|
||||
}, [filteredPeople, people, currentIndex])
|
||||
|
||||
const currentMatches = useMemo(() => {
|
||||
if (!currentPerson) return []
|
||||
return matchesCache[currentPerson.person_id] || []
|
||||
}, [currentPerson, matchesCache])
|
||||
|
||||
// Check if any matches are selected
|
||||
const hasSelectedMatches = useMemo(() => {
|
||||
return currentMatches.some(match => selectedFaces[match.id] === true)
|
||||
}, [currentMatches, selectedFaces])
|
||||
|
||||
// Load matches for a specific person (lazy loading)
|
||||
const loadPersonMatches = async (personId: number) => {
|
||||
// Skip if already cached
|
||||
if (matchesCache[personId]) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await facesApi.getAutoMatchPersonMatches(personId, {
|
||||
tolerance,
|
||||
filter_frontal_only: false
|
||||
})
|
||||
|
||||
setMatchesCache(prev => ({
|
||||
...prev,
|
||||
[personId]: response.matches
|
||||
}))
|
||||
|
||||
// Update total_matches in people list
|
||||
setPeople(prev => prev.map(p =>
|
||||
p.person_id === personId
|
||||
? { ...p, total_matches: response.total_matches }
|
||||
: p
|
||||
))
|
||||
|
||||
// If no matches found, remove person from list (matching original behavior)
|
||||
// Original endpoint only returns people who have matches
|
||||
if (response.total_matches === 0) {
|
||||
setPeople(prev => {
|
||||
const removedIndex = prev.findIndex(p => p.person_id === personId)
|
||||
// Adjust current index if needed
|
||||
if (removedIndex !== -1) {
|
||||
setCurrentIndex(currentIdx => {
|
||||
if (currentIdx >= removedIndex) {
|
||||
return Math.max(0, currentIdx - 1)
|
||||
}
|
||||
return currentIdx
|
||||
})
|
||||
}
|
||||
return prev.filter(p => p.person_id !== personId)
|
||||
})
|
||||
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load matches for person:', error)
|
||||
// Set empty matches on error, and remove person from list
|
||||
setMatchesCache(prev => ({
|
||||
...prev,
|
||||
[personId]: []
|
||||
}))
|
||||
// Remove person if matches failed to load (assume no matches)
|
||||
setPeople(prev => prev.filter(p => p.person_id !== personId))
|
||||
setFilteredPeople(prev => prev.filter(p => p.person_id !== personId))
|
||||
}
|
||||
}
|
||||
|
||||
// Shared function for auto-load and refresh (loads people list only - fast)
|
||||
const loadAutoMatch = async (clearState: boolean = false) => {
|
||||
if (tolerance < 0 || tolerance > 1) {
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
setIsRefreshing(true)
|
||||
try {
|
||||
// Clear saved state if explicitly requested (Refresh button)
|
||||
if (clearState) {
|
||||
sessionStorage.removeItem(STATE_KEY)
|
||||
setMatchesCache({}) // Clear matches cache
|
||||
}
|
||||
|
||||
// Load people list only (fast - no match calculations)
|
||||
const response = await facesApi.getAutoMatchPeople({
|
||||
filter_frontal_only: false
|
||||
})
|
||||
|
||||
if (response.people.length === 0) {
|
||||
setHasNoResults(true)
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setIsActive(false)
|
||||
setBusy(false)
|
||||
setIsRefreshing(false)
|
||||
return
|
||||
}
|
||||
|
||||
setHasNoResults(false)
|
||||
setPeople(response.people)
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setIsActive(true)
|
||||
|
||||
// Load matches for first person immediately
|
||||
if (response.people.length > 0) {
|
||||
await loadPersonMatches(response.people[0].person_id)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-match failed:', error)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load settings from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(SETTINGS_KEY)
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved)
|
||||
if (settings.tolerance !== undefined) setTolerance(settings.tolerance)
|
||||
if (settings.autoAcceptThreshold !== undefined) setAutoAcceptThreshold(settings.autoAcceptThreshold)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading settings from sessionStorage:', error)
|
||||
} finally {
|
||||
setSettingsLoaded(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Load state from sessionStorage on mount (people, current index, selected faces)
|
||||
// Note: This effect runs after settings are loaded, so tolerance is already set
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return // Wait for settings to load first
|
||||
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STATE_KEY)
|
||||
if (saved) {
|
||||
const state = JSON.parse(saved)
|
||||
// Only restore state if tolerance matches (cached state is for current tolerance)
|
||||
if (state.people && Array.isArray(state.people) && state.people.length > 0 &&
|
||||
state.tolerance === tolerance) {
|
||||
setPeople(state.people)
|
||||
if (state.currentIndex !== undefined) {
|
||||
setCurrentIndex(Math.min(state.currentIndex, state.people.length - 1))
|
||||
}
|
||||
if (state.selectedFaces && typeof state.selectedFaces === 'object') {
|
||||
setSelectedFaces(state.selectedFaces)
|
||||
}
|
||||
if (state.originalSelectedFaces && typeof state.originalSelectedFaces === 'object') {
|
||||
setOriginalSelectedFaces(state.originalSelectedFaces)
|
||||
}
|
||||
if (state.matchesCache && typeof state.matchesCache === 'object') {
|
||||
setMatchesCache(state.matchesCache)
|
||||
}
|
||||
if (state.isActive !== undefined) {
|
||||
setIsActive(state.isActive)
|
||||
}
|
||||
if (state.hasNoResults !== undefined) {
|
||||
setHasNoResults(state.hasNoResults)
|
||||
}
|
||||
// Mark that we restored state, so we don't reload
|
||||
initialLoadRef.current = true
|
||||
// Mark restoration as complete after state is restored
|
||||
setTimeout(() => {
|
||||
restorationCompleteRef.current = true
|
||||
}, 50)
|
||||
} else if (state.tolerance !== undefined && state.tolerance !== tolerance) {
|
||||
// Tolerance changed, clear old cache
|
||||
sessionStorage.removeItem(STATE_KEY)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading state from sessionStorage:', error)
|
||||
} finally {
|
||||
setStateRestored(true)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded])
|
||||
|
||||
// Save state to sessionStorage whenever it changes (but only after initial restore)
|
||||
useEffect(() => {
|
||||
if (!stateRestored) return // Don't save during initial restore
|
||||
|
||||
try {
|
||||
const state = {
|
||||
people,
|
||||
currentIndex,
|
||||
selectedFaces,
|
||||
originalSelectedFaces,
|
||||
matchesCache,
|
||||
isActive,
|
||||
hasNoResults,
|
||||
tolerance, // Include tolerance to validate cache on restore
|
||||
}
|
||||
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error('Error saving state to sessionStorage:', error)
|
||||
}
|
||||
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance, stateRestored])
|
||||
|
||||
// Save state on unmount (when navigating away) - use refs to capture latest values
|
||||
const peopleRef = useRef(people)
|
||||
const currentIndexRef = useRef(currentIndex)
|
||||
const selectedFacesRef = useRef(selectedFaces)
|
||||
const originalSelectedFacesRef = useRef(originalSelectedFaces)
|
||||
const matchesCacheRef = useRef(matchesCache)
|
||||
const isActiveRef = useRef(isActive)
|
||||
const hasNoResultsRef = useRef(hasNoResults)
|
||||
const toleranceRef = useRef(tolerance)
|
||||
|
||||
// Update refs whenever state changes
|
||||
useEffect(() => {
|
||||
peopleRef.current = people
|
||||
currentIndexRef.current = currentIndex
|
||||
selectedFacesRef.current = selectedFaces
|
||||
originalSelectedFacesRef.current = originalSelectedFaces
|
||||
matchesCacheRef.current = matchesCache
|
||||
isActiveRef.current = isActive
|
||||
hasNoResultsRef.current = hasNoResults
|
||||
toleranceRef.current = tolerance
|
||||
}, [people, currentIndex, selectedFaces, originalSelectedFaces, matchesCache, isActive, hasNoResults, tolerance])
|
||||
|
||||
// Save state on unmount (when navigating away)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
try {
|
||||
const state = {
|
||||
people: peopleRef.current,
|
||||
currentIndex: currentIndexRef.current,
|
||||
selectedFaces: selectedFacesRef.current,
|
||||
originalSelectedFaces: originalSelectedFacesRef.current,
|
||||
matchesCache: matchesCacheRef.current,
|
||||
isActive: isActiveRef.current,
|
||||
hasNoResults: hasNoResultsRef.current,
|
||||
tolerance: toleranceRef.current, // Include tolerance to validate cache on restore
|
||||
}
|
||||
sessionStorage.setItem(STATE_KEY, JSON.stringify(state))
|
||||
} catch (error) {
|
||||
console.error('Error saving state on unmount:', error)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save settings to sessionStorage whenever they change (but only after initial load)
|
||||
useEffect(() => {
|
||||
if (!settingsLoaded) return // Don't save during initial load
|
||||
try {
|
||||
const settings = {
|
||||
tolerance,
|
||||
autoAcceptThreshold,
|
||||
}
|
||||
sessionStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
||||
} catch (error) {
|
||||
console.error('Error saving settings to sessionStorage:', error)
|
||||
}
|
||||
}, [tolerance, autoAcceptThreshold, settingsLoaded])
|
||||
|
||||
// Load all people for dropdown
|
||||
useEffect(() => {
|
||||
const loadAllPeople = async () => {
|
||||
try {
|
||||
setLoadingPeople(true)
|
||||
const response = await peopleApi.list()
|
||||
setAllPeople(response.items || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load people:', error)
|
||||
setAllPeople([])
|
||||
} finally {
|
||||
setLoadingPeople(false)
|
||||
}
|
||||
}
|
||||
loadAllPeople()
|
||||
}, [])
|
||||
|
||||
// Initial load on mount (after settings and state are loaded)
|
||||
useEffect(() => {
|
||||
if (!initialLoadRef.current && settingsLoaded && stateRestored) {
|
||||
initialLoadRef.current = true
|
||||
// Only load if we didn't restore state (no people means we need to load)
|
||||
if (people.length === 0) {
|
||||
loadAutoMatch()
|
||||
// If we're loading fresh, mark restoration as complete immediately
|
||||
restorationCompleteRef.current = true
|
||||
} else {
|
||||
// If state was restored, restorationCompleteRef is already set in the state restoration effect
|
||||
// But ensure it's set in case state restoration didn't happen
|
||||
if (!restorationCompleteRef.current) {
|
||||
setTimeout(() => {
|
||||
restorationCompleteRef.current = true
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [settingsLoaded, stateRestored])
|
||||
|
||||
// Reload when tolerance changes (immediate reload)
|
||||
// But only if restoration is complete (prevents reload during initial restoration)
|
||||
useEffect(() => {
|
||||
if (initialLoadRef.current && restorationCompleteRef.current) {
|
||||
// Clear matches cache when tolerance changes (matches depend on tolerance)
|
||||
setMatchesCache({})
|
||||
loadAutoMatch()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tolerance])
|
||||
|
||||
// Apply search filter
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setFilteredPeople([])
|
||||
return
|
||||
}
|
||||
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
const filtered = people.filter(person => {
|
||||
// Extract last name from person name (matching desktop logic)
|
||||
let lastName = ''
|
||||
if (person.person_name.includes(',')) {
|
||||
lastName = person.person_name.split(',')[0].trim().toLowerCase()
|
||||
} else {
|
||||
const nameParts = person.person_name.trim().split(' ')
|
||||
if (nameParts.length > 0) {
|
||||
lastName = nameParts[nameParts.length - 1].toLowerCase()
|
||||
}
|
||||
}
|
||||
return lastName.includes(query)
|
||||
})
|
||||
|
||||
setFilteredPeople(filtered)
|
||||
setCurrentIndex(0)
|
||||
}, [searchQuery, people])
|
||||
|
||||
const startAutoMatch = async () => {
|
||||
if (tolerance < 0 || tolerance > 1) {
|
||||
alert('Please enter a valid tolerance value between 0.0 and 1.0.')
|
||||
return
|
||||
}
|
||||
|
||||
if (autoAcceptThreshold < 0 || autoAcceptThreshold > 100) {
|
||||
alert('Please enter a valid auto-accept threshold between 0 and 100.')
|
||||
return
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
const response = await facesApi.autoMatch({
|
||||
tolerance,
|
||||
auto_accept: true,
|
||||
auto_accept_threshold: autoAcceptThreshold
|
||||
})
|
||||
|
||||
// Show summary if auto-accept was performed
|
||||
if (response.auto_accepted) {
|
||||
const summary = [
|
||||
`✅ Auto-matched ${response.auto_accepted_faces || 0} faces`,
|
||||
response.skipped_persons ? `⚠️ Skipped ${response.skipped_persons} persons (non-frontal reference)` : '',
|
||||
response.skipped_matches ? `ℹ️ Skipped ${response.skipped_matches} matches (didn't meet criteria)` : ''
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
if (summary) {
|
||||
alert(summary)
|
||||
}
|
||||
|
||||
// Reload faces after auto-accept to remove auto-accepted faces from the list
|
||||
// Clear cache to get fresh data after auto-accept
|
||||
await loadAutoMatch(true)
|
||||
return
|
||||
}
|
||||
|
||||
if (response.people.length === 0) {
|
||||
alert('🔍 No similar faces found for auto-identification')
|
||||
setHasNoResults(true)
|
||||
setPeople([])
|
||||
setFilteredPeople([])
|
||||
setIsActive(false)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
|
||||
setHasNoResults(false)
|
||||
setPeople(response.people)
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setSelectedFaces({})
|
||||
setOriginalSelectedFaces({})
|
||||
setIsActive(true)
|
||||
} catch (error) {
|
||||
console.error('Auto-match failed:', error)
|
||||
alert('Failed to start auto-match. Please try again.')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFaceToggle = (faceId: number) => {
|
||||
setSelectedFaces(prev => ({
|
||||
...prev,
|
||||
[faceId]: !prev[faceId],
|
||||
}))
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newSelected[match.id] = true
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const newSelected: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newSelected[match.id] = false
|
||||
})
|
||||
setSelectedFaces(newSelected)
|
||||
}
|
||||
|
||||
const saveChanges = async () => {
|
||||
if (!currentPerson) return
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const faceIds = currentMatches
|
||||
.filter(match => selectedFaces[match.id] === true)
|
||||
.map(match => match.id)
|
||||
|
||||
await peopleApi.acceptMatches(currentPerson.person_id, faceIds)
|
||||
|
||||
// Update original selected faces to current state
|
||||
const newOriginal: Record<number, boolean> = {}
|
||||
currentMatches.forEach(match => {
|
||||
newOriginal[match.id] = selectedFaces[match.id] || false
|
||||
})
|
||||
setOriginalSelectedFaces(prev => ({ ...prev, ...newOriginal }))
|
||||
|
||||
alert(`✅ Saved ${faceIds.length} match(es)`)
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error)
|
||||
alert('Failed to save matches. Please try again.')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load matches when current person changes (lazy loading)
|
||||
useEffect(() => {
|
||||
if (currentPerson && restorationCompleteRef.current) {
|
||||
loadPersonMatches(currentPerson.person_id)
|
||||
|
||||
// Preload matches for next person in background
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
if (currentIndex + 1 < activePeople.length) {
|
||||
const nextPerson = activePeople[currentIndex + 1]
|
||||
loadPersonMatches(nextPerson.person_id)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentPerson?.person_id, currentIndex])
|
||||
|
||||
// Restore selected faces when navigating to a different person
|
||||
useEffect(() => {
|
||||
if (currentPerson) {
|
||||
const matches = matchesCache[currentPerson.person_id] || []
|
||||
const restored: Record<number, boolean> = {}
|
||||
matches.forEach(match => {
|
||||
restored[match.id] = originalSelectedFaces[match.id] || false
|
||||
})
|
||||
setSelectedFaces(restored)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIndex, filteredPeople.length, people.length, currentPerson?.person_id, matchesCache])
|
||||
|
||||
const goBack = () => {
|
||||
if (currentIndex > 0) {
|
||||
setCurrentIndex(currentIndex - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goNext = () => {
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
if (currentIndex < activePeople.length - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const clearSearch = () => {
|
||||
setSearchQuery('')
|
||||
setFilteredPeople([])
|
||||
setCurrentIndex(0)
|
||||
setShowPeopleDropdown(false)
|
||||
}
|
||||
|
||||
const formatPersonName = (person: Person): string => {
|
||||
const parts: string[] = []
|
||||
|
||||
// Last name with comma
|
||||
if (person.last_name) {
|
||||
parts.push(`${person.last_name},`)
|
||||
}
|
||||
|
||||
// Middle name between last and first
|
||||
if (person.middle_name) {
|
||||
parts.push(person.middle_name)
|
||||
}
|
||||
|
||||
// First name
|
||||
if (person.first_name) {
|
||||
parts.push(person.first_name)
|
||||
}
|
||||
|
||||
// Maiden name in parentheses
|
||||
if (person.maiden_name) {
|
||||
parts.push(`(${person.maiden_name})`)
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return person.first_name || person.last_name || 'Unknown'
|
||||
}
|
||||
|
||||
// Format as "Last, Middle First (Maiden)"
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
const formatFullPersonName = (personId: number): string => {
|
||||
const person = allPeople.find(p => p.id === personId)
|
||||
if (!person) {
|
||||
// Fallback to person_name if person not found in allPeople
|
||||
const currentPersonData = people.find(p => p.person_id === personId) ||
|
||||
filteredPeople.find(p => p.person_id === personId)
|
||||
return currentPersonData?.person_name || 'Unknown'
|
||||
}
|
||||
|
||||
return formatPersonName(person)
|
||||
}
|
||||
|
||||
const handlePersonSelect = (personId: number) => {
|
||||
const person = allPeople.find(p => p.id === personId)
|
||||
if (person) {
|
||||
// Extract last name and set as search query
|
||||
const lastName = person.last_name || ''
|
||||
setSearchQuery(lastName)
|
||||
setShowPeopleDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Filter people based on search query for dropdown
|
||||
const filteredPeopleForDropdown = useMemo(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
return allPeople
|
||||
}
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
return allPeople.filter(person => {
|
||||
const lastName = (person.last_name || '').toLowerCase()
|
||||
const firstName = (person.first_name || '').toLowerCase()
|
||||
const middleName = (person.middle_name || '').toLowerCase()
|
||||
const fullName = `${lastName}, ${firstName}${middleName ? ` ${middleName}` : ''}`.toLowerCase()
|
||||
return fullName.includes(query) || lastName.includes(query) || firstName.includes(query)
|
||||
})
|
||||
}, [searchQuery, allPeople])
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
searchInputRef.current &&
|
||||
!searchInputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowPeopleDropdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const activePeople = filteredPeople.length > 0 ? filteredPeople : people
|
||||
const canGoBack = currentIndex > 0
|
||||
const canGoNext = currentIndex < activePeople.length - 1
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
|
||||
{/* Configuration */}
|
||||
<div className="bg-white rounded-lg shadow p-4 mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => loadAutoMatch(true)}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title="Refresh and start from beginning"
|
||||
>
|
||||
{isRefreshing ? 'Refreshing...' : '🔄 Refresh'}
|
||||
</button>
|
||||
{isDeveloperMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Tolerance:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={tolerance}
|
||||
onChange={(e) => setTolerance(parseFloat(e.target.value) || 0)}
|
||||
disabled={busy}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">(lower = stricter matching)</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={startAutoMatch}
|
||||
disabled={busy || hasNoResults}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
title={hasNoResults ? 'No matches found. Adjust tolerance or process more photos.' : ''}
|
||||
>
|
||||
{busy ? 'Processing...' : hasNoResults ? 'No Matches Available' : '🚀 Run Auto-Match'}
|
||||
</button>
|
||||
</div>
|
||||
{isDeveloperMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700">Auto-Accept Threshold:</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={autoAcceptThreshold}
|
||||
onChange={(e) => setAutoAcceptThreshold(parseInt(e.target.value) || 70)}
|
||||
disabled={busy || hasNoResults}
|
||||
className="w-20 px-2 py-1 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
<span className="text-xs text-gray-500">% (min similarity)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-600 bg-blue-50 border border-blue-200 rounded p-2">
|
||||
<span className="font-medium">ℹ️ Auto-Match Criteria:</span> Only faces with similarity higher than 70% and picture quality higher than 50% will be auto-matched. Profile faces are excluded for better accuracy.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isActive && (
|
||||
<>
|
||||
{/* Main panels */}
|
||||
<div className="flex-1 grid grid-cols-2 gap-4 mb-4">
|
||||
{/* Left panel - Identified Person */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Identified Person</h2>
|
||||
|
||||
{/* Search controls */}
|
||||
<div className="mb-4">
|
||||
<div className="flex gap-2 mb-2 relative">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Type Last Name or Select Person"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setShowPeopleDropdown(true)
|
||||
}}
|
||||
onFocus={() => setShowPeopleDropdown(true)}
|
||||
disabled={people.length === 1 || loadingPeople}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded text-sm"
|
||||
/>
|
||||
{showPeopleDropdown && filteredPeopleForDropdown.length > 0 && !loadingPeople && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded shadow-lg max-h-60 overflow-auto"
|
||||
>
|
||||
{filteredPeopleForDropdown.map((person) => (
|
||||
<div
|
||||
key={person.id}
|
||||
onClick={() => handlePersonSelect(person.id)}
|
||||
className="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
|
||||
>
|
||||
{formatPersonName(person)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={clearSearch}
|
||||
disabled={people.length === 1}
|
||||
className="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{people.length === 1 && (
|
||||
<p className="text-xs text-gray-500">(Search disabled - only one person found)</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Person info */}
|
||||
{currentPerson && (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{isDeveloperMode && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Person {currentIndex + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-x-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
className="px-2 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:cursor-not-allowed disabled:text-gray-400"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="font-semibold">👤 Person: {formatFullPersonName(currentPerson.person_id)}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
📁 Photo: {currentPerson.reference_photo_filename}
|
||||
</p>
|
||||
{isDeveloperMode && (
|
||||
<p className="text-sm text-gray-600">
|
||||
📍 Face location: {currentPerson.reference_location}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-600">
|
||||
📊 {currentPerson.face_count} faces already identified
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Person face image */}
|
||||
<div className="mb-4 flex justify-center">
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${currentPerson.reference_photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/faces/${currentPerson.reference_face_id}/crop`}
|
||||
alt="Reference face"
|
||||
className="max-w-[300px] max-h-[300px] rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
onClick={saveChanges}
|
||||
disabled={saving || !hasSelectedMatches}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? '💾 Saving...' : `💾 Save matches for ${currentPerson.person_name}`}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - Unidentified Faces */}
|
||||
<div className="bg-white rounded-lg shadow p-4 flex flex-col">
|
||||
<h2 className="text-lg font-semibold mb-4">Unidentified Faces to Match</h2>
|
||||
|
||||
{/* Select All / Clear All buttons */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☑️ Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={currentMatches.length === 0}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed text-sm"
|
||||
>
|
||||
☐ Clear All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Matches grid */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{currentMatches.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">No matches found</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{currentMatches.map((match) => (
|
||||
<div
|
||||
key={match.id}
|
||||
className="flex items-center gap-3 p-2 border border-gray-200 rounded hover:bg-gray-50"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces[match.id] || false}
|
||||
onChange={() => handleFaceToggle(match.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${match.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`/api/v1/faces/${match.id}/crop`}
|
||||
alt="Match face"
|
||||
className="w-20 h-20 object-cover rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`px-2 py-1 rounded text-xs font-semibold ${
|
||||
match.similarity >= 70
|
||||
? 'bg-green-100 text-green-800'
|
||||
: match.similarity >= 60
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-orange-100 text-orange-800'
|
||||
}`}
|
||||
>
|
||||
{Math.round(match.similarity)}% Match
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600">📁 {match.photo_filename}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation controls */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow p-4">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={goBack}
|
||||
disabled={!canGoBack}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏮️ Back
|
||||
</button>
|
||||
<button
|
||||
onClick={goNext}
|
||||
disabled={!canGoNext}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
>
|
||||
⏭️ Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
{isDeveloperMode && `Person ${currentIndex + 1} `}
|
||||
{currentPerson && `• ${currentPerson.total_matches} matches`}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
296
admin-frontend/src/pages/Dashboard.tsx
Normal file
296
admin-frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,296 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { photosApi, PhotoSearchResult } from '../api/photos'
|
||||
import apiClient from '../api/client'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { username } = useAuth()
|
||||
const [samplePhotos, setSamplePhotos] = useState<PhotoSearchResult[]>([])
|
||||
const [loadingPhotos, setLoadingPhotos] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadSamplePhotos()
|
||||
}, [])
|
||||
|
||||
const loadSamplePhotos = async () => {
|
||||
try {
|
||||
setLoadingPhotos(true)
|
||||
// Try to get some recent photos to display
|
||||
const result = await photosApi.searchPhotos({
|
||||
search_type: 'processed',
|
||||
page: 1,
|
||||
page_size: 6,
|
||||
})
|
||||
setSamplePhotos(result.items || [])
|
||||
} catch (error) {
|
||||
console.error('Failed to load sample photos:', error)
|
||||
setSamplePhotos([])
|
||||
} finally {
|
||||
setLoadingPhotos(false)
|
||||
}
|
||||
}
|
||||
|
||||
const getPhotoImageUrl = (photoId: number): string => {
|
||||
return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Hero Section */}
|
||||
<section className="relative py-12 px-4 overflow-hidden" style={{ background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%)' }}>
|
||||
<div className="max-w-6xl mx-auto relative z-10">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight" style={{ color: '#F97316' }}>
|
||||
Welcome to PunimTag
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl mb-3" style={{ color: '#2563EB' }}>
|
||||
Your Intelligent Photo Management System
|
||||
</p>
|
||||
<p className="text-lg max-w-2xl mx-auto text-gray-600">
|
||||
Organize, identify, and search through your photo collection like never before.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Showcase 1 - AI Recognition */}
|
||||
<section className="py-16 px-4 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="text-5xl mb-4">🤖</div>
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Recognize Faces Automatically
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Never lose track of who's in your photos again. Our smart system
|
||||
automatically finds and recognizes faces in all your pictures. Just
|
||||
tell it who someone is once, and it will find them in thousands of
|
||||
photos—even from years ago.
|
||||
</p>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Automatically finds faces in all your photos</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Recognizes the same person across different photos</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Works even with photos taken years apart</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
|
||||
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">👥</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Face recognition in action
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Showcase 2 - Smart Search */}
|
||||
<section className="py-16 px-4 bg-gray-50">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div className="order-2 md:order-1 relative">
|
||||
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
|
||||
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🔍</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Powerful search interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="order-1 md:order-2">
|
||||
<div className="text-5xl mb-4">🔍</div>
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Find Anything, Instantly
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Search your entire photo collection by people, dates, tags, or
|
||||
folders. Our advanced filtering system makes it easy to find
|
||||
exactly what you're looking for, no matter how large your
|
||||
collection grows.
|
||||
</p>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Search by person name across all photos</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Filter by date ranges and folders</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Tag-based organization and filtering</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature Showcase 3 - Batch Processing */}
|
||||
<section className="py-16 px-4 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="text-5xl mb-4">⚡</div>
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Process Thousands at Once
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 mb-6">
|
||||
Don't let a large photo collection overwhelm you. Our batch
|
||||
processing system efficiently handles thousands of photos with
|
||||
real-time progress tracking. Watch as your photos are organized
|
||||
automatically.
|
||||
</p>
|
||||
<ul className="space-y-3 text-gray-700">
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Batch face detection and recognition</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Real-time progress updates</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<span className="text-xl" style={{ color: '#2563EB' }}>✓</span>
|
||||
<span>Background job processing</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="bg-gray-50 rounded-2xl p-8 shadow-xl">
|
||||
<div className="aspect-video bg-white rounded-lg shadow-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📊</div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
Batch processing dashboard
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Visual Gallery Section */}
|
||||
<section className="py-16 px-4 bg-white">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-4xl font-bold text-center text-gray-900 mb-4">
|
||||
Organize Your Memories
|
||||
</h2>
|
||||
<p className="text-center text-lg text-gray-600 mb-12 max-w-2xl mx-auto">
|
||||
Transform your photo collection into an organized, searchable library
|
||||
of memories. Find any moment, any person, any time.
|
||||
</p>
|
||||
{loadingPhotos ? (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gray-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center animate-pulse"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📸</div>
|
||||
<p className="text-gray-500 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : samplePhotos.length > 0 ? (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{samplePhotos.slice(0, 6).map((photo) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="bg-gray-50 rounded-xl p-2 shadow-md aspect-square overflow-hidden group hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={getPhotoImageUrl(photo.id)}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className =
|
||||
'w-full h-full flex items-center justify-center error-fallback'
|
||||
fallback.innerHTML =
|
||||
'<div class="text-center"><div class="text-6xl mb-4">📸</div><p class="text-gray-500 text-sm">Photo</p></div>'
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gray-50 rounded-xl p-6 shadow-md aspect-square flex items-center justify-center"
|
||||
>
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">📸</div>
|
||||
<p className="text-gray-500 text-sm">Your photos</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4 bg-white">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-4xl md:text-5xl font-bold mb-6" style={{ color: '#F97316' }}>
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-xl mb-8 max-w-2xl mx-auto text-gray-600">
|
||||
Begin organizing your photo collection today. Use the navigation menu
|
||||
to explore all the powerful features PunimTag has to offer.
|
||||
</p>
|
||||
<div className="flex flex-wrap justify-center gap-4">
|
||||
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
|
||||
<span className="font-semibold">🗂️</span> Scan Photos
|
||||
</div>
|
||||
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
|
||||
<span className="font-semibold">⚙️</span> Process Faces
|
||||
</div>
|
||||
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
|
||||
<span className="font-semibold">👤</span> Identify People
|
||||
</div>
|
||||
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
|
||||
<span className="font-semibold">🤖</span> Auto-Match
|
||||
</div>
|
||||
<div className="border rounded-lg px-6 py-3 text-sm" style={{ borderColor: '#2563EB', color: '#2563EB' }}>
|
||||
<span className="font-semibold">🔍</span> Search Photos
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
435
admin-frontend/src/pages/FacesMaintenance.tsx
Normal file
435
admin-frontend/src/pages/FacesMaintenance.tsx
Normal file
@ -0,0 +1,435 @@
|
||||
import { useEffect, useState, useMemo } from 'react'
|
||||
import facesApi, { MaintenanceFaceItem } from '../api/faces'
|
||||
import { apiClient } from '../api/client'
|
||||
|
||||
type SortColumn = 'person_name' | 'quality' | 'photo_path' | 'excluded'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
type ExcludedFilter = 'all' | 'excluded' | 'included'
|
||||
type IdentifiedFilter = 'all' | 'identified' | 'unidentified'
|
||||
|
||||
export default function FacesMaintenance() {
|
||||
const [faces, setFaces] = useState<MaintenanceFaceItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [minQuality, setMinQuality] = useState(0.0)
|
||||
const [maxQuality, setMaxQuality] = useState(1.0)
|
||||
const [excludedFilter, setExcludedFilter] = useState<ExcludedFilter>('all')
|
||||
const [identifiedFilter, setIdentifiedFilter] = useState<IdentifiedFilter>('all')
|
||||
const [selectedFaces, setSelectedFaces] = useState<Set<number>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
const [excluding, setExcluding] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [sortColumn, setSortColumn] = useState<SortColumn | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
|
||||
const loadFaces = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await facesApi.getMaintenanceFaces({
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
min_quality: minQuality,
|
||||
max_quality: maxQuality,
|
||||
excluded_filter: excludedFilter,
|
||||
identified_filter: identifiedFilter,
|
||||
})
|
||||
setFaces(res.items)
|
||||
setTotal(res.total)
|
||||
setSelectedFaces(new Set()) // Clear selection when reloading
|
||||
} catch (error) {
|
||||
console.error('Error loading faces:', error)
|
||||
alert('Error loading faces. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadFaces()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageSize, minQuality, maxQuality, excludedFilter, identifiedFilter])
|
||||
|
||||
const toggleSelection = (faceId: number) => {
|
||||
setSelectedFaces(prev => {
|
||||
const newSet = new Set(prev)
|
||||
if (newSet.has(faceId)) {
|
||||
newSet.delete(faceId)
|
||||
} else {
|
||||
newSet.add(faceId)
|
||||
}
|
||||
return newSet
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedFaces(new Set(sortedFaces.map(f => f.id)))
|
||||
}
|
||||
|
||||
const unselectAll = () => {
|
||||
setSelectedFaces(new Set())
|
||||
}
|
||||
|
||||
const handleSort = (column: SortColumn) => {
|
||||
if (sortColumn === column) {
|
||||
setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortColumn(column)
|
||||
setSortDir('asc')
|
||||
}
|
||||
}
|
||||
|
||||
const sortedFaces = useMemo(() => {
|
||||
if (!sortColumn) return faces
|
||||
|
||||
return [...faces].sort((a, b) => {
|
||||
let aVal: any
|
||||
let bVal: any
|
||||
|
||||
switch (sortColumn) {
|
||||
case 'person_name':
|
||||
aVal = a.person_name || 'Unidentified'
|
||||
bVal = b.person_name || 'Unidentified'
|
||||
break
|
||||
case 'quality':
|
||||
aVal = a.quality_score
|
||||
bVal = b.quality_score
|
||||
break
|
||||
case 'photo_path':
|
||||
aVal = a.photo_path
|
||||
bVal = b.photo_path
|
||||
break
|
||||
case 'excluded':
|
||||
aVal = a.excluded ? 1 : 0
|
||||
bVal = b.excluded ? 1 : 0
|
||||
break
|
||||
}
|
||||
|
||||
if (typeof aVal === 'string') {
|
||||
aVal = aVal.toLowerCase()
|
||||
bVal = bVal.toLowerCase()
|
||||
}
|
||||
|
||||
if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
|
||||
if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
|
||||
return 0
|
||||
})
|
||||
}, [faces, sortColumn, sortDir])
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedFaces.size === 0) {
|
||||
alert('Please select at least one face to delete.')
|
||||
return
|
||||
}
|
||||
setShowDeleteConfirm(true)
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false)
|
||||
setDeleting(true)
|
||||
try {
|
||||
await facesApi.deleteFaces({
|
||||
face_ids: Array.from(selectedFaces),
|
||||
})
|
||||
// Reload faces after deletion
|
||||
await loadFaces()
|
||||
alert(`Successfully deleted ${selectedFaces.size} face(s)`)
|
||||
} catch (error) {
|
||||
console.error('Error deleting faces:', error)
|
||||
alert('Error deleting faces. Please try again.')
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExclude = async () => {
|
||||
if (selectedFaces.size === 0) {
|
||||
alert('Please select at least one face to exclude.')
|
||||
return
|
||||
}
|
||||
setExcluding(true)
|
||||
try {
|
||||
const faceIds = Array.from(selectedFaces)
|
||||
// Exclude each selected face
|
||||
await Promise.all(faceIds.map(faceId => facesApi.setExcluded(faceId, true)))
|
||||
// Reload faces after exclusion
|
||||
await loadFaces()
|
||||
alert(`Successfully excluded ${selectedFaces.size} face(s)`)
|
||||
} catch (error) {
|
||||
console.error('Error excluding faces:', error)
|
||||
alert('Error excluding faces. Please try again.')
|
||||
} finally {
|
||||
setExcluding(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white rounded-lg shadow mb-4 p-4">
|
||||
<div className="grid grid-cols-5 gap-4">
|
||||
{/* Quality Range Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Quality Range
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={minQuality}
|
||||
onChange={(e) => setMinQuality(parseFloat(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={maxQuality}
|
||||
onChange={(e) => setMaxQuality(parseFloat(e.target.value))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>Min: {(minQuality * 100).toFixed(0)}%</span>
|
||||
<span>Max: {(maxQuality * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Excluded Faces Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Excluded Faces
|
||||
</label>
|
||||
<select
|
||||
value={excludedFilter}
|
||||
onChange={(e) => setExcludedFilter(e.target.value as ExcludedFilter)}
|
||||
className="block w-auto border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="excluded">Excluded only</option>
|
||||
<option value="included">Included only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Identified Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Identified
|
||||
</label>
|
||||
<select
|
||||
value={identifiedFilter}
|
||||
onChange={(e) => setIdentifiedFilter(e.target.value as IdentifiedFilter)}
|
||||
className="block w-auto border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="all">All</option>
|
||||
<option value="identified">Identified only</option>
|
||||
<option value="unidentified">Unidentified only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Batch Size */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(parseInt(e.target.value))}
|
||||
className="block w-auto border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{[25, 50, 100, 200, 500, 1000, 1500, 2000].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-end gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
disabled={faces.length === 0}
|
||||
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Select All
|
||||
</button>
|
||||
<button
|
||||
onClick={unselectAll}
|
||||
disabled={selectedFaces.size === 0}
|
||||
className="px-3 py-2 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
>
|
||||
Unselect All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExclude}
|
||||
disabled={selectedFaces.size === 0 || excluding}
|
||||
className="px-3 py-2 text-sm bg-orange-600 text-white rounded hover:bg-orange-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{excluding ? 'Excluding...' : 'Exclude Selected'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={selectedFaces.size === 0 || deleting}
|
||||
className="px-3 py-2 text-sm bg-red-600 text-white rounded hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deleting ? 'Deleting...' : 'Delete Selected'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="bg-white rounded-lg shadow p-4">
|
||||
<div className="mb-4">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Total: {total} face(s)
|
||||
</span>
|
||||
{selectedFaces.size > 0 && (
|
||||
<span className="ml-4 text-sm text-gray-600">
|
||||
Selected: {selectedFaces.size} face(s)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-gray-500">Loading faces...</div>
|
||||
) : sortedFaces.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">No faces found</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 w-12">☑</th>
|
||||
<th className="text-left p-2 w-24">Thumbnail</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('person_name')}
|
||||
>
|
||||
Person Name {sortColumn === 'person_name' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('photo_path')}
|
||||
>
|
||||
File Path {sortColumn === 'photo_path' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('quality')}
|
||||
>
|
||||
Quality {sortColumn === 'quality' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th
|
||||
className="text-left p-2 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => handleSort('excluded')}
|
||||
>
|
||||
Excluded {sortColumn === 'excluded' && (sortDir === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedFaces.map((face) => (
|
||||
<tr key={face.id} className="border-b hover:bg-gray-50">
|
||||
<td className="p-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFaces.has(face.id)}
|
||||
onChange={() => toggleSelection(face.id)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div
|
||||
className="w-20 h-20 bg-gray-100 rounded overflow-hidden flex items-center justify-center relative group cursor-pointer"
|
||||
onClick={() => {
|
||||
const photoUrl = `${apiClient.defaults.baseURL}/api/v1/photos/${face.photo_id}/image`
|
||||
window.open(photoUrl, '_blank')
|
||||
}}
|
||||
title="Click to open full photo"
|
||||
>
|
||||
<img
|
||||
src={`${apiClient.defaults.baseURL}/api/v1/faces/${face.id}/crop`}
|
||||
alt={`Face ${face.id}`}
|
||||
className="max-w-full max-h-full object-contain pointer-events-none"
|
||||
crossOrigin="anonymous"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${face.id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-10 transition-opacity pointer-events-none" />
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{face.person_name || (
|
||||
<span className="text-gray-400 italic">Unidentified</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<span className="text-blue-600" title={face.photo_path}>
|
||||
{face.photo_path}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{(face.quality_score * 100).toFixed(1)}%
|
||||
</td>
|
||||
<td className="p-2">
|
||||
{face.excluded ? (
|
||||
<span className="text-red-600 font-medium">Yes</span>
|
||||
) : (
|
||||
<span className="text-gray-500">No</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||
<h3 className="text-lg font-bold mb-4">Confirm Delete</h3>
|
||||
<p className="text-gray-700 mb-6">
|
||||
Are you sure you want to delete {selectedFaces.size} face(s) from
|
||||
the database? This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1409
admin-frontend/src/pages/Help.tsx
Normal file
1409
admin-frontend/src/pages/Help.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2072
admin-frontend/src/pages/Identify.tsx
Normal file
2072
admin-frontend/src/pages/Identify.tsx
Normal file
File diff suppressed because it is too large
Load Diff
133
admin-frontend/src/pages/Login.tsx
Normal file
133
admin-frontend/src/pages/Login.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login, isAuthenticated, isLoading } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if user is already authenticated (e.g., visiting /login while logged in)
|
||||
// Don't redirect on isLoading changes during login attempts
|
||||
if (isAuthenticated && !isLoading) {
|
||||
navigate('/', { replace: true })
|
||||
}
|
||||
}, [isAuthenticated, isLoading, navigate])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const result = await login(username, password)
|
||||
if (result.success) {
|
||||
navigate('/', { replace: true })
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
setLoading(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Login failed')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Only show loading screen on initial auth check, not during login attempts
|
||||
if (isLoading && !loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<div className="max-w-md w-full">
|
||||
<div className="bg-white rounded-lg shadow-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="PunimTag"
|
||||
className="h-16 w-auto"
|
||||
onError={(e) => {
|
||||
// Fallback if logo.png doesn't exist, try logo.svg
|
||||
const target = e.target as HTMLImageElement
|
||||
if (target.src.endsWith('logo.png')) {
|
||||
target.src = '/logo.svg'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-gray-600">Photo Management System</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute inset-y-0 right-2 flex items-center text-gray-500 hover:text-gray-700 focus:outline-none"
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? '🙈' : '👁️'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
11
admin-frontend/src/pages/ManagePhotos.tsx
Normal file
11
admin-frontend/src/pages/ManagePhotos.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
export default function ManagePhotos() {
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p className="text-gray-600">Photo management functionality coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
1916
admin-frontend/src/pages/ManageUsers.tsx
Normal file
1916
admin-frontend/src/pages/ManageUsers.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1136
admin-frontend/src/pages/Modify.tsx
Normal file
1136
admin-frontend/src/pages/Modify.tsx
Normal file
File diff suppressed because it is too large
Load Diff
808
admin-frontend/src/pages/PendingPhotos.tsx
Normal file
808
admin-frontend/src/pages/PendingPhotos.tsx
Normal file
@ -0,0 +1,808 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { pendingPhotosApi, PendingPhotoResponse, ReviewDecision, CleanupResponse } from '../api/pendingPhotos'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { videosApi } from '../api/videos'
|
||||
|
||||
type SortKey = 'photo' | 'uploaded_by' | 'file_info' | 'submitted_at' | 'status'
|
||||
|
||||
export default function PendingPhotos() {
|
||||
const { hasPermission, isAdmin } = useAuth()
|
||||
const canManageUploads = hasPermission('user_uploaded')
|
||||
const canRunCleanup = isAdmin
|
||||
const [pendingPhotos, setPendingPhotos] = useState<PendingPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'approve' | 'reject' | null>>({})
|
||||
const [rejectionReasons, setRejectionReasons] = useState<Record<number, string>>({})
|
||||
const [bulkRejectionReason, setBulkRejectionReason] = useState<string>('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [imageUrls, setImageUrls] = useState<Record<number, string>>({})
|
||||
const [notification, setNotification] = useState<{
|
||||
approved: number
|
||||
rejected: number
|
||||
warnings: string[]
|
||||
errors: string[]
|
||||
} | null>(null)
|
||||
const imageUrlsRef = useRef<Record<number, string>>({})
|
||||
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const loadPendingPhotos = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingPhotosApi.listPendingPhotos(
|
||||
statusFilter || undefined
|
||||
)
|
||||
setPendingPhotos(response.items)
|
||||
|
||||
// Clear decisions when loading different status
|
||||
setDecisions({})
|
||||
setRejectionReasons({})
|
||||
|
||||
// Load images as blobs with authentication
|
||||
const newImageUrls: Record<number, string> = {}
|
||||
for (const photo of response.items) {
|
||||
try {
|
||||
const blobUrl = await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
|
||||
newImageUrls[photo.id] = blobUrl
|
||||
} catch (err) {
|
||||
console.error(`Failed to load image for photo ${photo.id}:`, err)
|
||||
}
|
||||
}
|
||||
setImageUrls(newImageUrls)
|
||||
imageUrlsRef.current = newImageUrls
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load pending photos')
|
||||
console.error('Error loading pending photos:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
// Cleanup blob URLs on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(imageUrlsRef.current).forEach((url) => {
|
||||
URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadPendingPhotos()
|
||||
}, [loadPendingPhotos])
|
||||
|
||||
const sortedPendingPhotos = useMemo(() => {
|
||||
const items = [...pendingPhotos]
|
||||
const direction = sortDirection === 'asc' ? 1 : -1
|
||||
|
||||
const compareStrings = (a: string | null | undefined, b: string | null | undefined) =>
|
||||
(a || '').localeCompare(b || '', undefined, { sensitivity: 'base' })
|
||||
|
||||
items.sort((a, b) => {
|
||||
if (sortBy === 'photo') {
|
||||
return (a.id - b.id) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'uploaded_by') {
|
||||
const aName = a.user_name || a.user_email || ''
|
||||
const bName = b.user_name || b.user_email || ''
|
||||
return compareStrings(aName, bName) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'file_info') {
|
||||
return compareStrings(a.original_filename, b.original_filename) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'submitted_at') {
|
||||
const aTime = a.submitted_at || ''
|
||||
const bTime = b.submitted_at || ''
|
||||
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'status') {
|
||||
return compareStrings(a.status, b.status) * direction
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return items
|
||||
}, [pendingPhotos, sortBy, sortDirection])
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
setSortBy((currentKey) => {
|
||||
if (currentKey === key) {
|
||||
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
|
||||
return currentKey
|
||||
}
|
||||
setSortDirection('asc')
|
||||
return key
|
||||
})
|
||||
}
|
||||
|
||||
const renderSortLabel = (label: string, key: SortKey) => {
|
||||
const isActive = sortBy === key
|
||||
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px]">{directionSymbol}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'approve' | 'reject') => {
|
||||
const currentDecision = decisions[id]
|
||||
const isUnselecting = currentDecision === decision
|
||||
|
||||
setDecisions((prev) => {
|
||||
// If clicking the same option, unselect it
|
||||
if (prev[id] === decision) {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
}
|
||||
// Otherwise, set the new decision (this automatically unchecks the other checkbox)
|
||||
return {
|
||||
...prev,
|
||||
[id]: decision,
|
||||
}
|
||||
})
|
||||
|
||||
// Handle rejection reasons
|
||||
if (isUnselecting) {
|
||||
// Unselecting - clear rejection reason
|
||||
setRejectionReasons((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
})
|
||||
} else if (decision === 'approve') {
|
||||
// Switching to approve - clear rejection reason
|
||||
setRejectionReasons((prev) => {
|
||||
const updated = { ...prev }
|
||||
delete updated[id]
|
||||
return updated
|
||||
})
|
||||
} else if (decision === 'reject' && bulkRejectionReason.trim()) {
|
||||
// Switching to reject - apply bulk rejection reason if set
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
[id]: bulkRejectionReason,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRejectionReasonChange = (id: number, reason: string) => {
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
[id]: reason,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAllApprove = () => {
|
||||
const pendingPhotoIds = pendingPhotos
|
||||
.filter((photo) => photo.status === 'pending')
|
||||
.map((photo) => photo.id)
|
||||
|
||||
const newDecisions: Record<number, 'approve'> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newDecisions[id] = 'approve'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
|
||||
// Clear all rejection reasons and bulk rejection reason since we're approving
|
||||
setRejectionReasons({})
|
||||
setBulkRejectionReason('')
|
||||
}
|
||||
|
||||
const handleSelectAllReject = () => {
|
||||
const pendingPhotoIds = pendingPhotos
|
||||
.filter((photo) => photo.status === 'pending')
|
||||
.map((photo) => photo.id)
|
||||
|
||||
const newDecisions: Record<number, 'reject'> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newDecisions[id] = 'reject'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
|
||||
// Apply bulk rejection reason if set
|
||||
if (bulkRejectionReason.trim()) {
|
||||
const newRejectionReasons: Record<number, string> = {}
|
||||
pendingPhotoIds.forEach((id) => {
|
||||
newRejectionReasons[id] = bulkRejectionReason
|
||||
})
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
...newRejectionReasons,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkRejectionReasonChange = (reason: string) => {
|
||||
setBulkRejectionReason(reason)
|
||||
|
||||
// Apply to all currently rejected photos
|
||||
const rejectedPhotoIds = Object.entries(decisions)
|
||||
.filter(([id, decision]) => decision === 'reject')
|
||||
.map(([id]) => parseInt(id))
|
||||
|
||||
if (rejectedPhotoIds.length > 0) {
|
||||
const newRejectionReasons: Record<number, string> = {}
|
||||
rejectedPhotoIds.forEach((id) => {
|
||||
newRejectionReasons[id] = reason
|
||||
})
|
||||
setRejectionReasons((prev) => ({
|
||||
...prev,
|
||||
...newRejectionReasons,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made for pending items
|
||||
const decisionsList: ReviewDecision[] = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const photo = pendingPhotos.find((p) => p.id === parseInt(id))
|
||||
return decision !== null && photo && photo.status === 'pending'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
decision: decision!,
|
||||
rejection_reason: decision === 'reject' ? (rejectionReasons[parseInt(id)] || null) : null,
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Please select Approve or Reject for at least one pending photo.')
|
||||
return
|
||||
}
|
||||
|
||||
// Show confirmation
|
||||
const approveCount = decisionsList.filter((d) => d.decision === 'approve').length
|
||||
const rejectCount = decisionsList.filter((d) => d.decision === 'reject').length
|
||||
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will approve ${approveCount} photo(s) and reject ${rejectCount} photo(s).`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await pendingPhotosApi.reviewPendingPhotos({
|
||||
decisions: decisionsList,
|
||||
})
|
||||
|
||||
// Show custom notification instead of alert
|
||||
setNotification({
|
||||
approved: response.approved,
|
||||
rejected: response.rejected,
|
||||
warnings: response.warnings || [],
|
||||
errors: response.errors,
|
||||
})
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Errors:', response.errors)
|
||||
}
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Warnings:', response.warnings)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
await loadPendingPhotos()
|
||||
// Clear decisions and reasons
|
||||
setDecisions({})
|
||||
setRejectionReasons({})
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error submitting decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanupFiles = async (statusFilter?: string) => {
|
||||
const confirmMessage = statusFilter
|
||||
? `Delete files from shared space for ${statusFilter} photos? This cannot be undone.`
|
||||
: 'Delete files from shared space for all approved/rejected photos? This cannot be undone.'
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response: CleanupResponse = await pendingPhotosApi.cleanupFiles(statusFilter)
|
||||
const message = [
|
||||
`✅ Deleted ${response.deleted_files} file(s) from shared space`,
|
||||
response.warnings && response.warnings.length > 0
|
||||
? `ℹ️ ${response.warnings.length} file(s) were already deleted`
|
||||
: '',
|
||||
response.errors.length > 0 ? `⚠️ Errors: ${response.errors.length}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(message)
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Cleanup warnings:', response.warnings)
|
||||
}
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Cleanup errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list
|
||||
await loadPendingPhotos()
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to cleanup files'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error cleaning up files:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCleanupDatabase = async (statusFilter?: string) => {
|
||||
const confirmMessage = statusFilter
|
||||
? `Delete all ${statusFilter} records from pending_photos table? This cannot be undone.`
|
||||
: 'Delete all approved and rejected records from pending_photos table? Pending records will be kept. This cannot be undone.'
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response: CleanupResponse = await pendingPhotosApi.cleanupDatabase(statusFilter)
|
||||
const message = [
|
||||
`✅ Deleted ${response.deleted_records} record(s) from database`,
|
||||
response.warnings && response.warnings.length > 0
|
||||
? `ℹ️ ${response.warnings.join(', ')}`
|
||||
: '',
|
||||
response.errors.length > 0
|
||||
? `⚠️ Errors: ${response.errors.join('; ')}`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(message)
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Cleanup warnings:', response.warnings)
|
||||
}
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Cleanup errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list
|
||||
await loadPendingPhotos()
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to cleanup database'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error cleaning up database:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Notification */}
|
||||
{notification && (
|
||||
<div className="mb-4 bg-white border border-gray-200 rounded-lg shadow-lg p-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-green-600 text-lg">✅</span>
|
||||
<span className="font-medium">Approved: {notification.approved}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-red-600 text-lg">✓</span>
|
||||
<span className="font-medium">Rejected: {notification.rejected}</span>
|
||||
</div>
|
||||
{notification.warnings.length > 0 && (
|
||||
<div className="text-xs text-gray-600 ml-7">
|
||||
{notification.warnings.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{notification.errors.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-yellow-600 text-lg">⚠️</span>
|
||||
<span className="font-medium">Errors: {notification.errors.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotification(null)}
|
||||
className="mt-3 px-3 py-1.5 text-sm text-gray-600 bg-gray-50 rounded hover:bg-gray-100 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading pending photos...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
<button
|
||||
onClick={loadPendingPhotos}
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total photos: <span className="font-semibold">{pendingPhotos.length}</span>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canManageUploads && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canRunCleanup) {
|
||||
return
|
||||
}
|
||||
handleCleanupFiles()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete files from shared space for approved/rejected photos'
|
||||
: 'Cleanup files is restricted to admins'
|
||||
}
|
||||
>
|
||||
🗑️ Cleanup Files
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canRunCleanup) {
|
||||
return
|
||||
}
|
||||
handleCleanupDatabase()
|
||||
}}
|
||||
disabled={!canRunCleanup}
|
||||
className="px-3 py-1.5 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
title={
|
||||
canRunCleanup
|
||||
? 'Delete approved and rejected records from pending_photos table (pending records will be kept)'
|
||||
: 'Clear database is restricted to admins'
|
||||
}
|
||||
>
|
||||
🗑️ Clear Database
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{pendingPhotos.filter((p) => p.status === 'pending').length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSelectAllApprove}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSelectAllReject}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Reject
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
submitting ||
|
||||
Object.values(decisions).filter((d) => d !== null).length === 0
|
||||
}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{Object.values(decisions).some((d) => d === 'reject') && (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-700 font-medium whitespace-nowrap">
|
||||
Bulk Rejection Reason:
|
||||
</label>
|
||||
<textarea
|
||||
value={bulkRejectionReason}
|
||||
onChange={(e) => handleBulkRejectionReasonChange(e.target.value)}
|
||||
placeholder="Enter rejection reason to apply to all rejected photos..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pendingPhotos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No pending photos found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Photo', 'photo')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Uploaded By', 'uploaded_by')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('File Info', 'file_info')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted At', 'submitted_at')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Status', 'status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Rejection Reason
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedPendingPhotos.map((photo) => {
|
||||
const isPending = photo.status === 'pending'
|
||||
const isApproved = photo.status === 'approved'
|
||||
const isRejected = photo.status === 'rejected'
|
||||
const canMakeDecision = isPending
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={photo.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isApproved || isRejected ? 'opacity-60 bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={async () => {
|
||||
const isVideo = photo.mime_type?.startsWith('video/')
|
||||
if (isVideo) {
|
||||
// For videos, open the video file directly
|
||||
const videoUrl = `${apiClient.defaults.baseURL}/api/v1/pending-photos/${photo.id}/image`
|
||||
window.open(videoUrl, '_blank')
|
||||
} else {
|
||||
// For images, fetch as blob and open in new tab
|
||||
try {
|
||||
const blobUrl = imageUrls[photo.id] || await pendingPhotosApi.getPendingPhotoImageBlob(photo.id)
|
||||
// Create a new window with the blob URL
|
||||
const newWindow = window.open()
|
||||
if (newWindow) {
|
||||
newWindow.location.href = blobUrl
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to open full-size image:', err)
|
||||
alert('Failed to load full-size image')
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={photo.mime_type?.startsWith('video/') ? 'Click to open video' : 'Click to open full photo'}
|
||||
>
|
||||
{photo.mime_type?.startsWith('video/') ? (
|
||||
<div className="w-24 h-24 bg-gray-800 rounded border border-gray-300 flex items-center justify-center relative">
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
<div className="absolute bottom-1 right-1 bg-black bg-opacity-70 text-white text-[8px] px-1 rounded">
|
||||
VIDEO
|
||||
</div>
|
||||
</div>
|
||||
) : imageUrls[photo.id] ? (
|
||||
<img
|
||||
src={imageUrls[photo.id]}
|
||||
alt={photo.original_filename}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className =
|
||||
'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = 'Image not found'
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 bg-gray-200 rounded border border-gray-300 flex items-center justify-center text-xs text-gray-400">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{photo.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{photo.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-900">
|
||||
{photo.original_filename}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatFileSize(photo.file_size)} • {photo.mime_type}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(photo.submitted_at)}
|
||||
</div>
|
||||
{photo.reviewed_at && (
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Reviewed: {formatDate(photo.reviewed_at)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
photo.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: photo.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{photo.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{canMakeDecision ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decisions[photo.id] === 'approve'}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleDecisionChange(photo.id, 'approve')
|
||||
} else {
|
||||
// Unchecking - remove decision
|
||||
handleDecisionChange(photo.id, 'approve')
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decisions[photo.id] === 'reject'}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleDecisionChange(photo.id, 'reject')
|
||||
} else {
|
||||
// Unchecking - remove decision
|
||||
handleDecisionChange(photo.id, 'reject')
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500 rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Reject</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 italic">
|
||||
{isApproved ? 'Approved' : isRejected ? 'Rejected' : '-'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{canMakeDecision && decisions[photo.id] === 'reject' ? (
|
||||
<textarea
|
||||
value={rejectionReasons[photo.id] || ''}
|
||||
onChange={(e) =>
|
||||
handleRejectionReasonChange(photo.id, e.target.value)
|
||||
}
|
||||
placeholder="Optional: Enter rejection reason..."
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none focus:ring-blue-500 focus:border-blue-500"
|
||||
rows={2}
|
||||
/>
|
||||
) : isRejected && photo.rejection_reason ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
<div className="bg-red-50 p-2 rounded border border-red-200">
|
||||
{photo.rejection_reason}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
459
admin-frontend/src/pages/Process.tsx
Normal file
459
admin-frontend/src/pages/Process.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { facesApi, ProcessFacesRequest } from '../api/faces'
|
||||
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
interface JobProgress {
|
||||
id: string
|
||||
status: string
|
||||
progress: number
|
||||
message: string
|
||||
processed?: number
|
||||
total?: number
|
||||
faces_detected?: number
|
||||
faces_stored?: number
|
||||
}
|
||||
|
||||
const DETECTOR_OPTIONS = ['retinaface', 'mtcnn', 'opencv', 'ssd']
|
||||
const MODEL_OPTIONS = ['ArcFace', 'Facenet', 'Facenet512', 'VGG-Face']
|
||||
|
||||
export default function Process() {
|
||||
const { isDeveloperMode } = useDeveloperMode()
|
||||
const [batchSize, setBatchSize] = useState<number | undefined>(undefined)
|
||||
const [detectorBackend, setDetectorBackend] = useState('retinaface')
|
||||
const [modelName, setModelName] = useState('ArcFace')
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
|
||||
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Cleanup event source on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStartProcessing = async () => {
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
setCurrentJob(null)
|
||||
setJobProgress(null)
|
||||
|
||||
try {
|
||||
const request: ProcessFacesRequest = {
|
||||
batch_size: batchSize || undefined,
|
||||
detector_backend: detectorBackend,
|
||||
model_name: modelName,
|
||||
}
|
||||
|
||||
const response = await facesApi.processFaces(request)
|
||||
|
||||
// Set processing state immediately
|
||||
setIsProcessing(true)
|
||||
|
||||
setCurrentJob({
|
||||
id: response.job_id,
|
||||
status: JobStatus.PENDING,
|
||||
progress: 0,
|
||||
message: response.message,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Start SSE stream for job progress
|
||||
startJobProgressStream(response.job_id)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Processing failed')
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleStopProcessing = async () => {
|
||||
// Use jobProgress if currentJob is not available (might happen if status is still Pending)
|
||||
const jobId = currentJob?.id || jobProgress?.id
|
||||
|
||||
if (!jobId) {
|
||||
console.error('Cannot stop: No job ID available')
|
||||
setError('Cannot stop: No active job found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[Process] STOP button clicked for job ${jobId}`)
|
||||
|
||||
try {
|
||||
// Call API to cancel the job
|
||||
console.log(`[Process] Calling cancelJob API for job ${jobId}`)
|
||||
const result = await jobsApi.cancelJob(jobId)
|
||||
console.log('[Process] Job cancellation requested:', result)
|
||||
|
||||
// Update job status to show cancellation is in progress
|
||||
if (currentJob) {
|
||||
setCurrentJob({
|
||||
...currentJob,
|
||||
status: JobStatus.PROGRESS,
|
||||
message: 'Cancellation requested - finishing current photo...',
|
||||
})
|
||||
} else if (jobProgress) {
|
||||
// If currentJob is not set, update jobProgress
|
||||
setJobProgress({
|
||||
...jobProgress,
|
||||
status: 'progress',
|
||||
message: 'Cancellation requested - finishing current photo...',
|
||||
})
|
||||
}
|
||||
|
||||
// Don't close SSE stream yet - keep it open to wait for job to actually stop
|
||||
// The job will finish the current photo, then stop and send a final status update
|
||||
// The SSE stream handler will close the stream when job status becomes SUCCESS or FAILURE
|
||||
|
||||
// Set a flag to indicate cancellation was requested
|
||||
// This will be checked in the SSE handler
|
||||
setError(null) // Clear any previous errors
|
||||
} catch (err: any) {
|
||||
console.error('[Process] Error cancelling job:', err)
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to cancel job'
|
||||
setError(errorMessage)
|
||||
console.error('[Process] Full error details:', {
|
||||
message: err.message,
|
||||
response: err.response?.data,
|
||||
status: err.response?.status,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const startJobProgressStream = (jobId: string) => {
|
||||
// Close existing stream if any
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const eventSource = jobsApi.streamJobProgress(jobId)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: JobProgress = JSON.parse(event.data)
|
||||
setJobProgress(data)
|
||||
|
||||
// Update job status
|
||||
const statusMap: Record<string, JobStatus> = {
|
||||
pending: JobStatus.PENDING,
|
||||
started: JobStatus.STARTED,
|
||||
progress: JobStatus.PROGRESS,
|
||||
success: JobStatus.SUCCESS,
|
||||
failure: JobStatus.FAILURE,
|
||||
cancelled: JobStatus.CANCELLED,
|
||||
}
|
||||
|
||||
const jobStatus = statusMap[data.status] || JobStatus.PENDING
|
||||
|
||||
setCurrentJob({
|
||||
id: data.id,
|
||||
status: jobStatus,
|
||||
progress: data.progress,
|
||||
message: data.message,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Keep processing state true while job is running
|
||||
if (jobStatus === JobStatus.STARTED || jobStatus === JobStatus.PROGRESS) {
|
||||
setIsProcessing(true)
|
||||
}
|
||||
|
||||
// Check if job is complete
|
||||
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.FAILURE || jobStatus === JobStatus.CANCELLED) {
|
||||
setIsProcessing(false)
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
|
||||
// Handle cancelled jobs
|
||||
if (jobStatus === JobStatus.CANCELLED) {
|
||||
const progressInfo = data.processed !== undefined && data.total !== undefined
|
||||
? ` (processed ${data.processed} of ${data.total} photos)`
|
||||
: ''
|
||||
setError(`Processing stopped: ${data.message || 'Cancelled by user'}${progressInfo}`)
|
||||
}
|
||||
// Show error message for failures
|
||||
else if (jobStatus === JobStatus.FAILURE) {
|
||||
// Show failure message with progress info if available
|
||||
const progressInfo = data.processed !== undefined && data.total !== undefined
|
||||
? ` (processed ${data.processed} of ${data.total} photos)`
|
||||
: ''
|
||||
setError(`Processing failed: ${data.message || 'Unknown error'}${progressInfo}`)
|
||||
}
|
||||
|
||||
// Fetch final job result to get processing stats for successful or cancelled jobs
|
||||
if (jobStatus === JobStatus.SUCCESS || jobStatus === JobStatus.CANCELLED) {
|
||||
fetchJobResult(jobId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('SSE error:', err)
|
||||
// Don't automatically set isProcessing to false on error
|
||||
// Job might still be running even if SSE connection failed
|
||||
// Check job status directly instead
|
||||
if (currentJob) {
|
||||
// Try to fetch job status directly
|
||||
jobsApi.getJob(currentJob.id).then((job) => {
|
||||
const stillRunning = job.status === JobStatus.STARTED || job.status === JobStatus.PROGRESS
|
||||
setIsProcessing(stillRunning)
|
||||
setCurrentJob(job)
|
||||
}).catch(() => {
|
||||
// If we can't get status, assume job might still be running
|
||||
console.warn('Could not fetch job status after SSE error')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchJobResult = async (jobId: string) => {
|
||||
try {
|
||||
const job = await jobsApi.getJob(jobId)
|
||||
setCurrentJob(job)
|
||||
} catch (err) {
|
||||
console.error('Error fetching job result:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: JobStatus) => {
|
||||
switch (status) {
|
||||
case JobStatus.SUCCESS:
|
||||
return 'text-green-600'
|
||||
case JobStatus.FAILURE:
|
||||
return 'text-red-600'
|
||||
case JobStatus.STARTED:
|
||||
case JobStatus.PROGRESS:
|
||||
return 'text-blue-600'
|
||||
default:
|
||||
return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Configuration Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Processing Configuration
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Batch Size */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="batch-size"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
id="batch-size"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
pattern="[0-9]*"
|
||||
value={batchSize || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
// Only allow numeric input
|
||||
if (value === '' || /^\d+$/.test(value)) {
|
||||
setBatchSize(value ? parseInt(value, 10) : undefined)
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Allow: backspace, delete, tab, escape, enter, and decimal point
|
||||
if (
|
||||
[8, 9, 27, 13, 46, 110, 190].indexOf(e.keyCode) !== -1 ||
|
||||
// Allow: Ctrl+A, Ctrl+C, Ctrl+V, Ctrl+X
|
||||
(e.keyCode === 65 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 67 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 86 && e.ctrlKey === true) ||
|
||||
(e.keyCode === 88 && e.ctrlKey === true) ||
|
||||
// Allow: home, end, left, right
|
||||
(e.keyCode >= 35 && e.keyCode <= 39)
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Ensure that it is a number and stop the keypress
|
||||
if ((e.shiftKey || (e.keyCode < 48 || e.keyCode > 57)) && (e.keyCode < 96 || e.keyCode > 105)) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
className="w-32 px-2 py-1 text-sm border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Leave empty to process all unprocessed photos
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Detector Backend - Only visible in developer mode */}
|
||||
{isDeveloperMode && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="detector-backend"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Face Detector
|
||||
</label>
|
||||
<select
|
||||
id="detector-backend"
|
||||
value={detectorBackend}
|
||||
onChange={(e) => setDetectorBackend(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{DETECTOR_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option.charAt(0).toUpperCase() + option.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
RetinaFace recommended for best accuracy
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Name - Only visible in developer mode */}
|
||||
{isDeveloperMode && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="model-name"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Recognition Model
|
||||
</label>
|
||||
<select
|
||||
id="model-name"
|
||||
value={modelName}
|
||||
onChange={(e) => setModelName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{MODEL_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-gray-500">
|
||||
ArcFace recommended for best accuracy
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Control Buttons */}
|
||||
<div className="flex gap-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartProcessing}
|
||||
disabled={isProcessing}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isProcessing ? 'Processing...' : 'Start Processing'}
|
||||
</button>
|
||||
{isProcessing && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
console.log('[Process] STOP button clicked, isProcessing:', isProcessing)
|
||||
console.log('[Process] currentJob:', currentJob)
|
||||
console.log('[Process] jobProgress:', jobProgress)
|
||||
handleStopProcessing()
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={!currentJob && !jobProgress}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(currentJob || jobProgress) && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Processing Progress
|
||||
</h2>
|
||||
|
||||
{currentJob && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span
|
||||
className={`text-sm font-medium ${getStatusColor(
|
||||
currentJob.status
|
||||
)}`}
|
||||
>
|
||||
{currentJob.status === JobStatus.SUCCESS && '✓ '}
|
||||
{currentJob.status === JobStatus.FAILURE && '✗ '}
|
||||
{currentJob.status === JobStatus.CANCELLED && '⏹ '}
|
||||
{currentJob.status === JobStatus.CANCELLED
|
||||
? 'Stopped'
|
||||
: currentJob.status.charAt(0).toUpperCase() +
|
||||
currentJob.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentJob.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${currentJob.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jobProgress && (
|
||||
<div className="space-y-2 text-sm text-gray-600">
|
||||
{jobProgress.processed !== undefined &&
|
||||
jobProgress.total !== undefined && (
|
||||
<p>
|
||||
Photos processed: {jobProgress.processed} /{' '}
|
||||
{jobProgress.total}
|
||||
</p>
|
||||
)}
|
||||
{jobProgress.faces_detected !== undefined && (
|
||||
<p>Faces detected: {jobProgress.faces_detected}</p>
|
||||
)}
|
||||
{jobProgress.faces_stored !== undefined && (
|
||||
<p>Faces stored: {jobProgress.faces_stored}</p>
|
||||
)}
|
||||
{jobProgress.message && (
|
||||
<p className="mt-1 font-medium">{jobProgress.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Error Section */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
509
admin-frontend/src/pages/ReportedPhotos.tsx
Normal file
509
admin-frontend/src/pages/ReportedPhotos.tsx
Normal file
@ -0,0 +1,509 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
reportedPhotosApi,
|
||||
ReportedPhotoResponse,
|
||||
ReviewDecision,
|
||||
} from '../api/reportedPhotos'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { videosApi } from '../api/videos'
|
||||
|
||||
export default function ReportedPhotos() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [reportedPhotos, setReportedPhotos] = useState<ReportedPhotoResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [decisions, setDecisions] = useState<Record<number, 'keep' | 'remove' | null>>({})
|
||||
const [reviewNotes, setReviewNotes] = useState<Record<number, string>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
|
||||
const loadReportedPhotos = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await reportedPhotosApi.listReportedPhotos(
|
||||
statusFilter || undefined
|
||||
)
|
||||
setReportedPhotos(response.items)
|
||||
|
||||
// Initialize review notes from existing data
|
||||
const existingNotes: Record<number, string> = {}
|
||||
response.items.forEach((item) => {
|
||||
if (item.review_notes) {
|
||||
existingNotes[item.id] = item.review_notes
|
||||
}
|
||||
})
|
||||
setReviewNotes(existingNotes)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load reported photos')
|
||||
console.error('Error loading reported photos:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadReportedPhotos()
|
||||
}, [loadReportedPhotos])
|
||||
|
||||
const formatDate = (dateString: string | null | undefined): string => {
|
||||
if (!dateString) return '-'
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
const handleDecisionChange = (id: number, decision: 'keep' | 'remove') => {
|
||||
setDecisions((prev) => {
|
||||
const currentDecision = prev[id] ?? null
|
||||
const nextDecision = currentDecision === decision ? null : decision
|
||||
return {
|
||||
...prev,
|
||||
[id]: nextDecision,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleReviewNotesChange = (id: number, notes: string) => {
|
||||
setReviewNotes((prev) => ({
|
||||
...prev,
|
||||
[id]: notes,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Get all decisions that have been made for pending or reviewed items
|
||||
const decisionsList: ReviewDecision[] = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const reported = reportedPhotos.find((p) => p.id === parseInt(id))
|
||||
return decision !== null && reported && (reported.status === 'pending' || reported.status === 'reviewed')
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: parseInt(id),
|
||||
decision: decision!,
|
||||
review_notes: reviewNotes[parseInt(id)] || null,
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Please select Keep or Remove for at least one reported photo.')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if there are any 'remove' decisions
|
||||
const removeDecisions = decisionsList.filter((d) => d.decision === 'remove')
|
||||
const keepDecisions = decisionsList.filter((d) => d.decision === 'keep')
|
||||
|
||||
// Show specific confirmation for removal
|
||||
if (removeDecisions.length > 0) {
|
||||
const removeCount = removeDecisions.length
|
||||
const photoDetails = removeDecisions
|
||||
.map((d) => {
|
||||
const reported = reportedPhotos.find((p) => p.id === d.id)
|
||||
return reported?.photo_filename || `Photo #${reported?.photo_id || d.id}`
|
||||
})
|
||||
.join('\n - ')
|
||||
|
||||
const confirmMessage = `⚠️ WARNING: You are about to PERMANENTLY REMOVE ${removeCount} photo(s):\n\n - ${photoDetails}\n\nThis will:\n • Delete the photo(s) from the database\n • Delete all faces detected in the photo(s)\n • Delete all encodings related to those faces\n\nThis action CANNOT be undone!\n\nAre you sure you want to proceed?`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Show general confirmation if there are also 'keep' decisions
|
||||
if (keepDecisions.length > 0) {
|
||||
const confirmMessage = `Submit ${decisionsList.length} decision(s)?\n\nThis will ${
|
||||
removeDecisions.length
|
||||
} remove photo(s) and ${keepDecisions.length} keep photo(s).`
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await reportedPhotosApi.reviewReportedPhotos({
|
||||
decisions: decisionsList,
|
||||
})
|
||||
|
||||
const messageParts = [
|
||||
`✅ Kept: ${response.kept}`,
|
||||
`❌ Removed: ${response.removed}`,
|
||||
]
|
||||
|
||||
if (import.meta.env.DEV && response.errors.length > 0) {
|
||||
messageParts.push(`⚠️ Errors: ${response.errors.length}`)
|
||||
}
|
||||
|
||||
const message = messageParts.join('\n')
|
||||
|
||||
alert(message)
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Reported photo review errors:', response.errors)
|
||||
}
|
||||
|
||||
// Reload the list to show updated status
|
||||
await loadReportedPhotos()
|
||||
// Clear decisions and notes
|
||||
setDecisions({})
|
||||
setReviewNotes({})
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error submitting decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDatabase = async () => {
|
||||
const confirmMessage = [
|
||||
'Delete all kept and removed reported photo records from the auth database?',
|
||||
'',
|
||||
'Only photos with Pending status will remain.',
|
||||
'This action cannot be undone.',
|
||||
].join('\n')
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setClearing(true)
|
||||
try {
|
||||
const response = await reportedPhotosApi.cleanupReportedPhotos()
|
||||
const summary = [
|
||||
`✅ Deleted ${response.deleted_records} record(s)`,
|
||||
response.warnings && response.warnings.length > 0
|
||||
? `ℹ️ ${response.warnings.join('; ')}`
|
||||
: '',
|
||||
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(summary || 'Cleanup complete.')
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Cleanup errors:', response.errors)
|
||||
}
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Cleanup warnings:', response.warnings)
|
||||
}
|
||||
|
||||
await loadReportedPhotos()
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to cleanup reported photos'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error clearing reported photos:', err)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
|
||||
{loading && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-600">Loading reported photos...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded text-red-700">
|
||||
<p className="font-semibold">Error loading data</p>
|
||||
<p className="text-sm mt-1">{error}</p>
|
||||
<button
|
||||
onClick={loadReportedPhotos}
|
||||
className="mt-3 px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<div className="mb-4 flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Total reported photos:{' '}
|
||||
<span className="font-semibold">{reportedPhotos.length}</span>
|
||||
</div>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-3 py-1 border border-gray-300 rounded-md text-sm"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="reviewed">Reviewed</option>
|
||||
<option value="dismissed">Dismissed</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
submitting ||
|
||||
Object.values(decisions).filter((d) => d !== null).length === 0
|
||||
}
|
||||
className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
return
|
||||
}
|
||||
handleClearDatabase()
|
||||
}}
|
||||
disabled={clearing || !isAdmin}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
title={
|
||||
isAdmin
|
||||
? 'Delete kept/removed records'
|
||||
: 'Only admins can clear reported photos'
|
||||
}
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Clear kept/removed records
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reportedPhotos.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<p>No reported photos found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Photo
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reported By
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Reported At
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Report Comment
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Review Notes
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{reportedPhotos.map((reported) => {
|
||||
const isReviewed = reported.status === 'reviewed'
|
||||
const isDismissed = reported.status === 'dismissed'
|
||||
const canMakeDecision = !isDismissed && (reported.status === 'pending' || reported.status === 'reviewed')
|
||||
return (
|
||||
<tr
|
||||
key={reported.id}
|
||||
className={`hover:bg-gray-50 ${
|
||||
isReviewed || isDismissed ? 'opacity-60 bg-gray-50' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
{reported.photo_id ? (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
const isVideo = reported.photo_media_type === 'video'
|
||||
const url = isVideo
|
||||
? videosApi.getVideoUrl(reported.photo_id)
|
||||
: `${apiClient.defaults.baseURL}/api/v1/photos/${reported.photo_id}/image`
|
||||
window.open(url, '_blank')
|
||||
}}
|
||||
title={reported.photo_media_type === 'video' ? 'Click to open video' : 'Click to open full photo'}
|
||||
>
|
||||
{reported.photo_media_type === 'video' ? (
|
||||
<img
|
||||
src={videosApi.getThumbnailUrl(reported.photo_id)}
|
||||
alt={`Video ${reported.photo_id}`}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className =
|
||||
'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${reported.photo_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={`/api/v1/photos/${reported.photo_id}/image`}
|
||||
alt={`Photo ${reported.photo_id}`}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
const target = e.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.error-fallback')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className =
|
||||
'text-gray-400 text-xs error-fallback'
|
||||
fallback.textContent = `#${reported.photo_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-400 text-xs">Photo not found</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{reported.photo_filename || `Photo #${reported.photo_id}`}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{reported.user_name || 'Unknown'}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{reported.user_email || '-'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500">
|
||||
{formatDate(reported.reported_at)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{reported.report_comment ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 p-2 rounded border border-gray-200">
|
||||
{reported.report_comment}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
reported.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: reported.status === 'reviewed'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{reported.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{canMakeDecision ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`decision-${reported.id}-keep`}
|
||||
value="keep"
|
||||
checked={decisions[reported.id] === 'keep'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'keep')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Keep</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={`decision-${reported.id}-remove`}
|
||||
value="remove"
|
||||
checked={decisions[reported.id] === 'remove'}
|
||||
onChange={() => handleDecisionChange(reported.id, 'remove')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Remove</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{isReviewed || isDismissed ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap">
|
||||
{reported.review_notes ? (
|
||||
<div className="bg-gray-50 p-2 rounded border border-gray-200">
|
||||
{reported.review_notes}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 italic">-</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{reported.review_notes && (
|
||||
<div className="bg-blue-50 p-2 rounded border border-blue-200 text-sm text-gray-700">
|
||||
<div className="text-xs text-blue-600 font-medium mb-1">
|
||||
Existing notes:
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap">
|
||||
{reported.review_notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
value={reviewNotes[reported.id] || ''}
|
||||
onChange={(e) =>
|
||||
handleReviewNotesChange(reported.id, e.target.value)
|
||||
}
|
||||
placeholder="Optional review notes..."
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded-md resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
460
admin-frontend/src/pages/Scan.tsx
Normal file
460
admin-frontend/src/pages/Scan.tsx
Normal file
@ -0,0 +1,460 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { photosApi, PhotoImportRequest } from '../api/photos'
|
||||
import { jobsApi, JobResponse, JobStatus } from '../api/jobs'
|
||||
|
||||
interface JobProgress {
|
||||
id: string
|
||||
status: string
|
||||
progress: number
|
||||
message: string
|
||||
processed?: number
|
||||
total?: number
|
||||
}
|
||||
|
||||
export default function Scan() {
|
||||
const [folderPath, setFolderPath] = useState('')
|
||||
const [recursive, setRecursive] = useState(true)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isBrowsing, setIsBrowsing] = useState(false)
|
||||
const [currentJob, setCurrentJob] = useState<JobResponse | null>(null)
|
||||
const [jobProgress, setJobProgress] = useState<JobProgress | null>(null)
|
||||
const [importResult, setImportResult] = useState<{
|
||||
added?: number
|
||||
existing?: number
|
||||
total?: number
|
||||
} | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
// Cleanup event source on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFolderBrowse = async () => {
|
||||
setIsBrowsing(true)
|
||||
setError(null)
|
||||
|
||||
// Try backend API first (uses tkinter for native folder picker with full path)
|
||||
try {
|
||||
console.log('Attempting to open native folder picker...')
|
||||
const result = await photosApi.browseFolder()
|
||||
console.log('Backend folder picker result:', result)
|
||||
|
||||
if (result.success && result.path) {
|
||||
// Ensure we have a valid absolute path (not just folder name)
|
||||
const path = result.path.trim()
|
||||
if (path && path.length > 0) {
|
||||
// Verify it looks like an absolute path:
|
||||
// - Unix/Linux: starts with / (includes mounted network shares like /mnt/...)
|
||||
// - Windows local: starts with drive letter like C:\
|
||||
// - Windows UNC: starts with \\ (network paths like \\server\share\folder)
|
||||
const isUnixPath = path.startsWith('/')
|
||||
const isWindowsLocalPath = /^[A-Za-z]:[\\/]/.test(path)
|
||||
const isWindowsUncPath = path.startsWith('\\\\') || path.startsWith('//')
|
||||
|
||||
if (isUnixPath || isWindowsLocalPath || isWindowsUncPath) {
|
||||
setFolderPath(path)
|
||||
setIsBrowsing(false)
|
||||
return
|
||||
} else {
|
||||
// Backend validated it, so trust it even if it doesn't match our patterns
|
||||
// (might be a valid path format we didn't account for)
|
||||
console.warn('Backend returned path with unexpected format:', path)
|
||||
setFolderPath(path)
|
||||
setIsBrowsing(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, result.success was false or path was empty
|
||||
console.warn('Backend folder picker returned no path:', result)
|
||||
if (result.success === false && result.message) {
|
||||
setError(result.message || 'No folder was selected. Please try again.')
|
||||
} else {
|
||||
setError('No folder was selected. Please try again.')
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
} catch (err: any) {
|
||||
// Backend API failed, fall back to browser picker
|
||||
console.warn('Backend folder picker unavailable, using browser fallback:', err)
|
||||
|
||||
// Extract error message from various possible locations
|
||||
const errorMsg = err?.response?.data?.detail ||
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
String(err) ||
|
||||
''
|
||||
|
||||
console.log('Error details:', {
|
||||
status: err?.response?.status,
|
||||
detail: err?.response?.data?.detail,
|
||||
message: err?.message,
|
||||
fullError: err
|
||||
})
|
||||
|
||||
// Check if it's a display/availability issue
|
||||
if (errorMsg.includes('display') || errorMsg.includes('DISPLAY') || errorMsg.includes('tkinter')) {
|
||||
// Show user-friendly message about display issue
|
||||
setError('Native folder picker unavailable. Using browser fallback.')
|
||||
} else if (err?.response?.status === 503) {
|
||||
// 503 Service Unavailable - likely tkinter or display issue
|
||||
setError('Native folder picker unavailable (tkinter/display issue). Using browser fallback.')
|
||||
} else {
|
||||
// Other error - log it but continue to browser fallback
|
||||
console.error('Error calling backend folder picker:', err)
|
||||
setError('Native folder picker unavailable. Using browser fallback.')
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Use browser-based folder picker
|
||||
// This code runs if backend API failed or returned no path
|
||||
console.log('Attempting browser fallback folder picker...')
|
||||
|
||||
// Use File System Access API if available (modern browsers)
|
||||
if (typeof window !== 'undefined' && 'showDirectoryPicker' in window) {
|
||||
try {
|
||||
console.log('Using File System Access API...')
|
||||
const directoryHandle = await (window as any).showDirectoryPicker()
|
||||
// Get the folder name from the handle
|
||||
const folderName = directoryHandle.name
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
console.log('Selected folder name:', folderName)
|
||||
|
||||
// Browser picker only gives folder name, not full path
|
||||
// Set the folder name and show helpful message
|
||||
setFolderPath(folderName)
|
||||
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
|
||||
} catch (err: any) {
|
||||
// User cancelled the picker
|
||||
if (err.name !== 'AbortError') {
|
||||
console.error('Error selecting folder:', err)
|
||||
setError('Error opening folder picker: ' + err.message)
|
||||
} else {
|
||||
// User cancelled - clear any previous error
|
||||
setError(null)
|
||||
}
|
||||
} finally {
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
} else {
|
||||
// Fallback: use a hidden directory input
|
||||
// Note: This will show a browser confirmation dialog that cannot be removed
|
||||
console.log('Using file input fallback...')
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.setAttribute('webkitdirectory', '')
|
||||
input.setAttribute('directory', '')
|
||||
input.setAttribute('multiple', '')
|
||||
input.style.display = 'none'
|
||||
|
||||
input.onchange = (e: any) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
const firstFile = files[0]
|
||||
const relativePath = firstFile.webkitRelativePath
|
||||
const pathParts = relativePath.split('/')
|
||||
const rootFolder = pathParts[0]
|
||||
// Note: Browsers don't expose full absolute paths for security reasons
|
||||
console.log('Selected folder name:', rootFolder)
|
||||
|
||||
// Browser picker only gives folder name, not full path
|
||||
// Set the folder name and show helpful message
|
||||
setFolderPath(rootFolder)
|
||||
setError('Browser picker only shows folder name. Please enter the full absolute path manually in the input field above.')
|
||||
}
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
|
||||
input.oncancel = () => {
|
||||
if (document.body.contains(input)) {
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
setIsBrowsing(false)
|
||||
}
|
||||
|
||||
document.body.appendChild(input)
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
const handleScanFolder = async () => {
|
||||
if (!folderPath.trim()) {
|
||||
setError('Please enter a folder path')
|
||||
return
|
||||
}
|
||||
|
||||
setIsImporting(true)
|
||||
setError(null)
|
||||
setImportResult(null)
|
||||
setCurrentJob(null)
|
||||
setJobProgress(null)
|
||||
|
||||
try {
|
||||
const request: PhotoImportRequest = {
|
||||
folder_path: folderPath.trim(),
|
||||
recursive,
|
||||
}
|
||||
|
||||
const response = await photosApi.importPhotos(request)
|
||||
setCurrentJob({
|
||||
id: response.job_id,
|
||||
status: JobStatus.PENDING,
|
||||
progress: 0,
|
||||
message: response.message,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Start SSE stream for job progress
|
||||
startJobProgressStream(response.job_id)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Import failed')
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startJobProgressStream = (jobId: string) => {
|
||||
// Close existing stream if any
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
|
||||
const eventSource = photosApi.streamJobProgress(jobId)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data: JobProgress = JSON.parse(event.data)
|
||||
setJobProgress(data)
|
||||
|
||||
// Update job status
|
||||
const statusMap: Record<string, JobStatus> = {
|
||||
pending: JobStatus.PENDING,
|
||||
started: JobStatus.STARTED,
|
||||
progress: JobStatus.PROGRESS,
|
||||
success: JobStatus.SUCCESS,
|
||||
failure: JobStatus.FAILURE,
|
||||
}
|
||||
|
||||
setCurrentJob({
|
||||
id: data.id,
|
||||
status: statusMap[data.status] || JobStatus.PENDING,
|
||||
progress: data.progress,
|
||||
message: data.message,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
|
||||
// Check if job is complete
|
||||
if (data.status === 'success' || data.status === 'failure') {
|
||||
setIsImporting(false)
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
|
||||
// Fetch final job result to get added/existing counts
|
||||
if (data.status === 'success') {
|
||||
fetchJobResult(jobId)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing SSE event:', err)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = (err) => {
|
||||
console.error('SSE error:', err)
|
||||
eventSource.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchJobResult = async (jobId: string) => {
|
||||
try {
|
||||
const job = await jobsApi.getJob(jobId)
|
||||
// Job result may contain added/existing counts in metadata
|
||||
// For now, we'll just update the job status
|
||||
setCurrentJob(job)
|
||||
} catch (err) {
|
||||
console.error('Error fetching job result:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = (status: JobStatus) => {
|
||||
switch (status) {
|
||||
case JobStatus.SUCCESS:
|
||||
return 'text-green-600'
|
||||
case JobStatus.FAILURE:
|
||||
return 'text-red-600'
|
||||
case JobStatus.STARTED:
|
||||
case JobStatus.PROGRESS:
|
||||
return 'text-blue-600'
|
||||
default:
|
||||
return 'text-gray-600'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Folder Scan Section */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Scan Folder
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="folder-path"
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
Folder Path
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="folder-path"
|
||||
type="text"
|
||||
value={folderPath}
|
||||
onChange={(e) => setFolderPath(e.target.value)}
|
||||
placeholder="/path/to/photos"
|
||||
className="w-1/2 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFolderBrowse}
|
||||
disabled={isImporting || isBrowsing}
|
||||
className="px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isBrowsing ? 'Opening...' : 'Browse'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Enter the full absolute path to the folder containing photos / videos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="recursive"
|
||||
type="checkbox"
|
||||
checked={recursive}
|
||||
onChange={(e) => setRecursive(e.target.checked)}
|
||||
disabled={isImporting}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label
|
||||
htmlFor="recursive"
|
||||
className="ml-2 block text-sm text-gray-700"
|
||||
>
|
||||
Scan subdirectories recursively
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScanFolder}
|
||||
disabled={isImporting || !folderPath.trim()}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isImporting ? 'Scanning...' : 'Start Scanning'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Section */}
|
||||
{(currentJob || jobProgress) && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Import Progress
|
||||
</h2>
|
||||
|
||||
{currentJob && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span
|
||||
className={`text-sm font-medium ${getStatusColor(currentJob.status)}`}
|
||||
>
|
||||
{currentJob.status === JobStatus.SUCCESS && '✓ '}
|
||||
{currentJob.status === JobStatus.FAILURE && '✗ '}
|
||||
{currentJob.status.charAt(0).toUpperCase() +
|
||||
currentJob.status.slice(1)}
|
||||
</span>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentJob.progress}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${currentJob.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{jobProgress && (
|
||||
<div className="text-sm text-gray-600">
|
||||
{jobProgress.processed !== undefined &&
|
||||
jobProgress.total !== undefined && (
|
||||
<p>
|
||||
Processed: {jobProgress.processed} /{' '}
|
||||
{jobProgress.total}
|
||||
</p>
|
||||
)}
|
||||
{jobProgress.message && (
|
||||
<p className="mt-1">{jobProgress.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results Section */}
|
||||
{importResult && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Import Results
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
{importResult.added !== undefined && (
|
||||
<p className="text-green-600">
|
||||
✓ {importResult.added} new photos added
|
||||
</p>
|
||||
)}
|
||||
{importResult.existing !== undefined && (
|
||||
<p className="text-gray-600">
|
||||
{importResult.existing} photos already in database
|
||||
</p>
|
||||
)}
|
||||
{importResult.total !== undefined && (
|
||||
<p className="text-gray-700 font-medium">
|
||||
Total: {importResult.total} photos
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Section */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2005
admin-frontend/src/pages/Search.tsx
Normal file
2005
admin-frontend/src/pages/Search.tsx
Normal file
File diff suppressed because it is too large
Load Diff
38
admin-frontend/src/pages/Settings.tsx
Normal file
38
admin-frontend/src/pages/Settings.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { useDeveloperMode } from '../context/DeveloperModeContext'
|
||||
|
||||
export default function Settings() {
|
||||
const { isDeveloperMode, setDeveloperMode } = useDeveloperMode()
|
||||
|
||||
return (
|
||||
<div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Developer Options</h2>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-gray-200">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="developer-mode" className="text-sm font-medium text-gray-700">
|
||||
Developer Mode
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Enable developer features. Additional features will be available when enabled.
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="developer-mode"
|
||||
checked={isDeveloperMode}
|
||||
onChange={(e) => setDeveloperMode(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
2039
admin-frontend/src/pages/Tags.tsx
Normal file
2039
admin-frontend/src/pages/Tags.tsx
Normal file
File diff suppressed because it is too large
Load Diff
598
admin-frontend/src/pages/UserTaggedPhotos.tsx
Normal file
598
admin-frontend/src/pages/UserTaggedPhotos.tsx
Normal file
@ -0,0 +1,598 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
pendingLinkagesApi,
|
||||
PendingLinkageResponse,
|
||||
ReviewDecision,
|
||||
} from '../api/pendingLinkages'
|
||||
import { apiClient } from '../api/client'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { videosApi } from '../api/videos'
|
||||
|
||||
type DecisionValue = 'approve' | 'deny'
|
||||
|
||||
type SortKey = 'photo' | 'tag' | 'submitted_by' | 'submitted_at' | 'status'
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) {
|
||||
return '-'
|
||||
}
|
||||
try {
|
||||
return new Date(value).toLocaleString()
|
||||
} catch (error) {
|
||||
console.error('Failed to format date', error)
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export default function UserTaggedPhotos() {
|
||||
const { isAdmin } = useAuth()
|
||||
const [linkages, setLinkages] = useState<PendingLinkageResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('pending')
|
||||
const [decisions, setDecisions] = useState<Record<number, DecisionValue | null>>({})
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [clearing, setClearing] = useState(false)
|
||||
const [sortBy, setSortBy] = useState<SortKey>('submitted_at')
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
|
||||
|
||||
const loadLinkages = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await pendingLinkagesApi.listPendingLinkages(
|
||||
statusFilter || undefined
|
||||
)
|
||||
setLinkages(response.items)
|
||||
setDecisions({})
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || err.message || 'Failed to load user tagged photos')
|
||||
console.error('Error loading pending linkages:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
loadLinkages()
|
||||
}, [loadLinkages])
|
||||
|
||||
const pendingCount = useMemo(
|
||||
() => linkages.filter((item) => item.status === 'pending').length,
|
||||
[linkages]
|
||||
)
|
||||
|
||||
const sortedLinkages = useMemo(() => {
|
||||
const items = [...linkages]
|
||||
items.sort((a, b) => {
|
||||
const direction = sortDirection === 'asc' ? 1 : -1
|
||||
|
||||
const compareStrings = (x: string | null | undefined, y: string | null | undefined) =>
|
||||
(x || '').localeCompare(y || '', undefined, { sensitivity: 'base' })
|
||||
|
||||
if (sortBy === 'photo') {
|
||||
return (a.photo_id - b.photo_id) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'tag') {
|
||||
const aTag = a.resolved_tag_name || a.proposed_tag_name || ''
|
||||
const bTag = b.resolved_tag_name || b.proposed_tag_name || ''
|
||||
return compareStrings(aTag, bTag) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'submitted_by') {
|
||||
const aName = a.user_name || a.user_email || ''
|
||||
const bName = b.user_name || b.user_email || ''
|
||||
return compareStrings(aName, bName) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'submitted_at') {
|
||||
const aTime = a.created_at || ''
|
||||
const bTime = b.created_at || ''
|
||||
return (aTime < bTime ? -1 : aTime > bTime ? 1 : 0) * direction
|
||||
}
|
||||
|
||||
if (sortBy === 'status') {
|
||||
return compareStrings(a.status, b.status) * direction
|
||||
}
|
||||
|
||||
return 0
|
||||
})
|
||||
return items
|
||||
}, [linkages, sortBy, sortDirection])
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
setSortBy((currentKey) => {
|
||||
if (currentKey === key) {
|
||||
setSortDirection((currentDirection) => (currentDirection === 'asc' ? 'desc' : 'asc'))
|
||||
return currentKey
|
||||
}
|
||||
setSortDirection('asc')
|
||||
return key
|
||||
})
|
||||
}
|
||||
|
||||
const renderSortLabel = (label: string, key: SortKey) => {
|
||||
const isActive = sortBy === key
|
||||
const directionSymbol = !isActive ? '↕' : sortDirection === 'asc' ? '▲' : '▼'
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSort(key)}
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-gray-500 uppercase tracking-wider hover:text-gray-700"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<span className="text-[10px]">{directionSymbol}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const hasPendingDecision = useMemo(
|
||||
() =>
|
||||
Object.entries(decisions).some(([id, value]) => {
|
||||
const linkage = linkages.find((item) => item.id === Number(id))
|
||||
return value !== null && linkage?.status === 'pending'
|
||||
}),
|
||||
[decisions, linkages]
|
||||
)
|
||||
|
||||
const handleDecisionChange = (id: number, nextDecision: DecisionValue) => {
|
||||
setDecisions((prev) => {
|
||||
const current = prev[id] ?? null
|
||||
const toggled = current === nextDecision ? null : nextDecision
|
||||
return {
|
||||
...prev,
|
||||
[id]: toggled,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleSelectAllApprove = () => {
|
||||
const pendingIds = linkages
|
||||
.filter((item) => item.status === 'pending')
|
||||
.map((item) => item.id)
|
||||
|
||||
if (pendingIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDecisions: Record<number, DecisionValue> = {}
|
||||
pendingIds.forEach((id) => {
|
||||
newDecisions[id] = 'approve'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSelectAllDeny = () => {
|
||||
const pendingIds = linkages
|
||||
.filter((item) => item.status === 'pending')
|
||||
.map((item) => item.id)
|
||||
|
||||
if (pendingIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDecisions: Record<number, DecisionValue> = {}
|
||||
pendingIds.forEach((id) => {
|
||||
newDecisions[id] = 'deny'
|
||||
})
|
||||
|
||||
setDecisions((prev) => ({
|
||||
...prev,
|
||||
...newDecisions,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const decisionsList: ReviewDecision[] = Object.entries(decisions)
|
||||
.filter(([id, decision]) => {
|
||||
const linkage = linkages.find((item) => item.id === Number(id))
|
||||
return decision !== null && linkage?.status === 'pending'
|
||||
})
|
||||
.map(([id, decision]) => ({
|
||||
id: Number(id),
|
||||
decision: decision as DecisionValue,
|
||||
}))
|
||||
|
||||
if (decisionsList.length === 0) {
|
||||
alert('Select Approve or Deny for at least one pending tag.')
|
||||
return
|
||||
}
|
||||
|
||||
const approveCount = decisionsList.filter((item) => item.decision === 'approve').length
|
||||
const denyCount = decisionsList.length - approveCount
|
||||
|
||||
const confirmMessage = [
|
||||
`Submit ${decisionsList.length} decision(s)?`,
|
||||
approveCount ? `✅ Approve: ${approveCount}` : null,
|
||||
denyCount ? `❌ Deny: ${denyCount}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await pendingLinkagesApi.reviewPendingLinkages({
|
||||
decisions: decisionsList,
|
||||
})
|
||||
|
||||
const summary = [
|
||||
`Approved: ${response.approved}`,
|
||||
`Denied: ${response.denied}`,
|
||||
response.tags_created ? `New tags: ${response.tags_created}` : null,
|
||||
response.linkages_created ? `New linkages: ${response.linkages_created}` : null,
|
||||
response.errors.length ? `Errors: ${response.errors.join('; ')}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(summary || 'Review complete.')
|
||||
await loadLinkages()
|
||||
setDecisions({})
|
||||
} catch (err: any) {
|
||||
const message = err.response?.data?.detail || err.message || 'Failed to submit decisions'
|
||||
alert(message)
|
||||
console.error('Error submitting pending linkage decisions:', err)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearDatabase = async () => {
|
||||
const confirmMessage = [
|
||||
'Delete all approved and denied records?',
|
||||
'',
|
||||
'Only records with Pending status will remain.',
|
||||
'This action cannot be undone.',
|
||||
].join('\n')
|
||||
|
||||
if (!confirm(confirmMessage)) {
|
||||
return
|
||||
}
|
||||
|
||||
setClearing(true)
|
||||
try {
|
||||
const response = await pendingLinkagesApi.cleanupPendingLinkages()
|
||||
const summary = [
|
||||
`✅ Deleted ${response.deleted_records} record(s)`,
|
||||
response.warnings && response.warnings.length > 0
|
||||
? `ℹ️ ${response.warnings.join('; ')}`
|
||||
: '',
|
||||
response.errors.length > 0 ? `⚠️ ${response.errors.join('; ')}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
alert(summary || 'Cleanup complete.')
|
||||
|
||||
if (response.errors.length > 0) {
|
||||
console.error('Cleanup errors:', response.errors)
|
||||
}
|
||||
if (response.warnings && response.warnings.length > 0) {
|
||||
console.info('Cleanup warnings:', response.warnings)
|
||||
}
|
||||
|
||||
await loadLinkages()
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to cleanup pending linkages'
|
||||
alert(`Error: ${errorMessage}`)
|
||||
console.error('Error clearing pending linkages:', err)
|
||||
} finally {
|
||||
setClearing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-gray-600">
|
||||
Review tags suggested by users. Approving creates/links the tag to the selected photo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
||||
Status
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="border border-gray-300 rounded-md px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="denied">Denied</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadLinkages}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<div className="text-sm text-gray-500">
|
||||
Pending items: <span className="font-semibold text-gray-800">{pendingCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{linkages.filter((item) => item.status === 'pending').length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllApprove}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSelectAllDeny}
|
||||
className="px-4 py-1 text-sm bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 font-medium"
|
||||
>
|
||||
Select All to Deny
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || loading || !hasPendingDecision}
|
||||
className={`inline-flex items-center px-4 py-2 rounded-md text-sm font-semibold text-white ${
|
||||
submitting || loading || !hasPendingDecision
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isAdmin) {
|
||||
return
|
||||
}
|
||||
handleClearDatabase()
|
||||
}}
|
||||
disabled={clearing || !isAdmin}
|
||||
className="px-3 py-1.5 text-sm bg-red-100 text-red-700 rounded-md hover:bg-red-200 disabled:bg-gray-200 disabled:text-gray-500 disabled:cursor-not-allowed font-medium"
|
||||
title={
|
||||
isAdmin
|
||||
? 'Delete approved/denied records'
|
||||
: 'Only admins can clear pending linkages'
|
||||
}
|
||||
>
|
||||
{clearing ? 'Clearing...' : '🗑️ Clear Database'}
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Clear approved/denied records
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64 text-gray-500">Loading...</div>
|
||||
) : linkages.length === 0 ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-6 text-center text-gray-500">
|
||||
No user tagged photos found for this filter.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Photo', 'photo')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Proposed Tag', 'tag')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Current Tags
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted By', 'submitted_by')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Submitted At', 'submitted_at')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left">
|
||||
{renderSortLabel('Status', 'status')}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Decision
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedLinkages.map((linkage) => {
|
||||
const canReview = linkage.status === 'pending'
|
||||
const decision = decisions[linkage.id] ?? null
|
||||
return (
|
||||
<tr key={linkage.id} className={canReview ? '' : 'opacity-70 bg-gray-50'}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{linkage.photo_id ? (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity w-24"
|
||||
onClick={() => {
|
||||
const isVideo = linkage.photo_media_type === 'video'
|
||||
const url = isVideo
|
||||
? videosApi.getVideoUrl(linkage.photo_id)
|
||||
: `${apiClient.defaults.baseURL}/api/v1/photos/${linkage.photo_id}/image`
|
||||
window.open(url, '_blank')
|
||||
}}
|
||||
title={linkage.photo_media_type === 'video' ? 'Open video in new tab' : 'Open photo in new tab'}
|
||||
>
|
||||
{linkage.photo_media_type === 'video' ? (
|
||||
<img
|
||||
src={videosApi.getThumbnailUrl(linkage.photo_id)}
|
||||
alt={`Video ${linkage.photo_id}`}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.fallback-text')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
|
||||
fallback.textContent = `#${linkage.photo_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={`/api/v1/photos/${linkage.photo_id}/image`}
|
||||
alt={`Photo ${linkage.photo_id}`}
|
||||
className="w-24 h-24 object-cover rounded border border-gray-300"
|
||||
loading="lazy"
|
||||
onError={(event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
const parent = target.parentElement
|
||||
if (parent && !parent.querySelector('.fallback-text')) {
|
||||
const fallback = document.createElement('div')
|
||||
fallback.className = 'fallback-text text-gray-400 text-xs text-center'
|
||||
fallback.textContent = `#${linkage.photo_id}`
|
||||
parent.appendChild(fallback)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">Photo not found</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{linkage.photo_filename || `Photo #${linkage.photo_id}`}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{linkage.resolved_tag_name || linkage.proposed_tag_name || '-'}
|
||||
</span>
|
||||
{linkage.tag_id === null && linkage.proposed_tag_name && (
|
||||
<span className="text-xs text-yellow-700 bg-yellow-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
|
||||
<span>New tag</span>
|
||||
</span>
|
||||
)}
|
||||
{linkage.tag_id && (
|
||||
<span className="text-xs text-green-700 bg-green-50 px-2 py-0.5 rounded mt-1 inline-flex items-center gap-1">
|
||||
<span>Existing tag</span>
|
||||
<span>#{linkage.tag_id}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{linkage.photo_tags.length === 0 ? (
|
||||
<span className="text-sm text-gray-400 italic">No tags</span>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{linkage.photo_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded-full"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{linkage.user_name || 'Unknown'}</div>
|
||||
<div className="text-xs text-gray-500">{linkage.user_email || '-'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatDate(linkage.created_at)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{linkage.notes ? (
|
||||
<div className="text-sm text-gray-700 whitespace-pre-wrap bg-gray-50 border border-gray-200 rounded p-2">
|
||||
{linkage.notes}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
linkage.status === 'pending'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: linkage.status === 'approved'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{linkage.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{canReview ? (
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decision === 'approve'}
|
||||
onChange={() => handleDecisionChange(linkage.id, 'approve')}
|
||||
className="w-4 h-4 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Approve</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={decision === 'deny'}
|
||||
onChange={() => handleDecisionChange(linkage.id, 'deny')}
|
||||
className="w-4 h-4 text-red-600 focus:ring-red-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Deny</span>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 italic">-</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
10
admin-frontend/src/vite-env.d.ts
vendored
Normal file
10
admin-frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
12
admin-frontend/tailwind.config.js
Normal file
12
admin-frontend/tailwind.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
26
admin-frontend/tsconfig.json
Normal file
26
admin-frontend/tsconfig.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
11
admin-frontend/tsconfig.node.json
Normal file
11
admin-frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
17
admin-frontend/vite.config.ts
Normal file
17
admin-frontend/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
0
backend/__init__.py
Normal file
0
backend/__init__.py
Normal file
3
backend/api/__init__.py
Normal file
3
backend/api/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""API routers package for PunimTag Web."""
|
||||
|
||||
|
||||
357
backend/api/auth.py
Normal file
357
backend/api/auth.py
Normal file
@ -0,0 +1,357 @@
|
||||
"""Authentication endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import JWTError, jwt
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
)
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import User
|
||||
from backend.utils.password import verify_password, hash_password
|
||||
from backend.schemas.auth import (
|
||||
LoginRequest,
|
||||
RefreshRequest,
|
||||
TokenResponse,
|
||||
UserResponse,
|
||||
PasswordChangeRequest,
|
||||
PasswordChangeResponse,
|
||||
)
|
||||
from backend.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
security = HTTPBearer()
|
||||
|
||||
# Placeholder secrets - replace with env vars in production
|
||||
SECRET_KEY = "dev-secret-key-change-in-production"
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 360
|
||||
REFRESH_TOKEN_EXPIRE_DAYS = 7
|
||||
|
||||
# Single user mode placeholder - read from environment or use defaults
|
||||
SINGLE_USER_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
SINGLE_USER_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin") # Change in production
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: timedelta) -> str:
|
||||
"""Create JWT access token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Create JWT refresh token."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
|
||||
) -> dict:
|
||||
"""Get current user from JWT token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
return {"username": username}
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid authentication credentials",
|
||||
)
|
||||
|
||||
|
||||
def get_current_user_with_id(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get current user with ID from main database.
|
||||
|
||||
Looks up the user in the main database and returns username and user_id.
|
||||
If user doesn't exist, creates them (for bootstrap scenarios).
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if user exists in main database
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
# If user doesn't exist, create them (for bootstrap scenarios)
|
||||
if not user:
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Generate unique email to avoid conflicts
|
||||
base_email = f"{username}@example.com"
|
||||
email = base_email
|
||||
counter = 1
|
||||
# Ensure email is unique
|
||||
while db.query(User).filter(User.email == email).first():
|
||||
email = f"{username}+{counter}@example.com"
|
||||
counter += 1
|
||||
|
||||
# Create user (they should change password)
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
email=email,
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {"username": username, "user_id": user.id}
|
||||
|
||||
|
||||
def _resolve_user_role(user: User | None, is_admin_flag: bool) -> str:
|
||||
"""Determine the role value for a user, ensuring it is valid."""
|
||||
if user and user.role in ROLE_VALUES:
|
||||
return user.role
|
||||
return DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
"""Authenticate user and return tokens.
|
||||
|
||||
First checks main database for users, falls back to hardcoded admin/admin
|
||||
for backward compatibility.
|
||||
"""
|
||||
# First, try to find user in main database
|
||||
user = db.query(User).filter(User.username == credentials.username).first()
|
||||
|
||||
if user:
|
||||
# User exists in main database - verify password
|
||||
if not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Account is inactive",
|
||||
)
|
||||
|
||||
# Check if password_hash exists (migration might not have run)
|
||||
if not user.password_hash:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Password not set. Please contact administrator to set your password.",
|
||||
)
|
||||
|
||||
if not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
# Update last login
|
||||
user.last_login = datetime.utcnow()
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
# Generate tokens
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": credentials.username},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": credentials.username})
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
password_change_required=user.password_change_required,
|
||||
)
|
||||
|
||||
# Fallback to hardcoded admin/admin for backward compatibility
|
||||
if (
|
||||
credentials.username == SINGLE_USER_USERNAME
|
||||
and credentials.password == SINGLE_USER_PASSWORD
|
||||
):
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": credentials.username},
|
||||
expires_delta=access_token_expires,
|
||||
)
|
||||
refresh_token = create_refresh_token(data={"sub": credentials.username})
|
||||
return TokenResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
password_change_required=False, # Hardcoded admin doesn't require password change
|
||||
)
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh_token(request: RefreshRequest) -> TokenResponse:
|
||||
"""Refresh access token using refresh token."""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
request.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token type",
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
)
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": username}, expires_delta=access_token_expires
|
||||
)
|
||||
new_refresh_token = create_refresh_token(data={"sub": username})
|
||||
return TokenResponse(
|
||||
access_token=access_token, refresh_token=new_refresh_token
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def get_current_user_info(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Get current user information including admin status."""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if user exists in main database to get admin status
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
# If user doesn't exist in main database, check if we should bootstrap them
|
||||
if not user:
|
||||
# Check if any admin users exist
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
|
||||
# If no admins exist, bootstrap current user as admin
|
||||
if admin_count == 0:
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Generate unique email to avoid conflicts
|
||||
base_email = f"{username}@example.com"
|
||||
email = base_email
|
||||
counter = 1
|
||||
# Ensure email is unique
|
||||
while db.query(User).filter(User.email == email).first():
|
||||
email = f"{username}+{counter}@example.com"
|
||||
counter += 1
|
||||
|
||||
# Create user as admin for bootstrap (they should change password)
|
||||
default_password_hash = hash_password("changeme")
|
||||
try:
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
email=email,
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
is_admin = True
|
||||
except Exception:
|
||||
# If creation fails (e.g., race condition), try to get existing user
|
||||
db.rollback()
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user:
|
||||
# Update existing user to be admin if no admins exist
|
||||
if not user.is_admin:
|
||||
user.is_admin = True
|
||||
user.role = DEFAULT_ADMIN_ROLE
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
is_admin = user.is_admin
|
||||
else:
|
||||
is_admin = False
|
||||
else:
|
||||
is_admin = False
|
||||
else:
|
||||
is_admin = user.is_admin if user else False
|
||||
|
||||
role_value = _resolve_user_role(user, is_admin)
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
permissions = permissions_map.get(role_value, {})
|
||||
|
||||
return UserResponse(
|
||||
username=username,
|
||||
is_admin=is_admin,
|
||||
role=role_value,
|
||||
permissions=permissions,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/change-password", response_model=PasswordChangeResponse)
|
||||
def change_password(
|
||||
request: PasswordChangeRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> PasswordChangeResponse:
|
||||
"""Change user password.
|
||||
|
||||
Requires current password verification.
|
||||
After successful change, clears password_change_required flag.
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Find user in main database
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found",
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not verify_password(request.current_password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Current password is incorrect",
|
||||
)
|
||||
|
||||
# Update password
|
||||
user.password_hash = hash_password(request.new_password)
|
||||
user.password_change_required = False # Clear the flag after password change
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
return PasswordChangeResponse(
|
||||
success=True,
|
||||
message="Password changed successfully",
|
||||
)
|
||||
|
||||
|
||||
|
||||
699
backend/api/auth_users.py
Normal file
699
backend/api/auth_users.py
Normal file
@ -0,0 +1,699 @@
|
||||
"""Auth database user management endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.api.users import get_current_admin_user
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.schemas.auth_users import (
|
||||
AuthUserCreateRequest,
|
||||
AuthUserResponse,
|
||||
AuthUserUpdateRequest,
|
||||
AuthUsersListResponse,
|
||||
)
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/auth-users", tags=["auth-users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_column_exists(auth_db: Session, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a table."""
|
||||
try:
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
inspector = sqlalchemy_inspect(auth_db.bind)
|
||||
columns = {col["name"] for col in inspector.get_columns(table_name)}
|
||||
return column_name in columns
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _get_role_from_is_admin(auth_db: Session, is_admin: bool) -> str:
|
||||
"""Get role value from is_admin boolean. Returns 'Admin' if is_admin is True, 'User' otherwise."""
|
||||
return "Admin" if is_admin else "User"
|
||||
|
||||
|
||||
def _get_is_admin_from_role(role: str | None) -> bool:
|
||||
"""Get is_admin boolean from role string. Returns True if role is 'Admin', False otherwise."""
|
||||
return role == "Admin" if role else False
|
||||
|
||||
|
||||
@router.get("", response_model=AuthUsersListResponse)
|
||||
def list_auth_users(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUsersListResponse:
|
||||
"""List all users from auth database - admin only."""
|
||||
try:
|
||||
# Check if optional columns exist
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
|
||||
|
||||
# Query users from auth database with all columns from schema
|
||||
# Try to include is_active and role if columns exist
|
||||
result = None
|
||||
try:
|
||||
# Build SELECT query based on which columns exist
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
except Exception:
|
||||
# Rollback the failed transaction before trying again
|
||||
auth_db.rollback()
|
||||
try:
|
||||
# Try with is_active only (no role)
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
except Exception:
|
||||
# Rollback again before final attempt
|
||||
auth_db.rollback()
|
||||
# Base columns only
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
id,
|
||||
email,
|
||||
name,
|
||||
is_admin,
|
||||
has_write_access,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM users
|
||||
ORDER BY COALESCE(name, email) ASC
|
||||
"""))
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to query auth users",
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
users = []
|
||||
for row in rows:
|
||||
try:
|
||||
# Access row attributes directly - SQLAlchemy Row objects support attribute access
|
||||
user_id = int(row.id)
|
||||
email = str(row.email)
|
||||
name = row.name if row.name is not None else None
|
||||
|
||||
# Get boolean fields - convert to proper boolean
|
||||
# These columns have defaults so they should always have values
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
|
||||
# Optional columns - try to get from row, default to None if not selected
|
||||
is_active = None
|
||||
try:
|
||||
is_active = row.is_active
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
except (AttributeError, KeyError):
|
||||
# Column not selected or doesn't exist - default to True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
created_at = getattr(row, 'created_at', None)
|
||||
updated_at = getattr(row, 'updated_at', None)
|
||||
|
||||
users.append(AuthUserResponse(
|
||||
id=user_id,
|
||||
name=name,
|
||||
email=email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
))
|
||||
except Exception as row_error:
|
||||
logger.warning(f"Error processing auth user row: {row_error}")
|
||||
# Skip this row and continue
|
||||
continue
|
||||
|
||||
return AuthUsersListResponse(items=users, total=len(users))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
error_detail = f"Failed to list auth users: {str(e)}\n{traceback.format_exc()}"
|
||||
logger.error(f"Error listing auth users: {error_detail}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to list auth users: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=AuthUserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: AuthUserCreateRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Create a new user in auth database - admin only."""
|
||||
try:
|
||||
# Check if user with same email already exists (email is unique)
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""), {"email": request.email})
|
||||
|
||||
existing = check_result.first()
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User with email '{request.email}' already exists",
|
||||
)
|
||||
|
||||
# Insert new user
|
||||
# Check database dialect for RETURNING support
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
|
||||
supports_returning = dialect == 'postgresql'
|
||||
|
||||
# Hash the password
|
||||
password_hash = hash_password(request.password)
|
||||
|
||||
if supports_returning:
|
||||
result = auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
RETURNING id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": request.has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
row = result.first()
|
||||
else:
|
||||
# SQLite - insert then select
|
||||
auth_db.execute(text("""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""), {
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": request.is_admin,
|
||||
"has_write_access": request.has_write_access,
|
||||
})
|
||||
auth_db.commit()
|
||||
# Get the last inserted row
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = last_insert_rowid()
|
||||
"""))
|
||||
row = result.first()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to create user",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to create auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=AuthUserResponse)
|
||||
def get_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Get a specific auth user by ID - admin only."""
|
||||
try:
|
||||
# Check if optional columns exist
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
has_is_active_column = _check_column_exists(auth_db, "users", "is_active")
|
||||
|
||||
# Try to include is_active and role if columns exist
|
||||
try:
|
||||
# Build SELECT query based on which columns exist
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
except Exception:
|
||||
# Rollback the failed transaction before trying again
|
||||
auth_db.rollback()
|
||||
try:
|
||||
# Try with is_active only (no role)
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if has_is_active_column:
|
||||
select_fields += ", is_active"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
SELECT {select_fields}
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
except Exception:
|
||||
# Rollback again before final attempt
|
||||
auth_db.rollback()
|
||||
# Columns don't exist, select without them
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
is_active = getattr(row, 'is_active', None)
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to get auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=AuthUserResponse)
|
||||
def update_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
request: AuthUserUpdateRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> AuthUserResponse:
|
||||
"""Update an auth user - admin only."""
|
||||
try:
|
||||
# Check if user exists
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT id FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
if not check_result.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
# Check if email conflicts with another user (email is unique)
|
||||
check_conflict = auth_db.execute(text("""
|
||||
SELECT id FROM users
|
||||
WHERE id != :user_id AND email = :email
|
||||
"""), {
|
||||
"user_id": user_id,
|
||||
"email": request.email,
|
||||
})
|
||||
|
||||
if check_conflict.first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"User with email '{request.email}' already exists",
|
||||
)
|
||||
|
||||
# Check if role column exists
|
||||
has_role_column = _check_column_exists(auth_db, "users", "role")
|
||||
|
||||
# Update all fields (all are required, is_active and role are optional)
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else 'postgresql'
|
||||
supports_returning = dialect == 'postgresql'
|
||||
|
||||
# Determine is_admin value - if role is provided and role column doesn't exist, use role to set is_admin
|
||||
is_admin_value = request.is_admin
|
||||
if request.role is not None and not has_role_column:
|
||||
# Role column doesn't exist, derive is_admin from role
|
||||
is_admin_value = _get_is_admin_from_role(request.role)
|
||||
|
||||
# Build UPDATE query - include is_active and role if provided and column exists
|
||||
update_fields = ["email = :email", "name = :name", "is_admin = :is_admin", "has_write_access = :has_write_access"]
|
||||
update_params = {
|
||||
"user_id": user_id,
|
||||
"email": request.email,
|
||||
"name": request.name,
|
||||
"is_admin": is_admin_value,
|
||||
"has_write_access": request.has_write_access,
|
||||
}
|
||||
|
||||
# Update password if provided
|
||||
if request.password and request.password.strip():
|
||||
password_hash = hash_password(request.password)
|
||||
update_fields.append("password_hash = :password_hash")
|
||||
update_params["password_hash"] = password_hash
|
||||
|
||||
if request.is_active is not None:
|
||||
update_fields.append("is_active = :is_active")
|
||||
update_params["is_active"] = request.is_active
|
||||
|
||||
if request.role is not None and has_role_column:
|
||||
# Only update role if the column exists
|
||||
update_fields.append("role = :role")
|
||||
update_params["role"] = request.role
|
||||
|
||||
update_sql = f"""
|
||||
UPDATE users
|
||||
SET {', '.join(update_fields)}
|
||||
WHERE id = :user_id
|
||||
"""
|
||||
|
||||
if supports_returning:
|
||||
# Build select fields - try to include optional columns
|
||||
select_fields = "id, email, name, is_admin, has_write_access"
|
||||
if request.is_active is not None or _check_column_exists(auth_db, "users", "is_active"):
|
||||
select_fields += ", is_active"
|
||||
if has_role_column:
|
||||
select_fields += ", role"
|
||||
select_fields += ", created_at, updated_at"
|
||||
result = auth_db.execute(text(f"""
|
||||
{update_sql}
|
||||
RETURNING {select_fields}
|
||||
"""), update_params)
|
||||
auth_db.commit()
|
||||
row = result.first()
|
||||
else:
|
||||
# SQLite - update then select
|
||||
auth_db.execute(text(update_sql), update_params)
|
||||
auth_db.commit()
|
||||
# Get the updated row - try to include optional columns
|
||||
try:
|
||||
if has_role_column:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, role, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
except Exception:
|
||||
auth_db.rollback()
|
||||
try:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, is_active, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
except Exception:
|
||||
auth_db.rollback()
|
||||
result = auth_db.execute(text("""
|
||||
SELECT id, email, name, is_admin, has_write_access, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
row = result.first()
|
||||
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update user",
|
||||
)
|
||||
|
||||
is_admin = bool(row.is_admin)
|
||||
has_write_access = bool(row.has_write_access)
|
||||
is_active = getattr(row, 'is_active', None)
|
||||
if is_active is not None:
|
||||
is_active = bool(is_active)
|
||||
else:
|
||||
# NULL values should be treated as True (active)
|
||||
is_active = True
|
||||
|
||||
# Get role - if column doesn't exist, derive from is_admin
|
||||
if has_role_column:
|
||||
role = getattr(row, 'role', None)
|
||||
else:
|
||||
role = _get_role_from_is_admin(auth_db, is_admin)
|
||||
|
||||
return AuthUserResponse(
|
||||
id=row.id,
|
||||
name=getattr(row, 'name', None),
|
||||
email=row.email,
|
||||
is_admin=is_admin,
|
||||
has_write_access=has_write_access,
|
||||
is_active=is_active,
|
||||
role=role,
|
||||
created_at=getattr(row, 'created_at', None),
|
||||
updated_at=getattr(row, 'updated_at', None),
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to update auth user: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_auth_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> Response:
|
||||
"""Delete an auth user - admin only.
|
||||
|
||||
If the user has linked data (pending_photos, pending_identifications,
|
||||
inappropriate_photo_reports), the user will be set to inactive instead
|
||||
of deleted. Admins will be notified via logging.
|
||||
"""
|
||||
try:
|
||||
# Check if user exists and get user info
|
||||
user_result = auth_db.execute(text("""
|
||||
SELECT id, email, name FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
|
||||
user_row = user_result.first()
|
||||
if not user_row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Auth user with ID {user_id} not found",
|
||||
)
|
||||
|
||||
user_email = user_row.email
|
||||
user_name = user_row.name or user_email
|
||||
|
||||
# Check for linked data in auth database
|
||||
pending_photos_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM pending_photos WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
pending_identifications_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM pending_identifications WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
inappropriate_reports_count = auth_db.execute(text("""
|
||||
SELECT COUNT(*) FROM inappropriate_photo_reports WHERE user_id = :user_id
|
||||
"""), {"user_id": user_id}).scalar() or 0
|
||||
|
||||
has_linked_data = (
|
||||
pending_photos_count > 0 or
|
||||
pending_identifications_count > 0 or
|
||||
inappropriate_reports_count > 0
|
||||
)
|
||||
|
||||
if has_linked_data:
|
||||
# Check if is_active column exists by trying to query it
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
|
||||
has_is_active_column = False
|
||||
|
||||
try:
|
||||
# Try to select is_active column to check if it exists
|
||||
test_result = auth_db.execute(text("""
|
||||
SELECT is_active FROM users WHERE id = :user_id LIMIT 1
|
||||
"""), {"user_id": user_id})
|
||||
test_result.first()
|
||||
has_is_active_column = True
|
||||
except Exception:
|
||||
# Column doesn't exist - this should have been added at startup
|
||||
# but if it wasn't, we can't proceed
|
||||
error_msg = "is_active column does not exist in auth database users table"
|
||||
logger.error(
|
||||
f"Cannot deactivate auth user '{user_name}' (ID: {user_id}): {error_msg}"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=(
|
||||
f"Cannot delete user '{user_name}' because they have linked data "
|
||||
f"({pending_photos_count} pending photo(s), "
|
||||
f"{pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s)) "
|
||||
f"and the is_active column does not exist in the auth database users table. "
|
||||
f"Please restart the server to add the column automatically, or contact "
|
||||
f"your database administrator to add it manually: "
|
||||
f"ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE"
|
||||
),
|
||||
)
|
||||
|
||||
# Set user inactive instead of deleting
|
||||
if dialect == "postgresql":
|
||||
auth_db.execute(text("""
|
||||
UPDATE users SET is_active = FALSE WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
else:
|
||||
# SQLite uses 0 for FALSE
|
||||
auth_db.execute(text("""
|
||||
UPDATE users SET is_active = 0 WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
auth_db.commit()
|
||||
|
||||
# Notify admins via logging
|
||||
logger.warning(
|
||||
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was set to inactive "
|
||||
f"instead of deleted because they have linked data: {pending_photos_count} pending "
|
||||
f"photo(s), {pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s). "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"pending_photos_count": pending_photos_count,
|
||||
"pending_identifications_count": pending_identifications_count,
|
||||
"inappropriate_reports_count": inappropriate_reports_count,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
# Return success but indicate user was deactivated
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"message": (
|
||||
f"User '{user_name}' has been set to inactive because they have "
|
||||
f"linked data ({pending_photos_count} pending photo(s), "
|
||||
f"{pending_identifications_count} pending identification(s), "
|
||||
f"{inappropriate_reports_count} inappropriate photo report(s))."
|
||||
),
|
||||
"deactivated": True,
|
||||
"pending_photos_count": pending_photos_count,
|
||||
"pending_identifications_count": pending_identifications_count,
|
||||
"inappropriate_reports_count": inappropriate_reports_count,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No linked data - safe to delete
|
||||
auth_db.execute(text("""
|
||||
DELETE FROM users WHERE id = :user_id
|
||||
"""), {"user_id": user_id})
|
||||
auth_db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Auth user '{user_name}' (ID: {user_id}, email: {user_email}) was deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"user_email": user_email,
|
||||
"user_name": user_name,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
error_str = str(e)
|
||||
# Check for permission errors
|
||||
if "permission denied" in error_str.lower() or "insufficient privilege" in error_str.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Permission denied: The database user does not have DELETE permission on the users table. Please contact your database administrator.",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete auth user: {error_str}",
|
||||
)
|
||||
|
||||
1026
backend/api/faces.py
Normal file
1026
backend/api/faces.py
Normal file
File diff suppressed because it is too large
Load Diff
14
backend/api/health.py
Normal file
14
backend/api/health.py
Normal file
@ -0,0 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health_check() -> dict[str, str]:
|
||||
"""Basic health endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
263
backend/api/jobs.py
Normal file
263
backend/api/jobs.py
Normal file
@ -0,0 +1,263 @@
|
||||
"""Job management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from rq import Queue
|
||||
from rq.job import Job
|
||||
from redis import Redis
|
||||
import json
|
||||
import time
|
||||
|
||||
from backend.schemas.jobs import JobResponse, JobStatus
|
||||
|
||||
router = APIRouter(prefix="/jobs", tags=["jobs"])
|
||||
|
||||
# Redis connection for RQ
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
queue = Queue(connection=redis_conn)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=JobResponse)
|
||||
def get_job(job_id: str) -> JobResponse:
|
||||
"""Get job status by ID."""
|
||||
try:
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
rq_status = job.get_status()
|
||||
status_map = {
|
||||
"queued": JobStatus.PENDING,
|
||||
"started": JobStatus.STARTED, # Job is actively running
|
||||
"finished": JobStatus.SUCCESS,
|
||||
"failed": JobStatus.FAILURE,
|
||||
}
|
||||
job_status = status_map.get(rq_status, JobStatus.PENDING)
|
||||
|
||||
# If job is started, check if it has progress
|
||||
if rq_status == "started":
|
||||
# Job is running - show progress if available
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
message = job.meta.get("message", "Processing...") if job.meta else "Processing..."
|
||||
# Map to PROGRESS status if we have actual progress
|
||||
if progress > 0:
|
||||
job_status = JobStatus.PROGRESS
|
||||
elif job_status == JobStatus.STARTED or job_status == JobStatus.PROGRESS:
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
elif job_status == JobStatus.SUCCESS:
|
||||
progress = 100
|
||||
else:
|
||||
progress = 0
|
||||
|
||||
message = job.meta.get("message", "") if job.meta else ""
|
||||
|
||||
# Check if job was cancelled
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
# If job finished gracefully after cancellation, mark as CANCELLED
|
||||
if rq_status == "finished":
|
||||
job_status = JobStatus.CANCELLED
|
||||
# If still running, show current status but with cancellation message
|
||||
elif rq_status == "started":
|
||||
job_status = JobStatus.PROGRESS if progress > 0 else JobStatus.STARTED
|
||||
else:
|
||||
job_status = JobStatus.CANCELLED
|
||||
message = job.meta.get("message", "Cancelled by user")
|
||||
|
||||
# If job failed, include error message
|
||||
if rq_status == "failed" and job.exc_info:
|
||||
# Extract error message from exception info
|
||||
error_lines = job.exc_info.split("\n")
|
||||
if error_lines:
|
||||
message = f"Failed: {error_lines[0]}"
|
||||
|
||||
return JobResponse(
|
||||
id=job.id,
|
||||
status=job_status,
|
||||
progress=progress,
|
||||
message=message,
|
||||
created_at=datetime.fromisoformat(str(job.created_at)),
|
||||
updated_at=datetime.fromisoformat(
|
||||
str(job.ended_at or job.started_at or job.created_at)
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job {job_id} not found: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/stream/{job_id}")
|
||||
def stream_job_progress(job_id: str):
|
||||
"""Stream job progress via Server-Sent Events (SSE)."""
|
||||
|
||||
def event_generator():
|
||||
"""Generate SSE events for job progress."""
|
||||
last_progress = -1
|
||||
last_message = ""
|
||||
|
||||
while True:
|
||||
try:
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
rq_status = job.get_status()
|
||||
status_map = {
|
||||
"queued": JobStatus.PENDING,
|
||||
"started": JobStatus.STARTED,
|
||||
"finished": JobStatus.SUCCESS,
|
||||
"failed": JobStatus.FAILURE,
|
||||
}
|
||||
job_status = status_map.get(rq_status, JobStatus.PENDING)
|
||||
|
||||
# Check if job was cancelled - this takes priority
|
||||
if job.meta and job.meta.get("cancelled", False):
|
||||
# If job is finished and was cancelled, it completed gracefully
|
||||
if rq_status == "finished":
|
||||
job_status = JobStatus.CANCELLED
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
# If job is still running but cancellation was requested, keep it as PROGRESS/STARTED
|
||||
# until it actually stops
|
||||
elif rq_status == "started":
|
||||
# Job is still running - let it finish current photo
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
job_status = JobStatus.PROGRESS if progress > 0 else JobStatus.STARTED
|
||||
else:
|
||||
job_status = JobStatus.CANCELLED
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
message = job.meta.get("message", "Cancelled by user")
|
||||
else:
|
||||
progress = 0
|
||||
if job_status == JobStatus.STARTED:
|
||||
# Job is running - show progress if available
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
# Map to PROGRESS status if we have actual progress
|
||||
if progress > 0:
|
||||
job_status = JobStatus.PROGRESS
|
||||
elif job_status == JobStatus.PROGRESS:
|
||||
progress = job.meta.get("progress", 0) if job.meta else 0
|
||||
elif job_status == JobStatus.SUCCESS:
|
||||
progress = 100
|
||||
elif job_status == JobStatus.FAILURE:
|
||||
progress = 0
|
||||
|
||||
message = job.meta.get("message", "") if job.meta else ""
|
||||
|
||||
# Only send event if progress or message changed
|
||||
if progress != last_progress or message != last_message:
|
||||
event_data = {
|
||||
"id": job.id,
|
||||
"status": job_status.value,
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
"processed": job.meta.get("processed", 0) if job.meta else 0,
|
||||
"total": job.meta.get("total", 0) if job.meta else 0,
|
||||
"faces_detected": job.meta.get("faces_detected", 0) if job.meta else 0,
|
||||
"faces_stored": job.meta.get("faces_stored", 0) if job.meta else 0,
|
||||
}
|
||||
|
||||
yield f"data: {json.dumps(event_data)}\n\n"
|
||||
last_progress = progress
|
||||
last_message = message
|
||||
|
||||
# Stop streaming if job is complete, failed, or cancelled
|
||||
if job_status in (JobStatus.SUCCESS, JobStatus.FAILURE, JobStatus.CANCELLED):
|
||||
break
|
||||
|
||||
time.sleep(0.5) # Poll every 500ms
|
||||
|
||||
except Exception as e:
|
||||
error_data = {"error": str(e)}
|
||||
yield f"data: {json.dumps(error_data)}\n\n"
|
||||
break
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{job_id}")
|
||||
def cancel_job(job_id: str) -> dict:
|
||||
"""Cancel a job (if queued) or stop a running job.
|
||||
|
||||
Note: For running jobs, this sets a cancellation flag.
|
||||
The job will check this flag and exit gracefully.
|
||||
"""
|
||||
try:
|
||||
print(f"[Jobs API] Cancel request for job_id={job_id}")
|
||||
job = Job.fetch(job_id, connection=redis_conn)
|
||||
rq_status = job.get_status()
|
||||
print(f"[Jobs API] Job {job_id} current status: {rq_status}")
|
||||
|
||||
if rq_status == "finished":
|
||||
return {
|
||||
"message": f"Job {job_id} is already finished",
|
||||
"status": "finished",
|
||||
}
|
||||
|
||||
if rq_status == "failed":
|
||||
return {
|
||||
"message": f"Job {job_id} already failed",
|
||||
"status": "failed",
|
||||
}
|
||||
|
||||
if rq_status == "queued":
|
||||
# Cancel queued job - remove from queue
|
||||
job.cancel()
|
||||
print(f"[Jobs API] ✓ Cancelled queued job {job_id}")
|
||||
return {
|
||||
"message": f"Job {job_id} cancelled (was queued)",
|
||||
"status": "cancelled",
|
||||
}
|
||||
|
||||
if rq_status == "started":
|
||||
# For running jobs, set cancellation flag in metadata
|
||||
# The task will check this and exit gracefully
|
||||
print(f"[Jobs API] Setting cancellation flag for running job {job_id}")
|
||||
if job.meta is None:
|
||||
job.meta = {}
|
||||
job.meta["cancelled"] = True
|
||||
job.meta["message"] = "Cancellation requested..."
|
||||
# CRITICAL: Save metadata immediately and verify it was saved
|
||||
job.save_meta()
|
||||
print(f"[Jobs API] Saved metadata for job {job_id}")
|
||||
|
||||
# Verify the flag was saved by fetching fresh
|
||||
try:
|
||||
fresh_job = Job.fetch(job_id, connection=redis_conn)
|
||||
if not fresh_job.meta or not fresh_job.meta.get("cancelled", False):
|
||||
print(f"[Jobs API] ❌ WARNING: Cancellation flag NOT found after save for job {job_id}")
|
||||
print(f"[Jobs API] Fresh job meta: {fresh_job.meta}")
|
||||
else:
|
||||
print(f"[Jobs API] ✓ Verified: Cancellation flag is set for job {job_id}")
|
||||
except Exception as verify_error:
|
||||
print(f"[Jobs API] ⚠️ Could not verify cancellation flag: {verify_error}")
|
||||
|
||||
# Also try to cancel the job (which will interrupt it if possible)
|
||||
# This sends a signal to the worker process
|
||||
try:
|
||||
job.cancel()
|
||||
print(f"[Jobs API] ✓ RQ cancel() called for job {job_id}")
|
||||
except Exception as cancel_error:
|
||||
# Job might already be running, that's OK - metadata flag will be checked
|
||||
print(f"[Jobs API] Note: RQ cancel() raised exception (may be expected): {cancel_error}")
|
||||
|
||||
return {
|
||||
"message": f"Job {job_id} cancellation requested",
|
||||
"status": "cancelling",
|
||||
}
|
||||
|
||||
print(f"[Jobs API] Job {job_id} in unexpected status: {rq_status}")
|
||||
return {
|
||||
"message": f"Job {job_id} status: {rq_status}",
|
||||
"status": rq_status,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Jobs API] ❌ Error cancelling job {job_id}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job {job_id} not found: {str(e)}",
|
||||
)
|
||||
|
||||
17
backend/api/metrics.py
Normal file
17
backend/api/metrics.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Metrics endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def get_metrics() -> dict:
|
||||
"""Basic metrics endpoint - placeholder for Phase 1."""
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Metrics endpoint - to be enhanced in future phases",
|
||||
}
|
||||
|
||||
542
backend/api/pending_identifications.py
Normal file
542
backend/api/pending_identifications.py
Normal file
@ -0,0 +1,542 @@
|
||||
"""Pending identifications endpoints for approval workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Face, Person, PersonEncoding, User
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
|
||||
|
||||
|
||||
def get_or_create_frontend_user(db: Session) -> User:
|
||||
"""Get or create the special 'FrontEndUser' system user.
|
||||
|
||||
This user represents identifications made through the frontend approval UI,
|
||||
distinguishing them from direct user identifications.
|
||||
"""
|
||||
FRONTEND_USERNAME = "FrontEndUser"
|
||||
|
||||
# Try to get existing user
|
||||
user = db.query(User).filter(User.username == FRONTEND_USERNAME).first()
|
||||
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create the system user if it doesn't exist
|
||||
# Use a non-loginable password hash (random, won't be used for login)
|
||||
default_password_hash = hash_password("system_user_not_for_login")
|
||||
|
||||
user = User(
|
||||
username=FRONTEND_USERNAME,
|
||||
password_hash=default_password_hash,
|
||||
email="frontend@punimtag.system",
|
||||
full_name="Frontend System User",
|
||||
is_active=False, # Not an active user, just a system marker
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
password_change_required=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PendingIdentificationResponse(BaseModel):
|
||||
"""Pending identification DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
face_id: int
|
||||
photo_id: Optional[int] = None
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
middle_name: Optional[str] = None
|
||||
maiden_name: Optional[str] = None
|
||||
date_of_birth: Optional[date] = None
|
||||
status: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class PendingIdentificationsListResponse(BaseModel):
|
||||
"""List of pending identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingIdentificationResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ApproveDenyDecision(BaseModel):
|
||||
"""Decision for a single pending identification."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'deny'
|
||||
|
||||
|
||||
class ApproveDenyRequest(BaseModel):
|
||||
"""Request to approve/deny multiple pending identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ApproveDenyDecision]
|
||||
|
||||
|
||||
class ApproveDenyResponse(BaseModel):
|
||||
"""Response from approve/deny operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
denied: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class UserIdentificationStats(BaseModel):
|
||||
"""Statistics for a single user's identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
full_name: str
|
||||
email: str
|
||||
face_count: int
|
||||
first_identification_date: Optional[datetime] = None
|
||||
last_identification_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class IdentificationReportResponse(BaseModel):
|
||||
"""Response containing identification statistics grouped by user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[UserIdentificationStats]
|
||||
total_faces: int
|
||||
total_users: int
|
||||
|
||||
|
||||
class ClearDatabaseResponse(BaseModel):
|
||||
"""Response from clearing denied records."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingIdentificationsListResponse)
|
||||
def list_pending_identifications(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
include_denied: bool = False,
|
||||
db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> PendingIdentificationsListResponse:
|
||||
"""List all pending identifications from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all pending identifications from the pending_identifications table.
|
||||
By default, only shows records with status='pending' for approval.
|
||||
Set include_denied=True to also show denied records.
|
||||
"""
|
||||
try:
|
||||
# Query pending_identifications from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
# Filter by status='pending' to show only records awaiting approval
|
||||
# Optionally include denied records if include_denied is True
|
||||
if include_denied:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth,
|
||||
pi.status,
|
||||
pi.created_at,
|
||||
pi.updated_at
|
||||
FROM pending_identifications pi
|
||||
LEFT JOIN users u ON pi.user_id = u.id
|
||||
WHERE pi.status IN ('pending', 'denied')
|
||||
ORDER BY pi.status ASC, pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
|
||||
"""))
|
||||
else:
|
||||
result = db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth,
|
||||
pi.status,
|
||||
pi.created_at,
|
||||
pi.updated_at
|
||||
FROM pending_identifications pi
|
||||
LEFT JOIN users u ON pi.user_id = u.id
|
||||
WHERE pi.status = 'pending'
|
||||
ORDER BY pi.last_name ASC, pi.first_name ASC, pi.created_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
# Get photo_id from main database
|
||||
photo_id = None
|
||||
face = main_db.query(Face).filter(Face.id == row.face_id).first()
|
||||
if face:
|
||||
photo_id = face.photo_id
|
||||
|
||||
items.append(PendingIdentificationResponse(
|
||||
id=row.id,
|
||||
face_id=row.face_id,
|
||||
photo_id=photo_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
middle_name=row.middle_name,
|
||||
maiden_name=row.maiden_name,
|
||||
date_of_birth=row.date_of_birth,
|
||||
status=row.status,
|
||||
created_at=str(row.created_at) if row.created_at else '',
|
||||
updated_at=str(row.updated_at) if row.updated_at else '',
|
||||
))
|
||||
|
||||
return PendingIdentificationsListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/approve-deny", response_model=ApproveDenyResponse)
|
||||
def approve_deny_pending_identifications(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
request: ApproveDenyRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ApproveDenyResponse:
|
||||
"""Approve or deny pending identifications.
|
||||
|
||||
For approved identifications:
|
||||
- Updates status in auth database to 'approved'
|
||||
- Identifies the face in main database
|
||||
- Creates person if needed
|
||||
|
||||
For denied identifications:
|
||||
- Updates status in auth database to 'denied'
|
||||
"""
|
||||
approved_count = 0
|
||||
denied_count = 0
|
||||
errors = []
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get pending identification from auth database
|
||||
# Allow processing of both 'pending' and 'denied' status (to allow re-approval)
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pi.id,
|
||||
pi.face_id,
|
||||
pi.first_name,
|
||||
pi.last_name,
|
||||
pi.middle_name,
|
||||
pi.maiden_name,
|
||||
pi.date_of_birth
|
||||
FROM pending_identifications pi
|
||||
WHERE pi.id = :id AND pi.status IN ('pending', 'denied')
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Pending identification {decision.id} not found or already processed")
|
||||
continue
|
||||
|
||||
if decision.decision == 'approve':
|
||||
# Identify the face in main database
|
||||
face = main_db.query(Face).filter(Face.id == row.face_id).first()
|
||||
if not face:
|
||||
errors.append(f"Face {row.face_id} not found in main database")
|
||||
# Still update status to denied since we can't process it
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'denied', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
denied_count += 1
|
||||
continue
|
||||
|
||||
# Check if person already exists (by name and DOB)
|
||||
# Match the unique constraint: first_name, last_name, middle_name, maiden_name, date_of_birth
|
||||
# Build query with proper None handling
|
||||
query = main_db.query(Person).filter(
|
||||
Person.first_name == row.first_name,
|
||||
Person.last_name == row.last_name,
|
||||
)
|
||||
# Handle optional fields - use IS NULL for None values
|
||||
if row.middle_name:
|
||||
query = query.filter(Person.middle_name == row.middle_name)
|
||||
else:
|
||||
query = query.filter(Person.middle_name.is_(None))
|
||||
|
||||
if row.maiden_name:
|
||||
query = query.filter(Person.maiden_name == row.maiden_name)
|
||||
else:
|
||||
query = query.filter(Person.maiden_name.is_(None))
|
||||
|
||||
if row.date_of_birth:
|
||||
query = query.filter(Person.date_of_birth == row.date_of_birth)
|
||||
else:
|
||||
query = query.filter(Person.date_of_birth.is_(None))
|
||||
|
||||
person = query.first()
|
||||
|
||||
# Create person if doesn't exist
|
||||
created_person = False
|
||||
if not person:
|
||||
# Explicitly set created_date to ensure it's a valid datetime object
|
||||
person = Person(
|
||||
first_name=row.first_name,
|
||||
last_name=row.last_name,
|
||||
middle_name=row.middle_name,
|
||||
maiden_name=row.maiden_name,
|
||||
date_of_birth=row.date_of_birth,
|
||||
created_date=datetime.utcnow(),
|
||||
)
|
||||
main_db.add(person)
|
||||
main_db.flush() # get person.id
|
||||
created_person = True
|
||||
|
||||
# Link face to person
|
||||
# Use FrontEndUser to indicate this was approved through the frontend UI
|
||||
frontend_user = get_or_create_frontend_user(main_db)
|
||||
face.person_id = person.id
|
||||
face.identified_by_user_id = frontend_user.id
|
||||
main_db.add(face)
|
||||
|
||||
# Insert person_encoding
|
||||
pe = PersonEncoding(
|
||||
person_id=person.id,
|
||||
face_id=face.id,
|
||||
encoding=face.encoding,
|
||||
quality_score=face.quality_score,
|
||||
detector_backend=face.detector_backend,
|
||||
model_name=face.model_name,
|
||||
)
|
||||
main_db.add(pe)
|
||||
main_db.commit()
|
||||
|
||||
# Update status in auth database
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'approved', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
|
||||
approved_count += 1
|
||||
|
||||
elif decision.decision == 'deny':
|
||||
# Update status to denied
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_identifications
|
||||
SET status = 'denied', updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""), {"id": decision.id, "updated_at": datetime.utcnow()})
|
||||
auth_db.commit()
|
||||
|
||||
denied_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for pending identification {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending identification {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
|
||||
return ApproveDenyResponse(
|
||||
approved=approved_count,
|
||||
denied=denied_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.get("/report", response_model=IdentificationReportResponse)
|
||||
def get_identification_report(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_identified"))
|
||||
],
|
||||
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> IdentificationReportResponse:
|
||||
"""Get identification statistics grouped by user.
|
||||
|
||||
Shows how many faces each user identified and when.
|
||||
Can be filtered by date range using PersonEncoding.created_date.
|
||||
"""
|
||||
# Query faces that have been identified (have person_id and identified_by_user_id)
|
||||
# Join with PersonEncoding to get created_date (when face was identified)
|
||||
# Join with User to get user information
|
||||
# Use distinct count to avoid counting the same face multiple times
|
||||
# (in case person encodings were updated, creating multiple PersonEncoding records)
|
||||
query = (
|
||||
main_db.query(
|
||||
User.id.label('user_id'),
|
||||
User.username,
|
||||
User.full_name,
|
||||
User.email,
|
||||
func.count(func.distinct(Face.id)).label('face_count'),
|
||||
func.min(PersonEncoding.created_date).label('first_date'),
|
||||
func.max(PersonEncoding.created_date).label('last_date')
|
||||
)
|
||||
.join(Face, User.id == Face.identified_by_user_id)
|
||||
.join(PersonEncoding, Face.id == PersonEncoding.face_id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.filter(Face.identified_by_user_id.isnot(None))
|
||||
.group_by(User.id, User.username, User.full_name, User.email)
|
||||
)
|
||||
|
||||
# Apply date filtering if provided (filter before grouping)
|
||||
if date_from:
|
||||
try:
|
||||
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) >= date_from_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_from format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) <= date_to_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_to format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Execute query and get results
|
||||
results = query.order_by(func.count(Face.id).desc(), User.username.asc()).all()
|
||||
|
||||
# Convert to response model
|
||||
items = []
|
||||
total_faces = 0
|
||||
|
||||
for row in results:
|
||||
total_faces += row.face_count
|
||||
items.append(
|
||||
UserIdentificationStats(
|
||||
user_id=row.user_id,
|
||||
username=row.username,
|
||||
full_name=row.full_name or row.username,
|
||||
email=row.email,
|
||||
face_count=row.face_count,
|
||||
first_identification_date=row.first_date,
|
||||
last_identification_date=row.last_date,
|
||||
)
|
||||
)
|
||||
|
||||
return IdentificationReportResponse(
|
||||
items=items,
|
||||
total_faces=total_faces,
|
||||
total_users=len(items)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
|
||||
def clear_denied_identifications(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> ClearDatabaseResponse:
|
||||
"""Delete all denied pending identifications from the database.
|
||||
|
||||
This permanently removes all records with status='denied' from the
|
||||
pending_identifications table in the auth database.
|
||||
"""
|
||||
deleted_records = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# First check if there are any denied records
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
denied_count = check_result.fetchone().count if check_result else 0
|
||||
|
||||
if denied_count == 0:
|
||||
# No denied records to delete
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Delete all denied records
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0 and denied_count > 0:
|
||||
errors.append("No records were deleted despite finding denied records")
|
||||
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
error_msg = str(e)
|
||||
errors.append(f"Error deleting denied records: {error_msg}")
|
||||
|
||||
# Check if it's a permission error
|
||||
if "permission denied" in error_msg.lower() or "insufficient privilege" in error_msg.lower():
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_identifications TO punimtag;\""
|
||||
)
|
||||
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
450
backend/api/pending_linkages.py
Normal file
450
backend/api/pending_linkages.py
Normal file
@ -0,0 +1,450 @@
|
||||
"""Pending linkage review endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.users import require_feature_permission
|
||||
from backend.db.models import Photo, PhotoTagLinkage, Tag
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
|
||||
router = APIRouter(prefix="/pending-linkages", tags=["pending-linkages"])
|
||||
|
||||
|
||||
def _get_or_create_tag_by_name(db: Session, tag_name: str) -> tuple[Tag, bool]:
|
||||
"""Return a tag for the provided name, creating it if necessary."""
|
||||
normalized = (tag_name or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("Tag name cannot be empty")
|
||||
|
||||
existing = (
|
||||
db.query(Tag)
|
||||
.filter(Tag.tag_name.ilike(normalized))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
return existing, False
|
||||
|
||||
tag = Tag(tag_name=normalized)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag, True
|
||||
|
||||
|
||||
def _format_datetime(value: Union[str, datetime, None]) -> Optional[str]:
|
||||
"""Safely serialize datetime values returned from different drivers."""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
class PendingLinkageResponse(BaseModel):
|
||||
"""Pending linkage DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
tag_id: Optional[int] = None
|
||||
proposed_tag_name: Optional[str] = None
|
||||
resolved_tag_name: Optional[str] = None
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
created_at: str
|
||||
updated_at: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
photo_path: Optional[str] = None
|
||||
photo_media_type: Optional[str] = None
|
||||
photo_tags: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class PendingLinkagesListResponse(BaseModel):
|
||||
"""List of pending linkage rows."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingLinkageResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision payload for a pending linkage row."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'deny'
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request payload for reviewing pending linkages."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Review summary returned after processing decisions."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
denied: int
|
||||
tags_created: int
|
||||
linkages_created: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingLinkagesListResponse)
|
||||
def list_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(
|
||||
description="Optional status filter: pending, approved, or denied."
|
||||
),
|
||||
] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> PendingLinkagesListResponse:
|
||||
"""List all pending linkages stored in the auth database."""
|
||||
valid_statuses = {"pending", "approved", "denied"}
|
||||
if status_filter and status_filter not in valid_statuses:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid status_filter. Use pending, approved, or denied.",
|
||||
)
|
||||
|
||||
try:
|
||||
params = {}
|
||||
status_clause = ""
|
||||
if status_filter:
|
||||
status_clause = "WHERE pl.status = :status_filter"
|
||||
params["status_filter"] = status_filter
|
||||
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT
|
||||
pl.id,
|
||||
pl.photo_id,
|
||||
pl.tag_id,
|
||||
pl.tag_name,
|
||||
pl.user_id,
|
||||
pl.status,
|
||||
pl.notes,
|
||||
pl.created_at,
|
||||
pl.updated_at,
|
||||
u.name AS user_name,
|
||||
u.email AS user_email
|
||||
FROM pending_linkages pl
|
||||
LEFT JOIN users u ON pl.user_id = u.id
|
||||
{status_clause}
|
||||
ORDER BY pl.created_at DESC
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
photo_ids = {row.photo_id for row in rows if row.photo_id}
|
||||
tag_ids = {row.tag_id for row in rows if row.tag_id}
|
||||
|
||||
photo_map: dict[int, Photo] = {}
|
||||
if photo_ids:
|
||||
photos = (
|
||||
main_db.query(Photo)
|
||||
.filter(Photo.id.in_(photo_ids))
|
||||
.all()
|
||||
)
|
||||
photo_map = {photo.id: photo for photo in photos}
|
||||
|
||||
tag_map: dict[int, str] = {}
|
||||
if tag_ids:
|
||||
tags = (
|
||||
main_db.query(Tag)
|
||||
.filter(Tag.id.in_(tag_ids))
|
||||
.all()
|
||||
)
|
||||
tag_map = {tag.id: tag.tag_name for tag in tags}
|
||||
|
||||
photo_tags_map: dict[int, list[str]] = {
|
||||
photo_id: [] for photo_id in photo_ids
|
||||
}
|
||||
if photo_ids:
|
||||
tag_rows = (
|
||||
main_db.query(PhotoTagLinkage.photo_id, Tag.tag_name)
|
||||
.join(Tag, Tag.id == PhotoTagLinkage.tag_id)
|
||||
.filter(PhotoTagLinkage.photo_id.in_(photo_ids))
|
||||
.all()
|
||||
)
|
||||
for photo_id, tag_name in tag_rows:
|
||||
photo_tags_map.setdefault(photo_id, []).append(tag_name)
|
||||
|
||||
items: list[PendingLinkageResponse] = []
|
||||
for row in rows:
|
||||
created_at = _format_datetime(getattr(row, "created_at", None)) or ""
|
||||
updated_at = _format_datetime(getattr(row, "updated_at", None))
|
||||
photo = photo_map.get(row.photo_id)
|
||||
resolved_tag_name = None
|
||||
if row.tag_id:
|
||||
resolved_tag_name = tag_map.get(row.tag_id)
|
||||
proposal_name = row.tag_name
|
||||
items.append(
|
||||
PendingLinkageResponse(
|
||||
id=row.id,
|
||||
photo_id=row.photo_id,
|
||||
tag_id=row.tag_id,
|
||||
proposed_tag_name=proposal_name,
|
||||
resolved_tag_name=resolved_tag_name or proposal_name,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
status=row.status,
|
||||
notes=row.notes,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
photo_filename=photo.filename if photo else None,
|
||||
photo_path=photo.path if photo else None,
|
||||
photo_media_type=photo.media_type if photo else None,
|
||||
photo_tags=photo_tags_map.get(row.photo_id, []),
|
||||
)
|
||||
)
|
||||
|
||||
return PendingLinkagesListResponse(items=items, total=len(items))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading pending linkages: {exc}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Approve or deny pending user-proposed tag linkages."""
|
||||
approved = 0
|
||||
denied = 0
|
||||
tags_created = 0
|
||||
linkages_created = 0
|
||||
errors: list[str] = []
|
||||
now = datetime.utcnow()
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
row = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, photo_id, tag_id, tag_name, status
|
||||
FROM pending_linkages
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": decision.id},
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
errors.append(
|
||||
f"Pending linkage {decision.id} not found or already deleted"
|
||||
)
|
||||
continue
|
||||
|
||||
if row.status != "pending":
|
||||
errors.append(
|
||||
f"Pending linkage {decision.id} cannot be reviewed (status={row.status})"
|
||||
)
|
||||
continue
|
||||
|
||||
if decision.decision == "deny":
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_linkages
|
||||
SET status = 'denied',
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{"id": decision.id, "updated_at": now},
|
||||
)
|
||||
auth_db.commit()
|
||||
denied += 1
|
||||
continue
|
||||
|
||||
if decision.decision != "approve":
|
||||
errors.append(
|
||||
f"Invalid decision '{decision.decision}' for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
|
||||
photo = (
|
||||
main_db.query(Photo)
|
||||
.filter(Photo.id == row.photo_id)
|
||||
.first()
|
||||
)
|
||||
if not photo:
|
||||
errors.append(
|
||||
f"Photo {row.photo_id} not found for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
|
||||
tag_obj: Optional[Tag] = None
|
||||
created_tag = False
|
||||
|
||||
if row.tag_id:
|
||||
tag_obj = (
|
||||
main_db.query(Tag)
|
||||
.filter(Tag.id == row.tag_id)
|
||||
.first()
|
||||
)
|
||||
if not tag_obj and row.tag_name:
|
||||
tag_obj, created_tag = _get_or_create_tag_by_name(
|
||||
main_db, row.tag_name
|
||||
)
|
||||
elif not tag_obj:
|
||||
errors.append(
|
||||
f"Tag {row.tag_id} missing for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
else:
|
||||
if not row.tag_name:
|
||||
errors.append(
|
||||
f"No tag information provided for linkage {decision.id}"
|
||||
)
|
||||
continue
|
||||
tag_obj, created_tag = _get_or_create_tag_by_name(
|
||||
main_db, row.tag_name
|
||||
)
|
||||
|
||||
if created_tag:
|
||||
tags_created += 1
|
||||
|
||||
resolved_tag_id = tag_obj.id # type: ignore[union-attr]
|
||||
|
||||
existing_linkage = (
|
||||
main_db.query(PhotoTagLinkage)
|
||||
.filter(
|
||||
PhotoTagLinkage.photo_id == row.photo_id,
|
||||
PhotoTagLinkage.tag_id == resolved_tag_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not existing_linkage:
|
||||
linkage = PhotoTagLinkage(
|
||||
photo_id=row.photo_id,
|
||||
tag_id=resolved_tag_id,
|
||||
)
|
||||
main_db.add(linkage)
|
||||
linkages_created += 1
|
||||
|
||||
main_db.commit()
|
||||
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_linkages
|
||||
SET status = 'approved',
|
||||
tag_id = :tag_id,
|
||||
updated_at = :updated_at
|
||||
WHERE id = :id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"id": decision.id,
|
||||
"tag_id": resolved_tag_id,
|
||||
"updated_at": now,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
approved += 1
|
||||
except ValueError as exc:
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
errors.append(f"Validation error for linkage {decision.id}: {exc}")
|
||||
except Exception as exc:
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
errors.append(f"Error processing linkage {decision.id}: {exc}")
|
||||
|
||||
return ReviewResponse(
|
||||
approved=approved,
|
||||
denied=denied,
|
||||
tags_created=tags_created,
|
||||
linkages_created=linkages_created,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response payload for cleanup operations."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_pending_linkages(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_tagged"))
|
||||
],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete all approved or denied records from pending_linkages table."""
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM pending_linkages
|
||||
WHERE status IN ('approved', 'denied')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0:
|
||||
warnings.append("No approved or denied pending linkages to delete.")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cleanup pending linkages: {exc}",
|
||||
)
|
||||
|
||||
719
backend/api/pending_photos.py
Normal file
719
backend/api/pending_photos.py
Normal file
@ -0,0 +1,719 @@
|
||||
"""Pending photos endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.services.photo_service import import_photo_from_path, calculate_file_hash
|
||||
from backend.settings import PHOTO_STORAGE_DIR
|
||||
|
||||
router = APIRouter(prefix="/pending-photos", tags=["pending-photos"])
|
||||
|
||||
|
||||
class PendingPhotoResponse(BaseModel):
|
||||
"""Pending photo DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
filename: str
|
||||
original_filename: str
|
||||
file_path: str
|
||||
file_size: int
|
||||
mime_type: str
|
||||
status: str
|
||||
submitted_at: str
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class PendingPhotosListResponse(BaseModel):
|
||||
"""List of pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[PendingPhotoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision for a single pending photo."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'approve' or 'reject'
|
||||
rejection_reason: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request to review multiple pending photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Response from review operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
approved: int
|
||||
rejected: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = [] # Informational messages (e.g., duplicates)
|
||||
|
||||
|
||||
@router.get("", response_model=PendingPhotosListResponse)
|
||||
def list_pending_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> PendingPhotosListResponse:
|
||||
"""List all pending photos from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all pending photos from the pending_photos table.
|
||||
Optionally filter by status: 'pending', 'approved', or 'rejected'.
|
||||
"""
|
||||
try:
|
||||
# Query pending_photos from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
WHERE pp.status = :status_filter
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
pp.filename,
|
||||
pp.original_filename,
|
||||
pp.file_path,
|
||||
pp.file_size,
|
||||
pp.mime_type,
|
||||
pp.status,
|
||||
pp.submitted_at,
|
||||
pp.reviewed_at,
|
||||
pp.reviewed_by,
|
||||
pp.rejection_reason
|
||||
FROM pending_photos pp
|
||||
LEFT JOIN users u ON pp.user_id = u.id
|
||||
ORDER BY pp.submitted_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(PendingPhotoResponse(
|
||||
id=row.id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
filename=row.filename,
|
||||
original_filename=row.original_filename,
|
||||
file_path=row.file_path,
|
||||
file_size=row.file_size,
|
||||
mime_type=row.mime_type,
|
||||
status=row.status,
|
||||
submitted_at=str(row.submitted_at) if row.submitted_at else '',
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
rejection_reason=row.rejection_reason,
|
||||
))
|
||||
|
||||
return PendingPhotosListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{photo_id}/image")
|
||||
def get_pending_photo_image(
|
||||
photo_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> FileResponse:
|
||||
"""Get the image file for a pending photo.
|
||||
|
||||
Photos are stored in /mnt/db-server-uploads. The file_path in the database
|
||||
may be relative (just filename) or absolute. This function handles both cases.
|
||||
"""
|
||||
import os
|
||||
|
||||
try:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT file_path, mime_type, filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Pending photo {photo_id} not found"
|
||||
)
|
||||
|
||||
# Base directory for uploaded photos
|
||||
base_dir = Path("/mnt/db-server-uploads")
|
||||
|
||||
# Handle both absolute and relative paths
|
||||
db_file_path = row.file_path
|
||||
if os.path.isabs(db_file_path):
|
||||
# Absolute path - use as is
|
||||
file_path = Path(db_file_path)
|
||||
else:
|
||||
# Relative path - prepend base directory
|
||||
file_path = base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist at constructed path, try just the filename
|
||||
if not file_path.exists():
|
||||
# Try with just the filename from database
|
||||
file_path = base_dir / row.filename
|
||||
if not file_path.exists():
|
||||
# Try with original_filename if available
|
||||
result2 = auth_db.execute(text("""
|
||||
SELECT original_filename
|
||||
FROM pending_photos
|
||||
WHERE id = :id
|
||||
"""), {"id": photo_id})
|
||||
row2 = result2.fetchone()
|
||||
if row2 and row2.original_filename:
|
||||
file_path = base_dir / row2.original_filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Photo file not found at {file_path}"
|
||||
)
|
||||
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
media_type=row.mime_type or "image/jpeg",
|
||||
filename=file_path.name
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error retrieving photo: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_pending_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_uploaded"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Review pending photos - approve or reject them.
|
||||
|
||||
For 'approve' decision:
|
||||
- Moves photo file from /mnt/db-server-uploads to main photo storage
|
||||
- Imports photo into main database (Scan process)
|
||||
- Updates status in auth database to 'approved'
|
||||
|
||||
For 'reject' decision:
|
||||
- Updates status in auth database to 'rejected'
|
||||
- Photo file remains in place (can be deleted later if needed)
|
||||
"""
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
approved_count = 0
|
||||
rejected_count = 0
|
||||
duplicate_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Base directories
|
||||
# Try to get upload directory from environment, fallback to hardcoded path
|
||||
upload_base_dir = Path(os.getenv("UPLOAD_DIR") or os.getenv("PENDING_PHOTOS_DIR") or "/mnt/db-server-uploads")
|
||||
main_storage_dir = Path(PHOTO_STORAGE_DIR)
|
||||
main_storage_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get pending photo from auth database with file info
|
||||
# Only allow processing 'pending' status photos
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
pp.id,
|
||||
pp.status,
|
||||
pp.file_path,
|
||||
pp.filename,
|
||||
pp.original_filename
|
||||
FROM pending_photos pp
|
||||
WHERE pp.id = :id AND pp.status = 'pending'
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Pending photo {decision.id} not found or already reviewed")
|
||||
continue
|
||||
|
||||
if decision.decision == 'approve':
|
||||
# Find the source file
|
||||
db_file_path = row.file_path
|
||||
source_path = None
|
||||
|
||||
# Try to find the file - handle both absolute and relative paths
|
||||
if os.path.isabs(db_file_path):
|
||||
# Use absolute path directly
|
||||
source_path = Path(db_file_path)
|
||||
else:
|
||||
# Try relative to upload base directory
|
||||
source_path = upload_base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist, try alternative locations
|
||||
if not source_path.exists():
|
||||
# Try with just the filename in upload_base_dir
|
||||
source_path = upload_base_dir / row.filename
|
||||
if not source_path.exists() and row.original_filename:
|
||||
# Try with original filename
|
||||
source_path = upload_base_dir / row.original_filename
|
||||
# If still not found, try looking in user subdirectories
|
||||
if not source_path.exists() and upload_base_dir.exists():
|
||||
# Check if file_path contains user ID subdirectory
|
||||
# file_path format might be: {userId}/{filename} or full path
|
||||
try:
|
||||
for user_id_dir in upload_base_dir.iterdir():
|
||||
if user_id_dir.is_dir():
|
||||
potential_path = user_id_dir / row.filename
|
||||
if potential_path.exists():
|
||||
source_path = potential_path
|
||||
break
|
||||
if row.original_filename:
|
||||
potential_path = user_id_dir / row.original_filename
|
||||
if potential_path.exists():
|
||||
source_path = potential_path
|
||||
break
|
||||
except (PermissionError, OSError) as e:
|
||||
# Can't read directory, skip this search
|
||||
pass
|
||||
|
||||
if not source_path.exists():
|
||||
errors.append(f"Photo file not found for pending photo {decision.id}. Tried: {db_file_path}, {upload_base_dir / row.filename}, {upload_base_dir / row.original_filename if row.original_filename else 'N/A'}")
|
||||
continue
|
||||
|
||||
# Calculate file hash and check for duplicates BEFORE moving file
|
||||
try:
|
||||
file_hash = calculate_file_hash(str(source_path))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to calculate hash for pending photo {decision.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Check if photo with same hash already exists in main database
|
||||
# Handle case where file_hash column might not exist or be NULL for old photos
|
||||
try:
|
||||
existing_photo = main_db.execute(text("""
|
||||
SELECT id, path FROM photos WHERE file_hash = :file_hash AND file_hash IS NOT NULL
|
||||
"""), {"file_hash": file_hash}).fetchone()
|
||||
except Exception as e:
|
||||
# If file_hash column doesn't exist, skip duplicate check
|
||||
# This can happen if database schema is outdated
|
||||
if "no such column" in str(e).lower() or "file_hash" in str(e).lower():
|
||||
existing_photo = None
|
||||
else:
|
||||
raise
|
||||
|
||||
if existing_photo:
|
||||
# Photo already exists - mark as duplicate and skip import
|
||||
# Don't add to errors - we'll show a summary message instead
|
||||
# Update status to rejected with duplicate reason
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'rejected',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
rejection_reason = 'Duplicate photo already exists in database'
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
})
|
||||
auth_db.commit()
|
||||
rejected_count += 1
|
||||
duplicate_count += 1
|
||||
continue
|
||||
|
||||
# Generate unique filename for main storage to avoid conflicts
|
||||
file_ext = source_path.suffix
|
||||
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
||||
dest_path = main_storage_dir / unique_filename
|
||||
|
||||
# Copy file to main storage (keep original in shared location)
|
||||
try:
|
||||
shutil.copy2(str(source_path), str(dest_path))
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to copy photo file for {decision.id}: {str(e)}")
|
||||
continue
|
||||
|
||||
# Import photo into main database (Scan process)
|
||||
# This will also check for duplicates by hash, but we've already checked above
|
||||
try:
|
||||
photo, is_new = import_photo_from_path(main_db, str(dest_path))
|
||||
if not is_new:
|
||||
# Photo already exists (shouldn't happen due to hash check above, but handle gracefully)
|
||||
if dest_path.exists():
|
||||
dest_path.unlink()
|
||||
errors.append(f"Photo already exists in main database: {photo.path}")
|
||||
continue
|
||||
except Exception as e:
|
||||
# If import fails, delete the copied file (original remains in shared location)
|
||||
if dest_path.exists():
|
||||
try:
|
||||
dest_path.unlink()
|
||||
except:
|
||||
pass
|
||||
errors.append(f"Failed to import photo {decision.id} into main database: {str(e)}")
|
||||
continue
|
||||
|
||||
# Update status to approved in auth database
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'approved',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
approved_count += 1
|
||||
|
||||
elif decision.decision == 'reject':
|
||||
# Update status to rejected
|
||||
auth_db.execute(text("""
|
||||
UPDATE pending_photos
|
||||
SET status = 'rejected',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
rejection_reason = :rejection_reason
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"rejection_reason": decision.rejection_reason or None,
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
rejected_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for pending photo {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending photo {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
auth_db.rollback()
|
||||
main_db.rollback()
|
||||
|
||||
# Add friendly message about duplicates if any were found
|
||||
warnings = []
|
||||
if duplicate_count > 0:
|
||||
if duplicate_count == 1:
|
||||
warnings.append(f"{duplicate_count} photo was not added as it already exists in the database")
|
||||
else:
|
||||
warnings.append(f"{duplicate_count} photos were not added as they already exist in the database")
|
||||
|
||||
return ReviewResponse(
|
||||
approved=approved_count,
|
||||
rejected=rejected_count,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response from cleanup operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_files: int
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = [] # Informational messages (e.g., files already deleted)
|
||||
|
||||
|
||||
@router.post("/cleanup-files", response_model=CleanupResponse)
|
||||
def cleanup_shared_files(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for both"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete photo files from shared space for approved or rejected photos.
|
||||
|
||||
Args:
|
||||
status_filter: Optional filter - 'approved', 'rejected', or None for both
|
||||
"""
|
||||
deleted_files = 0
|
||||
errors = []
|
||||
warnings = []
|
||||
upload_base_dir = Path("/mnt/db-server-uploads")
|
||||
|
||||
# Build query based on status filter
|
||||
if status_filter:
|
||||
query = text("""
|
||||
SELECT id, file_path, filename, original_filename, status
|
||||
FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
""")
|
||||
result = auth_db.execute(query, {"status_filter": status_filter})
|
||||
else:
|
||||
query = text("""
|
||||
SELECT id, file_path, filename, original_filename, status
|
||||
FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
""")
|
||||
result = auth_db.execute(query)
|
||||
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
try:
|
||||
# Find the file - handle both absolute and relative paths
|
||||
db_file_path = row.file_path
|
||||
file_path = None
|
||||
|
||||
if os.path.isabs(db_file_path):
|
||||
file_path = Path(db_file_path)
|
||||
else:
|
||||
file_path = upload_base_dir / db_file_path
|
||||
|
||||
# If file doesn't exist, try with filename
|
||||
if not file_path.exists():
|
||||
file_path = upload_base_dir / row.filename
|
||||
if not file_path.exists() and row.original_filename:
|
||||
file_path = upload_base_dir / row.original_filename
|
||||
|
||||
if file_path.exists():
|
||||
try:
|
||||
file_path.unlink()
|
||||
deleted_files += 1
|
||||
except PermissionError:
|
||||
errors.append(f"Permission denied deleting file for pending photo {row.id}: {file_path}")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to delete file for pending photo {row.id}: {str(e)}")
|
||||
else:
|
||||
# File not found is expected if already deleted - show as warning, not error
|
||||
warnings.append(f"File already deleted for pending photo {row.id}")
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing pending photo {row.id}: {str(e)}")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_files=deleted_files,
|
||||
deleted_records=0, # Files only, not records
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup-database", response_model=CleanupResponse)
|
||||
def cleanup_pending_photos_database(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Optional[str] = Query(None, description="Filter by status: 'approved', 'rejected', or None for approved+rejected (excludes pending)"),
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete records from pending_photos table.
|
||||
|
||||
Args:
|
||||
status_filter: Optional filter - 'approved', 'rejected', or None for approved+rejected (excludes pending)
|
||||
"""
|
||||
deleted_records = 0
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
try:
|
||||
# First check if table exists and has records
|
||||
if status_filter:
|
||||
# Check count for specific status
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
# Check count for approved and rejected (exclude pending)
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
"""))
|
||||
total_count = check_result.fetchone().count if check_result else 0
|
||||
|
||||
if total_count == 0:
|
||||
# No records to delete - not an error, just return success
|
||||
return CleanupResponse(
|
||||
deleted_files=0,
|
||||
deleted_records=0,
|
||||
errors=[],
|
||||
warnings=[]
|
||||
)
|
||||
|
||||
# Perform deletion
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
# Default behavior: delete only approved and rejected, exclude pending
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status IN ('approved', 'rejected')
|
||||
"""))
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0 and total_count > 0:
|
||||
# No records matched the filter - this shouldn't be an error if status_filter was provided
|
||||
# But if no filter and total_count > 0, something went wrong
|
||||
if not status_filter:
|
||||
errors.append(f"Expected to delete {total_count} record(s) but deleted 0. Check database permissions.")
|
||||
else:
|
||||
warnings.append(f"No records found matching status filter: {status_filter}")
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
error_details = traceback.format_exc()
|
||||
|
||||
# Check if this is a permission error
|
||||
error_str = str(e)
|
||||
if "InsufficientPrivilege" in error_str or "permission denied" in error_str.lower():
|
||||
# Try to automatically grant the permission using sudo (non-interactive)
|
||||
import subprocess
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
# Get database name from connection
|
||||
auth_db_url = os.getenv("DATABASE_URL_AUTH", "")
|
||||
if auth_db_url:
|
||||
# Parse database URL to get database name
|
||||
if auth_db_url.startswith("postgresql+psycopg2://"):
|
||||
auth_db_url = auth_db_url.replace("postgresql+psycopg2://", "postgresql://")
|
||||
parsed = urlparse(auth_db_url)
|
||||
db_name = parsed.path.lstrip("/")
|
||||
|
||||
# Try to grant permission using sudo -n (non-interactive, requires passwordless sudo)
|
||||
result = subprocess.run(
|
||||
[
|
||||
"sudo", "-n", "-u", "postgres", "psql", "-d", db_name,
|
||||
"-c", "GRANT DELETE ON TABLE pending_photos TO punimtag;"
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Permission granted, try deletion again
|
||||
try:
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
WHERE status = :status_filter
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_photos
|
||||
"""))
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
# Success - return early
|
||||
return CleanupResponse(
|
||||
deleted_files=0,
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=[]
|
||||
)
|
||||
except Exception as retry_e:
|
||||
errors.append(f"Permission granted but deletion still failed: {str(retry_e)}")
|
||||
else:
|
||||
# Sudo failed (needs password) - provide instructions
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
f"sudo -u postgres psql -d {db_name} -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
else:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
except Exception as grant_e:
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_photos TO punimtag;\""
|
||||
)
|
||||
else:
|
||||
errors.append(f"Failed to delete records from database: {str(e)}")
|
||||
|
||||
# Log full traceback for debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Cleanup database error: {error_details}")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_files=0, # Database only, not files
|
||||
deleted_records=deleted_records,
|
||||
errors=errors,
|
||||
warnings=warnings
|
||||
)
|
||||
|
||||
320
backend/api/people.py
Normal file
320
backend/api/people.py
Normal file
@ -0,0 +1,320 @@
|
||||
"""People management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import Person, Face, PersonEncoding, PhotoPersonLinkage, Photo
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
from backend.schemas.people import (
|
||||
PeopleListResponse,
|
||||
PersonCreateRequest,
|
||||
PersonResponse,
|
||||
PersonUpdateRequest,
|
||||
PersonWithFacesResponse,
|
||||
PeopleWithFacesListResponse,
|
||||
)
|
||||
from backend.schemas.faces import PersonFacesResponse, PersonFaceItem, AcceptMatchesRequest, IdentifyFaceResponse
|
||||
from backend.services.face_service import accept_auto_match_matches
|
||||
|
||||
router = APIRouter(prefix="/people", tags=["people"])
|
||||
|
||||
|
||||
@router.get("", response_model=PeopleListResponse)
|
||||
def list_people(
|
||||
last_name: str | None = Query(None, description="Filter by last name (case-insensitive)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PeopleListResponse:
|
||||
"""List all people sorted by last_name, first_name.
|
||||
|
||||
Optionally filter by last_name if provided (case-insensitive search).
|
||||
"""
|
||||
query = db.query(Person)
|
||||
|
||||
if last_name:
|
||||
# Case-insensitive search on last_name
|
||||
query = query.filter(func.lower(Person.last_name).contains(func.lower(last_name)))
|
||||
|
||||
people = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
|
||||
items = [PersonResponse.model_validate(p) for p in people]
|
||||
return PeopleListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/with-faces", response_model=PeopleWithFacesListResponse)
|
||||
def list_people_with_faces(
|
||||
last_name: str | None = Query(None, description="Filter by last name or maiden name (case-insensitive)"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> PeopleWithFacesListResponse:
|
||||
"""List all people with face counts and video counts, sorted by last_name, first_name.
|
||||
|
||||
Optionally filter by last_name or maiden_name if provided (case-insensitive search).
|
||||
Returns all people, including those with zero faces or videos.
|
||||
"""
|
||||
# Query people with face counts using LEFT OUTER JOIN to include people with no faces
|
||||
query = (
|
||||
db.query(
|
||||
Person,
|
||||
func.count(Face.id.distinct()).label('face_count')
|
||||
)
|
||||
.outerjoin(Face, Person.id == Face.person_id)
|
||||
.group_by(Person.id)
|
||||
)
|
||||
|
||||
if last_name:
|
||||
# Case-insensitive search on both last_name and maiden_name
|
||||
search_term = last_name.lower()
|
||||
query = query.filter(
|
||||
(func.lower(Person.last_name).contains(search_term)) |
|
||||
((Person.maiden_name.isnot(None)) & (func.lower(Person.maiden_name).contains(search_term)))
|
||||
)
|
||||
|
||||
results = query.order_by(Person.last_name.asc(), Person.first_name.asc()).all()
|
||||
|
||||
# Get video counts separately for each person
|
||||
person_ids = [person.id for person, _ in results]
|
||||
video_counts = {}
|
||||
if person_ids:
|
||||
video_count_query = (
|
||||
db.query(
|
||||
PhotoPersonLinkage.person_id,
|
||||
func.count(PhotoPersonLinkage.id).label('video_count')
|
||||
)
|
||||
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
|
||||
.filter(
|
||||
PhotoPersonLinkage.person_id.in_(person_ids),
|
||||
Photo.media_type == "video"
|
||||
)
|
||||
.group_by(PhotoPersonLinkage.person_id)
|
||||
)
|
||||
for person_id, video_count in video_count_query.all():
|
||||
video_counts[person_id] = video_count
|
||||
|
||||
items = [
|
||||
PersonWithFacesResponse(
|
||||
id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
face_count=face_count or 0, # Convert None to 0 for people with no faces
|
||||
video_count=video_counts.get(person.id, 0), # Get video count or default to 0
|
||||
)
|
||||
for person, face_count in results
|
||||
]
|
||||
|
||||
return PeopleWithFacesListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=PersonResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_person(request: PersonCreateRequest, db: Session = Depends(get_db)) -> PersonResponse:
|
||||
"""Create a new person."""
|
||||
first_name = request.first_name.strip()
|
||||
last_name = request.last_name.strip()
|
||||
middle_name = request.middle_name.strip() if request.middle_name else None
|
||||
maiden_name = request.maiden_name.strip() if request.maiden_name else None
|
||||
# Explicitly set created_date to ensure it's a valid datetime object
|
||||
person = Person(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
middle_name=middle_name,
|
||||
maiden_name=maiden_name,
|
||||
date_of_birth=request.date_of_birth,
|
||||
created_date=datetime.utcnow(),
|
||||
)
|
||||
db.add(person)
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
db.refresh(person)
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{person_id}", response_model=PersonResponse)
|
||||
def get_person(person_id: int, db: Session = Depends(get_db)) -> PersonResponse:
|
||||
"""Get person by ID."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.put("/{person_id}", response_model=PersonResponse)
|
||||
def update_person(
|
||||
person_id: int,
|
||||
request: PersonUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> PersonResponse:
|
||||
"""Update person information."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
# Update fields
|
||||
person.first_name = request.first_name.strip()
|
||||
person.last_name = request.last_name.strip()
|
||||
person.middle_name = request.middle_name.strip() if request.middle_name else None
|
||||
person.maiden_name = request.maiden_name.strip() if request.maiden_name else None
|
||||
person.date_of_birth = request.date_of_birth
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(person)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
|
||||
return PersonResponse.model_validate(person)
|
||||
|
||||
|
||||
@router.get("/{person_id}/faces", response_model=PersonFacesResponse)
|
||||
def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFacesResponse:
|
||||
"""Get all faces for a specific person."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
from backend.db.models import Photo
|
||||
|
||||
faces = (
|
||||
db.query(Face)
|
||||
.join(Photo, Face.photo_id == Photo.id)
|
||||
.filter(Face.person_id == person_id)
|
||||
.order_by(Photo.filename)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
PersonFaceItem(
|
||||
id=face.id,
|
||||
photo_id=face.photo_id,
|
||||
photo_path=face.photo.path,
|
||||
photo_filename=face.photo.filename,
|
||||
location=face.location,
|
||||
face_confidence=float(face.face_confidence),
|
||||
quality_score=float(face.quality_score),
|
||||
detector_backend=face.detector_backend,
|
||||
model_name=face.model_name,
|
||||
)
|
||||
for face in faces
|
||||
]
|
||||
|
||||
return PersonFacesResponse(person_id=person_id, items=items, total=len(items))
|
||||
|
||||
|
||||
@router.get("/{person_id}/videos")
|
||||
def get_person_videos(person_id: int, db: Session = Depends(get_db)) -> dict:
|
||||
"""Get all videos linked to a specific person."""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Person {person_id} not found")
|
||||
|
||||
# Get all video linkages for this person
|
||||
linkages = (
|
||||
db.query(PhotoPersonLinkage, Photo)
|
||||
.join(Photo, PhotoPersonLinkage.photo_id == Photo.id)
|
||||
.filter(
|
||||
PhotoPersonLinkage.person_id == person_id,
|
||||
Photo.media_type == "video"
|
||||
)
|
||||
.order_by(Photo.filename)
|
||||
.all()
|
||||
)
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": photo.id,
|
||||
"filename": photo.filename,
|
||||
"path": photo.path,
|
||||
"date_taken": photo.date_taken.isoformat() if photo.date_taken else None,
|
||||
"date_added": photo.date_added.isoformat() if photo.date_added else None,
|
||||
"linkage_id": linkage.id,
|
||||
}
|
||||
for linkage, photo in linkages
|
||||
]
|
||||
|
||||
return {
|
||||
"person_id": person_id,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{person_id}/accept-matches", response_model=IdentifyFaceResponse)
|
||||
def accept_matches(
|
||||
person_id: int,
|
||||
request: AcceptMatchesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyFaceResponse:
|
||||
"""Accept auto-match matches for a person.
|
||||
|
||||
Matches desktop auto-match save workflow exactly:
|
||||
1. Identifies selected faces with this person
|
||||
2. Inserts person_encodings for each identified face
|
||||
3. Updates person encodings (removes old, adds current)
|
||||
Tracks which user identified the faces.
|
||||
"""
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
|
||||
user_id = current_user["user_id"]
|
||||
identified_count, updated_count = accept_auto_match_matches(
|
||||
db, person_id, request.face_ids, user_id=user_id
|
||||
)
|
||||
|
||||
return IdentifyFaceResponse(
|
||||
identified_face_ids=request.face_ids,
|
||||
person_id=person_id,
|
||||
created_person=False,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{person_id}")
|
||||
def delete_person(person_id: int, db: Session = Depends(get_db)) -> Response:
|
||||
"""Delete a person and all their linkages.
|
||||
|
||||
This will:
|
||||
1. Delete all person_encodings for this person
|
||||
2. Unlink all faces (set person_id to NULL)
|
||||
3. Delete all video linkages (PhotoPersonLinkage records)
|
||||
4. Delete the person record
|
||||
"""
|
||||
person = db.query(Person).filter(Person.id == person_id).first()
|
||||
if not person:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Person {person_id} not found",
|
||||
)
|
||||
|
||||
try:
|
||||
# Delete all person_encodings for this person
|
||||
db.query(PersonEncoding).filter(PersonEncoding.person_id == person_id).delete(synchronize_session=False)
|
||||
|
||||
# Unlink all faces (set person_id to NULL)
|
||||
db.query(Face).filter(Face.person_id == person_id).update(
|
||||
{"person_id": None}, synchronize_session=False
|
||||
)
|
||||
|
||||
# Delete all video linkages (PhotoPersonLinkage records)
|
||||
db.query(PhotoPersonLinkage).filter(PhotoPersonLinkage.person_id == person_id).delete(synchronize_session=False)
|
||||
|
||||
# Delete the person record
|
||||
db.delete(person)
|
||||
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to delete person: {str(e)}",
|
||||
)
|
||||
|
||||
1014
backend/api/photos.py
Normal file
1014
backend/api/photos.py
Normal file
File diff suppressed because it is too large
Load Diff
375
backend/api/reported_photos.py
Normal file
375
backend/api/reported_photos.py
Normal file
@ -0,0 +1,375 @@
|
||||
"""Reported photos endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Photo, PhotoTagLinkage
|
||||
from backend.api.users import get_current_admin_user, require_feature_permission
|
||||
|
||||
router = APIRouter(prefix="/reported-photos", tags=["reported-photos"])
|
||||
|
||||
|
||||
class ReportedPhotoResponse(BaseModel):
|
||||
"""Reported photo DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
photo_id: int
|
||||
user_id: int
|
||||
user_name: Optional[str] = None
|
||||
user_email: Optional[str] = None
|
||||
status: str
|
||||
reported_at: str
|
||||
reviewed_at: Optional[str] = None
|
||||
reviewed_by: Optional[int] = None
|
||||
review_notes: Optional[str] = None
|
||||
report_comment: Optional[str] = None
|
||||
# Photo details from main database
|
||||
photo_path: Optional[str] = None
|
||||
photo_filename: Optional[str] = None
|
||||
photo_media_type: Optional[str] = None
|
||||
|
||||
|
||||
class ReportedPhotosListResponse(BaseModel):
|
||||
"""List of reported photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[ReportedPhotoResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class ReviewDecision(BaseModel):
|
||||
"""Decision for a single reported photo."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
id: int
|
||||
decision: str # 'keep' or 'remove'
|
||||
review_notes: Optional[str] = None
|
||||
|
||||
|
||||
class ReviewRequest(BaseModel):
|
||||
"""Request to review multiple reported photos."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
decisions: list[ReviewDecision]
|
||||
|
||||
|
||||
class ReviewResponse(BaseModel):
|
||||
"""Response from review operation."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
kept: int
|
||||
removed: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class CleanupResponse(BaseModel):
|
||||
"""Response payload for cleanup operations."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
@router.get("", response_model=ReportedPhotosListResponse)
|
||||
def list_reported_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
status_filter: Optional[str] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReportedPhotosListResponse:
|
||||
"""List all reported photos from the auth database.
|
||||
|
||||
This endpoint reads from the separate auth database (DATABASE_URL_AUTH)
|
||||
and returns all reported photos from the inappropriate_photo_reports table.
|
||||
Optionally filter by status: 'pending', 'reviewed', or 'dismissed'.
|
||||
"""
|
||||
try:
|
||||
# Query inappropriate_photo_reports from auth database using raw SQL
|
||||
# Join with users table to get user name/email
|
||||
if status_filter:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
ipr.status,
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
WHERE ipr.status = :status_filter
|
||||
ORDER BY ipr.reported_at DESC
|
||||
"""), {"status_filter": status_filter})
|
||||
else:
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.user_id,
|
||||
u.name as user_name,
|
||||
u.email as user_email,
|
||||
ipr.status,
|
||||
ipr.reported_at,
|
||||
ipr.reviewed_at,
|
||||
ipr.reviewed_by,
|
||||
ipr.review_notes,
|
||||
ipr.report_comment
|
||||
FROM inappropriate_photo_reports ipr
|
||||
LEFT JOIN users u ON ipr.user_id = u.id
|
||||
ORDER BY ipr.reported_at DESC
|
||||
"""))
|
||||
|
||||
rows = result.fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
# Get photo details from main database
|
||||
photo_path = None
|
||||
photo_filename = None
|
||||
photo_media_type = None
|
||||
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
|
||||
if photo:
|
||||
photo_path = photo.path
|
||||
photo_filename = photo.filename
|
||||
photo_media_type = photo.media_type
|
||||
|
||||
items.append(ReportedPhotoResponse(
|
||||
id=row.id,
|
||||
photo_id=row.photo_id,
|
||||
user_id=row.user_id,
|
||||
user_name=row.user_name,
|
||||
user_email=row.user_email,
|
||||
status=row.status,
|
||||
reported_at=str(row.reported_at) if row.reported_at else '',
|
||||
reviewed_at=str(row.reviewed_at) if row.reviewed_at else None,
|
||||
reviewed_by=row.reviewed_by,
|
||||
review_notes=row.review_notes,
|
||||
report_comment=row.report_comment,
|
||||
photo_path=photo_path,
|
||||
photo_filename=photo_filename,
|
||||
photo_media_type=photo_media_type,
|
||||
))
|
||||
|
||||
return ReportedPhotosListResponse(items=items, total=len(items))
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Error reading from auth database: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/review", response_model=ReviewResponse)
|
||||
def review_reported_photos(
|
||||
current_user: Annotated[
|
||||
dict, Depends(require_feature_permission("user_reported"))
|
||||
],
|
||||
request: ReviewRequest,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> ReviewResponse:
|
||||
"""Review reported photos - keep or remove them.
|
||||
|
||||
For 'keep' decision:
|
||||
- Updates status in auth database to 'reviewed'
|
||||
- Photo remains in main database
|
||||
|
||||
For 'remove' decision:
|
||||
- Updates status in auth database to 'reviewed'
|
||||
- Deletes photo from main database (cascade deletes faces, tags, etc.)
|
||||
"""
|
||||
kept_count = 0
|
||||
removed_count = 0
|
||||
errors = []
|
||||
admin_user_id = current_user.get("user_id")
|
||||
now = datetime.utcnow()
|
||||
|
||||
for decision in request.decisions:
|
||||
try:
|
||||
# Get reported photo from auth database
|
||||
# Allow processing 'pending' and 'reviewed' status reports (to allow changing decisions)
|
||||
result = auth_db.execute(text("""
|
||||
SELECT
|
||||
ipr.id,
|
||||
ipr.photo_id,
|
||||
ipr.status
|
||||
FROM inappropriate_photo_reports ipr
|
||||
WHERE ipr.id = :id AND ipr.status IN ('pending', 'reviewed')
|
||||
"""), {"id": decision.id})
|
||||
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
errors.append(f"Reported photo {decision.id} not found or cannot be reviewed (status: dismissed)")
|
||||
continue
|
||||
|
||||
if decision.decision == 'remove':
|
||||
# Delete photo from main database (cascade will handle related records)
|
||||
photo = main_db.query(Photo).filter(Photo.id == row.photo_id).first()
|
||||
if not photo:
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'dismissed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo not found in database; auto-dismissed"
|
||||
})
|
||||
auth_db.commit()
|
||||
removed_count += 1
|
||||
continue
|
||||
|
||||
# Delete tag linkages for this photo
|
||||
main_db.query(PhotoTagLinkage).filter(
|
||||
PhotoTagLinkage.photo_id == photo.id
|
||||
).delete(synchronize_session=False)
|
||||
|
||||
# Delete the photo (cascade will delete faces, etc.)
|
||||
main_db.delete(photo)
|
||||
main_db.commit()
|
||||
|
||||
# Update status in auth database to dismissed
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'dismissed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo removed"
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
removed_count += 1
|
||||
|
||||
elif decision.decision == 'keep':
|
||||
# Update status to reviewed (photo stays in database)
|
||||
auth_db.execute(text("""
|
||||
UPDATE inappropriate_photo_reports
|
||||
SET status = 'reviewed',
|
||||
reviewed_at = :reviewed_at,
|
||||
reviewed_by = :reviewed_by,
|
||||
review_notes = :review_notes
|
||||
WHERE id = :id
|
||||
"""), {
|
||||
"id": decision.id,
|
||||
"reviewed_at": now,
|
||||
"reviewed_by": admin_user_id,
|
||||
"review_notes": decision.review_notes or "Photo kept"
|
||||
})
|
||||
auth_db.commit()
|
||||
|
||||
kept_count += 1
|
||||
else:
|
||||
errors.append(f"Invalid decision '{decision.decision}' for reported photo {decision.id}")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Error processing reported photo {decision.id}: {str(e)}")
|
||||
# Rollback any partial changes
|
||||
main_db.rollback()
|
||||
auth_db.rollback()
|
||||
|
||||
return ReviewResponse(
|
||||
kept=kept_count,
|
||||
removed=removed_count,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=CleanupResponse)
|
||||
def cleanup_reported_photos(
|
||||
current_admin: dict = Depends(get_current_admin_user),
|
||||
status_filter: Annotated[
|
||||
Optional[str],
|
||||
Query(description="Use 'keep' to clear reviewed or 'remove' to clear dismissed records.")
|
||||
] = None,
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> CleanupResponse:
|
||||
"""Delete rows from inappropriate_photo_reports based on review status."""
|
||||
status_mapping = {
|
||||
"keep": "reviewed",
|
||||
"remove": "dismissed",
|
||||
}
|
||||
|
||||
if status_filter and status_filter not in status_mapping:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid status_filter. Use 'keep', 'remove', or omit the parameter.",
|
||||
)
|
||||
|
||||
db_status_filter = status_mapping.get(status_filter)
|
||||
warnings: list[str] = []
|
||||
|
||||
try:
|
||||
if db_status_filter:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status = :status_filter
|
||||
"""
|
||||
),
|
||||
{"status_filter": db_status_filter},
|
||||
)
|
||||
else:
|
||||
result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
DELETE FROM inappropriate_photo_reports
|
||||
WHERE status IN ('reviewed', 'dismissed')
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, "rowcount") else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0:
|
||||
if db_status_filter:
|
||||
warnings.append(
|
||||
f"No reported photos matched the '{status_filter}' decision filter."
|
||||
)
|
||||
else:
|
||||
warnings.append("No reviewed or dismissed reported photos to delete.")
|
||||
|
||||
return CleanupResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=[],
|
||||
warnings=warnings,
|
||||
)
|
||||
except Exception as exc:
|
||||
auth_db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Failed to cleanup reported photos: {exc}",
|
||||
)
|
||||
|
||||
74
backend/api/role_permissions.py
Normal file
74
backend/api/role_permissions.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Manage role-to-feature permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.users import get_current_admin_user
|
||||
from backend.constants.role_features import ROLE_FEATURES, ROLE_FEATURE_KEYS
|
||||
from backend.constants.roles import ROLE_VALUES
|
||||
from backend.db.session import get_db
|
||||
from backend.schemas.role_permissions import (
|
||||
RoleFeatureSchema,
|
||||
RolePermissionsResponse,
|
||||
RolePermissionsUpdateRequest,
|
||||
)
|
||||
from backend.services.role_permissions import (
|
||||
ensure_role_permissions_initialized,
|
||||
fetch_role_permissions_map,
|
||||
set_role_permissions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/role-permissions", tags=["role-permissions"])
|
||||
|
||||
|
||||
@router.get("", response_model=RolePermissionsResponse)
|
||||
def list_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Return the current role/feature permission matrix."""
|
||||
|
||||
ensure_role_permissions_initialized(db)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
|
||||
@router.put("", response_model=RolePermissionsResponse)
|
||||
def update_role_permissions(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: RolePermissionsUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> RolePermissionsResponse:
|
||||
"""Update permissions for the provided matrix."""
|
||||
|
||||
invalid_roles = set(request.permissions.keys()) - set(ROLE_VALUES)
|
||||
if invalid_roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role(s): {', '.join(sorted(invalid_roles))}",
|
||||
)
|
||||
|
||||
for feature_map in request.permissions.values():
|
||||
invalid_features = set(feature_map.keys()) - set(ROLE_FEATURE_KEYS)
|
||||
if invalid_features:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid feature(s): {', '.join(sorted(invalid_features))}",
|
||||
)
|
||||
|
||||
set_role_permissions(db, request.permissions)
|
||||
permissions = fetch_role_permissions_map(db)
|
||||
features = [RoleFeatureSchema(**feature) for feature in ROLE_FEATURES]
|
||||
return RolePermissionsResponse(features=features, permissions=permissions)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
191
backend/api/tags.py
Normal file
191
backend/api/tags.py
Normal file
@ -0,0 +1,191 @@
|
||||
"""Tag management endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.schemas.tags import (
|
||||
PhotoTagsRequest,
|
||||
PhotoTagsResponse,
|
||||
TagCreateRequest,
|
||||
TagResponse,
|
||||
TagsResponse,
|
||||
TagUpdateRequest,
|
||||
TagDeleteRequest,
|
||||
PhotoTagsListResponse,
|
||||
PhotoTagItem,
|
||||
PhotosWithTagsResponse,
|
||||
PhotoWithTagsItem,
|
||||
)
|
||||
from backend.services.tag_service import (
|
||||
add_tags_to_photos,
|
||||
get_or_create_tag,
|
||||
list_tags,
|
||||
remove_tags_from_photos,
|
||||
get_photo_tags,
|
||||
update_tag,
|
||||
delete_tags,
|
||||
get_photos_with_tags,
|
||||
)
|
||||
from backend.db.models import Photo
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("", response_model=TagsResponse)
|
||||
def get_tags(db: Session = Depends(get_db)) -> TagsResponse:
|
||||
"""List all tags."""
|
||||
tags = list_tags(db)
|
||||
items = [TagResponse.model_validate(t) for t in tags]
|
||||
return TagsResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=TagResponse)
|
||||
def create_tag(
|
||||
request: TagCreateRequest, db: Session = Depends(get_db)
|
||||
) -> TagResponse:
|
||||
"""Create a new tag (or return existing if already exists)."""
|
||||
tag = get_or_create_tag(db, request.tag_name)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return TagResponse.model_validate(tag)
|
||||
|
||||
|
||||
@router.post("/photos/add", response_model=PhotoTagsResponse)
|
||||
def add_tags_to_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Add tags to multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_added = add_tags_to_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Added tags to {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=tags_added,
|
||||
tags_removed=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/photos/remove", response_model=PhotoTagsResponse)
|
||||
def remove_tags_from_photos_endpoint(
|
||||
request: PhotoTagsRequest, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsResponse:
|
||||
"""Remove tags from multiple photos."""
|
||||
if not request.photo_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="photo_ids is required"
|
||||
)
|
||||
if not request.tag_names:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail="tag_names is required"
|
||||
)
|
||||
|
||||
photos_updated, tags_removed = remove_tags_from_photos(
|
||||
db, request.photo_ids, request.tag_names
|
||||
)
|
||||
|
||||
return PhotoTagsResponse(
|
||||
message=f"Removed tags from {photos_updated} photos",
|
||||
photos_updated=photos_updated,
|
||||
tags_added=0,
|
||||
tags_removed=tags_removed,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/photos/{photo_id}", response_model=PhotoTagsListResponse)
|
||||
def get_photo_tags_endpoint(
|
||||
photo_id: int, db: Session = Depends(get_db)
|
||||
) -> PhotoTagsListResponse:
|
||||
"""Get all tags for a specific photo."""
|
||||
# Validate photo exists
|
||||
photo = db.query(Photo).filter(Photo.id == photo_id).first()
|
||||
if not photo:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"Photo {photo_id} not found"
|
||||
)
|
||||
|
||||
tags_data = get_photo_tags(db, photo_id)
|
||||
|
||||
items = [
|
||||
PhotoTagItem(tag_id=tag_id, tag_name=tag_name)
|
||||
for tag_id, tag_name in tags_data
|
||||
]
|
||||
|
||||
return PhotoTagsListResponse(photo_id=photo_id, tags=items, total=len(items))
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=TagResponse)
|
||||
def update_tag_endpoint(
|
||||
tag_id: int, request: TagUpdateRequest, db: Session = Depends(get_db)
|
||||
) -> TagResponse:
|
||||
"""Update a tag name."""
|
||||
try:
|
||||
tag = update_tag(db, tag_id, request.tag_name)
|
||||
return TagResponse.model_validate(tag)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/delete", response_model=dict)
|
||||
def delete_tags_endpoint(
|
||||
request: TagDeleteRequest, db: Session = Depends(get_db)
|
||||
) -> dict:
|
||||
"""Delete tags and all their linkages."""
|
||||
if not request.tag_ids:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="tag_ids list cannot be empty",
|
||||
)
|
||||
|
||||
deleted_count = delete_tags(db, request.tag_ids)
|
||||
|
||||
return {
|
||||
"message": f"Deleted {deleted_count} tag(s)",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/photos", response_model=PhotosWithTagsResponse)
|
||||
def get_photos_with_tags_endpoint(db: Session = Depends(get_db)) -> PhotosWithTagsResponse:
|
||||
"""Get all photos with tags and face counts, matching desktop tag manager query exactly.
|
||||
|
||||
Returns all photos with their tags (comma-separated) and face counts,
|
||||
ordered by date_taken DESC, filename.
|
||||
"""
|
||||
photos_data = get_photos_with_tags(db)
|
||||
|
||||
items = [
|
||||
PhotoWithTagsItem(
|
||||
id=p['id'],
|
||||
filename=p['filename'],
|
||||
path=p['path'],
|
||||
processed=p['processed'],
|
||||
date_taken=p['date_taken'],
|
||||
date_added=p['date_added'],
|
||||
face_count=p['face_count'],
|
||||
unidentified_face_count=p['unidentified_face_count'],
|
||||
tags=p['tags'],
|
||||
people_names=p.get('people_names', ''),
|
||||
)
|
||||
for p in photos_data
|
||||
]
|
||||
|
||||
return PhotosWithTagsResponse(items=items, total=len(items))
|
||||
|
||||
534
backend/api/users.py
Normal file
534
backend/api/users.py
Normal file
@ -0,0 +1,534 @@
|
||||
"""User management endpoints - admin only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.api.auth import get_current_user
|
||||
from backend.constants.roles import (
|
||||
DEFAULT_ADMIN_ROLE,
|
||||
DEFAULT_USER_ROLE,
|
||||
ROLE_VALUES,
|
||||
UserRole,
|
||||
is_admin_role,
|
||||
)
|
||||
from backend.db.session import get_auth_db, get_db
|
||||
from backend.db.models import Face, PhotoFavorite, PhotoPersonLinkage, User
|
||||
from backend.schemas.users import (
|
||||
UserCreateRequest,
|
||||
UserResponse,
|
||||
UserUpdateRequest,
|
||||
UsersListResponse,
|
||||
)
|
||||
from backend.utils.password import hash_password
|
||||
from backend.services.role_permissions import fetch_role_permissions_map
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_role_and_admin(
|
||||
role: str | None,
|
||||
is_admin_flag: bool | None,
|
||||
) -> tuple[str, bool]:
|
||||
"""Normalize requested role/is_admin values into a consistent pair."""
|
||||
selected_role = role or (DEFAULT_ADMIN_ROLE if is_admin_flag else DEFAULT_USER_ROLE)
|
||||
if selected_role not in ROLE_VALUES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid role '{selected_role}'",
|
||||
)
|
||||
derived_is_admin = is_admin_role(selected_role)
|
||||
if is_admin_flag is not None and is_admin_flag != derived_is_admin:
|
||||
logger.warning(
|
||||
"Role/is_admin mismatch detected. Using role-derived admin flag.",
|
||||
extra={"role": selected_role, "is_admin_flag": is_admin_flag},
|
||||
)
|
||||
return selected_role, derived_is_admin
|
||||
|
||||
|
||||
def _ensure_role_set(user: User) -> None:
|
||||
"""Guarantee that a User instance has a valid role value."""
|
||||
if user.role in ROLE_VALUES:
|
||||
return
|
||||
fallback_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
user.role = fallback_role
|
||||
|
||||
|
||||
def get_auth_db_optional() -> Session | None:
|
||||
"""Get auth database session if available, otherwise return None."""
|
||||
try:
|
||||
return next(get_auth_db())
|
||||
except ValueError:
|
||||
# Auth database not configured
|
||||
return None
|
||||
|
||||
|
||||
def create_auth_user_if_missing(
|
||||
email: str,
|
||||
full_name: str,
|
||||
password_hash: str,
|
||||
is_admin: bool,
|
||||
) -> None:
|
||||
"""Create matching auth user if one does not already exist."""
|
||||
if not email:
|
||||
return
|
||||
|
||||
auth_db = get_auth_db_optional()
|
||||
if auth_db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
check_result = auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id FROM users
|
||||
WHERE email = :email
|
||||
"""
|
||||
),
|
||||
{"email": email},
|
||||
)
|
||||
|
||||
existing_auth = check_result.first()
|
||||
if existing_auth:
|
||||
return
|
||||
|
||||
dialect = auth_db.bind.dialect.name if auth_db.bind else "postgresql"
|
||||
supports_returning = dialect == "postgresql"
|
||||
has_write_access = is_admin
|
||||
|
||||
if supports_returning:
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
else:
|
||||
auth_db.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO users (email, name, password_hash, is_admin, has_write_access)
|
||||
VALUES (:email, :name, :password_hash, :is_admin, :has_write_access)
|
||||
"""
|
||||
),
|
||||
{
|
||||
"email": email,
|
||||
"name": full_name,
|
||||
"password_hash": password_hash,
|
||||
"is_admin": is_admin,
|
||||
"has_write_access": has_write_access,
|
||||
},
|
||||
)
|
||||
auth_db.commit()
|
||||
except Exception as e: # pragma: no cover - logging helper
|
||||
auth_db.rollback()
|
||||
import traceback
|
||||
|
||||
print(
|
||||
f"Warning: Failed to create auth user: {str(e)}\n{traceback.format_exc()}"
|
||||
)
|
||||
finally:
|
||||
auth_db.close()
|
||||
|
||||
|
||||
def get_current_admin_user(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get current user and verify admin status from main database.
|
||||
|
||||
Raises HTTPException if user is not an admin.
|
||||
If no admin users exist, allows the current user to bootstrap as admin.
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if any admin users exist
|
||||
admin_count = db.query(User).filter(User.is_admin == True).count()
|
||||
|
||||
# If no admins exist, allow current user to bootstrap as admin
|
||||
if admin_count == 0:
|
||||
# Check if user already exists in main database
|
||||
main_user = db.query(User).filter(User.username == username).first()
|
||||
if not main_user:
|
||||
# Create the user as admin for bootstrap
|
||||
# Use a default password hash (user should change password after first login)
|
||||
# In production, this should be handled differently
|
||||
default_password_hash = hash_password("changeme")
|
||||
main_user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=True,
|
||||
role=DEFAULT_ADMIN_ROLE,
|
||||
)
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
db.refresh(main_user)
|
||||
elif not main_user.is_admin:
|
||||
# User exists but is not admin - make them admin for bootstrap
|
||||
main_user.is_admin = True
|
||||
main_user.role = DEFAULT_ADMIN_ROLE
|
||||
db.add(main_user)
|
||||
db.commit()
|
||||
db.refresh(main_user)
|
||||
|
||||
return {"username": username, "user_id": main_user.id}
|
||||
|
||||
# Normal admin check - user must exist and be admin
|
||||
main_user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
if not main_user or not main_user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin access required",
|
||||
)
|
||||
|
||||
return {"username": username, "user_id": main_user.id}
|
||||
|
||||
|
||||
def require_feature_permission(feature_key: str):
|
||||
"""Return a dependency that enforces feature-level access via role permissions."""
|
||||
|
||||
def dependency(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
username = current_user["username"]
|
||||
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if not user:
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
role=DEFAULT_USER_ROLE,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
_ensure_role_set(user)
|
||||
|
||||
has_access = user.is_admin or is_admin_role(user.role)
|
||||
if not has_access:
|
||||
permissions_map = fetch_role_permissions_map(db)
|
||||
role_permissions = permissions_map.get(user.role, {})
|
||||
has_access = bool(role_permissions.get(feature_key))
|
||||
|
||||
if not has_access:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied for this feature",
|
||||
)
|
||||
|
||||
return {
|
||||
"username": username,
|
||||
"user_id": user.id,
|
||||
"role": user.role,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
@router.get("", response_model=UsersListResponse)
|
||||
def list_users(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
is_active: bool | None = Query(None, description="Filter by active status"),
|
||||
is_admin: bool | None = Query(None, description="Filter by admin status"),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UsersListResponse:
|
||||
"""List all users - admin only.
|
||||
|
||||
Optionally filter by is_active and/or is_admin status.
|
||||
"""
|
||||
query = db.query(User)
|
||||
|
||||
if is_active is not None:
|
||||
query = query.filter(User.is_active == is_active)
|
||||
|
||||
if is_admin is not None:
|
||||
query = query.filter(User.is_admin == is_admin)
|
||||
|
||||
users = query.order_by(User.username.asc()).all()
|
||||
for user in users:
|
||||
_ensure_role_set(user)
|
||||
items = [UserResponse.model_validate(u) for u in users]
|
||||
return UsersListResponse(items=items, total=len(items))
|
||||
|
||||
|
||||
@router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
request: UserCreateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Create a new user - admin only.
|
||||
|
||||
If give_frontend_permission is True, also creates the user in the auth database
|
||||
for frontend access.
|
||||
"""
|
||||
# Check if username already exists
|
||||
existing_user = db.query(User).filter(User.username == request.username).first()
|
||||
if existing_user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Username '{request.username}' already exists",
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
existing_email = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{request.email}' is already in use",
|
||||
)
|
||||
|
||||
# Hash the password before storing
|
||||
password_hash = hash_password(request.password)
|
||||
if request.role is None:
|
||||
requested_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
requested_role = request.role.value
|
||||
else:
|
||||
requested_role = str(request.role)
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
requested_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
user = User(
|
||||
username=request.username,
|
||||
password_hash=password_hash,
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
is_active=request.is_active,
|
||||
is_admin=normalized_is_admin,
|
||||
role=normalized_role,
|
||||
password_change_required=True, # Force password change on first login
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if request.give_frontend_permission:
|
||||
create_auth_user_if_missing(
|
||||
email=request.email,
|
||||
full_name=request.full_name,
|
||||
password_hash=password_hash,
|
||||
is_admin=normalized_is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.get("/{user_id}", response_model=UserResponse)
|
||||
def get_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Get a specific user by ID - admin only."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
_ensure_role_set(user)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.put("/{user_id}", response_model=UserResponse)
|
||||
def update_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
request: UserUpdateRequest,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
"""Update a user - admin only."""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
|
||||
if request.role is None:
|
||||
desired_role = None
|
||||
elif isinstance(request.role, UserRole):
|
||||
desired_role = request.role.value
|
||||
else:
|
||||
desired_role = str(request.role)
|
||||
if desired_role is None:
|
||||
if request.is_admin is not None:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if request.is_admin else DEFAULT_USER_ROLE
|
||||
elif user.role:
|
||||
desired_role = user.role
|
||||
else:
|
||||
desired_role = DEFAULT_ADMIN_ROLE if user.is_admin else DEFAULT_USER_ROLE
|
||||
normalized_role, normalized_is_admin = _normalize_role_and_admin(
|
||||
desired_role,
|
||||
request.is_admin,
|
||||
)
|
||||
|
||||
# Prevent admin from removing their own admin status
|
||||
if current_admin["username"] == user.username and not normalized_is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot remove your own admin status",
|
||||
)
|
||||
|
||||
# Check if email is being changed and if the new email already exists
|
||||
if request.email is not None and request.email != user.email:
|
||||
existing_email = db.query(User).filter(User.email == request.email).first()
|
||||
if existing_email:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Email address '{request.email}' is already in use",
|
||||
)
|
||||
|
||||
# Update fields if provided
|
||||
if request.password is not None:
|
||||
user.password_hash = hash_password(request.password)
|
||||
if request.email is not None:
|
||||
user.email = request.email
|
||||
if request.full_name is not None:
|
||||
user.full_name = request.full_name
|
||||
if request.is_active is not None:
|
||||
user.is_active = request.is_active
|
||||
user.is_admin = normalized_is_admin
|
||||
user.role = normalized_role
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
if request.give_frontend_permission:
|
||||
create_auth_user_if_missing(
|
||||
email=user.email,
|
||||
full_name=user.full_name or user.username,
|
||||
password_hash=user.password_hash,
|
||||
is_admin=user.is_admin,
|
||||
)
|
||||
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.delete("/{user_id}")
|
||||
def delete_user(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Delete a user - admin only.
|
||||
|
||||
If the user has linked data (faces identified, video person linkages),
|
||||
the user will be set to inactive instead of deleted, and favorites will
|
||||
be removed. Admins will be notified via logging.
|
||||
|
||||
Prevents admin from deleting themselves.
|
||||
"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"User with ID {user_id} not found",
|
||||
)
|
||||
|
||||
# Prevent admin from deleting themselves
|
||||
if current_admin["username"] == user.username:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Cannot delete your own account",
|
||||
)
|
||||
|
||||
# Check for linked data (faces or photo_person_linkages identified by this user)
|
||||
faces_count = db.query(Face).filter(Face.identified_by_user_id == user_id).count()
|
||||
linkages_count = db.query(PhotoPersonLinkage).filter(
|
||||
PhotoPersonLinkage.identified_by_user_id == user_id
|
||||
).count()
|
||||
|
||||
has_linked_data = faces_count > 0 or linkages_count > 0
|
||||
|
||||
# Always delete favorites (they use username, not user_id)
|
||||
favorites_deleted = db.query(PhotoFavorite).filter(
|
||||
PhotoFavorite.username == user.username
|
||||
).delete()
|
||||
|
||||
if has_linked_data:
|
||||
# Set user inactive instead of deleting
|
||||
user.is_active = False
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
# Notify admins via logging
|
||||
logger.warning(
|
||||
f"User '{user.username}' (ID: {user_id}) was set to inactive instead of deleted "
|
||||
f"because they have linked data: {faces_count} face(s) and {linkages_count} "
|
||||
f"video person linkage(s). {favorites_deleted} favorite(s) were deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"faces_count": faces_count,
|
||||
"linkages_count": linkages_count,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
# Return success but indicate user was deactivated
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={
|
||||
"message": (
|
||||
f"User '{user.username}' has been set to inactive because they have "
|
||||
f"linked data ({faces_count} face(s), {linkages_count} linkage(s)). "
|
||||
f"{favorites_deleted} favorite(s) were deleted."
|
||||
),
|
||||
"deactivated": True,
|
||||
"faces_count": faces_count,
|
||||
"linkages_count": linkages_count,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No linked data - safe to delete
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"User '{user.username}' (ID: {user_id}) was deleted. "
|
||||
f"{favorites_deleted} favorite(s) were deleted. "
|
||||
f"Action performed by admin: {current_admin['username']}",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"username": user.username,
|
||||
"favorites_deleted": favorites_deleted,
|
||||
"admin_username": current_admin["username"],
|
||||
}
|
||||
)
|
||||
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
16
backend/api/version.py
Normal file
16
backend/api/version.py
Normal file
@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.settings import APP_VERSION
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/version")
|
||||
def version() -> dict[str, str]:
|
||||
"""Return API version information."""
|
||||
return {"version": APP_VERSION}
|
||||
|
||||
|
||||
343
backend/api/videos.py
Normal file
343
backend/api/videos.py
Normal file
@ -0,0 +1,343 @@
|
||||
"""Video person identification endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.db.session import get_db
|
||||
from backend.db.models import Photo, User
|
||||
from backend.api.auth import get_current_user_with_id
|
||||
from backend.schemas.videos import (
|
||||
ListVideosResponse,
|
||||
VideoListItem,
|
||||
PersonInfo,
|
||||
VideoPeopleResponse,
|
||||
VideoPersonInfo,
|
||||
IdentifyVideoRequest,
|
||||
IdentifyVideoResponse,
|
||||
RemoveVideoPersonResponse,
|
||||
)
|
||||
from backend.services.video_service import (
|
||||
list_videos_for_identification,
|
||||
get_video_people,
|
||||
identify_person_in_video,
|
||||
remove_person_from_video,
|
||||
get_video_people_count,
|
||||
)
|
||||
from backend.services.thumbnail_service import get_video_thumbnail_path
|
||||
|
||||
router = APIRouter(prefix="/videos", tags=["videos"])
|
||||
|
||||
|
||||
@router.get("", response_model=ListVideosResponse)
|
||||
def list_videos(
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
folder_path: Optional[str] = Query(None, description="Filter by folder path"),
|
||||
date_from: Optional[str] = Query(None, description="Filter by date taken (from, YYYY-MM-DD)"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by date taken (to, YYYY-MM-DD)"),
|
||||
has_people: Optional[bool] = Query(None, description="Filter videos with/without identified people"),
|
||||
person_name: Optional[str] = Query(None, description="Filter videos containing person with this name"),
|
||||
sort_by: str = Query("filename", description="Sort field: filename, date_taken, date_added"),
|
||||
sort_dir: str = Query("asc", description="Sort direction: asc or desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
) -> ListVideosResponse:
|
||||
"""List videos for person identification."""
|
||||
# Parse date filters
|
||||
date_from_parsed = None
|
||||
date_to_parsed = None
|
||||
if date_from:
|
||||
try:
|
||||
date_from_parsed = date.fromisoformat(date_from)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date_from format: {date_from}. Use YYYY-MM-DD",
|
||||
)
|
||||
if date_to:
|
||||
try:
|
||||
date_to_parsed = date.fromisoformat(date_to)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid date_to format: {date_to}. Use YYYY-MM-DD",
|
||||
)
|
||||
|
||||
# Validate sort parameters
|
||||
if sort_by not in ["filename", "date_taken", "date_added"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid sort_by: {sort_by}. Must be filename, date_taken, or date_added",
|
||||
)
|
||||
if sort_dir not in ["asc", "desc"]:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid sort_dir: {sort_dir}. Must be asc or desc",
|
||||
)
|
||||
|
||||
# Get videos
|
||||
videos, total = list_videos_for_identification(
|
||||
db=db,
|
||||
folder_path=folder_path,
|
||||
date_from=date_from_parsed,
|
||||
date_to=date_to_parsed,
|
||||
has_people=has_people,
|
||||
person_name=person_name,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
# Build response items
|
||||
items = []
|
||||
for video in videos:
|
||||
# Get people for this video
|
||||
people_data = get_video_people(db, video.id)
|
||||
identified_people = []
|
||||
for person, linkage in people_data:
|
||||
identified_people.append(
|
||||
PersonInfo(
|
||||
id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
)
|
||||
)
|
||||
|
||||
# Convert date_added to date if it's datetime
|
||||
date_added = video.date_added
|
||||
if hasattr(date_added, "date"):
|
||||
date_added = date_added.date()
|
||||
|
||||
items.append(
|
||||
VideoListItem(
|
||||
id=video.id,
|
||||
filename=video.filename,
|
||||
path=video.path,
|
||||
date_taken=video.date_taken,
|
||||
date_added=date_added,
|
||||
identified_people=identified_people,
|
||||
identified_people_count=len(identified_people),
|
||||
)
|
||||
)
|
||||
|
||||
return ListVideosResponse(items=items, page=page, page_size=page_size, total=total)
|
||||
|
||||
|
||||
@router.get("/{video_id}/people", response_model=VideoPeopleResponse)
|
||||
def get_video_people_endpoint(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> VideoPeopleResponse:
|
||||
"""Get all people identified in a video."""
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
# Get people
|
||||
people_data = get_video_people(db, video_id)
|
||||
|
||||
people = []
|
||||
for person, linkage in people_data:
|
||||
# Get username if identified_by_user_id exists
|
||||
username = None
|
||||
if linkage.identified_by_user_id:
|
||||
user = db.query(User).filter(User.id == linkage.identified_by_user_id).first()
|
||||
if user:
|
||||
username = user.username
|
||||
|
||||
people.append(
|
||||
VideoPersonInfo(
|
||||
person_id=person.id,
|
||||
first_name=person.first_name,
|
||||
last_name=person.last_name,
|
||||
middle_name=person.middle_name,
|
||||
maiden_name=person.maiden_name,
|
||||
date_of_birth=person.date_of_birth,
|
||||
identified_by=username,
|
||||
identified_date=linkage.created_date,
|
||||
)
|
||||
)
|
||||
|
||||
return VideoPeopleResponse(video_id=video_id, people=people)
|
||||
|
||||
|
||||
@router.post("/{video_id}/identify", response_model=IdentifyVideoResponse)
|
||||
def identify_person_in_video_endpoint(
|
||||
video_id: int,
|
||||
request: IdentifyVideoRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyVideoResponse:
|
||||
"""Identify a person in a video."""
|
||||
user_id = current_user.get("id")
|
||||
|
||||
try:
|
||||
person, created_person = identify_person_in_video(
|
||||
db=db,
|
||||
video_id=video_id,
|
||||
person_id=request.person_id,
|
||||
first_name=request.first_name,
|
||||
last_name=request.last_name,
|
||||
middle_name=request.middle_name,
|
||||
maiden_name=request.maiden_name,
|
||||
date_of_birth=request.date_of_birth,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Person '{person.first_name} {person.last_name}' identified in video"
|
||||
if not created_person
|
||||
else f"Created new person '{person.first_name} {person.last_name}' and identified in video"
|
||||
)
|
||||
|
||||
return IdentifyVideoResponse(
|
||||
video_id=video_id,
|
||||
person_id=person.id,
|
||||
created_person=created_person,
|
||||
message=message,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{video_id}/people/{person_id}", response_model=RemoveVideoPersonResponse)
|
||||
def remove_person_from_video_endpoint(
|
||||
video_id: int,
|
||||
person_id: int,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> RemoveVideoPersonResponse:
|
||||
"""Remove person identification from video."""
|
||||
try:
|
||||
removed = remove_person_from_video(
|
||||
db=db,
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
)
|
||||
|
||||
if removed:
|
||||
return RemoveVideoPersonResponse(
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
removed=True,
|
||||
message=f"Person {person_id} removed from video {video_id}",
|
||||
)
|
||||
else:
|
||||
return RemoveVideoPersonResponse(
|
||||
video_id=video_id,
|
||||
person_id=person_id,
|
||||
removed=False,
|
||||
message=f"Person {person_id} not found in video {video_id}",
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{video_id}/thumbnail")
|
||||
def get_video_thumbnail(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Get video thumbnail (generated on-demand and cached)."""
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
# Generate or get cached thumbnail
|
||||
thumbnail_path = get_video_thumbnail_path(video.path)
|
||||
|
||||
if not thumbnail_path or not thumbnail_path.exists():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to generate video thumbnail",
|
||||
)
|
||||
|
||||
# Return thumbnail with caching headers
|
||||
response = FileResponse(
|
||||
str(thumbnail_path),
|
||||
media_type="image/jpeg",
|
||||
)
|
||||
response.headers["Cache-Control"] = "public, max-age=86400" # Cache for 1 day
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{video_id}/video")
|
||||
def get_video_file(
|
||||
video_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
) -> FileResponse:
|
||||
"""Serve video file for playback."""
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
# Verify video exists
|
||||
video = db.query(Photo).filter(
|
||||
Photo.id == video_id,
|
||||
Photo.media_type == "video"
|
||||
).first()
|
||||
|
||||
if not video:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video {video_id} not found",
|
||||
)
|
||||
|
||||
if not os.path.exists(video.path):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Video file not found: {video.path}",
|
||||
)
|
||||
|
||||
# Determine media type from file extension
|
||||
media_type, _ = mimetypes.guess_type(video.path)
|
||||
if not media_type or not media_type.startswith('video/'):
|
||||
media_type = "video/mp4"
|
||||
|
||||
# Use FileResponse with range request support for video streaming
|
||||
response = FileResponse(
|
||||
video.path,
|
||||
media_type=media_type,
|
||||
)
|
||||
response.headers["Content-Disposition"] = "inline"
|
||||
response.headers["Accept-Ranges"] = "bytes"
|
||||
response.headers["Cache-Control"] = "public, max-age=3600"
|
||||
return response
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
730
backend/app.py
Normal file
730
backend/app.py
Normal file
@ -0,0 +1,730 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
from backend.api.auth import router as auth_router
|
||||
from backend.api.faces import router as faces_router
|
||||
from backend.api.health import router as health_router
|
||||
from backend.api.jobs import router as jobs_router
|
||||
from backend.api.metrics import router as metrics_router
|
||||
from backend.api.people import router as people_router
|
||||
from backend.api.pending_identifications import router as pending_identifications_router
|
||||
from backend.api.pending_linkages import router as pending_linkages_router
|
||||
from backend.api.photos import router as photos_router
|
||||
from backend.api.reported_photos import router as reported_photos_router
|
||||
from backend.api.pending_photos import router as pending_photos_router
|
||||
from backend.api.tags import router as tags_router
|
||||
from backend.api.users import router as users_router
|
||||
from backend.api.auth_users import router as auth_users_router
|
||||
from backend.api.role_permissions import router as role_permissions_router
|
||||
from backend.api.videos import router as videos_router
|
||||
from backend.api.version import router as version_router
|
||||
from backend.settings import APP_TITLE, APP_VERSION
|
||||
from backend.constants.roles import DEFAULT_ADMIN_ROLE, DEFAULT_USER_ROLE, ROLE_VALUES
|
||||
from backend.db.base import Base, engine
|
||||
from backend.db.session import auth_engine, database_url, get_auth_database_url
|
||||
# Import models to ensure they're registered with Base.metadata
|
||||
from backend.db import models # noqa: F401
|
||||
from backend.db.models import RolePermission
|
||||
from backend.utils.password import hash_password
|
||||
|
||||
# Global worker process (will be set in lifespan)
|
||||
_worker_process: subprocess.Popen | None = None
|
||||
|
||||
|
||||
def start_worker() -> None:
|
||||
"""Start RQ worker in background subprocess."""
|
||||
global _worker_process
|
||||
|
||||
try:
|
||||
from redis import Redis
|
||||
|
||||
# Check Redis connection first
|
||||
redis_conn = Redis(host="localhost", port=6379, db=0, decode_responses=False)
|
||||
redis_conn.ping()
|
||||
|
||||
# Start worker as a subprocess (avoids signal handler issues)
|
||||
# __file__ is backend/app.py, so parent.parent is the project root (punimtag/)
|
||||
project_root = Path(__file__).parent.parent
|
||||
|
||||
# Use explicit Python path to avoid Cursor interception
|
||||
# Check if sys.executable is Cursor, if so use /usr/bin/python3
|
||||
python_executable = sys.executable
|
||||
if "cursor" in python_executable.lower() or not python_executable.startswith("/usr"):
|
||||
python_executable = "/usr/bin/python3"
|
||||
|
||||
# Ensure PYTHONPATH is set correctly and pass DATABASE_URL_AUTH explicitly
|
||||
# Load .env file to get DATABASE_URL_AUTH if not already in environment
|
||||
from dotenv import load_dotenv
|
||||
env_file = project_root / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(dotenv_path=env_file)
|
||||
|
||||
worker_env = {
|
||||
**{k: v for k, v in os.environ.items()},
|
||||
"PYTHONPATH": str(project_root),
|
||||
}
|
||||
|
||||
# Explicitly ensure DATABASE_URL_AUTH is passed to worker subprocess
|
||||
if "DATABASE_URL_AUTH" in os.environ:
|
||||
worker_env["DATABASE_URL_AUTH"] = os.environ["DATABASE_URL_AUTH"]
|
||||
|
||||
_worker_process = subprocess.Popen(
|
||||
[
|
||||
python_executable,
|
||||
"-m",
|
||||
"backend.worker",
|
||||
],
|
||||
cwd=str(project_root),
|
||||
stdout=None, # Don't capture - let output go to console
|
||||
stderr=None, # Don't capture - let errors go to console
|
||||
env=worker_env
|
||||
)
|
||||
# Give it a moment to start, then check if it's still running
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
if _worker_process.poll() is not None:
|
||||
# Process already exited - there was an error
|
||||
print(f"❌ Worker process exited immediately with code {_worker_process.returncode}")
|
||||
print(" Check worker errors above")
|
||||
else:
|
||||
print(f"✅ RQ worker started in background subprocess (PID: {_worker_process.pid})")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Failed to start RQ worker: {e}")
|
||||
print(" Background jobs will not be processed. Ensure Redis is running.")
|
||||
|
||||
|
||||
def stop_worker() -> None:
|
||||
"""Stop RQ worker gracefully."""
|
||||
global _worker_process
|
||||
|
||||
if _worker_process:
|
||||
try:
|
||||
_worker_process.terminate()
|
||||
try:
|
||||
_worker_process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
_worker_process.kill()
|
||||
print("✅ RQ worker stopped")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def ensure_user_password_hash_column(inspector) -> None:
|
||||
"""Ensure users table contains password_hash column."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
print("ℹ️ Users table does not exist yet - will be created with password_hash column")
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "password_hash" in columns:
|
||||
print("ℹ️ password_hash column already exists in users table")
|
||||
return
|
||||
|
||||
print("🔄 Adding password_hash column to users table...")
|
||||
|
||||
default_hash = hash_password("changeme")
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
# PostgreSQL: Add column as nullable first, then update, then set NOT NULL
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT")
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
"UPDATE users SET password_hash = :default_hash "
|
||||
"WHERE password_hash IS NULL OR password_hash = ''"
|
||||
),
|
||||
{"default_hash": default_hash},
|
||||
)
|
||||
# Set NOT NULL constraint
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ALTER COLUMN password_hash SET NOT NULL")
|
||||
)
|
||||
print("✅ Added password_hash column to users table (default password: changeme)")
|
||||
|
||||
|
||||
def ensure_user_password_change_required_column(inspector) -> None:
|
||||
"""Ensure users table contains password_change_required column."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
if "password_change_required" in columns:
|
||||
print("ℹ️ password_change_required column already exists in users table")
|
||||
return
|
||||
|
||||
print("🔄 Adding password_change_required column to users table...")
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS password_change_required BOOLEAN NOT NULL DEFAULT true")
|
||||
)
|
||||
print("✅ Added password_change_required column to users table")
|
||||
|
||||
|
||||
def ensure_user_email_unique_constraint(inspector) -> None:
|
||||
"""Ensure users table email column has a unique constraint."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
# Check if email column exists
|
||||
columns = {col["name"] for col in inspector.get_columns("users")}
|
||||
if "email" not in columns:
|
||||
print("ℹ️ email column does not exist in users table yet")
|
||||
return
|
||||
|
||||
# Check if unique constraint already exists on email
|
||||
with engine.connect() as connection:
|
||||
# Check if unique constraint exists
|
||||
result = connection.execute(text("""
|
||||
SELECT constraint_name
|
||||
FROM information_schema.table_constraints
|
||||
WHERE table_name = 'users'
|
||||
AND constraint_type = 'UNIQUE'
|
||||
AND constraint_name LIKE '%email%'
|
||||
"""))
|
||||
if result.first():
|
||||
print("ℹ️ Unique constraint on email column already exists")
|
||||
return
|
||||
|
||||
# Try to add unique constraint (will fail if duplicates exist)
|
||||
try:
|
||||
print("🔄 Adding unique constraint to email column...")
|
||||
connection.execute(text("ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email)"))
|
||||
connection.commit()
|
||||
print("✅ Added unique constraint to email column")
|
||||
except Exception as e:
|
||||
# If constraint already exists or duplicates exist, that's okay
|
||||
# API validation will prevent new duplicates
|
||||
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
|
||||
print(f"ℹ️ Could not add unique constraint (may have duplicates): {e}")
|
||||
else:
|
||||
print(f"⚠️ Could not add unique constraint: {e}")
|
||||
|
||||
|
||||
def ensure_face_identified_by_user_id_column(inspector) -> None:
|
||||
"""Ensure faces table contains identified_by_user_id column."""
|
||||
if "faces" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("faces")}
|
||||
if "identified_by_user_id" in columns:
|
||||
print("ℹ️ identified_by_user_id column already exists in faces table")
|
||||
return
|
||||
|
||||
print("🔄 Adding identified_by_user_id column to faces table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS identified_by_user_id INTEGER REFERENCES users(id)")
|
||||
)
|
||||
# Add index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_faces_identified_by ON faces(identified_by_user_id)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added identified_by_user_id column to faces table")
|
||||
|
||||
|
||||
def ensure_user_role_column(inspector) -> None:
|
||||
"""Ensure users table has a role column with valid values."""
|
||||
if "users" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("users")}
|
||||
dialect = engine.dialect.name
|
||||
role_values = sorted(ROLE_VALUES)
|
||||
placeholder_parts = ", ".join(
|
||||
f":role_value_{index}" for index, _ in enumerate(role_values)
|
||||
)
|
||||
where_clause = (
|
||||
"role IS NULL OR role = ''"
|
||||
if not placeholder_parts
|
||||
else f"role IS NULL OR role = '' OR role NOT IN ({placeholder_parts})"
|
||||
)
|
||||
params = {
|
||||
f"role_value_{index}": value for index, value in enumerate(role_values)
|
||||
}
|
||||
params["admin_role"] = DEFAULT_ADMIN_ROLE
|
||||
params["default_role"] = DEFAULT_USER_ROLE
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if "role" not in columns:
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT "
|
||||
f"NOT NULL DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
text(
|
||||
f"ALTER TABLE users ADD COLUMN role TEXT "
|
||||
f"DEFAULT '{DEFAULT_USER_ROLE}'"
|
||||
)
|
||||
)
|
||||
connection.execute(
|
||||
text(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET role = CASE
|
||||
WHEN is_admin THEN :admin_role
|
||||
ELSE :default_role
|
||||
END
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_users_role ON users(role)")
|
||||
)
|
||||
print("✅ Ensured users.role column exists and is populated")
|
||||
|
||||
|
||||
def ensure_photo_media_type_column(inspector) -> None:
|
||||
"""Ensure photos table contains media_type column."""
|
||||
if "photos" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("photos")}
|
||||
if "media_type" in columns:
|
||||
print("ℹ️ media_type column already exists in photos table")
|
||||
return
|
||||
|
||||
print("🔄 Adding media_type column to photos table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE photos ADD COLUMN IF NOT EXISTS media_type TEXT NOT NULL DEFAULT 'image'")
|
||||
)
|
||||
# Add index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_photos_media_type ON photos(media_type)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added media_type column to photos table")
|
||||
|
||||
|
||||
def ensure_face_excluded_column(inspector) -> None:
|
||||
"""Ensure faces table contains excluded column."""
|
||||
if "faces" not in inspector.get_table_names():
|
||||
print("ℹ️ Faces table does not exist yet - will be created with excluded column")
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("faces")}
|
||||
if "excluded" in columns:
|
||||
# Column already exists, no need to print or do anything
|
||||
return
|
||||
|
||||
print("🔄 Adding excluded column to faces table...")
|
||||
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
# PostgreSQL: Add column with default value
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS excluded BOOLEAN DEFAULT FALSE NOT NULL")
|
||||
)
|
||||
# Create index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_faces_excluded ON faces(excluded)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added excluded column to faces table")
|
||||
|
||||
|
||||
def ensure_photo_person_linkage_table(inspector) -> None:
|
||||
"""Ensure photo_person_linkage table exists for direct video-person associations."""
|
||||
if "photo_person_linkage" in inspector.get_table_names():
|
||||
print("ℹ️ photo_person_linkage table already exists")
|
||||
return
|
||||
|
||||
print("🔄 Creating photo_person_linkage table...")
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
connection.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS photo_person_linkage (
|
||||
id SERIAL PRIMARY KEY,
|
||||
photo_id INTEGER NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||
person_id INTEGER NOT NULL REFERENCES people(id) ON DELETE CASCADE,
|
||||
identified_by_user_id INTEGER REFERENCES users(id),
|
||||
created_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(photo_id, person_id)
|
||||
)
|
||||
"""))
|
||||
# Create indexes
|
||||
for idx_name, idx_col in [
|
||||
("idx_photo_person_photo", "photo_id"),
|
||||
("idx_photo_person_person", "person_id"),
|
||||
("idx_photo_person_user", "identified_by_user_id"),
|
||||
]:
|
||||
try:
|
||||
connection.execute(
|
||||
text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON photo_person_linkage({idx_col})")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Created photo_person_linkage table")
|
||||
|
||||
|
||||
def ensure_auth_user_is_active_column() -> None:
|
||||
"""Ensure auth database users table contains is_active column.
|
||||
|
||||
NOTE: Auth database is managed by the frontend. This function only checks/updates
|
||||
if the database and table already exist. It will not fail if they don't exist.
|
||||
"""
|
||||
if auth_engine is None:
|
||||
# Auth database not configured
|
||||
return
|
||||
|
||||
try:
|
||||
from sqlalchemy import inspect as sqlalchemy_inspect
|
||||
|
||||
# Try to get inspector - gracefully handle if database doesn't exist
|
||||
try:
|
||||
auth_inspector = sqlalchemy_inspect(auth_engine)
|
||||
except Exception as inspect_exc:
|
||||
error_str = str(inspect_exc).lower()
|
||||
if "does not exist" in error_str or "database" in error_str:
|
||||
# Database doesn't exist - that's okay, frontend will create it
|
||||
return
|
||||
# Some other error - log but don't fail
|
||||
print(f"ℹ️ Could not inspect auth database: {inspect_exc}")
|
||||
return
|
||||
|
||||
if "users" not in auth_inspector.get_table_names():
|
||||
# Table doesn't exist - that's okay, frontend will create it
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in auth_inspector.get_columns("users")}
|
||||
if "is_active" in columns:
|
||||
print("ℹ️ is_active column already exists in auth database users table")
|
||||
return
|
||||
|
||||
# Column doesn't exist - try to add it
|
||||
print("🔄 Adding is_active column to auth database users table...")
|
||||
|
||||
dialect = auth_engine.dialect.name
|
||||
|
||||
try:
|
||||
with auth_engine.connect() as connection:
|
||||
with connection.begin():
|
||||
connection.execute(
|
||||
text("ALTER TABLE users ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE")
|
||||
)
|
||||
print("✅ Added is_active column to auth database users table")
|
||||
except Exception as alter_exc:
|
||||
# Check if it's a permission error
|
||||
error_str = str(alter_exc)
|
||||
if "permission" in error_str.lower() or "insufficient" in error_str.lower() or "owner" in error_str.lower():
|
||||
print("⚠️ Cannot add is_active column: insufficient database privileges")
|
||||
print(" The column will need to be added manually by a database administrator:")
|
||||
print(" ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE;")
|
||||
print(" Until then, users with linked data cannot be deleted.")
|
||||
else:
|
||||
# Some other error
|
||||
print(f"⚠️ Failed to add is_active column to auth database users table: {alter_exc}")
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to check/add is_active column to auth database users table: {exc}")
|
||||
# Don't raise - auth database might not be available or have permission issues
|
||||
# The delete endpoint will handle this case gracefully
|
||||
|
||||
|
||||
def ensure_role_permissions_table(inspector) -> None:
|
||||
"""Ensure the role_permissions table exists for permission matrix."""
|
||||
if "role_permissions" in inspector.get_table_names():
|
||||
return
|
||||
|
||||
try:
|
||||
print("🔄 Creating role_permissions table...")
|
||||
RolePermission.__table__.create(bind=engine, checkfirst=True)
|
||||
print("✅ Created role_permissions table")
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to create role_permissions table: {exc}")
|
||||
|
||||
|
||||
def ensure_postgresql_database(db_url: str) -> None:
|
||||
"""Ensure PostgreSQL database exists, create it if it doesn't."""
|
||||
if not db_url.startswith("postgresql"):
|
||||
return # Not PostgreSQL, skip
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import os
|
||||
import psycopg2
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
|
||||
# Parse the database URL
|
||||
parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://"))
|
||||
db_name = parsed.path.lstrip("/")
|
||||
user = parsed.username
|
||||
password = parsed.password
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or 5432
|
||||
|
||||
if not db_name:
|
||||
return # No database name specified
|
||||
|
||||
# Try to connect to the database
|
||||
try:
|
||||
test_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database=db_name
|
||||
)
|
||||
test_conn.close()
|
||||
return # Database exists
|
||||
except psycopg2.OperationalError as e:
|
||||
if "does not exist" not in str(e):
|
||||
# Some other error (permissions, connection, etc.)
|
||||
print(f"⚠️ Cannot check if database '{db_name}' exists: {e}")
|
||||
return
|
||||
|
||||
# Database doesn't exist - try to create it
|
||||
print(f"🔄 Creating PostgreSQL database '{db_name}'...")
|
||||
|
||||
# Connect to postgres database to create the new database
|
||||
# Try with the configured user first (they might have CREATEDB privilege)
|
||||
admin_conn = None
|
||||
try:
|
||||
admin_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user=user,
|
||||
password=password,
|
||||
database="postgres"
|
||||
)
|
||||
except psycopg2.OperationalError:
|
||||
# Try postgres superuser (might need password from environment or .pgpass)
|
||||
try:
|
||||
import os
|
||||
postgres_password = os.getenv("POSTGRES_PASSWORD", "")
|
||||
admin_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user="postgres",
|
||||
password=postgres_password if postgres_password else None,
|
||||
database="postgres"
|
||||
)
|
||||
except psycopg2.OperationalError as e:
|
||||
print(f"⚠️ Cannot create database '{db_name}': insufficient privileges")
|
||||
print(f" Error: {e}")
|
||||
print(f" Please create it manually:")
|
||||
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
|
||||
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
|
||||
return
|
||||
|
||||
if admin_conn is None:
|
||||
return
|
||||
|
||||
admin_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cursor = admin_conn.cursor()
|
||||
|
||||
# Check if database exists
|
||||
cursor.execute(
|
||||
"SELECT 1 FROM pg_database WHERE datname = %s",
|
||||
(db_name,)
|
||||
)
|
||||
exists = cursor.fetchone()
|
||||
|
||||
if not exists:
|
||||
# Create the database
|
||||
try:
|
||||
cursor.execute(f'CREATE DATABASE "{db_name}"')
|
||||
if user != "postgres" and admin_conn.info.user == "postgres":
|
||||
# Grant privileges to the user if we're connected as postgres
|
||||
try:
|
||||
cursor.execute(f'GRANT ALL PRIVILEGES ON DATABASE "{db_name}" TO "{user}"')
|
||||
except Exception as grant_exc:
|
||||
print(f"⚠️ Created database '{db_name}' but could not grant privileges: {grant_exc}")
|
||||
|
||||
# Grant schema permissions (needed for creating tables)
|
||||
if admin_conn.info.user == "postgres":
|
||||
try:
|
||||
# Connect to the new database to grant schema permissions
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
schema_conn = psycopg2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
user="postgres",
|
||||
password=os.getenv("POSTGRES_PASSWORD", "") if os.getenv("POSTGRES_PASSWORD") else None,
|
||||
database=db_name
|
||||
)
|
||||
schema_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
schema_cursor = schema_conn.cursor()
|
||||
schema_cursor.execute(f'GRANT ALL ON SCHEMA public TO "{user}"')
|
||||
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "{user}"')
|
||||
schema_cursor.execute(f'ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "{user}"')
|
||||
schema_cursor.close()
|
||||
schema_conn.close()
|
||||
print(f"✅ Granted schema permissions to user '{user}'")
|
||||
except Exception as schema_exc:
|
||||
print(f"⚠️ Created database '{db_name}' but could not grant schema permissions: {schema_exc}")
|
||||
print(f" Please run manually:")
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"GRANT ALL ON SCHEMA public TO {user};\"")
|
||||
print(f" sudo -u postgres psql -d {db_name} -c \"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO {user};\"")
|
||||
|
||||
print(f"✅ Created database '{db_name}'")
|
||||
except Exception as create_exc:
|
||||
print(f"⚠️ Failed to create database '{db_name}': {create_exc}")
|
||||
print(f" Please create it manually:")
|
||||
print(f" sudo -u postgres psql -c \"CREATE DATABASE {db_name};\"")
|
||||
if user != "postgres":
|
||||
print(f" sudo -u postgres psql -c \"GRANT ALL PRIVILEGES ON DATABASE {db_name} TO {user};\"")
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
return
|
||||
else:
|
||||
print(f"ℹ️ Database '{db_name}' already exists")
|
||||
|
||||
cursor.close()
|
||||
admin_conn.close()
|
||||
except Exception as exc:
|
||||
print(f"⚠️ Failed to ensure database exists: {exc}")
|
||||
import traceback
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
# Don't raise - let the connection attempt fail naturally with a clearer error
|
||||
|
||||
|
||||
def ensure_auth_database_tables() -> None:
|
||||
"""Ensure auth database tables exist, create them if they don't.
|
||||
|
||||
NOTE: This function is deprecated. Auth database is now managed by the frontend.
|
||||
This function is kept for backward compatibility but will not create tables.
|
||||
"""
|
||||
# Auth database is managed by the frontend - do not create tables here
|
||||
return
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown events."""
|
||||
# Ensure database exists and tables are created on first run
|
||||
try:
|
||||
# Ensure main PostgreSQL database exists
|
||||
# This must happen BEFORE we try to use the engine
|
||||
ensure_postgresql_database(database_url)
|
||||
|
||||
# Note: Auth database is managed by the frontend, not created here
|
||||
|
||||
# Only create tables if they don't already exist (safety check)
|
||||
inspector = inspect(engine)
|
||||
existing_tables = set(inspector.get_table_names())
|
||||
|
||||
# Check if required application tables exist (not just alembic_version)
|
||||
required_tables = {"photos", "people", "faces", "tags", "phototaglinkage", "person_encodings", "photo_favorites", "users", "photo_person_linkage"}
|
||||
missing_tables = required_tables - existing_tables
|
||||
|
||||
if missing_tables:
|
||||
# Some required tables are missing - create all tables
|
||||
# create_all() only creates missing tables, won't drop existing ones
|
||||
Base.metadata.create_all(bind=engine)
|
||||
if len(missing_tables) == len(required_tables):
|
||||
print("✅ Database initialized (first run - tables created)")
|
||||
else:
|
||||
print(f"✅ Database tables created (missing tables: {', '.join(missing_tables)})")
|
||||
else:
|
||||
# All required tables exist - don't recreate (prevents data loss)
|
||||
print(f"✅ Database already initialized ({len(existing_tables)} tables exist)")
|
||||
|
||||
# Ensure new columns exist (backward compatibility without migrations)
|
||||
ensure_user_password_hash_column(inspector)
|
||||
ensure_user_password_change_required_column(inspector)
|
||||
ensure_user_email_unique_constraint(inspector)
|
||||
ensure_face_identified_by_user_id_column(inspector)
|
||||
ensure_user_role_column(inspector)
|
||||
ensure_photo_media_type_column(inspector)
|
||||
ensure_photo_person_linkage_table(inspector)
|
||||
ensure_face_excluded_column(inspector)
|
||||
ensure_role_permissions_table(inspector)
|
||||
|
||||
# Setup auth database tables for both frontends (viewer and admin)
|
||||
if auth_engine is not None:
|
||||
try:
|
||||
ensure_auth_user_is_active_column()
|
||||
# Import and call worker's setup function to create all auth tables
|
||||
from backend.worker import setup_auth_database_tables
|
||||
setup_auth_database_tables()
|
||||
except Exception as auth_exc:
|
||||
# Auth database might not exist yet - that's okay, frontend will handle it
|
||||
print(f"ℹ️ Auth database not available: {auth_exc}")
|
||||
print(" Frontend will manage auth database setup")
|
||||
except Exception as exc:
|
||||
print(f"❌ Database initialization failed: {exc}")
|
||||
raise
|
||||
# Startup
|
||||
start_worker()
|
||||
yield
|
||||
# Shutdown
|
||||
stop_worker()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""Create and configure the FastAPI application instance."""
|
||||
app = FastAPI(
|
||||
title=APP_TITLE,
|
||||
version=APP_VERSION,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(health_router, tags=["health"])
|
||||
app.include_router(version_router, tags=["meta"])
|
||||
app.include_router(metrics_router, tags=["metrics"])
|
||||
app.include_router(auth_router, prefix="/api/v1")
|
||||
app.include_router(jobs_router, prefix="/api/v1")
|
||||
app.include_router(photos_router, prefix="/api/v1")
|
||||
app.include_router(faces_router, prefix="/api/v1")
|
||||
app.include_router(people_router, prefix="/api/v1")
|
||||
app.include_router(videos_router, prefix="/api/v1")
|
||||
app.include_router(pending_identifications_router, prefix="/api/v1")
|
||||
app.include_router(pending_linkages_router, prefix="/api/v1")
|
||||
app.include_router(reported_photos_router, prefix="/api/v1")
|
||||
app.include_router(pending_photos_router, prefix="/api/v1")
|
||||
app.include_router(tags_router, prefix="/api/v1")
|
||||
app.include_router(users_router, prefix="/api/v1")
|
||||
app.include_router(auth_users_router, prefix="/api/v1")
|
||||
app.include_router(role_permissions_router, prefix="/api/v1")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
|
||||
29
backend/config.py
Normal file
29
backend/config.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""Configuration values used by the PunimTag web services.
|
||||
|
||||
This module replaces the legacy desktop configuration to keep the web
|
||||
application self-contained.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Supported image formats for uploads/imports
|
||||
SUPPORTED_IMAGE_FORMATS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"}
|
||||
|
||||
# Supported video formats for scanning (not processed for faces)
|
||||
SUPPORTED_VIDEO_FORMATS = {".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".flv", ".wmv", ".mpg", ".mpeg"}
|
||||
|
||||
# DeepFace behavior
|
||||
DEEPFACE_ENFORCE_DETECTION = False
|
||||
DEEPFACE_ALIGN_FACES = True
|
||||
|
||||
# Face filtering thresholds
|
||||
MIN_FACE_CONFIDENCE = 0.4
|
||||
MIN_FACE_SIZE = 40
|
||||
MAX_FACE_SIZE = 1500
|
||||
|
||||
# Matching tolerance and calibration options
|
||||
DEFAULT_FACE_TOLERANCE = 0.6
|
||||
USE_CALIBRATED_CONFIDENCE = True
|
||||
CONFIDENCE_CALIBRATION_METHOD = "empirical" # "empirical", "linear", or "sigmoid"
|
||||
|
||||
|
||||
43
backend/constants/role_features.py
Normal file
43
backend/constants/role_features.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Feature definitions and default role permissions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, Final, List, Set
|
||||
|
||||
from backend.constants.roles import UserRole
|
||||
|
||||
ROLE_FEATURES: Final[List[dict[str, str]]] = [
|
||||
{"key": "scan", "label": "Scan"},
|
||||
{"key": "process", "label": "Process"},
|
||||
{"key": "search_photos", "label": "Search Photos"},
|
||||
{"key": "identify_people", "label": "Identify People"},
|
||||
{"key": "auto_match", "label": "Auto-Match"},
|
||||
{"key": "modify_people", "label": "Modify People"},
|
||||
{"key": "tag_photos", "label": "Tag Photos"},
|
||||
{"key": "faces_maintenance", "label": "Faces Maintenance"},
|
||||
{"key": "user_identified", "label": "User Identified"},
|
||||
{"key": "user_reported", "label": "User Reported"},
|
||||
{"key": "user_tagged", "label": "User Tagged Photos"},
|
||||
{"key": "user_uploaded", "label": "User Uploaded"},
|
||||
{"key": "manage_users", "label": "Manage Users"},
|
||||
{"key": "manage_roles", "label": "Manage Roles"},
|
||||
]
|
||||
|
||||
ROLE_FEATURE_KEYS: Final[List[str]] = [feature["key"] for feature in ROLE_FEATURES]
|
||||
|
||||
DEFAULT_ROLE_FEATURE_MATRIX: Final[Dict[str, Set[str]]] = {
|
||||
UserRole.ADMIN.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MANAGER.value: set(ROLE_FEATURE_KEYS),
|
||||
UserRole.MODERATOR.value: {"scan", "process", "manage_users"},
|
||||
UserRole.REVIEWER.value: {"user_identified", "user_reported", "user_uploaded", "user_tagged"},
|
||||
UserRole.EDITOR.value: {"user_identified", "user_uploaded", "manage_users", "user_tagged"},
|
||||
UserRole.IMPORTER.value: {"user_uploaded"},
|
||||
UserRole.VIEWER.value: {"user_identified", "user_reported", "user_tagged"},
|
||||
}
|
||||
|
||||
|
||||
def get_default_permission(role: str, feature_key: str) -> bool:
|
||||
"""Return the default allowed value for a role/feature pair."""
|
||||
allowed_features = DEFAULT_ROLE_FEATURE_MATRIX.get(role, set())
|
||||
return feature_key in allowed_features
|
||||
|
||||
32
backend/constants/roles.py
Normal file
32
backend/constants/roles.py
Normal file
@ -0,0 +1,32 @@
|
||||
"""Shared role definitions for backend user management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Final, Set
|
||||
|
||||
|
||||
class UserRole(str, Enum):
|
||||
"""Enumerated set of supported user roles."""
|
||||
|
||||
ADMIN = "admin"
|
||||
MANAGER = "manager"
|
||||
MODERATOR = "moderator"
|
||||
REVIEWER = "reviewer"
|
||||
EDITOR = "editor"
|
||||
IMPORTER = "importer"
|
||||
VIEWER = "viewer"
|
||||
|
||||
|
||||
ROLE_VALUES: Final[Set[str]] = {role.value for role in UserRole}
|
||||
ADMIN_ROLE_VALUES: Final[Set[str]] = {
|
||||
UserRole.ADMIN.value,
|
||||
}
|
||||
DEFAULT_ADMIN_ROLE: Final[str] = UserRole.ADMIN.value
|
||||
DEFAULT_USER_ROLE: Final[str] = UserRole.VIEWER.value
|
||||
|
||||
|
||||
def is_admin_role(role: str) -> bool:
|
||||
"""Return True when the provided role is considered an admin role."""
|
||||
return role in ADMIN_ROLE_VALUES
|
||||
|
||||
3
backend/db/__init__.py
Normal file
3
backend/db/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
"""Database package for PunimTag Web."""
|
||||
|
||||
|
||||
9
backend/db/base.py
Normal file
9
backend/db/base.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""Database base configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from backend.db.models import Base
|
||||
from backend.db.session import engine
|
||||
|
||||
__all__ = ["Base", "engine"]
|
||||
|
||||
286
backend/db/models.py
Normal file
286
backend/db/models.py
Normal file
@ -0,0 +1,286 @@
|
||||
"""SQLAlchemy models for PunimTag Web - matching desktop schema exactly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
Numeric,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
CheckConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import declarative_base, relationship
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class Photo(Base):
|
||||
"""Photo model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "photos"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
path = Column(Text, unique=True, nullable=False, index=True)
|
||||
filename = Column(Text, nullable=False)
|
||||
date_added = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
date_taken = Column(Date, nullable=True, index=True)
|
||||
processed = Column(Boolean, default=False, nullable=False, index=True)
|
||||
file_hash = Column(Text, nullable=True, index=True) # Nullable to support existing photos without hashes
|
||||
media_type = Column(Text, default="image", nullable=False, index=True) # "image" or "video"
|
||||
|
||||
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
|
||||
photo_tags = relationship(
|
||||
"PhotoTagLinkage", back_populates="photo", cascade="all, delete-orphan"
|
||||
)
|
||||
favorites = relationship("PhotoFavorite", back_populates="photo", cascade="all, delete-orphan")
|
||||
video_people = relationship(
|
||||
"PhotoPersonLinkage", back_populates="photo", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_photos_processed", "processed"),
|
||||
Index("idx_photos_date_taken", "date_taken"),
|
||||
Index("idx_photos_date_added", "date_added"),
|
||||
Index("idx_photos_file_hash", "file_hash"),
|
||||
)
|
||||
|
||||
|
||||
class Person(Base):
|
||||
"""Person model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "people"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
first_name = Column(Text, nullable=False)
|
||||
last_name = Column(Text, nullable=False)
|
||||
middle_name = Column(Text, nullable=True)
|
||||
maiden_name = Column(Text, nullable=True)
|
||||
date_of_birth = Column(Date, nullable=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
faces = relationship("Face", back_populates="person")
|
||||
person_encodings = relationship(
|
||||
"PersonEncoding", back_populates="person", cascade="all, delete-orphan"
|
||||
)
|
||||
video_photos = relationship(
|
||||
"PhotoPersonLinkage", back_populates="person", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"first_name", "last_name", "middle_name", "maiden_name", "date_of_birth",
|
||||
name="uq_people_names_dob"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Face(Base):
|
||||
"""Face detection model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "faces"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=True, index=True)
|
||||
encoding = Column(LargeBinary, nullable=False)
|
||||
location = Column(Text, nullable=False)
|
||||
confidence = Column(Numeric, default=0.0, nullable=False)
|
||||
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
|
||||
is_primary_encoding = Column(Boolean, default=False, nullable=False)
|
||||
detector_backend = Column(Text, default="retinaface", nullable=False)
|
||||
model_name = Column(Text, default="ArcFace", nullable=False)
|
||||
face_confidence = Column(Numeric, default=0.0, nullable=False)
|
||||
exif_orientation = Column(Integer, nullable=True)
|
||||
pose_mode = Column(Text, default="frontal", nullable=False, index=True)
|
||||
yaw_angle = Column(Numeric, nullable=True)
|
||||
pitch_angle = Column(Numeric, nullable=True)
|
||||
roll_angle = Column(Numeric, nullable=True)
|
||||
landmarks = Column(Text, nullable=True) # JSON string of facial landmarks
|
||||
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
excluded = Column(Boolean, default=False, nullable=False, index=True) # Exclude from identification
|
||||
|
||||
photo = relationship("Photo", back_populates="faces")
|
||||
person = relationship("Person", back_populates="faces")
|
||||
person_encodings = relationship(
|
||||
"PersonEncoding", back_populates="face", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_faces_person_id", "person_id"),
|
||||
Index("idx_faces_photo_id", "photo_id"),
|
||||
Index("idx_faces_quality", "quality_score"),
|
||||
Index("idx_faces_pose_mode", "pose_mode"),
|
||||
Index("idx_faces_identified_by", "identified_by_user_id"),
|
||||
Index("idx_faces_excluded", "excluded"),
|
||||
)
|
||||
|
||||
|
||||
class PersonEncoding(Base):
|
||||
"""Person encoding model - matches desktop schema exactly (was person_encodings)."""
|
||||
|
||||
__tablename__ = "person_encodings"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
|
||||
face_id = Column(Integer, ForeignKey("faces.id"), nullable=False, index=True)
|
||||
encoding = Column(LargeBinary, nullable=False)
|
||||
quality_score = Column(Numeric, default=0.0, nullable=False, index=True)
|
||||
detector_backend = Column(Text, default="retinaface", nullable=False)
|
||||
model_name = Column(Text, default="ArcFace", nullable=False)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
person = relationship("Person", back_populates="person_encodings")
|
||||
face = relationship("Face", back_populates="person_encodings")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_person_encodings_person_id", "person_id"),
|
||||
Index("idx_person_encodings_quality", "quality_score"),
|
||||
)
|
||||
|
||||
|
||||
class Tag(Base):
|
||||
"""Tag model - matches desktop schema exactly."""
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
tag_name = Column(Text, unique=True, nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo_tags = relationship(
|
||||
"PhotoTagLinkage", back_populates="tag", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class PhotoTagLinkage(Base):
|
||||
"""Photo-Tag linkage model - matches desktop schema exactly (was phototaglinkage)."""
|
||||
|
||||
__tablename__ = "phototaglinkage"
|
||||
|
||||
linkage_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
tag_id = Column(Integer, ForeignKey("tags.id"), nullable=False, index=True)
|
||||
linkage_type = Column(
|
||||
Integer, default=0, nullable=False,
|
||||
server_default="0"
|
||||
)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="photo_tags")
|
||||
tag = relationship("Tag", back_populates="photo_tags")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("photo_id", "tag_id", name="uq_photo_tag"),
|
||||
CheckConstraint("linkage_type IN (0, 1)", name="ck_linkage_type"),
|
||||
Index("idx_photo_tags_tag", "tag_id"),
|
||||
Index("idx_photo_tags_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
class PhotoFavorite(Base):
|
||||
"""Photo favorites model - user-specific favorites."""
|
||||
|
||||
__tablename__ = "photo_favorites"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
username = Column(Text, nullable=False, index=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="favorites")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("username", "photo_id", name="uq_user_photo_favorite"),
|
||||
Index("idx_favorites_username", "username"),
|
||||
Index("idx_favorites_photo", "photo_id"),
|
||||
)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""User model for main database - separate from auth database users."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||
username = Column(Text, unique=True, nullable=False, index=True)
|
||||
password_hash = Column(Text, nullable=False) # Hashed password
|
||||
email = Column(Text, unique=True, nullable=False, index=True)
|
||||
full_name = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True, nullable=False)
|
||||
is_admin = Column(Boolean, default=False, nullable=False, index=True)
|
||||
role = Column(
|
||||
Text,
|
||||
nullable=False,
|
||||
default=DEFAULT_USER_ROLE,
|
||||
server_default=DEFAULT_USER_ROLE,
|
||||
index=True,
|
||||
)
|
||||
password_change_required = Column(Boolean, default=True, nullable=False, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = Column(DateTime, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_users_username", "username"),
|
||||
Index("idx_users_email", "email"),
|
||||
Index("idx_users_is_admin", "is_admin"),
|
||||
Index("idx_users_password_change_required", "password_change_required"),
|
||||
Index("idx_users_role", "role"),
|
||||
)
|
||||
|
||||
|
||||
class PhotoPersonLinkage(Base):
|
||||
"""Direct linkage between Video (Photo with media_type='video') and Person.
|
||||
|
||||
This allows identifying people in videos without requiring face detection.
|
||||
Only used for videos, not photos (photos use Face model for identification).
|
||||
"""
|
||||
|
||||
__tablename__ = "photo_person_linkage"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
photo_id = Column(Integer, ForeignKey("photos.id"), nullable=False, index=True)
|
||||
person_id = Column(Integer, ForeignKey("people.id"), nullable=False, index=True)
|
||||
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
created_date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
photo = relationship("Photo", back_populates="video_people")
|
||||
person = relationship("Person", back_populates="video_photos")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("photo_id", "person_id", name="uq_photo_person"),
|
||||
Index("idx_photo_person_photo", "photo_id"),
|
||||
Index("idx_photo_person_person", "person_id"),
|
||||
Index("idx_photo_person_user", "identified_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""Role-to-feature permission matrix."""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
role = Column(Text, nullable=False, index=True)
|
||||
feature_key = Column(Text, nullable=False, index=True)
|
||||
allowed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("role", "feature_key", name="uq_role_feature"),
|
||||
Index("idx_role_permissions_role_feature", "role", "feature_key"),
|
||||
)
|
||||
|
||||
106
backend/db/session.py
Normal file
106
backend/db/session.py
Normal file
@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Load environment variables from .env file if it exists
|
||||
# Path: backend/db/session.py -> backend/db -> backend -> punimtag/ -> .env
|
||||
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||
load_dotenv(dotenv_path=env_path)
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""Fetch database URL from environment or defaults."""
|
||||
import os
|
||||
# Check for environment variable first
|
||||
db_url = os.getenv("DATABASE_URL")
|
||||
if db_url:
|
||||
return db_url
|
||||
# Default to PostgreSQL for development
|
||||
return "postgresql+psycopg2://punimtag:punimtag_password@localhost:5432/punimtag"
|
||||
|
||||
|
||||
def get_auth_database_url() -> str:
|
||||
"""Fetch auth database URL from environment."""
|
||||
import os
|
||||
db_url = os.getenv("DATABASE_URL_AUTH")
|
||||
if not db_url:
|
||||
raise ValueError("DATABASE_URL_AUTH environment variable not set")
|
||||
return db_url
|
||||
|
||||
|
||||
database_url = get_database_url()
|
||||
# PostgreSQL connection pool settings
|
||||
pool_kwargs = {
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
}
|
||||
|
||||
engine = create_engine(
|
||||
database_url,
|
||||
future=True,
|
||||
**pool_kwargs
|
||||
)
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
def get_db() -> Generator:
|
||||
"""Yield a DB session for request lifecycle."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Auth database setup
|
||||
try:
|
||||
auth_database_url = get_auth_database_url()
|
||||
auth_pool_kwargs = {
|
||||
"pool_pre_ping": True,
|
||||
"pool_size": 10,
|
||||
"max_overflow": 20,
|
||||
"pool_recycle": 3600,
|
||||
}
|
||||
|
||||
auth_engine = create_engine(
|
||||
auth_database_url,
|
||||
future=True,
|
||||
**auth_pool_kwargs
|
||||
)
|
||||
AuthSessionLocal = sessionmaker(bind=auth_engine, autoflush=False, autocommit=False, future=True)
|
||||
except ValueError as e:
|
||||
# DATABASE_URL_AUTH not set - auth database not available
|
||||
print(f"[DB Session] ⚠️ Auth database not configured: {e}")
|
||||
auth_engine = None
|
||||
AuthSessionLocal = None
|
||||
except Exception as e:
|
||||
# Other errors (connection failures, etc.) - log but don't crash
|
||||
import os
|
||||
print(f"[DB Session] ⚠️ Failed to initialize auth database: {e}")
|
||||
print(f"[DB Session] URL was: {os.getenv('DATABASE_URL_AUTH', 'not set')}")
|
||||
auth_engine = None
|
||||
AuthSessionLocal = None
|
||||
|
||||
|
||||
def get_auth_db() -> Generator:
|
||||
"""Yield a DB session for auth database request lifecycle."""
|
||||
if AuthSessionLocal is None:
|
||||
from fastapi import HTTPException, status
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Auth database not configured. Please set DATABASE_URL_AUTH environment variable in the backend configuration."
|
||||
)
|
||||
db = AuthSessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
2
backend/schemas/__init__.py
Normal file
2
backend/schemas/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Pydantic schemas for PunimTag Web."""
|
||||
|
||||
65
backend/schemas/auth.py
Normal file
65
backend/schemas/auth.py
Normal file
@ -0,0 +1,65 @@
|
||||
"""Authentication schemas for web API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from backend.constants.roles import DEFAULT_USER_ROLE, UserRole
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""Login request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class RefreshRequest(BaseModel):
|
||||
"""Refresh token request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
refresh_token: str
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
"""Token response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
password_change_required: bool = False
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""User response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
username: str
|
||||
is_admin: bool = False
|
||||
role: UserRole = DEFAULT_USER_ROLE
|
||||
permissions: Dict[str, bool] = {}
|
||||
|
||||
|
||||
class PasswordChangeRequest(BaseModel):
|
||||
"""Password change request payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
current_password: str
|
||||
new_password: str
|
||||
|
||||
|
||||
class PasswordChangeResponse(BaseModel):
|
||||
"""Password change response payload."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
success: bool
|
||||
message: str
|
||||
61
backend/schemas/auth_users.py
Normal file
61
backend/schemas/auth_users.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Auth database user management schemas for web API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class AuthUserResponse(BaseModel):
|
||||
"""Auth user DTO returned from API."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True, protected_namespaces=())
|
||||
|
||||
id: int
|
||||
name: Optional[str] = None
|
||||
email: str
|
||||
is_admin: Optional[bool] = None
|
||||
has_write_access: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
role: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class AuthUserCreateRequest(BaseModel):
|
||||
"""Request payload to create a new auth user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
email: EmailStr = Field(..., description="Email address (unique, required)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
|
||||
password: str = Field(..., min_length=6, description="Password (minimum 6 characters, required)")
|
||||
is_admin: bool = Field(..., description="Admin role (required)")
|
||||
has_write_access: bool = Field(..., description="Write access (required)")
|
||||
|
||||
|
||||
class AuthUserUpdateRequest(BaseModel):
|
||||
"""Request payload to update an auth user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
email: EmailStr = Field(..., description="Email address (required)")
|
||||
name: str = Field(..., min_length=1, max_length=200, description="Name (required)")
|
||||
is_admin: bool = Field(..., description="Admin role (required)")
|
||||
has_write_access: bool = Field(..., description="Write access (required)")
|
||||
is_active: Optional[bool] = Field(None, description="Active status (optional)")
|
||||
role: Optional[str] = Field(None, description="Role: 'Admin' or 'User' (optional)")
|
||||
password: Optional[str] = Field(None, min_length=6, description="New password (optional, minimum 6 characters, leave empty to keep current)")
|
||||
|
||||
|
||||
class AuthUsersListResponse(BaseModel):
|
||||
"""List of auth users."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[AuthUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user