commit 21cb493267c4b80456a6c333f67d0bf5b70dfed8 Author: kiaro37 Date: Mon Jan 26 13:33:54 2026 +0300 initial commit diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e316d17 --- /dev/null +++ b/Dockerfile @@ -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"] + + diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..f1cffea --- /dev/null +++ b/alembic.ini @@ -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 + + diff --git a/app/alembic/env.py b/app/alembic/env.py new file mode 100644 index 0000000..ab0d119 --- /dev/null +++ b/app/alembic/env.py @@ -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() + + diff --git a/app/alembic/versions/2024_01_01_000001_init.py b/app/alembic/versions/2024_01_01_000001_init.py new file mode 100644 index 0000000..e3c573b --- /dev/null +++ b/app/alembic/versions/2024_01_01_000001_init.py @@ -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;") + + diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..7a33cdd --- /dev/null +++ b/app/api/__init__.py @@ -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"]) + + diff --git a/app/api/admin.py b/app/api/admin.py new file mode 100644 index 0000000..0788a42 --- /dev/null +++ b/app/api/admin.py @@ -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 + + diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..1942bce --- /dev/null +++ b/app/api/auth.py @@ -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} + + diff --git a/app/api/cemeteries.py b/app/api/cemeteries.py new file mode 100644 index 0000000..9e06905 --- /dev/null +++ b/app/api/cemeteries.py @@ -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 + + diff --git a/app/api/favorites.py b/app/api/favorites.py new file mode 100644 index 0000000..b0596da --- /dev/null +++ b/app/api/favorites.py @@ -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 + + diff --git a/app/api/graves.py b/app/api/graves.py new file mode 100644 index 0000000..44a7a20 --- /dev/null +++ b/app/api/graves.py @@ -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 + + diff --git a/app/api/health.py b/app/api/health.py new file mode 100644 index 0000000..599efc8 --- /dev/null +++ b/app/api/health.py @@ -0,0 +1,11 @@ +from fastapi import APIRouter + + +router = APIRouter() + + +@router.get("/health") +def healthcheck() -> dict[str, str]: + return {"status": "ok"} + + diff --git a/app/api/search.py b/app/api/search.py new file mode 100644 index 0000000..0bfc4bc --- /dev/null +++ b/app/api/search.py @@ -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() + ) + + diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6ef166e --- /dev/null +++ b/app/config.py @@ -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() + + diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..382385d --- /dev/null +++ b/app/db.py @@ -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() + + diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..a223d72 --- /dev/null +++ b/app/dependencies.py @@ -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 + + diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..a06acec --- /dev/null +++ b/app/main.py @@ -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) + + diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..1c3434b --- /dev/null +++ b/app/models.py @@ -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) + + diff --git a/app/notifications.py b/app/notifications.py new file mode 100644 index 0000000..18770e7 --- /dev/null +++ b/app/notifications.py @@ -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 + + diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..c35c333 --- /dev/null +++ b/app/schemas.py @@ -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 + + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f414e0b --- /dev/null +++ b/requirements.txt @@ -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 + diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..1b99754 --- /dev/null +++ b/start.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env sh +set -e +alembic -c /app/alembic.ini upgrade head || true +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 + +