initial commit
This commit is contained in:
17
app/api/__init__.py
Normal file
17
app/api/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import auth, cemeteries, graves, favorites, search, admin, health
|
||||
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
|
||||
api_router.include_router(cemeteries.router, prefix="/cemeteries", tags=["cemeteries"])
|
||||
api_router.include_router(graves.router, prefix="/graves", tags=["graves"])
|
||||
api_router.include_router(favorites.router, prefix="/favorites", tags=["favorites"])
|
||||
api_router.include_router(search.router, prefix="/search", tags=["search"])
|
||||
api_router.include_router(health.router, tags=["health"]) # /api/health
|
||||
|
||||
# Admin endpoints separated under /admin path
|
||||
api_router.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
|
||||
|
||||
70
app/api/admin.py
Normal file
70
app/api/admin.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Grave, GraveStatus, User, GraveHistory
|
||||
from ..schemas import GraveOut, RejectRequest
|
||||
from ..dependencies import require_admin, get_current_user
|
||||
from ..config import settings
|
||||
from ..notifications import send_push_notification
|
||||
|
||||
|
||||
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.post("/graves/{grave_id}/approve", response_model=GraveOut)
|
||||
def approve_grave(grave_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
grave.status = GraveStatus.APPROVED
|
||||
|
||||
# Move file from temp to permanent location
|
||||
if grave.temp_photo_path and os.path.exists(grave.temp_photo_path):
|
||||
os.makedirs(settings.uploads_graves_dir, exist_ok=True)
|
||||
filename = os.path.basename(grave.temp_photo_path)
|
||||
final_path = os.path.join(settings.uploads_graves_dir, filename)
|
||||
shutil.move(grave.temp_photo_path, final_path)
|
||||
grave.temp_photo_path = None
|
||||
grave.photo_url = f"/static/graves/{filename}"
|
||||
|
||||
db.commit()
|
||||
db.refresh(grave)
|
||||
db.add(GraveHistory(grave_id=grave.id, action="approved", actor_id=user.id))
|
||||
db.commit()
|
||||
|
||||
# Notify submitter if FCM token is present
|
||||
if grave.created_by:
|
||||
user = db.query(User).get(grave.created_by)
|
||||
if user and user.fcm_token:
|
||||
send_push_notification(
|
||||
user.fcm_token,
|
||||
title="Метка утверждена",
|
||||
body=f"'{grave.full_name}' утверждена модератором",
|
||||
data={"grave_id": grave.id, "status": grave.status.value},
|
||||
)
|
||||
return grave
|
||||
|
||||
|
||||
@router.post("/graves/{grave_id}/reject", response_model=GraveOut)
|
||||
def reject_grave(grave_id: int, payload: RejectRequest, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
grave.status = GraveStatus.REJECTED
|
||||
db.add(GraveHistory(grave_id=grave.id, action="rejected", changes={"comment": payload.comment}, actor_id=user.id))
|
||||
# store comment in history
|
||||
# actor id is not available via dependency here; rely on require_admin protecting route
|
||||
# We can extend later to include actor id if needed
|
||||
|
||||
# delete temp file if any
|
||||
if grave.temp_photo_path and os.path.exists(grave.temp_photo_path):
|
||||
os.remove(grave.temp_photo_path)
|
||||
grave.temp_photo_path = None
|
||||
db.commit()
|
||||
db.refresh(grave)
|
||||
return grave
|
||||
|
||||
|
||||
49
app/api/auth.py
Normal file
49
app/api/auth.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..dependencies import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import User, UserRole
|
||||
from ..schemas import UserCreate, UserOut, UserLogin, Token
|
||||
from ..config import settings
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserOut)
|
||||
def register(payload: UserCreate, db: Session = Depends(get_db)):
|
||||
existing = db.query(User).filter(User.email == payload.email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email already registered")
|
||||
user = User(email=payload.email, password_hash=get_password_hash(payload.password), role=UserRole.USER)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(payload: UserLogin, db: Session = Depends(get_db)):
|
||||
user = db.query(User).filter(User.email == payload.email).first()
|
||||
if not user or not verify_password(payload.password, user.password_hash):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect email or password")
|
||||
|
||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||
token = create_access_token(subject=user.email, expires_delta=access_token_expires)
|
||||
return Token(access_token=token, expires_in=int(access_token_expires.total_seconds()))
|
||||
|
||||
|
||||
@router.post("/fcm-token")
|
||||
def update_fcm_token(token: str, db: Session = Depends(get_db)):
|
||||
# Simple public endpoint to store FCM tokens is unsafe; should be authed in real app.
|
||||
# For MVP keep it simple via query/body param with email association at login step.
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
56
app/api/cemeteries.py
Normal file
56
app/api/cemeteries.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Cemetery
|
||||
from ..schemas import CemeteryCreate, CemeteryOut, CemeteryUpdate
|
||||
from ..dependencies import require_admin
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=CemeteryOut, dependencies=[Depends(require_admin)])
|
||||
def create_cemetery(payload: CemeteryCreate, db: Session = Depends(get_db)):
|
||||
cemetery = Cemetery(**payload.dict())
|
||||
db.add(cemetery)
|
||||
db.commit()
|
||||
db.refresh(cemetery)
|
||||
return cemetery
|
||||
|
||||
|
||||
@router.get("/", response_model=list[CemeteryOut])
|
||||
def list_cemeteries(db: Session = Depends(get_db)):
|
||||
return db.query(Cemetery).order_by(Cemetery.id.desc()).all()
|
||||
|
||||
|
||||
@router.get("/{cemetery_id}", response_model=CemeteryOut)
|
||||
def get_cemetery(cemetery_id: int, db: Session = Depends(get_db)):
|
||||
cemetery = db.query(Cemetery).get(cemetery_id)
|
||||
if not cemetery:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
return cemetery
|
||||
|
||||
|
||||
@router.patch("/{cemetery_id}", response_model=CemeteryOut, dependencies=[Depends(require_admin)])
|
||||
def update_cemetery(cemetery_id: int, payload: CemeteryUpdate, db: Session = Depends(get_db)):
|
||||
cemetery = db.query(Cemetery).get(cemetery_id)
|
||||
if not cemetery:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
setattr(cemetery, k, v)
|
||||
db.commit()
|
||||
db.refresh(cemetery)
|
||||
return cemetery
|
||||
|
||||
|
||||
@router.delete("/{cemetery_id}", status_code=204, dependencies=[Depends(require_admin)])
|
||||
def delete_cemetery(cemetery_id: int, db: Session = Depends(get_db)):
|
||||
cemetery = db.query(Cemetery).get(cemetery_id)
|
||||
if not cemetery:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
db.delete(cemetery)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
32
app/api/favorites.py
Normal file
32
app/api/favorites.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Favorite, Grave
|
||||
from ..schemas import FavoriteCreate, FavoriteOut
|
||||
from ..dependencies import get_current_user
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[FavoriteOut])
|
||||
def list_favorites(db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
return db.query(Favorite).filter(Favorite.user_id == user.id).all()
|
||||
|
||||
|
||||
@router.post("/", response_model=FavoriteOut)
|
||||
def add_favorite(payload: FavoriteCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(payload.grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Grave not found")
|
||||
existing = db.query(Favorite).filter(Favorite.user_id == user.id, Favorite.grave_id == payload.grave_id).first()
|
||||
if existing:
|
||||
return existing
|
||||
fav = Favorite(user_id=user.id, grave_id=payload.grave_id)
|
||||
db.add(fav)
|
||||
db.commit()
|
||||
db.refresh(fav)
|
||||
return fav
|
||||
|
||||
|
||||
93
app/api/graves.py
Normal file
93
app/api/graves.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status, Form
|
||||
from sqlalchemy.orm import Session
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Grave, GraveStatus, Cemetery, GraveHistory
|
||||
from ..schemas import GraveCreate, GraveOut, GraveUpdate
|
||||
from ..dependencies import get_current_user, require_admin
|
||||
from ..config import settings
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/", response_model=GraveOut)
|
||||
def create_grave(
|
||||
payload: GraveCreate,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user),
|
||||
):
|
||||
cemetery = db.query(Cemetery).get(payload.cemetery_id)
|
||||
if not cemetery:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cemetery not found")
|
||||
grave = Grave(**payload.dict(), created_by=user.id)
|
||||
db.add(grave)
|
||||
db.commit()
|
||||
db.refresh(grave)
|
||||
db.add(GraveHistory(grave_id=grave.id, action="created", actor_id=user.id))
|
||||
db.commit()
|
||||
return grave
|
||||
|
||||
|
||||
@router.get("/", response_model=list[GraveOut])
|
||||
def list_graves(cemetery_id: int | None = None, db: Session = Depends(get_db)):
|
||||
q = db.query(Grave)
|
||||
if cemetery_id:
|
||||
q = q.filter(Grave.cemetery_id == cemetery_id)
|
||||
return q.order_by(Grave.id.desc()).all()
|
||||
|
||||
|
||||
@router.get("/{grave_id}", response_model=GraveOut)
|
||||
def get_grave(grave_id: int, db: Session = Depends(get_db)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
return grave
|
||||
|
||||
|
||||
@router.patch("/{grave_id}", response_model=GraveOut, dependencies=[Depends(require_admin)])
|
||||
def update_grave(grave_id: int, payload: GraveUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
before_status = grave.status
|
||||
for k, v in payload.dict(exclude_unset=True).items():
|
||||
setattr(grave, k, v)
|
||||
db.commit()
|
||||
db.refresh(grave)
|
||||
action = "updated"
|
||||
if before_status != grave.status:
|
||||
action = grave.status.value
|
||||
db.add(GraveHistory(grave_id=grave.id, action=action, actor_id=user.id))
|
||||
db.commit()
|
||||
return grave
|
||||
|
||||
|
||||
@router.delete("/{grave_id}", status_code=204, dependencies=[Depends(require_admin)])
|
||||
def delete_grave(grave_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
db.add(GraveHistory(grave_id=grave.id, action="deleted", actor_id=user.id))
|
||||
db.delete(grave)
|
||||
db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{grave_id}/upload", response_model=GraveOut)
|
||||
def upload_temp_photo(grave_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), user=Depends(get_current_user)):
|
||||
grave = db.query(Grave).get(grave_id)
|
||||
if not grave:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
|
||||
os.makedirs(settings.uploads_temp_dir, exist_ok=True)
|
||||
file_path = os.path.join(settings.uploads_temp_dir, f"grave_{grave_id}_{file.filename}")
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
grave.temp_photo_path = file_path
|
||||
db.commit()
|
||||
db.refresh(grave)
|
||||
return grave
|
||||
|
||||
|
||||
11
app/api/health.py
Normal file
11
app/api/health.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def healthcheck() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
23
app/api/search.py
Normal file
23
app/api/search.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import Grave
|
||||
from ..schemas import GraveOut
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_model=list[GraveOut])
|
||||
def search_graves(cemetery_id: int, q: str, db: Session = Depends(get_db)):
|
||||
pattern = f"%{q.lower()}%"
|
||||
return (
|
||||
db.query(Grave)
|
||||
.filter(Grave.cemetery_id == cemetery_id)
|
||||
.filter(Grave.full_name.ilike(pattern))
|
||||
.order_by(Grave.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user