This commit is contained in:
Redsandy
2026-03-14 18:48:57 +03:00
parent 1d1350fc13
commit 3ea4fb4771
40 changed files with 2150 additions and 0 deletions

0
app/__init__.py Normal file
View File

0
app/auth/__init__.py Normal file
View File

36
app/auth/dependencies.py Normal file
View File

@@ -0,0 +1,36 @@
import uuid
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlmodel import Session, select
from app.database import get_session
from app.models.user import User
from app.auth.jwt import decode_token
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
session: Session = Depends(get_session),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception
user_id = uuid.UUID(user_id_str)
except (jwt.PyJWTError, ValueError):
raise credentials_exception
user = session.get(User, user_id)
if user is None:
raise credentials_exception
return user

43
app/auth/jwt.py Normal file
View File

@@ -0,0 +1,43 @@
from datetime import datetime, timedelta, timezone
import jwt
from pwdlib import PasswordHash
from app.config import (
SECRET_KEY,
ALGORITHM,
ACCESS_TOKEN_EXPIRE_MINUTES,
REFRESH_TOKEN_EXPIRE_DAYS,
)
password_hash = PasswordHash.recommended()
def hash_password(password: str) -> str:
return password_hash.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return password_hash.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode["exp"] = expire
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode["exp"] = expire
to_encode["type"] = "refresh"
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict:
"""Decode and verify a JWT token. Raises jwt.PyJWTError on failure."""
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])

38
app/config.py Normal file
View File

