initial commit

This commit is contained in:
kiaro37
2026-01-26 13:33:54 +03:00
commit 21cb493267
21 changed files with 1028 additions and 0 deletions

56
app/alembic/env.py Normal file
View File

@@ -0,0 +1,56 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
sys.path.append(os.path.join(os.path.dirname(__file__), "..", ".."))
from app.db import Base # type: ignore
from app.models import * # noqa
target_metadata = Base.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,97 @@
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '2024_01_01_000001'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
op.create_table(
'users',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('role', sa.Enum('user', 'admin', name='userrole'), nullable=False, server_default='user'),
sa.Column('fcm_token', sa.String(length=512), nullable=True),
sa.Column('is_active', sa.Boolean(), server_default=sa.text('true')),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
)
op.create_index('ix_users_email', 'users', ['email'], unique=True)
op.create_table(
'cemeteries',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('center_lat', sa.Float(), nullable=False),
sa.Column('center_lon', sa.Float(), nullable=False),
sa.Column('zoom', sa.Integer(), server_default='15'),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
)
op.create_table(
'graves',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('cemetery_id', sa.Integer(), sa.ForeignKey('cemeteries.id', ondelete='CASCADE'), nullable=False),
sa.Column('lat', sa.Float(), nullable=False),
sa.Column('lon', sa.Float(), nullable=False),
sa.Column('full_name', sa.String(length=255), nullable=False),
sa.Column('birth_date', sa.String(length=32), nullable=True),
sa.Column('death_date', sa.String(length=32), nullable=True),
sa.Column('temp_photo_path', sa.String(length=512), nullable=True),
sa.Column('photo_url', sa.String(length=512), nullable=True),
sa.Column('status', sa.Enum('pending', 'approved', 'rejected', name='gravestatus'), server_default='pending', nullable=False),
sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()')),
)
op.create_index('ix_graves_full_name', 'graves', ['full_name'])
op.create_table(
'grave_histories',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('grave_id', sa.Integer(), sa.ForeignKey('graves.id', ondelete='CASCADE'), nullable=False),
sa.Column('action', sa.String(length=64), nullable=False),
sa.Column('changes', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('actor_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
)
op.create_table(
'favorites',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('grave_id', sa.Integer(), sa.ForeignKey('graves.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
sa.UniqueConstraint('user_id', 'grave_id', name='uq_user_grave'),
)
op.create_table(
'app_logs',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('level', sa.String(length=32), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('context', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()')),
)
def downgrade() -> None:
op.drop_table('app_logs')
op.drop_table('favorites')
op.drop_table('grave_histories')
op.drop_index('ix_graves_full_name', table_name='graves')
op.drop_table('graves')
op.drop_table('cemeteries')
op.drop_index('ix_users_email', table_name='users')
op.drop_table('users')
op.execute("DROP TYPE IF EXISTS userrole;")
op.execute("DROP TYPE IF EXISTS gravestatus;")

17
app/api/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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()
)

36
app/config.py Normal file
View File

@@ -0,0 +1,36 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Cemetery Map API"
environment: str = os.getenv("ENVIRONMENT", "development")
# Security
jwt_secret_key: str = os.getenv("JWT_SECRET_KEY", "CHANGE_ME")
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 * 7 # 7 days
# Database
database_url: str = os.getenv(
"DATABASE_URL",
"postgresql+psycopg2://postgres:postgres@db:5432/cemeterymap",
)
# CORS
cors_origins: list[str] = [
os.getenv("ADMIN_ORIGIN", "http://localhost:5173"),
os.getenv("MOBILE_ORIGIN", "http://localhost:3000"),
]
# Uploads
uploads_temp_dir: str = os.getenv("UPLOADS_TEMP_DIR", "/data/uploads/tmp")
uploads_graves_dir: str = os.getenv("UPLOADS_GRAVES_DIR", "/data/uploads/graves")
# FCM
fcm_server_key: str | None = os.getenv("FCM_SERVER_KEY")
settings = Settings()

19
app/db.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from .config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()

60
app/dependencies.py Normal file
View File

@@ -0,0 +1,60 @@
from datetime import datetime, timedelta
from typing import Optional
from fastapi import HTTPException, Security, status, Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from .config import settings
from .db import get_db
from .models import User, UserRole
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security_scheme = HTTPBearer()
def verify_password(plain_password: str, password_hash: str) -> bool:
return pwd_context.verify(plain_password, password_hash)
def get_password_hash(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(subject: str, expires_delta: Optional[timedelta] = None) -> str:
if expires_delta is None:
expires_delta = timedelta(minutes=settings.access_token_expire_minutes)
expire = datetime.utcnow() + expires_delta
to_encode = {"sub": subject, "exp": expire}
encoded_jwt = jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
return encoded_jwt
def get_current_user(
credentials: HTTPAuthorizationCredentials = Security(security_scheme),
db: Session = Depends(get_db),
):
token = credentials.credentials
try:
payload = jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
subject: str = payload.get("sub")
if subject is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
except JWTError:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = db.query(User).filter(User.email == subject).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
return user
def require_admin(user: User = Depends(get_current_user)):
if user.role != UserRole.ADMIN:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin only")
return user

43
app/main.py Normal file
View File

@@ -0,0 +1,43 @@
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from .config import settings
from .db import Base, engine
from .api import api_router
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static for photos
os.makedirs(settings.uploads_graves_dir, exist_ok=True)
app.mount("/static/graves", StaticFiles(directory=settings.uploads_graves_dir), name="graves")
# Routers
app.include_router(api_router, prefix="/api")
# Healthcheck endpoint
@app.get("/api/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}
return app
app = create_app()
# create tables for MVP (later use Alembic)
Base.metadata.create_all(bind=engine)

119
app/models.py Normal file
View File

@@ -0,0 +1,119 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum as SqlEnum,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import relationship, Mapped, mapped_column
from .db import Base
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
role: Mapped[UserRole] = mapped_column(SqlEnum(UserRole), default=UserRole.USER, nullable=False)
fcm_token: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
favorites = relationship("Favorite", back_populates="user", cascade="all,delete")
class Cemetery(Base):
__tablename__ = "cemeteries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
region: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
center_lat: Mapped[float] = mapped_column()
center_lon: Mapped[float] = mapped_column()
zoom: Mapped[int] = mapped_column(Integer, default=15)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
graves = relationship("Grave", back_populates="cemetery", cascade="all,delete")
class GraveStatus(str, Enum):
PENDING = "pending" # yellow
APPROVED = "approved" # green
REJECTED = "rejected"
class Grave(Base):
__tablename__ = "graves"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
cemetery_id: Mapped[int] = mapped_column(ForeignKey("cemeteries.id", ondelete="CASCADE"), index=True)
lat: Mapped[float] = mapped_column()
lon: Mapped[float] = mapped_column()
full_name: Mapped[str] = mapped_column(String(255), index=True)
birth_date: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
death_date: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
temp_photo_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
photo_url: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
status: Mapped[GraveStatus] = mapped_column(SqlEnum(GraveStatus), default=GraveStatus.PENDING, index=True)
created_by: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
cemetery = relationship("Cemetery", back_populates="graves")
histories = relationship("GraveHistory", back_populates="grave", cascade="all,delete")
favorites = relationship("Favorite", back_populates="grave", cascade="all,delete")
class GraveHistory(Base):
__tablename__ = "grave_histories"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
grave_id: Mapped[int] = mapped_column(ForeignKey("graves.id", ondelete="CASCADE"), index=True)
action: Mapped[str] = mapped_column(String(64)) # created, updated, approved, rejected
changes: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
actor_id: Mapped[Optional[int]] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
grave = relationship("Grave", back_populates="histories")
class Favorite(Base):
__tablename__ = "favorites"
__table_args__ = (UniqueConstraint("user_id", "grave_id", name="uq_user_grave"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
grave_id: Mapped[int] = mapped_column(ForeignKey("graves.id", ondelete="CASCADE"), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
user = relationship("User", back_populates="favorites")
grave = relationship("Grave", back_populates="favorites")
class AppLog(Base):
__tablename__ = "app_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
level: Mapped[str] = mapped_column(String(32)) # info, warning, error
message: Mapped[str] = mapped_column(Text)
context: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

27
app/notifications.py Normal file
View File

@@ -0,0 +1,27 @@
import json
from typing import Optional
import requests
from .config import settings
def send_push_notification(fcm_token: str, title: str, body: str, data: Optional[dict] = None) -> bool:
if not settings.fcm_server_key:
return False
payload = {
"to": fcm_token,
"notification": {"title": title, "body": body},
"data": data or {},
"priority": "high",
}
headers = {
"Content-Type": "application/json",
"Authorization": f"key={settings.fcm_server_key}",
}
try:
resp = requests.post("https://fcm.googleapis.com/fcm/send", headers=headers, data=json.dumps(payload), timeout=5)
return resp.status_code == 200
except Exception:
return False

140
app/schemas.py Normal file
View File

@@ -0,0 +1,140 @@
from datetime import datetime, timedelta
from enum import Enum
from typing import Optional, List
from pydantic import BaseModel, EmailStr, Field
class UserRole(str, Enum):
user = "user"
admin = "admin"
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int = Field(..., description="Seconds until token expiration")
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str
class UserLogin(UserBase):
password: str
class UserOut(UserBase):
id: int
role: UserRole
created_at: datetime
class Config:
from_attributes = True
class CemeteryBase(BaseModel):
name: str
description: Optional[str] = None
region: Optional[str] = None
center_lat: float
center_lon: float
zoom: int = 15
class CemeteryCreate(CemeteryBase):
pass
class CemeteryUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
region: Optional[str] = None
center_lat: Optional[float] = None
center_lon: Optional[float] = None
zoom: Optional[int] = None
class CemeteryOut(CemeteryBase):
id: int
created_at: datetime
class Config:
from_attributes = True
class GraveStatus(str, Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class GraveBase(BaseModel):
cemetery_id: int
lat: float
lon: float
full_name: str
birth_date: Optional[str] = None
death_date: Optional[str] = None
class GraveCreate(GraveBase):
pass
class GraveUpdate(BaseModel):
lat: Optional[float] = None
lon: Optional[float] = None
full_name: Optional[str] = None
birth_date: Optional[str] = None
death_date: Optional[str] = None
status: Optional[GraveStatus] = None
class GraveOut(GraveBase):
id: int
status: GraveStatus
photo_url: Optional[str] = None
created_by: Optional[int]
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class FavoriteBase(BaseModel):
grave_id: int
class FavoriteCreate(FavoriteBase):
pass
class FavoriteOut(FavoriteBase):
id: int
user_id: int
created_at: datetime
class Config:
from_attributes = True
class SearchQuery(BaseModel):
cemetery_id: int
q: str = Field(..., description="Full name query")
class ApproveRequest(BaseModel):
approved: bool
comment: Optional[str] = None
class RejectRequest(BaseModel):
comment: str