migration to web
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""API routers package for PunimTag Web."""
|
||||
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Database package for PunimTag Web."""
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Pydantic schemas for PunimTag Web."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Service layer for PunimTag Web (application orchestration)."""
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
APP_TITLE = "PunimTag Web API"
|
||||
APP_VERSION = "0.1.0"
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user