@@ -0,0 +1,38 @@
import os
from pathlib import Path
from dotenv import load_dotenv
load_dotenv()
# --- Database ---
# SQLite for local development, PostgreSQL for production
# Set DATABASE_URL in .env to override
DATABASE_URL: str = os.getenv(
"DATABASE_URL",
"sqlite:///geozoner.db",
)
# --- Auth / JWT ---
SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15"))
REFRESH_TOKEN_EXPIRE_DAYS: int = int(os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
# --- FCM (Firebase Cloud Messaging) ---
FCM_CREDENTIALS_PATH: str | None = os.getenv("FCM_CREDENTIALS_PATH")
# --- App ---
APP_HOST: str = os.getenv("APP_HOST", "0.0.0.0")
APP_PORT: int = int(os.getenv("APP_PORT", "8000"))
DEBUG: bool = os.getenv("DEBUG", "true").lower() in ("true", "1", "yes")
# --- CORS ---
CORS_ORIGINS: list[str] = os.getenv(
"CORS_ORIGINS",
"http://localhost,http://localhost:8000,http://127.0.0.1",
).split(",")
# --- Geo settings ---
MIN_ZONE_AREA_M2: float = float(os.getenv("MIN_ZONE_AREA_M2", "5000"))
LOOP_CLOSURE_RADIUS_M: float = float(os.getenv("LOOP_CLOSURE_RADIUS_M", "50"))
DOUGLAS_PEUCKER_EPSILON_M: float = float(os.getenv("DOUGLAS_PEUCKER_EPSILON_M", "5"))

25
app/database.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlmodel import SQLModel, Session, create_engine
from app.config import DATABASE_URL
# SQLite requires check_same_thread=False for FastAPI
_connect_args: dict = {}
if DATABASE_URL.startswith("sqlite"):
_connect_args = {"check_same_thread": False}
engine = create_engine(
DATABASE_URL,
echo=True,
connect_args=_connect_args,
)
def create_db_and_tables() -> None:
"""Create all tables from SQLModel metadata.
Called once at application startup."""
SQLModel.metadata.create_all(engine)
def get_session():
"""FastAPI dependency — yields a SQLModel Session."""
with Session(engine) as session:
yield session

57
app/main.py Normal file
View File

@@ -0,0 +1,57 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import CORS_ORIGINS, DEBUG
from app.database import create_db_and_tables
# Import all models so SQLModel registers them
from app.models import (
User,
Activity,
Zone,
ZoneHistory,
Friendship,
Score,
Notification,
) # noqa: F401
from app.routers import auth, users, activities, zones, friends, leaderboard
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: create tables. Shutdown: nothing special yet."""
create_db_and_tables()
yield
app = FastAPI(
title="GeoZoner API",
version="0.1.0",
description="Territorial fitness tracker backend",
lifespan=lifespan,
)
# --- CORS ---
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Routers ---
app.include_router(auth.router)
app.include_router(users.router)
app.include_router(activities.router)
app.include_router(zones.router)
app.include_router(friends.router)
app.include_router(leaderboard.router)
@app.get("/health")
def health():
return {"status": "ok"}

17
app/models/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
from app.models.user import User
from app.models.activity import Activity
from app.models.zone import Zone
from app.models.zone_history import ZoneHistory
from app.models.friendship import Friendship
from app.models.score import Score
from app.models.notification import Notification
__all__ = [
"User",
"Activity",
"Zone",
"ZoneHistory",
"Friendship",
"Score",
"Notification",
]

19
app/models/activity.py Normal file
View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class Activity(SQLModel, table=True):
__tablename__ = "activities"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", index=True)
type: str = Field(max_length=16) # run | cycle | walk | hike
started_at: datetime | None = None
ended_at: datetime | None = None
distance_m: float | None = None
raw_gpx: str | None = None # deleted after 30 days (privacy)
status: str = Field(
default="pending", max_length=16
) # pending | completed | failed
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

11
app/models/friendship.py Normal file
View File

@@ -0,0 +1,11 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class Friendship(SQLModel, table=True):
__tablename__ = "friendships"
user_id: uuid.UUID = Field(foreign_key="users.id", primary_key=True)
friend_id: uuid.UUID = Field(foreign_key="users.id", primary_key=True)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

View File

@@ -0,0 +1,17 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field, Column
from sqlalchemy import JSON
class Notification(SQLModel, table=True):
__tablename__ = "notifications"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", index=True)
type: str = Field(
max_length=32
) # zone_captured | leaderboard_change | streak_risk | friend_joined | raid_weekend
payload: dict = Field(default_factory=dict, sa_column=Column(JSON))
sent_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
read_at: datetime | None = None

15
app/models/score.py Normal file
View File

@@ -0,0 +1,15 @@
import uuid
from datetime import date, datetime, timezone
from sqlmodel import SQLModel, Field
class Score(SQLModel, table=True):
__tablename__ = "scores"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
user_id: uuid.UUID = Field(foreign_key="users.id", index=True)
date: date
base_pts: int = 0
bonus_pts: int = 0
total_pts: int = 0
streak_days: int = 0

15
app/models/user.py Normal file
View File

@@ -0,0 +1,15 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class User(SQLModel, table=True):
__tablename__ = "users"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
username: str = Field(max_length=32, unique=True, index=True)
email: str = Field(unique=True, index=True)
password_hash: str
avatar_url: str | None = None
fcm_token: str | None = None
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

19
app/models/zone.py Normal file
View File

@@ -0,0 +1,19 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class Zone(SQLModel, table=True):
__tablename__ = "zones"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
owner_id: uuid.UUID = Field(foreign_key="users.id", index=True)
activity_id: uuid.UUID = Field(foreign_key="activities.id")
# Polygon stored as WKT text for SQLite compatibility.
# For PostgreSQL/PostGIS, a migration can add a GEOMETRY column.
polygon_wkt: str # e.g. "POLYGON((lon lat, lon lat, ...))"
area_m2: float
defense_level: int = Field(default=1)
defense_runs: int = Field(default=0)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
last_reinforced_at: datetime | None = None

View File

@@ -0,0 +1,14 @@
import uuid
from datetime import datetime, timezone
from sqlmodel import SQLModel, Field
class ZoneHistory(SQLModel, table=True):
__tablename__ = "zone_history"
id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True)
zone_id: uuid.UUID = Field(foreign_key="zones.id", index=True)
from_owner_id: uuid.UUID = Field(foreign_key="users.id")
to_owner_id: uuid.UUID = Field(foreign_key="users.id")
changed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
cause: str = Field(max_length=16) # capture | merge

0
app/routers/__init__.py Normal file
View File

77
app/routers/activities.py Normal file
View File

@@ -0,0 +1,77 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.user import User
from app.models.activity import Activity
from app.models.zone import Zone
from app.schemas.activity import ActivityCreate, ActivityRead, ActivityDetail
from app.auth.dependencies import get_current_user
from app.services.geo_pipeline import process_activity
router = APIRouter(prefix="/activities", tags=["activities"])
@router.post("", response_model=ActivityDetail, status_code=status.HTTP_201_CREATED)
def create_activity(
body: ActivityCreate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Upload a GPS track and trigger the zone computation pipeline."""
result = process_activity(
user_id=current_user.id,
activity_type=body.type,
started_at=body.started_at,
ended_at=body.ended_at,
gps_track=body.gps_track,
session=session,
)
return result
@router.get("", response_model=list[ActivityRead])
def list_activities(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
limit: int = 50,
offset: int = 0,
):
"""List current user's activities."""
activities = session.exec(
select(Activity)
.where(Activity.user_id == current_user.id)
.order_by(Activity.created_at.desc()) # type: ignore[union-attr]
.offset(offset)
.limit(limit)
).all()
return activities
@router.get("/{activity_id}", response_model=ActivityDetail)
def get_activity(
activity_id: uuid.UUID,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
activity = session.get(Activity, activity_id)
if not activity or activity.user_id != current_user.id:
raise HTTPException(status_code=404, detail="Activity not found")
# Find associated zone
zone = session.exec(select(Zone).where(Zone.activity_id == activity_id)).first()
return ActivityDetail(
id=activity.id,
user_id=activity.user_id,
type=activity.type,
started_at=activity.started_at,
ended_at=activity.ended_at,
distance_m=activity.distance_m,
status=activity.status,
created_at=activity.created_at,
zone_id=zone.id if zone else None,
area_m2=zone.area_m2 if zone else None,
)

76
app/routers/auth.py Normal file
View File

@@ -0,0 +1,76 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.user import User
from app.schemas.user import (
UserCreate,
UserRead,
LoginRequest,
TokenResponse,
RefreshRequest,
)
from app.auth.jwt import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(body: UserCreate, session: Session = Depends(get_session)):
# Check username uniqueness
existing = session.exec(select(User).where(User.username == body.username)).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
# Check email uniqueness
existing = session.exec(select(User).where(User.email == body.email)).first()
if existing:
raise HTTPException(status_code=400, detail="Email already registered")
user = User(
username=body.username,
email=body.email,
password_hash=hash_password(body.password),
)
session.add(user)
session.commit()
session.refresh(user)
return user
@router.post("/login", response_model=TokenResponse)
def login(body: LoginRequest, session: Session = Depends(get_session)):
user = session.exec(select(User).where(User.username == body.username)).first()
if not user or not verify_password(body.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token, refresh_token=refresh_token)
@router.post("/refresh", response_model=TokenResponse)
def refresh(body: RefreshRequest, session: Session = Depends(get_session)):
try:
payload = decode_token(body.refresh_token)
if payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid token type")
user_id = uuid.UUID(payload["sub"])
except Exception:
raise HTTPException(status_code=401, detail="Invalid refresh token")
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token({"sub": str(user.id)})
refresh_token = create_refresh_token({"sub": str(user.id)})
return TokenResponse(access_token=access_token, refresh_token=refresh_token)

91
app/routers/friends.py Normal file
View File

@@ -0,0 +1,91 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from app.database import get_session
from app.models.user import User
from app.models.friendship import Friendship
from app.schemas.user import UserRead
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/friends", tags=["friends"])
@router.post("", status_code=status.HTTP_201_CREATED)
def add_friend(
username: str,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Add a friend by username. Creates bidirectional friendship."""
friend = session.exec(select(User).where(User.username == username)).first()
if not friend:
raise HTTPException(status_code=404, detail="User not found")
if friend.id == current_user.id:
raise HTTPException(status_code=400, detail="Cannot add yourself")
# Check if already friends
existing = session.exec(
select(Friendship).where(
Friendship.user_id == current_user.id,
Friendship.friend_id == friend.id,
)
).first()
if existing:
raise HTTPException(status_code=400, detail="Already friends")
# Bidirectional
session.add(Friendship(user_id=current_user.id, friend_id=friend.id))
session.add(Friendship(user_id=friend.id, friend_id=current_user.id))
session.commit()
return {"detail": f"Now friends with {friend.username}"}
@router.get("", response_model=list[UserRead])
def list_friends(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""List all friends of the current user."""
friend_ids = session.exec(
select(Friendship.friend_id).where(Friendship.user_id == current_user.id)
).all()
if not friend_ids:
return []
friends = session.exec(
select(User).where(User.id.in_(friend_ids)) # type: ignore[union-attr]
).all()
return friends
@router.delete("/{friend_id}", status_code=status.HTTP_204_NO_CONTENT)
def remove_friend(
friend_id: uuid.UUID,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Remove a friend (bidirectional)."""
f1 = session.exec(
select(Friendship).where(
Friendship.user_id == current_user.id,
Friendship.friend_id == friend_id,
)
).first()
f2 = session.exec(
select(Friendship).where(
Friendship.user_id == friend_id,
Friendship.friend_id == current_user.id,
)
).first()
if not f1:
raise HTTPException(status_code=404, detail="Friendship not found")
session.delete(f1)
if f2:
session.delete(f2)
session.commit()

View File

@@ -0,0 +1,73 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session, select, func
from app.database import get_session
from app.models.user import User
from app.models.zone import Zone
from app.models.score import Score
from app.models.friendship import Friendship
from app.schemas.score import LeaderboardEntry
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/leaderboard", tags=["leaderboard"])
@router.get("", response_model=list[LeaderboardEntry])
def get_leaderboard(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Friend leaderboard sorted by total points.
Includes the current user and all their friends.
"""
# Get friend IDs + self
friend_ids = list(
session.exec(
select(Friendship.friend_id).where(Friendship.user_id == current_user.id)
).all()
)
user_ids = friend_ids + [current_user.id]
entries: list[LeaderboardEntry] = []
for uid in user_ids:
user = session.get(User, uid)
if not user:
continue
# Latest score
latest_score = session.exec(
select(Score).where(Score.user_id == uid).order_by(Score.date.desc()) # type: ignore[union-attr]
).first()
total_pts = latest_score.total_pts if latest_score else 0
# Total area
total_area = session.exec(
select(func.coalesce(func.sum(Zone.area_m2), 0.0)).where(
Zone.owner_id == uid
)
).one()
# Zone count
zone_count = session.exec(
select(func.count()).select_from(Zone).where(Zone.owner_id == uid)
).one()
entries.append(
LeaderboardEntry(
user_id=uid,
username=user.username,
avatar_url=user.avatar_url,
total_pts=total_pts,
total_area_m2=float(total_area),
zone_count=zone_count,
rank=0, # calculated below
)
)
# Sort by total_pts descending, assign rank
entries.sort(key=lambda e: e.total_pts, reverse=True)
for i, entry in enumerate(entries):
entry.rank = i + 1
return entries

91
app/routers/users.py Normal file
View File

@@ -0,0 +1,91 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select, func
from app.database import get_session
from app.models.user import User
from app.models.zone import Zone
from app.models.activity import Activity
from app.models.score import Score
from app.schemas.user import UserRead, UserUpdate, UserStats
from app.auth.dependencies import get_current_user
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/me", response_model=UserRead)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
@router.patch("/me", response_model=UserRead)
def update_me(
body: UserUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
if body.username is not None:
existing = session.exec(
select(User).where(
User.username == body.username, User.id != current_user.id
)
).first()
if existing:
raise HTTPException(status_code=400, detail="Username already taken")
current_user.username = body.username
if body.avatar_url is not None:
current_user.avatar_url = body.avatar_url
if body.fcm_token is not None:
current_user.fcm_token = body.fcm_token
session.add(current_user)
session.commit()
session.refresh(current_user)
return current_user
@router.get("/{user_id}/stats", response_model=UserStats)
def get_user_stats(
user_id: uuid.UUID,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Total area
total_area = session.exec(
select(func.coalesce(func.sum(Zone.area_m2), 0.0)).where(
Zone.owner_id == user_id
)
).one()
# Zone count
zone_count = session.exec(
select(func.count()).select_from(Zone).where(Zone.owner_id == user_id)
).one()
# Activity count
activity_count = session.exec(
select(func.count())
.select_from(Activity)
.where(Activity.user_id == user_id, Activity.status == "completed")
).one()
# Total points (latest score)
latest_score = session.exec(
select(Score).where(Score.user_id == user_id).order_by(Score.date.desc()) # type: ignore[union-attr]
).first()
total_pts = latest_score.total_pts if latest_score else 0
return UserStats(
id=user.id,
username=user.username,
avatar_url=user.avatar_url,
total_area_m2=float(total_area),
total_points=total_pts,
zone_count=zone_count,
activity_count=activity_count,
)

86
app/routers/zones.py Normal file
View File

@@ -0,0 +1,86 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from app.database import get_session
from app.models.user import User
from app.models.zone import Zone
from app.models.friendship import Friendship
from app.schemas.zone import ZoneRead, ZoneBrief
from app.auth.dependencies import get_current_user
from app.services.geo_pipeline import wkt_to_geojson
router = APIRouter(prefix="/zones", tags=["zones"])
@router.get("", response_model=list[ZoneBrief])
def list_my_zones(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Return all zones owned by the current user."""
zones = session.exec(select(Zone).where(Zone.owner_id == current_user.id)).all()
return [
ZoneBrief(
id=z.id,
owner_id=z.owner_id,
polygon_geojson=wkt_to_geojson(z.polygon_wkt),
area_m2=z.area_m2,
defense_level=z.defense_level,
)
for z in zones
]
@router.get("/friends", response_model=list[ZoneBrief])
def list_friend_zones(
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
"""Return all zones owned by friends of the current user."""
friend_ids = session.exec(
select(Friendship.friend_id).where(Friendship.user_id == current_user.id)
).all()
if not friend_ids:
return []
zones = session.exec(
select(Zone).where(Zone.owner_id.in_(friend_ids)) # type: ignore[union-attr]
).all()
return [
ZoneBrief(
id=z.id,
owner_id=z.owner_id,
polygon_geojson=wkt_to_geojson(z.polygon_wkt),
area_m2=z.area_m2,
defense_level=z.defense_level,
)
for z in zones
]
@router.get("/{zone_id}", response_model=ZoneRead)
def get_zone(
zone_id: uuid.UUID,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session),
):
zone = session.get(Zone, zone_id)
if not zone:
raise HTTPException(status_code=404, detail="Zone not found")
return ZoneRead(
id=zone.id,
owner_id=zone.owner_id,
activity_id=zone.activity_id,
polygon_geojson=wkt_to_geojson(zone.polygon_wkt),
area_m2=zone.area_m2,
defense_level=zone.defense_level,
defense_runs=zone.defense_runs,
created_at=zone.created_at,
last_reinforced_at=zone.last_reinforced_at,
)

0
app/schemas/__init__.py Normal file
View File

36
app/schemas/activity.py Normal file
View File

@@ -0,0 +1,36 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class GpsPoint(BaseModel):
lat: float
lon: float
timestamp: datetime | None = None
altitude: float | None = None
hdop: float | None = None
class ActivityCreate(BaseModel):
type: str # run | cycle | walk | hike
started_at: datetime
ended_at: datetime
gps_track: list[GpsPoint]
class ActivityRead(BaseModel):
id: uuid.UUID
user_id: uuid.UUID
type: str
started_at: datetime | None
ended_at: datetime | None
distance_m: float | None
status: str
created_at: datetime
class ActivityDetail(ActivityRead):
"""Activity with zone info attached."""
zone_id: uuid.UUID | None = None
area_m2: float | None = None

22
app/schemas/score.py Normal file
View File

@@ -0,0 +1,22 @@
import uuid
from datetime import date
from pydantic import BaseModel
class ScoreRead(BaseModel):
user_id: uuid.UUID
date: date
base_pts: int
bonus_pts: int
total_pts: int
streak_days: int
class LeaderboardEntry(BaseModel):
user_id: uuid.UUID
username: str
avatar_url: str | None = None
total_pts: int
total_area_m2: float
zone_count: int
rank: int

48
app/schemas/user.py Normal file
View File

@@ -0,0 +1,48 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
username: str
email: str
password: str
class UserRead(BaseModel):
id: uuid.UUID
username: str
email: str
avatar_url: str | None = None
created_at: datetime
class UserUpdate(BaseModel):
username: str | None = None
avatar_url: str | None = None
fcm_token: str | None = None
class UserStats(BaseModel):
id: uuid.UUID
username: str
avatar_url: str | None = None
total_area_m2: float = 0.0
total_points: int = 0
zone_count: int = 0
activity_count: int = 0
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class RefreshRequest(BaseModel):
refresh_token: str

34
app/schemas/zone.py Normal file
View File

@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
class ZoneRead(BaseModel):
id: uuid.UUID
owner_id: uuid.UUID
activity_id: uuid.UUID
polygon_geojson: dict # GeoJSON Polygon
area_m2: float
defense_level: int
defense_runs: int
created_at: datetime
last_reinforced_at: datetime | None = None
class ZoneBrief(BaseModel):
"""Lightweight zone for map rendering."""
id: uuid.UUID
owner_id: uuid.UUID
polygon_geojson: dict
area_m2: float
defense_level: int
class CaptureEvent(BaseModel):
"""Returned when a zone capture occurs."""
attacker_zone_id: uuid.UUID
victim_zone_id: uuid.UUID
victim_owner_id: uuid.UUID
captured_area_m2: float

0
app/services/__init__.py Normal file
View File

23
app/services/capture.py Normal file
View File

@@ -0,0 +1,23 @@
"""Zone capture logic — detects overlap and transfers territory.
TODO: Implement in Phase 3 (zone capture mechanics).
"""
import uuid
from sqlmodel import Session
def process_captures(
attacker_zone_id: uuid.UUID,
attacker_user_id: uuid.UUID,
session: Session,
) -> list[dict]:
"""Check if the new zone overlaps any friend zones and apply capture rules.
Returns a list of capture events (zone_id, victim_id, captured_area).
Not yet implemented — returns empty list for MVP Phase 1.
"""
# Phase 3: query friend zones, compute ST_Intersection with Shapely,
# apply defense level rules, update ownership, create ZoneHistory records.
return []

View File

@@ -0,0 +1,211 @@
"""Geo pipeline — processes raw GPS tracks into zone polygons.
All geo math is done via Shapely (pure Python), so it works
with both SQLite (dev) and PostgreSQL (prod).
"""
import json
import math
import uuid
from datetime import datetime, timezone
from rdp import rdp
from shapely.geometry import Polygon, mapping
from shapely import wkt
from sqlmodel import Session
from app.config import (
MIN_ZONE_AREA_M2,
LOOP_CLOSURE_RADIUS_M,
DOUGLAS_PEUCKER_EPSILON_M,
)
from app.models.activity import Activity
from app.models.zone import Zone
from app.schemas.activity import GpsPoint, ActivityDetail
def haversine_m(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""Haversine distance in meters between two lat/lon points."""
R = 6_371_000 # Earth radius in meters
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlam = math.radians(lon2 - lon1)
a = (
math.sin(dphi / 2) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
)
return 2 * R * math.atan2(math.sqrt(a), math.sqrt(1 - a))
def compute_distance_m(points: list[GpsPoint]) -> float:
"""Total distance in meters along a GPS track."""
total = 0.0
for i in range(1, len(points)):
total += haversine_m(
points[i - 1].lat, points[i - 1].lon, points[i].lat, points[i].lon
)
return total
def simplify_track(points: list[GpsPoint], epsilon: float) -> list[GpsPoint]:
"""Douglas-Peucker simplification on lat/lon coordinates.
epsilon is in approximate degrees; we convert from meters.
~0.00001 degree ≈ 1.11 m at the equator.
"""
coords = [(p.lat, p.lon) for p in points]
eps_deg = epsilon / 111_000 # rough meters->degrees
simplified = rdp(coords, epsilon=eps_deg)
# Rebuild GpsPoint list (drop timestamps for simplified points)
return [GpsPoint(lat=c[0], lon=c[1]) for c in simplified]
def is_loop_closed(points: list[GpsPoint], radius_m: float) -> bool:
"""Check if start and end points are within radius_m meters."""
if len(points) < 4:
return False
return (
haversine_m(points[0].lat, points[0].lon, points[-1].lat, points[-1].lon)
<= radius_m
)
def build_polygon(points: list[GpsPoint]) -> Polygon | None:
"""Build a Shapely Polygon from GPS points.
Uses (lon, lat) ordering for GeoJSON/WKT compatibility.
Closes the ring if needed.
"""
coords = [(p.lon, p.lat) for p in points]
if coords[0] != coords[-1]:
coords.append(coords[0])
if len(coords) < 4:
return None
poly = Polygon(coords)
if not poly.is_valid:
poly = poly.buffer(0) # attempt to fix self-intersections
return poly if poly.is_valid and not poly.is_empty else None
def polygon_area_m2(poly: Polygon) -> float:
"""Approximate area in m² from a WGS84 polygon.
Uses a simple cos(lat) correction. Accurate enough for city-scale zones.
"""
centroid = poly.centroid
lat_rad = math.radians(centroid.y)
# Degrees to meters conversion factors
m_per_deg_lat = 111_320.0
m_per_deg_lon = 111_320.0 * math.cos(lat_rad)
# Scale polygon to meters and compute area
coords = list(poly.exterior.coords)
scaled = [(x * m_per_deg_lon, y * m_per_deg_lat) for x, y in coords]
return abs(Polygon(scaled).area)
def wkt_to_geojson(wkt_str: str) -> dict:
"""Convert WKT polygon string to GeoJSON dict."""
geom = wkt.loads(wkt_str)
return mapping(geom)
def process_activity(
user_id: uuid.UUID,
activity_type: str,
started_at: datetime,
ended_at: datetime,
gps_track: list[GpsPoint],
session: Session,
) -> ActivityDetail:
"""Full geo pipeline: filter -> simplify -> validate loop -> build polygon -> persist.
Returns an ActivityDetail with zone info if a zone was created.
"""
# 1. Create activity record
distance = compute_distance_m(gps_track)
activity = Activity(
user_id=user_id,
type=activity_type,
started_at=started_at,
ended_at=ended_at,
distance_m=distance,
raw_gpx=json.dumps([{"lat": p.lat, "lon": p.lon} for p in gps_track]),
status="pending",
)
session.add(activity)
session.flush() # get activity.id
zone_id = None
area_m2 = None
try:
# 2. Filter outliers (skip points with hdop > 4 if present)
filtered = [p for p in gps_track if p.hdop is None or p.hdop <= 4]
if len(filtered) < 10:
activity.status = "failed"
session.commit()
return _to_detail(activity, None, None)
# 3. Simplify
simplified = simplify_track(filtered, DOUGLAS_PEUCKER_EPSILON_M)
# 4. Check loop closure
if not is_loop_closed(simplified, LOOP_CLOSURE_RADIUS_M):
activity.status = "failed"
session.commit()
return _to_detail(activity, None, None)
# 5. Build polygon
poly = build_polygon(simplified)
if poly is None:
activity.status = "failed"
session.commit()
return _to_detail(activity, None, None)
# 6. Validate area
area_m2 = polygon_area_m2(poly)
if area_m2 < MIN_ZONE_AREA_M2:
activity.status = "failed"
session.commit()
return _to_detail(activity, None, None)
# 7. Persist zone
zone = Zone(
owner_id=user_id,
activity_id=activity.id,
polygon_wkt=poly.wkt,
area_m2=area_m2,
)
session.add(zone)
activity.status = "completed"
session.commit()
session.refresh(zone)
session.refresh(activity)
zone_id = zone.id
area_m2 = zone.area_m2
except Exception:
activity.status = "failed"
session.commit()
return _to_detail(activity, zone_id, area_m2)
def _to_detail(
activity: Activity,
zone_id: uuid.UUID | None,
area_m2: float | None,
) -> ActivityDetail:
return ActivityDetail(
id=activity.id,
user_id=activity.user_id,
type=activity.type,
started_at=activity.started_at,
ended_at=activity.ended_at,
distance_m=activity.distance_m,
status=activity.status,
created_at=activity.created_at,
zone_id=zone_id,
area_m2=area_m2,
)

View File

@@ -0,0 +1,29 @@
"""Push notifications via Firebase Cloud Messaging.
TODO: Implement in Phase 2.
"""
import uuid
def send_zone_captured(
victim_user_id: uuid.UUID, attacker_name: str, area_m2: float
) -> None:
"""Notify a user that their zone was partially captured."""
# Phase 2: Use firebase-admin SDK to send FCM push
pass
def send_leaderboard_change(user_id: uuid.UUID, new_rank: int) -> None:
"""Notify a user that their leaderboard position changed."""
pass
def send_streak_at_risk(user_id: uuid.UUID, streak_days: int) -> None:
"""Notify a user that their streak is about to expire."""
pass
def send_friend_joined(user_id: uuid.UUID, friend_name: str) -> None:
"""Notify a user that a friend just joined GeoZoner."""
pass

22
app/services/scoring.py Normal file
View File

@@ -0,0 +1,22 @@
"""Daily scoring engine — calculates points per user.
TODO: Implement in Phase 2 (scoring system).
"""
from sqlmodel import Session
def calculate_daily_scores(session: Session) -> int:
"""Run daily score calculation for all users.
Returns the number of users scored.
Not yet implemented — will be triggered by a cron job or manual call.
"""
# Phase 2: For each user, compute:
# - base_pts: 1 pt per 1,000 m² zone area held
# - defense bonus: +20% per defense level above 1
# - capture bonus: +50 pts per zone captured that day
# - streak bonus: x(1 + streak_days * 0.05), capped at 2x
# - activity bonus: +10 pts per km covered
return 0