init
This commit is contained in:
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
0
app/auth/__init__.py
Normal file
36
app/auth/dependencies.py
Normal file
36
app/auth/dependencies.py
Normal 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
43
app/auth/jwt.py
Normal 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
38
app/config.py
Normal 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
25
app/database.py
Normal 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
57
app/main.py
Normal 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
17
app/models/__init__.py
Normal 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
19
app/models/activity.py
Normal 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
11
app/models/friendship.py
Normal 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))
|
||||
17
app/models/notification.py
Normal file
17
app/models/notification.py
Normal 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
15
app/models/score.py
Normal 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
15
app/models/user.py
Normal 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
19
app/models/zone.py
Normal 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
|
||||
14
app/models/zone_history.py
Normal file
14
app/models/zone_history.py
Normal 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
0
app/routers/__init__.py
Normal file
77
app/routers/activities.py
Normal file
77
app/routers/activities.py
Normal 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
76
app/routers/auth.py
Normal 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
91
app/routers/friends.py
Normal 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()
|
||||
73
app/routers/leaderboard.py
Normal file
73
app/routers/leaderboard.py
Normal 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
91
app/routers/users.py
Normal 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
86
app/routers/zones.py
Normal 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
0
app/schemas/__init__.py
Normal file
36
app/schemas/activity.py
Normal file
36
app/schemas/activity.py
Normal 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
22
app/schemas/score.py
Normal 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
48
app/schemas/user.py
Normal 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
34
app/schemas/zone.py
Normal 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
0
app/services/__init__.py
Normal file
23
app/services/capture.py
Normal file
23
app/services/capture.py
Normal 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 []
|
||||
211
app/services/geo_pipeline.py
Normal file
211
app/services/geo_pipeline.py
Normal 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,
|
||||
)
|
||||
29
app/services/notifications.py
Normal file
29
app/services/notifications.py
Normal 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
22
app/services/scoring.py
Normal 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
|
||||
Reference in New Issue
Block a user