migration to web

This commit is contained in:
tanyar09
2025-10-31 12:10:44 -04:00
parent d6b1e85998
commit 94385e3dcc
3560 changed files with 575829 additions and 169 deletions
+3
View File
@@ -0,0 +1,3 @@
"""API routers package for PunimTag Web."""
+132
View File
@@ -0,0 +1,132 @@
"""Authentication endpoints."""
from __future__ import annotations
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 src.web.schemas.auth import (
LoginRequest,
RefreshRequest,
TokenResponse,
UserResponse,
)
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 = 30
REFRESH_TOKEN_EXPIRE_DAYS = 7
# Single user mode placeholder
SINGLE_USER_USERNAME = "admin"
SINGLE_USER_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",
)
@router.post("/login", response_model=TokenResponse)
def login(credentials: LoginRequest) -> TokenResponse:
"""Authenticate user and return tokens."""
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
)
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)]
) -> UserResponse:
"""Get current user information."""
return UserResponse(username=current_user["username"])
+35
View File
@@ -0,0 +1,35 @@
"""Face management endpoints."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/faces", tags=["faces"])
@router.post("/process")
def process_faces() -> dict:
"""Process faces - placeholder for Phase 2."""
return {"message": "Process faces endpoint - to be implemented in Phase 2"}
@router.get("/unidentified")
def get_unidentified_faces() -> dict:
"""Get unidentified faces - placeholder for Phase 2."""
return {"message": "Unidentified faces endpoint - to be implemented in Phase 2"}
@router.post("/{face_id}/identify")
def identify_face(face_id: int) -> dict:
"""Identify face - placeholder for Phase 2."""
return {
"message": f"Identify face {face_id} - to be implemented in Phase 2",
"id": face_id,
}
@router.post("/auto-match")
def auto_match_faces() -> dict:
"""Auto-match faces - placeholder for Phase 2."""
return {"message": "Auto-match endpoint - to be implemented in Phase 2"}
+14
View 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"}
+53
View File
@@ -0,0 +1,53 @@
"""Job management endpoints."""
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from rq import Queue
from rq.job import Job
from redis import Redis
from src.web.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)
status_map = {
"queued": JobStatus.PENDING,
"started": JobStatus.STARTED,
"finished": JobStatus.SUCCESS,
"failed": JobStatus.FAILURE,
}
job_status = status_map.get(job.get_status(), JobStatus.PENDING)
progress = 0
if job_status == JobStatus.STARTED:
progress = job.meta.get("progress", 0) if job.meta else 0
elif job_status == JobStatus.SUCCESS:
progress = 100
message = job.meta.get("message", "") if job.meta else ""
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:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job {job_id} not found",
)
+17
View 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",
}
+29
View File
@@ -0,0 +1,29 @@
"""People management endpoints."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/people", tags=["people"])
@router.get("")
def list_people() -> dict:
"""List people - placeholder for Phase 2."""
return {"message": "People endpoint - to be implemented in Phase 2"}
@router.post("")
def create_person() -> dict:
"""Create person - placeholder for Phase 2."""
return {"message": "Create person endpoint - to be implemented in Phase 2"}
@router.get("/{person_id}")
def get_person(person_id: int) -> dict:
"""Get person by ID - placeholder for Phase 2."""
return {
"message": f"Get person {person_id} - to be implemented in Phase 2",
"id": person_id,
}
+29
View File
@@ -0,0 +1,29 @@
"""Photo management endpoints."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/photos", tags=["photos"])
@router.get("")
def list_photos() -> dict:
"""List photos - placeholder for Phase 2."""
return {"message": "Photos endpoint - to be implemented in Phase 2"}
@router.post("/import")
def import_photos() -> dict:
"""Import photos - placeholder for Phase 2."""
return {"message": "Import endpoint - to be implemented in Phase 2"}
@router.get("/{photo_id}")
def get_photo(photo_id: int) -> dict:
"""Get photo by ID - placeholder for Phase 2."""
return {
"message": f"Get photo {photo_id} - to be implemented in Phase 2",
"id": photo_id,
}
+29
View File
@@ -0,0 +1,29 @@
"""Tag management endpoints."""
from __future__ import annotations
from fastapi import APIRouter
router = APIRouter(prefix="/tags", tags=["tags"])
@router.get("")
def list_tags() -> dict:
"""List tags - placeholder for Phase 3."""
return {"message": "Tags endpoint - to be implemented in Phase 3"}
@router.post("")
def create_tag() -> dict:
"""Create tag - placeholder for Phase 3."""
return {"message": "Create tag endpoint - to be implemented in Phase 3"}
@router.post("/photos/{photo_id}/tags")
def add_tags_to_photo(photo_id: int) -> dict:
"""Add tags to photo - placeholder for Phase 3."""
return {
"message": f"Add tags to photo {photo_id} - to be implemented in Phase 3",
"id": photo_id,
}
+16
View File
@@ -0,0 +1,16 @@
from __future__ import annotations
from fastapi import APIRouter
from src.web.settings import APP_VERSION
router = APIRouter()
@router.get("/version")
def version() -> dict[str, str]:
"""Return API version information."""
return {"version": APP_VERSION}
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.web.api.auth import router as auth_router
from src.web.api.faces import router as faces_router
from src.web.api.health import router as health_router
from src.web.api.jobs import router as jobs_router
from src.web.api.metrics import router as metrics_router
from src.web.api.people import router as people_router
from src.web.api.photos import router as photos_router
from src.web.api.tags import router as tags_router
from src.web.api.version import router as version_router
from src.web.settings import APP_TITLE, APP_VERSION
def create_app() -> FastAPI:
"""Create and configure the FastAPI application instance."""
app = FastAPI(title=APP_TITLE, version=APP_VERSION)
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(tags_router, prefix="/api/v1")
return app
app = create_app()
+3
View File
@@ -0,0 +1,3 @@
"""Database package for PunimTag Web."""
+9
View File
@@ -0,0 +1,9 @@
"""Database base configuration."""
from __future__ import annotations
from src.web.db.models import Base
from src.web.db.session import engine
__all__ = ["Base", "engine"]
+148
View File
@@ -0,0 +1,148 @@
"""SQLAlchemy models for PunimTag Web."""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
Boolean,
Column,
DateTime,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import declarative_base, relationship
if TYPE_CHECKING:
from datetime import date
Base = declarative_base()
class Photo(Base):
"""Photo model."""
__tablename__ = "photos"
id = Column(Integer, primary_key=True, index=True)
path = Column(String(2048), unique=True, nullable=False, index=True)
filename = Column(String(512), nullable=False)
checksum = Column(String(64), unique=True, nullable=True, index=True)
date_added = Column(DateTime, default=datetime.utcnow, nullable=False)
date_taken = Column(DateTime, nullable=True, index=True)
width = Column(Integer, nullable=True)
height = Column(Integer, nullable=True)
mime_type = Column(String(128), nullable=True)
faces = relationship("Face", back_populates="photo", cascade="all, delete-orphan")
photo_tags = relationship(
"PhotoTag", back_populates="photo", cascade="all, delete-orphan"
)
class Person(Base):
"""Person model."""
__tablename__ = "people"
id = Column(Integer, primary_key=True, index=True)
display_name = Column(String(256), nullable=False, index=True)
given_name = Column(String(128), nullable=True)
family_name = Column(String(128), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
faces = relationship("Face", back_populates="person")
person_embeddings = relationship(
"PersonEmbedding", back_populates="person", cascade="all, delete-orphan"
)
class Face(Base):
"""Face detection model."""
__tablename__ = "faces"
id = Column(Integer, primary_key=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)
bbox_x = Column(Integer, nullable=False)
bbox_y = Column(Integer, nullable=False)
bbox_w = Column(Integer, nullable=False)
bbox_h = Column(Integer, nullable=False)
embedding = Column(LargeBinary, nullable=False)
confidence = Column(Integer, nullable=True)
quality = Column(Integer, nullable=True, index=True)
model = Column(String(64), nullable=True)
detector = Column(String(64), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
photo = relationship("Photo", back_populates="faces")
person = relationship("Person", back_populates="faces")
person_embeddings = relationship(
"PersonEmbedding", back_populates="face", cascade="all, delete-orphan"
)
__table_args__ = (Index("idx_faces_quality", "quality"),)
class PersonEmbedding(Base):
"""Person embedding reference model."""
__tablename__ = "person_embeddings"
id = Column(Integer, primary_key=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)
embedding = Column(LargeBinary, nullable=False)
quality = Column(Integer, nullable=True, index=True)
model = Column(String(64), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
person = relationship("Person", back_populates="person_embeddings")
face = relationship("Face", back_populates="person_embeddings")
__table_args__ = (
Index("idx_person_embeddings_quality", "quality"),
Index("idx_person_embeddings_person", "person_id"),
)
class Tag(Base):
"""Tag model."""
__tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True)
tag = Column(String(128), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
photo_tags = relationship(
"PhotoTag", back_populates="tag", cascade="all, delete-orphan"
)
class PhotoTag(Base):
"""Photo-Tag linkage model."""
__tablename__ = "photo_tags"
photo_id = Column(Integer, ForeignKey("photos.id"), primary_key=True)
tag_id = Column(Integer, ForeignKey("tags.id"), primary_key=True)
created_at = 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"),
Index("idx_photo_tags_tag", "tag_id"),
Index("idx_photo_tags_photo", "photo_id"),
)
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
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 SQLite for development
return "sqlite:///data/punimtag.db"
database_url = get_database_url()
# SQLite-specific configuration
connect_args = {}
if database_url.startswith("sqlite"):
connect_args = {"check_same_thread": False}
engine = create_engine(database_url, pool_pre_ping=True, future=True, connect_args=connect_args)
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()
+2
View File
@@ -0,0 +1,2 @@
"""Pydantic schemas for PunimTag Web."""
+33
View File
@@ -0,0 +1,33 @@
"""Authentication schemas."""
from __future__ import annotations
from pydantic import BaseModel
class LoginRequest(BaseModel):
"""Login request schema."""
username: str
password: str
class TokenResponse(BaseModel):
"""Token response schema."""
access_token: str
refresh_token: str
token_type: str = "bearer"
class UserResponse(BaseModel):
"""User response schema."""
username: str
class RefreshRequest(BaseModel):
"""Refresh token request schema."""
refresh_token: str
+30
View File
@@ -0,0 +1,30 @@
"""Job schemas."""
from __future__ import annotations
from datetime import datetime
from enum import Enum
from pydantic import BaseModel
class JobStatus(str, Enum):
"""Job status enum."""
PENDING = "pending"
STARTED = "started"
PROGRESS = "progress"
SUCCESS = "success"
FAILURE = "failure"
class JobResponse(BaseModel):
"""Job response schema."""
id: str
status: JobStatus
progress: int = 0
message: str = ""
created_at: datetime
updated_at: datetime
+3
View File
@@ -0,0 +1,3 @@
"""Service layer for PunimTag Web (application orchestration)."""
+6
View File
@@ -0,0 +1,6 @@
from __future__ import annotations
APP_TITLE = "PunimTag Web API"
APP_VERSION = "0.1.0"
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
import signal
import sys
from typing import NoReturn
def main() -> NoReturn:
"""Worker entrypoint placeholder (RQ/Celery to be wired)."""
def _handle_sigterm(_signum, _frame):
sys.exit(0)
signal.signal(signal.SIGTERM, _handle_sigterm)
signal.signal(signal.SIGINT, _handle_sigterm)
# Placeholder: actual worker loop will be implemented in Phase 2.
signal.pause()
if __name__ == "__main__":
main()