initial commit
This commit is contained in:
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app /app/app
|
||||||
|
COPY alembic.ini /app/alembic.ini
|
||||||
|
COPY app/alembic /app/app/alembic
|
||||||
|
COPY start.sh /app/start.sh
|
||||||
|
RUN chmod +x /app/start.sh
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["/app/start.sh"]
|
||||||
|
|
||||||
|
|
||||||
38
alembic.ini
Normal file
38
alembic.ini
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = app/alembic
|
||||||
|
sqlalchemy.url = postgresql+psycopg2://postgres:postgres@db:5432/cemeterymap
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
|
|
||||||
|
|
||||||
56
app/alembic/env.py
Normal file
56
app/alembic/env.py
Normal 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()
|
||||||
|
|
||||||
|
|
||||||
97
app/alembic/versions/2024_01_01_000001_init.py
Normal file
97
app/alembic/versions/2024_01_01_000001_init.py
Normal 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
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
36
app/config.py
Normal file
36
app/config.py
Normal 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
19
app/db.py
Normal 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
60
app/dependencies.py
Normal 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
43
app/main.py
Normal 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
119
app/models.py
Normal 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
27
app/notifications.py
Normal 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
140
app/schemas.py
Normal 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
|
||||||
|
|
||||||
|
|
||||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
fastapi==0.111.0
|
||||||
|
uvicorn[standard]==0.30.1
|
||||||
|
SQLAlchemy==2.0.31
|
||||||
|
psycopg2-binary==2.9.9
|
||||||
|
pydantic==2.8.2
|
||||||
|
pydantic-settings==2.4.0
|
||||||
|
python-jose[cryptography]==3.3.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
alembic==1.13.2
|
||||||
|
python-multipart==0.0.9
|
||||||
|
requests==2.32.3
|
||||||
|
|
||||||
Reference in New Issue
Block a user