Initial commit: SIBU 2.0 MISSION

This commit is contained in:
2026-02-21 09:53:31 -05:00
commit 0c7aa53c8b
400 changed files with 67708 additions and 0 deletions

View File

@ -0,0 +1,2 @@
"""API routes."""

View File

@ -0,0 +1,122 @@
from fastapi import APIRouter, Depends
from sqlmodel import Session, select, func
from app.core.database import get_session
from app.models.analytics import AnalyticsEvent
from app.models.user import User
from typing import Optional, Dict
from datetime import datetime, timedelta
from app.api.deps import get_current_user_optional
router = APIRouter()
@router.post("/event")
async def log_event(
event: Dict,
session: Session = Depends(get_session),
current_user: Optional[User] = Depends(get_current_user_optional)
):
user_id = current_user.id if current_user else None
new_event = AnalyticsEvent(
event_name=event.get("event_name"),
user_id=user_id,
screen_name=event.get("screen_name"),
item_id=event.get("item_id"),
properties=event.get("properties", {})
)
session.add(new_event)
session.commit()
return {"status": "ok"}
@router.get("/strategic")
async def get_strategic_analysis(
session: Session = Depends(get_session)
):
"""Deep analysis of how businesses and shuttles are performing."""
# 1. SHUTTLE PERFORMANCE
shuttle_previews = session.exec(
select(AnalyticsEvent.item_id, func.count(AnalyticsEvent.id))
.where(AnalyticsEvent.event_name == "shuttle_view")
.group_by(AnalyticsEvent.item_id)
).all()
shuttle_contacts = session.exec(
select(AnalyticsEvent.item_id, func.count(AnalyticsEvent.id))
.where(AnalyticsEvent.event_name == "shuttle_contact")
.group_by(AnalyticsEvent.item_id)
).all()
shuttle_map = {r[0]: {"views": r[1], "contacts": 0} for r in shuttle_previews if r[0]}
for r in shuttle_contacts:
if r[0] in shuttle_map:
shuttle_map[r[0]]["contacts"] = r[1]
else:
shuttle_map[r[0]] = {"views": 0, "contacts": r[1]}
# 2. BUSINESS PERFORMANCE
biz_views = session.exec(
select(AnalyticsEvent.item_id, func.count(AnalyticsEvent.id))
.where(AnalyticsEvent.event_name == "business_view")
.group_by(AnalyticsEvent.item_id)
).all()
promo_clicks = session.exec(
select(AnalyticsEvent.item_id, func.count(AnalyticsEvent.id))
.where(AnalyticsEvent.event_name == "promo_click")
.group_by(AnalyticsEvent.item_id)
).all()
biz_map = {r[0]: {"views": r[1], "promos": 0} for r in biz_views if r[0]}
for r in promo_clicks:
if r[0] in biz_map:
biz_map[r[0]]["promos"] = r[1]
else:
biz_map[r[0]] = {"views": 0, "promos": r[1]}
# 3. TOP STOPS (CASETAS CON MÁS GENTE)
top_stops = session.exec(
select(AnalyticsEvent.item_id, func.count(AnalyticsEvent.id))
.where(AnalyticsEvent.event_name == "stop_selected")
.group_by(AnalyticsEvent.item_id)
.order_by(func.count(AnalyticsEvent.id).desc())
.limit(10)
).all()
# 4. ACTIVE USERS
total_active_users = session.exec(select(func.count(func.distinct(AnalyticsEvent.user_id))).where(AnalyticsEvent.user_id != None)).one()
# 5. PEAK HOURS BY USER TYPE
hour_expr = func.extract('hour', AnalyticsEvent.timestamp)
reg_usage = session.exec(select(hour_expr, func.count(AnalyticsEvent.id)).where(AnalyticsEvent.user_id != None).group_by(hour_expr)).all()
guest_usage = session.exec(select(hour_expr, func.count(AnalyticsEvent.id)).where(AnalyticsEvent.user_id == None).group_by(hour_expr)).all()
usage_patterns = {
"registered": {int(h): c for h, c in reg_usage},
"guests": {int(h): c for h, c in guest_usage}
}
return {
"shuttles": shuttle_map,
"businesses": biz_map,
"top_stops": [{"id": r[0], "count": r[1]} for r in top_stops],
"users": {
"registered_active": total_active_users,
"patterns": usage_patterns
},
"summary": {
"total_shuttle_contacts": sum(sh[1] for sh in shuttle_contacts),
"total_promo_clicks": sum(p[1] for p in promo_clicks),
"total_biz_views": sum(b[1] for b in biz_views)
}
}
@router.get("/dashboard/stats")
async def get_dashboard_stats(
session: Session = Depends(get_session)
):
# Base dashboard stats for general overview
total_events = session.exec(select(func.count(AnalyticsEvent.id))).one()
return {
"total_events": total_events
}

View File

@ -0,0 +1,233 @@
import os
import shutil
from uuid import uuid4
from typing import Annotated, Optional
from fastapi import APIRouter, HTTPException, status, Depends, UploadFile, File, Form
from sqlmodel import Session, select
from app.core.database import get_session
from app.core.security import verify_password, get_password_hash, create_access_token, get_token_payload
from app.models.user import User, DriverProfile, UserRole, VehicleType
from app.api.deps import oauth2_scheme
from app.schemas.user import PassengerCreate, Token, UserResponse, LoginRequest
router = APIRouter(prefix="/api/auth", tags=["auth"])
UPLOAD_DIR = "uploads"
@router.post("/login", response_model=Token)
async def login(
data: LoginRequest,
session: Session = Depends(get_session)
):
print(f"DEBUG: Login attempt for email: {data.email}")
user = session.exec(select(User).where(User.email == data.email)).first()
if not user:
print(f"DEBUG: User not found: {data.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not verify_password(data.password, user.hashed_password):
print(f"DEBUG: Invalid password for: {data.email}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
print(f"DEBUG: Successful login for: {data.email} as {user.role}")
# Token expiration can be extended if keep_session is true
import datetime
expires = datetime.timedelta(days=30) if data.keep_session else datetime.timedelta(days=1)
access_token = create_access_token(
subject=user.id,
role=user.role,
full_name=user.full_name,
expires_delta=expires
)
return {
"access_token": access_token,
"token_type": "bearer",
"role": user.role,
"full_name": user.full_name,
"profile_photo_url": user.profile_photo_url
}
@router.post("/register/passenger", response_model=UserResponse)
async def register_passenger(
data: PassengerCreate,
session: Session = Depends(get_session)
):
# Check if user exists
existing_user = session.exec(select(User).where(User.email == data.email)).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
new_user = User(
email=data.email,
full_name=data.full_name,
hashed_password=get_password_hash(data.password),
role=UserRole.PASSENGER
)
session.add(new_user)
session.commit()
session.refresh(new_user)
return new_user
@router.post("/register/driver", response_model=UserResponse)
async def register_driver(
full_name: str = Form(...),
email: str = Form(...),
phone_number: str = Form(...),
password: str = Form(...),
cedula: str = Form(...),
vehicle_type: VehicleType = Form(...),
license_plate: str = Form(...),
cooperative_name: Optional[str] = Form(None),
profile_photo: Optional[UploadFile] = File(None),
vehicle_photo: UploadFile = File(...),
shift: Optional[str] = Form(None),
payment_methods: Optional[str] = Form(None),
speaks_english: bool = Form(False),
session: Session = Depends(get_session)
):
# Check if user exists
existing_user = session.exec(select(User).where(User.email == email)).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
# Save photos
profile_photo_url = None
if profile_photo:
ext = os.path.splitext(profile_photo.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "profiles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(profile_photo.file, buffer)
profile_photo_url = f"/uploads/profiles/{filename}"
ext_v = os.path.splitext(vehicle_photo.filename)[1]
v_filename = f"{uuid4()}{ext_v}"
v_path = os.path.join(UPLOAD_DIR, "vehicles", v_filename)
with open(v_path, "wb") as buffer:
shutil.copyfileobj(vehicle_photo.file, buffer)
vehicle_photo_url = f"/uploads/vehicles/{v_filename}"
# Create User
new_user = User(
email=email,
full_name=full_name,
hashed_password=get_password_hash(password),
role=UserRole.DRIVER,
is_verified=True, # Auto verify since it's admin registered now
profile_photo_url=profile_photo_url
)
session.add(new_user)
session.commit()
session.refresh(new_user)
# Create Driver Profile
profile = DriverProfile(
user_id=new_user.id,
cedula=cedula,
vehicle_type=vehicle_type,
license_plate=license_plate,
photo_url=profile_photo_url,
vehicle_photo_url=vehicle_photo_url,
cooperative_name=cooperative_name,
shift=shift,
payment_methods=payment_methods,
speaks_english=speaks_english
)
session.add(profile)
session.commit()
return new_user
@router.get("/me")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session)
):
"""Get current logged in user details."""
payload = get_token_payload(token)
user_id = payload.get("sub")
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
result = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"role": user.role,
"is_verified": user.is_verified,
"profile_photo_url": user.profile_photo_url,
"driver_profile": None
}
if user.driver_profile:
dp = user.driver_profile
result["driver_profile"] = {
"cedula": dp.cedula,
"vehicle_type": dp.vehicle_type,
"license_plate": dp.license_plate,
"cooperative_name": dp.cooperative_name,
"photo_url": dp.photo_url,
"shift": dp.shift,
"payment_methods": dp.payment_methods,
"speaks_english": dp.speaks_english
}
return result
@router.patch("/me", response_model=UserResponse)
async def update_me(
full_name: Optional[str] = Form(None),
password: Optional[str] = Form(None),
profile_photo: Optional[UploadFile] = File(None),
token: Annotated[str, Depends(oauth2_scheme)] = None,
session: Session = Depends(get_session)
):
"""Update current user profile info."""
payload = get_token_payload(token)
user_id = payload.get("sub")
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if full_name:
user.full_name = full_name
if password:
user.hashed_password = get_password_hash(password)
if profile_photo:
# Create directory if not exists
profile_dir = os.path.join(UPLOAD_DIR, "profiles")
os.makedirs(profile_dir, exist_ok=True)
ext = os.path.splitext(profile_photo.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(profile_dir, filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(profile_photo.file, buffer)
user.profile_photo_url = f"/uploads/profiles/{filename}"
# If user is driver, also update driver profile photo_url for backwards compatibility/sync
if user.driver_profile:
user.driver_profile.photo_url = user.profile_photo_url
session.add(user.driver_profile)
session.add(user)
session.commit()
session.refresh(user)
return user

View File

@ -0,0 +1,91 @@
"""Bus stops API endpoints."""
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from app.core.database import get_session
from app.models.bus_stop import BusStop
from app.schemas.bus_stop import BusStopResponse, BusStopCreate, BusStopUpdate
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/bus-stops", tags=["bus-stops"])
@router.get("", response_model=List[BusStopResponse])
async def get_bus_stops(session: Session = Depends(get_session)):
"""Get all bus stops."""
statement = select(BusStop)
stops = session.exec(statement).all()
return stops
@router.get("/{stop_id}", response_model=BusStopResponse)
async def get_bus_stop(stop_id: str, session: Session = Depends(get_session)):
"""Get a single bus stop by ID."""
stop = session.get(BusStop, stop_id)
if not stop:
raise HTTPException(status_code=404, detail="Bus stop not found")
return stop
@router.get("/{stop_id}/routes")
async def get_bus_stop_routes(stop_id: str, session: Session = Depends(get_session)):
"""Get all routes passing through a bus stop."""
from app.models.route_stop import RouteStop
from app.models.route import Route
statement = select(Route).join(
RouteStop, RouteStop.route_id == Route.id
).where(RouteStop.stop_id == stop_id)
routes = session.exec(statement).all()
return routes
@router.post("", response_model=BusStopResponse)
async def create_bus_stop(
bus_stop: BusStopCreate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Create a new bus stop (Admin only)."""
db_stop = BusStop.model_validate(bus_stop)
session.add(db_stop)
session.commit()
session.refresh(db_stop)
return db_stop
@router.put("/{stop_id}", response_model=BusStopResponse)
async def update_bus_stop(
stop_id: str,
stop_update: BusStopUpdate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a bus stop (Admin only)."""
db_stop = session.get(BusStop, stop_id)
if not db_stop:
raise HTTPException(status_code=404, detail="Bus stop not found")
stop_data = stop_update.model_dump(exclude_unset=True)
for key, value in stop_data.items():
setattr(db_stop, key, value)
session.add(db_stop)
session.commit()
session.refresh(db_stop)
return db_stop
@router.delete("/{stop_id}")
async def delete_bus_stop(
stop_id: str,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Delete a bus stop (Admin only)."""
db_stop = session.get(BusStop, stop_id)
if not db_stop:
raise HTTPException(status_code=404, detail="Bus stop not found")
session.delete(db_stop)
session.commit()
return {"ok": True}

View File

@ -0,0 +1,158 @@
from fastapi import APIRouter, Depends, HTTPException, status, Form, File, UploadFile
from sqlmodel import Session, select
from typing import List, Optional
from app.core.database import get_session
from app.models.business import Business
from app.models.user import User, UserRole
from app.api.deps import get_current_user
router = APIRouter(prefix="/api/businesses", tags=["businesses"])
@router.get("", response_model=List[Business])
async def list_businesses(
*,
session: Session = Depends(get_session)
):
"""List all businesses."""
statement = select(Business)
businesses = session.exec(statement).all()
return businesses
@router.post("", response_model=Business)
async def create_business(
*,
session: Session = Depends(get_session),
name: str = Form(...),
category: str = Form(...),
address: str = Form(...),
phone: Optional[str] = Form(None),
social_media: Optional[str] = Form(None),
latitude: Optional[float] = Form(None),
longitude: Optional[float] = Form(None),
image: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_user)
):
"""Create a new business (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can manage businesses"
)
image_url = None
if image:
import os
import shutil
from uuid import uuid4
UPLOAD_DIR = "uploads/businesses"
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
image_url = f"/uploads/businesses/{filename}"
db_business = Business(
name=name,
category=category,
address=address,
phone=phone,
social_media=social_media,
latitude=latitude,
longitude=longitude,
image_url=image_url
)
session.add(db_business)
session.commit()
session.refresh(db_business)
return db_business
@router.patch("/{business_id}", response_model=Business)
async def update_business(
*,
session: Session = Depends(get_session),
business_id: str,
name: Optional[str] = Form(None),
category: Optional[str] = Form(None),
address: Optional[str] = Form(None),
phone: Optional[str] = Form(None),
social_media: Optional[str] = Form(None),
latitude: Optional[float] = Form(None),
longitude: Optional[float] = Form(None),
image: Optional[UploadFile] = File(None),
current_user: User = Depends(get_current_user)
):
"""Update a business (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can manage businesses"
)
db_business = session.get(Business, business_id)
if not db_business:
raise HTTPException(status_code=404, detail="Business not found")
if name is not None:
db_business.name = name
if category is not None:
db_business.category = category
if address is not None:
db_business.address = address
if phone is not None:
db_business.phone = phone
if social_media is not None:
db_business.social_media = social_media
if latitude is not None:
db_business.latitude = latitude
if longitude is not None:
db_business.longitude = longitude
if image:
import os
import shutil
from uuid import uuid4
UPLOAD_DIR = "uploads/businesses"
os.makedirs(UPLOAD_DIR, exist_ok=True)
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
db_business.image_url = f"/uploads/businesses/{filename}"
session.add(db_business)
session.commit()
session.refresh(db_business)
return db_business
@router.get("/{business_id}", response_model=Business)
async def get_business(business_id: str, session: Session = Depends(get_session)):
"""Get a single business by ID."""
business = session.get(Business, business_id)
if not business:
raise HTTPException(status_code=404, detail="Business not found")
return business
@router.delete("/{business_id}")
async def delete_business(
*,
session: Session = Depends(get_session),
business_id: str,
current_user: User = Depends(get_current_user)
):
"""Delete a business (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can manage businesses"
)
db_business = session.get(Business, business_id)
if not db_business:
raise HTTPException(status_code=404, detail="Business not found")
session.delete(db_business)
session.commit()
return {"status": "success", "message": "Business deleted"}

View File

@ -0,0 +1,94 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from sqlalchemy.orm import joinedload
from typing import List
from app.core.database import get_session
from app.models.coupon import Coupon, CouponCreate, CouponUpdate
from app.models.user import User, UserRole
from app.api.deps import get_current_user
router = APIRouter(prefix="/api/coupons", tags=["coupons"])
@router.get("", response_model=List[Coupon])
async def list_coupons(
*,
session: Session = Depends(get_session),
active_only: bool = True
):
"""List all coupons."""
statement = select(Coupon).options(joinedload(Coupon.business))
if active_only:
statement = statement.where(Coupon.is_active)
coupons = session.exec(statement).all()
return coupons
@router.post("", response_model=Coupon)
async def create_coupon(
*,
session: Session = Depends(get_session),
coupon_in: CouponCreate,
current_user: User = Depends(get_current_user)
):
"""Create a new coupon (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can create coupons"
)
db_coupon = Coupon.from_orm(coupon_in)
session.add(db_coupon)
session.commit()
session.refresh(db_coupon)
return db_coupon
@router.patch("/{coupon_id}", response_model=Coupon)
async def update_coupon(
*,
session: Session = Depends(get_session),
coupon_id: str,
coupon_in: CouponUpdate,
current_user: User = Depends(get_current_user)
):
"""Update a coupon (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can update coupons"
)
db_coupon = session.get(Coupon, coupon_id)
if not db_coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
coupon_data = coupon_in.dict(exclude_unset=True)
for key, value in coupon_data.items():
setattr(db_coupon, key, value)
session.add(db_coupon)
session.commit()
session.refresh(db_coupon)
return db_coupon
@router.delete("/{coupon_id}")
async def delete_coupon(
*,
session: Session = Depends(get_session),
coupon_id: str,
current_user: User = Depends(get_current_user)
):
"""Delete a coupon (Promoters and Admins only)."""
if current_user.role not in [UserRole.ADMIN, UserRole.PROMOTER]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only promoters and admins can delete coupons"
)
db_coupon = session.get(Coupon, coupon_id)
if not db_coupon:
raise HTTPException(status_code=404, detail="Coupon not found")
session.delete(db_coupon)
session.commit()
return {"status": "success", "message": "Coupon deleted"}

76
backend/app/api/deps.py Normal file
View File

@ -0,0 +1,76 @@
from typing import Annotated, Dict, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import jwt, JWTError
from app.core.config import settings
from app.core.security import ALGORITHM
from sqlmodel import Session
from app.core.database import get_session
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login", auto_error=False)
def get_token_payload(token: Annotated[str, Depends(oauth2_scheme)]) -> Dict:
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
return payload
except JWTError:
raise credentials_exception
async def get_current_user_token(token: Annotated[str, Depends(oauth2_scheme)]) -> Dict:
return get_token_payload(token)
async def get_current_admin(token: Annotated[str, Depends(oauth2_scheme)]) -> bool:
from app.models.user import UserRole
payload = get_token_payload(token)
role: str = payload.get("role")
# Check for both "admin" and "ADMIN" for robust authorization
if role not in ["admin", "ADMIN", UserRole.ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The user doesn't have enough privileges",
)
return True
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
session: Session = Depends(get_session)
) -> User:
payload = get_token_payload(token)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
async def get_current_user_optional(
token: Optional[str] = Depends(oauth2_scheme),
session: Session = Depends(get_session)
) -> Optional[User]:
if not token:
return None
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
user_id = payload.get("sub")
if not user_id:
return None
return session.get(User, user_id)
except JWTError:
return None

View File

@ -0,0 +1,109 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlmodel import Session, select
from typing import List
from app.core.database import get_session
from app.models.favorite import Favorite
from app.api.deps import get_current_user
from app.models.user import User
from pydantic import BaseModel
router = APIRouter(prefix="/api/favorites", tags=["favorites"])
class FavoriteCreate(BaseModel):
item_type: str # 'coupon', 'business', 'taxi', 'route'
item_id: str
item_name: str | None = None
item_image: str | None = None
@router.get("")
async def get_favorites(
item_type: str | None = None,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
) -> List[Favorite]:
"""Get all favorites for the current user, optionally filtered by type."""
statement = select(Favorite).where(Favorite.user_id == current_user.id)
if item_type:
statement = statement.where(Favorite.item_type == item_type)
statement = statement.order_by(Favorite.created_at.desc())
favorites = session.exec(statement).all()
return list(favorites)
@router.post("")
async def add_favorite(
favorite_data: FavoriteCreate,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
) -> Favorite:
"""Add an item to favorites."""
# Check if already favorited
existing = session.exec(
select(Favorite).where(
Favorite.user_id == current_user.id,
Favorite.item_type == favorite_data.item_type,
Favorite.item_id == favorite_data.item_id
)
).first()
if existing:
raise HTTPException(status_code=400, detail="Item already in favorites")
favorite = Favorite(
user_id=current_user.id,
item_type=favorite_data.item_type,
item_id=favorite_data.item_id,
item_name=favorite_data.item_name,
item_image=favorite_data.item_image
)
session.add(favorite)
session.commit()
session.refresh(favorite)
return favorite
@router.delete("/{item_type}/{item_id}")
async def remove_favorite(
item_type: str,
item_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Remove an item from favorites."""
favorite = session.exec(
select(Favorite).where(
Favorite.user_id == current_user.id,
Favorite.item_type == item_type,
Favorite.item_id == item_id
)
).first()
if not favorite:
raise HTTPException(status_code=404, detail="Favorite not found")
session.delete(favorite)
session.commit()
return {"ok": True}
@router.get("/check/{item_type}/{item_id}")
async def check_favorite(
item_type: str,
item_id: str,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
) -> dict:
"""Check if an item is favorited."""
favorite = session.exec(
select(Favorite).where(
Favorite.user_id == current_user.id,
Favorite.item_type == item_type,
Favorite.item_id == item_id
)
).first()
return {"is_favorite": favorite is not None}

View File

@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from typing import List, Optional
from uuid import UUID
from app.core.database import get_session
from app.api.deps import get_current_admin, get_current_user_optional
from app.models.report import Report
from app.models.user import User
from app.schemas.report import ReportCreate, ReportUpdate, ReportResponse
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.post("", response_model=ReportResponse, status_code=status.HTTP_201_CREATED)
async def create_report(
report_in: ReportCreate,
session: Session = Depends(get_session),
current_user: Optional[User] = Depends(get_current_user_optional)
):
"""Create a new user report."""
report = Report(
message=report_in.message,
user_id=current_user.id if current_user else None
)
session.add(report)
session.commit()
session.refresh(report)
return ReportResponse(
id=report.id,
user_id=report.user_id,
user_name=current_user.full_name if current_user else "Anónimo",
message=report.message,
status=report.status,
created_at=report.created_at
)
@router.get("", response_model=List[ReportResponse])
async def get_reports(
session: Session = Depends(get_session),
admin_auth: bool = Depends(get_current_admin)
):
"""Get all reports (Admin only)."""
statement = select(Report)
results = session.exec(statement).all()
reports = []
for report in results:
user_name = "Anónimo"
if report.user_id:
user = session.get(User, report.user_id)
if user:
user_name = user.full_name
reports.append(ReportResponse(
id=report.id,
user_id=report.user_id,
user_name=user_name,
message=report.message,
status=report.status,
created_at=report.created_at
))
return reports
@router.patch("/{report_id}", response_model=ReportResponse)
async def update_report_status(
report_id: UUID,
report_update: ReportUpdate,
session: Session = Depends(get_session),
admin_auth: bool = Depends(get_current_admin)
):
"""Update report status (Admin only)."""
report = session.get(Report, report_id)
if not report:
raise HTTPException(status_code=404, detail="Report not found")
report.status = report_update.status
session.add(report)
session.commit()
session.refresh(report)
user_name = "Anónimo"
if report.user_id:
user = session.get(User, report.user_id)
if user:
user_name = user.full_name
return ReportResponse(
id=report.id,
user_id=report.user_id,
user_name=user_name,
message=report.message,
status=report.status,
created_at=report.created_at
)

View File

@ -0,0 +1,229 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from typing import List, Optional
from sqlalchemy import func
from app.core.database import get_session
from app.models.route import Route
from app.models.route_stop import RouteStop
from app.schemas.route import RouteResponse, RouteCreate, RouteUpdate
from app.schemas.route_stop import RouteStopCreate, RouteStopUpdate
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/routes", tags=["routes"])
@router.get("", response_model=List[RouteResponse])
async def get_routes(
origin_city: Optional[str] = Query(None),
destination_city: Optional[str] = Query(None),
session: Session = Depends(get_session)
):
"""Get all routes with optional filtering by origin and destination city."""
statement = select(Route)
if origin_city:
statement = statement.where(Route.origin_city.contains(origin_city))
if destination_city:
statement = statement.where(Route.destination_city.contains(destination_city))
routes = session.exec(statement).all()
return routes
@router.get("/{route_id}", response_model=RouteResponse)
async def get_route(route_id: str, session: Session = Depends(get_session)):
"""Get a single route by ID."""
route = session.get(Route, route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
return route
@router.get("/{route_id}/stops")
async def get_route_stops(route_id: str, session: Session = Depends(get_session)):
"""Get all stops for a route."""
from app.models.route_stop import RouteStop
from app.models.bus_stop import BusStop
statement = select(RouteStop, BusStop).join(
BusStop, RouteStop.stop_id == BusStop.id
).where(RouteStop.route_id == route_id).order_by(RouteStop.stop_order)
results = session.exec(statement).all()
# Merge RouteStop data into BusStop response
stops = []
for route_stop, bus_stop in results:
stop_data = bus_stop.model_dump()
stop_data['stop_order'] = route_stop.stop_order
stop_data['travel_time_minutes'] = route_stop.travel_time_minutes
stop_data['stop_delay_minutes'] = route_stop.stop_delay_minutes
stop_data['is_pickup_point'] = route_stop.is_pickup_point
stop_data['is_dropoff_point'] = route_stop.is_dropoff_point
stops.append(stop_data)
return stops
@router.post("", response_model=RouteResponse)
async def create_route(
route: RouteCreate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Create a new route (Admin only)."""
db_route = Route.model_validate(route)
session.add(db_route)
session.commit()
session.refresh(db_route)
return db_route
@router.put("/{route_id}", response_model=RouteResponse)
async def update_route(
route_id: str,
route_update: RouteUpdate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a route (Admin only)."""
db_route = session.get(Route, route_id)
if not db_route:
raise HTTPException(status_code=404, detail="Route not found")
route_data = route_update.model_dump(exclude_unset=True)
for key, value in route_data.items():
setattr(db_route, key, value)
session.add(db_route)
session.commit()
session.refresh(db_route)
return db_route
@router.delete("/{route_id}")
async def delete_route(
route_id: str,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Delete a route (Admin only)."""
db_route = session.get(Route, route_id)
if not db_route:
raise HTTPException(status_code=404, detail="Route not found")
session.delete(db_route)
session.commit()
return {"ok": True}
# Route Stop Management with Cascade
@router.post("/{route_id}/stops")
async def add_stop_to_route(
route_id: str,
stop_data: RouteStopCreate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Add a stop to a route with cascading order adjustment."""
# 1. Check if route exists
route = session.get(Route, route_id)
if not route:
raise HTTPException(status_code=404, detail="Route not found")
# 2. Determine stop order
if stop_data.stop_order is None:
# Append to end
max_order = session.exec(select(func.max(RouteStop.stop_order)).where(RouteStop.route_id == route_id)).one()
# handle case where max_order is None (no stops)
stop_data.stop_order = (max_order or 0) + 1
else:
# Shift existing stops equal to or greater than new order
existing_stops = session.exec(
select(RouteStop).where(RouteStop.route_id == route_id, RouteStop.stop_order >= stop_data.stop_order)
).all()
for stop in existing_stops:
stop.stop_order += 1
session.add(stop)
# 3. Create new RouteStop
new_stop = RouteStop(
route_id=route_id,
stop_id=stop_data.stop_id,
stop_order=stop_data.stop_order,
travel_time_minutes=stop_data.travel_time_minutes,
stop_delay_minutes=stop_data.stop_delay_minutes or 0,
is_pickup_point=stop_data.is_pickup_point,
is_dropoff_point=stop_data.is_dropoff_point
)
session.add(new_stop)
session.commit()
session.refresh(new_stop)
return new_stop
@router.put("/{route_id}/stops/{stop_id}")
async def update_route_stop_order(
route_id: str,
stop_id: str,
update_data: RouteStopUpdate,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a route stop, potentially reordering others."""
# This assumes we find the connection by route_id and stop_id.
# NOTE: If a stop is on a route multiple times, this logic needs ID, but for now assuming unique stop per route.
# Actually RouteStop has its own ID but we are using stop_id in path. Let's find the RouteStop entry.
route_stop = session.exec(
select(RouteStop).where(RouteStop.route_id == route_id, RouteStop.stop_id == stop_id)
).first()
if not route_stop:
raise HTTPException(status_code=404, detail="Stop not found on this route")
old_order = route_stop.stop_order
# Update fields
if update_data.is_pickup_point is not None:
route_stop.is_pickup_point = update_data.is_pickup_point
if update_data.is_dropoff_point is not None:
route_stop.is_dropoff_point = update_data.is_dropoff_point
if update_data.travel_time_minutes is not None:
route_stop.travel_time_minutes = update_data.travel_time_minutes
if update_data.stop_delay_minutes is not None:
route_stop.stop_delay_minutes = update_data.stop_delay_minutes
# Reordering logic
if update_data.stop_order is not None and update_data.stop_order != old_order:
new_order = update_data.stop_order
if new_order > old_order:
# Moving down: shift stops between old+1 and new DOWN (-1)
stops_to_shift = session.exec(
select(RouteStop).where(
RouteStop.route_id == route_id,
RouteStop.stop_order > old_order,
RouteStop.stop_order <= new_order
)
).all()
for s in stops_to_shift:
s.stop_order -= 1
session.add(s)
else:
# Moving up: shift stops between new and old-1 UP (+1)
stops_to_shift = session.exec(
select(RouteStop).where(
RouteStop.route_id == route_id,
RouteStop.stop_order >= new_order,
RouteStop.stop_order < old_order
)
).all()
for s in stops_to_shift:
s.stop_order += 1
session.add(s)
route_stop.stop_order = new_order
session.add(route_stop)
session.commit()
session.refresh(route_stop)
return route_stop

View File

@ -0,0 +1,86 @@
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlmodel import Session, select
from typing import Optional
from uuid import UUID
from app.core.database import get_session
from app.models.bus_schedule import BusSchedule
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/schedules", tags=["schedules"])
@router.get("")
async def get_schedules(
route_id: Optional[UUID] = Query(None),
stop_id: Optional[UUID] = Query(None),
only_published: bool = Query(True),
session: Session = Depends(get_session)
):
"""Get schedules for a route or stop."""
statement = select(BusSchedule)
if only_published:
statement = statement.where(BusSchedule.is_published)
if route_id:
statement = statement.where(BusSchedule.route_id == route_id)
if stop_id:
from app.models.route_stop import RouteStop
statement = statement.join(
RouteStop, BusSchedule.route_id == RouteStop.route_id
).where(RouteStop.stop_id == stop_id)
schedules = session.exec(statement).all()
return schedules
@router.post("")
async def create_schedule(
schedule: BusSchedule,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Create a new bus schedule (Admin only)."""
db_schedule = BusSchedule.model_validate(schedule)
session.add(db_schedule)
session.commit()
session.refresh(db_schedule)
return db_schedule
@router.put("/{schedule_id}")
async def update_schedule(
schedule_id: UUID,
schedule_update: dict,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a bus schedule (Admin only)."""
db_schedule = session.get(BusSchedule, schedule_id)
if not db_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
for key, value in schedule_update.items():
if hasattr(db_schedule, key):
setattr(db_schedule, key, value)
session.add(db_schedule)
session.commit()
session.refresh(db_schedule)
return db_schedule
@router.delete("/{schedule_id}")
async def delete_schedule(
schedule_id: UUID,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Delete a bus schedule (Admin only)."""
db_schedule = session.get(BusSchedule, schedule_id)
if not db_schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
session.delete(db_schedule)
session.commit()
return {"ok": True}

View File

@ -0,0 +1,161 @@
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
from sqlmodel import Session, select
from typing import Optional, List
import os
import shutil
from uuid import uuid4, UUID
from app.core.database import get_session
from app.models.shuttle import Shuttle
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/shuttles", tags=["shuttles"])
UPLOAD_DIR = "uploads"
@router.get("", response_model=List[Shuttle])
async def get_shuttles(
origin: Optional[str] = Query(None),
destination: Optional[str] = Query(None),
company_name: Optional[str] = Query(None),
trip_type: Optional[str] = Query(None),
is_active: bool = Query(True),
session: Session = Depends(get_session)
):
"""Get all shuttles with optional filters."""
statement = select(Shuttle).where(Shuttle.is_active == is_active)
if origin:
statement = statement.where(Shuttle.origin.contains(origin))
if destination:
statement = statement.where(Shuttle.destination.contains(destination))
if company_name:
statement = statement.where(Shuttle.company_name.contains(company_name))
if trip_type:
statement = statement.where(Shuttle.trip_type == trip_type)
shuttles = session.exec(statement).all()
return shuttles
@router.post("", response_model=Shuttle)
async def create_shuttle(
route_name: str = Form(...),
origin: str = Form(...),
destination: str = Form(...),
vehicle_type: str = Form(...),
company_name: Optional[str] = Form(None),
trip_type: str = Form("one_way"),
price_per_person: Optional[float] = Form(None),
price_private_trip: Optional[float] = Form(None),
estimated_duration: str = Form(...),
contact_whatsapp: str = Form(...),
phone_number: Optional[str] = Form(None),
english_speaking: bool = Form(False),
description: Optional[str] = Form(None),
departure_times: Optional[str] = Form(None),
is_active: bool = Form(True),
image: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Create a new shuttle trip (Admin only)."""
image_url = None
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "vehicles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
image_url = f"/uploads/vehicles/{filename}"
shuttle = Shuttle(
route_name=route_name,
origin=origin,
destination=destination,
vehicle_type=vehicle_type,
company_name=company_name,
trip_type=trip_type,
price_per_person=price_per_person,
price_private_trip=price_private_trip,
estimated_duration=estimated_duration,
contact_whatsapp=contact_whatsapp,
phone_number=phone_number,
english_speaking=english_speaking,
description=description,
departure_times=departure_times,
image_url=image_url,
is_active=is_active
)
session.add(shuttle)
session.commit()
session.refresh(shuttle)
return shuttle
@router.put("/{shuttle_id}", response_model=Shuttle)
async def update_shuttle(
shuttle_id: UUID,
route_name: str = Form(...),
origin: str = Form(...),
destination: str = Form(...),
vehicle_type: str = Form(...),
company_name: Optional[str] = Form(None),
trip_type: str = Form("one_way"),
price_per_person: Optional[float] = Form(None),
price_private_trip: Optional[float] = Form(None),
estimated_duration: str = Form(...),
contact_whatsapp: str = Form(...),
phone_number: Optional[str] = Form(None),
english_speaking: bool = Form(False),
description: Optional[str] = Form(None),
departure_times: Optional[str] = Form(None),
is_active: bool = Form(True),
image: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a shuttle trip (Admin only)."""
db_shuttle = session.get(Shuttle, shuttle_id)
if not db_shuttle:
raise HTTPException(status_code=404, detail="Shuttle not found")
db_shuttle.route_name = route_name
db_shuttle.origin = origin
db_shuttle.destination = destination
db_shuttle.vehicle_type = vehicle_type
db_shuttle.company_name = company_name
db_shuttle.trip_type = trip_type
db_shuttle.price_per_person = price_per_person
db_shuttle.price_private_trip = price_private_trip
db_shuttle.estimated_duration = estimated_duration
db_shuttle.contact_whatsapp = contact_whatsapp
db_shuttle.phone_number = phone_number
db_shuttle.english_speaking = english_speaking
db_shuttle.description = description
db_shuttle.departure_times = departure_times
db_shuttle.is_active = is_active
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "vehicles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
db_shuttle.image_url = f"/uploads/vehicles/{filename}"
session.add(db_shuttle)
session.commit()
session.refresh(db_shuttle)
return db_shuttle
@router.delete("/{shuttle_id}")
async def delete_shuttle(
shuttle_id: UUID,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Delete a shuttle trip (Admin only)."""
db_shuttle = session.get(Shuttle, shuttle_id)
if not db_shuttle:
raise HTTPException(status_code=404, detail="Shuttle not found")
session.delete(db_shuttle)
session.commit()
return {"ok": True}

View File

@ -0,0 +1,145 @@
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
from sqlmodel import Session, select
from typing import Optional
import os
import shutil
from uuid import uuid4
from app.core.database import get_session
from app.models.taxi import Taxi
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/taxis", tags=["taxis"])
UPLOAD_DIR = "uploads"
@router.get("")
async def get_taxis(
corregimiento: Optional[str] = Query(None),
shift: Optional[str] = Query(None),
english_speaking: Optional[bool] = Query(None),
is_active: Optional[bool] = Query(None),
session: Session = Depends(get_session)
):
"""Get all taxis with optional filters."""
statement = select(Taxi)
if is_active is not None:
statement = statement.where(Taxi.is_active == is_active)
if corregimiento:
statement = statement.where(Taxi.corregimiento.contains(corregimiento))
if shift:
statement = statement.where(Taxi.shift == shift)
if english_speaking is not None:
statement = statement.where(Taxi.english_speaking == english_speaking)
taxis = session.exec(statement).all()
return taxis
@router.post("")
async def create_taxi(
owner_name: str = Form(...),
phone_number: str = Form(...),
license_plate: str = Form(...),
corregimiento: str = Form(...),
shift: str = Form(...),
cooperative: Optional[str] = Form(None),
rating: float = Form(5.0),
english_speaking: bool = Form(False),
is_active: bool = Form(True),
image: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Create a new taxi entry (Admin only)."""
image_url = None
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "profiles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
image_url = f"/uploads/profiles/{filename}"
taxi = Taxi(
owner_name=owner_name,
phone_number=phone_number,
license_plate=license_plate,
cooperative=cooperative,
corregimiento=corregimiento,
shift=shift,
rating=rating,
english_speaking=english_speaking,
image_url=image_url,
is_active=is_active
)
session.add(taxi)
session.commit()
session.refresh(taxi)
return taxi
@router.put("/{taxi_id}")
async def update_taxi(
taxi_id: str,
owner_name: str = Form(...),
phone_number: str = Form(...),
license_plate: str = Form(...),
corregimiento: str = Form(...),
shift: str = Form(...),
cooperative: Optional[str] = Form(None),
rating: float = Form(5.0),
english_speaking: bool = Form(False),
is_active: bool = Form(True),
image: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Update a taxi entry (Admin only)."""
db_taxi = session.get(Taxi, taxi_id)
if not db_taxi:
raise HTTPException(status_code=404, detail="Taxi not found")
# Update fields
db_taxi.owner_name = owner_name
db_taxi.phone_number = phone_number
db_taxi.license_plate = license_plate
db_taxi.corregimiento = corregimiento
db_taxi.shift = shift
db_taxi.cooperative = cooperative
db_taxi.rating = rating
db_taxi.english_speaking = english_speaking
db_taxi.is_active = is_active
# Handle image upload
if image:
ext = os.path.splitext(image.filename)[1]
filename = f"{uuid4()}{ext}"
path = os.path.join(UPLOAD_DIR, "profiles", filename)
with open(path, "wb") as buffer:
shutil.copyfileobj(image.file, buffer)
db_taxi.image_url = f"/uploads/profiles/{filename}"
session.add(db_taxi)
session.commit()
session.refresh(db_taxi)
return db_taxi
@router.delete("/{taxi_id}")
async def delete_taxi(
taxi_id: str,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Delete a taxi entry (Admin only)."""
db_taxi = session.get(Taxi, taxi_id)
if not db_taxi:
raise HTTPException(status_code=404, detail="Taxi not found")
session.delete(db_taxi)
session.commit()
return {"ok": True}

View File

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from typing import List
from datetime import datetime, timedelta
from app.core.database import get_session
from app.models.telemetry import Telemetry, TelemetryCreate, VehicleStatus
from app.models.user import User
from app.api.deps import get_current_user
router = APIRouter(prefix="/api/telemetry", tags=["telemetry"])
@router.post("", response_model=Telemetry)
async def create_telemetry_record(
*,
session: Session = Depends(get_session),
telemetry_in: TelemetryCreate,
current_user: User = Depends(get_current_user)
):
"""
Create a new telemetry record for the current driver.
"""
if current_user.role != "driver":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only drivers can send telemetry data"
)
db_telemetry = Telemetry(
user_id=current_user.id,
latitude=telemetry_in.latitude,
longitude=telemetry_in.longitude,
speed=telemetry_in.speed,
heading=telemetry_in.heading,
status=telemetry_in.status
)
session.add(db_telemetry)
session.commit()
session.refresh(db_telemetry)
return db_telemetry
@router.get("/active", response_model=List[dict])
async def get_active_units(
*,
session: Session = Depends(get_session)
):
"""
Get the latest location of all active units (last 5 minutes).
"""
# Subquery to get the latest timestamp per user
five_minutes_ago = datetime.utcnow() - timedelta(minutes=5)
# This is a bit complex in SQLModel/SQLAlchemy for "latest record per group"
# We'll use a simpler approach: get all records from last 5 mins and filter in python
# or use a more efficient distinct on if supported.
statement = (
select(Telemetry)
.where(Telemetry.timestamp >= five_minutes_ago)
.where(Telemetry.status == VehicleStatus.ACTIVE)
.order_by(Telemetry.user_id, Telemetry.timestamp.desc())
)
results = session.exec(statement).all()
# Filter to get only the latest unique user_id
latest_units = {}
for t in results:
if t.user_id not in latest_units:
# We also want the driver name and vehicle info
user = session.get(User, t.user_id)
latest_units[t.user_id] = {
"user_id": t.user_id,
"full_name": user.full_name if user else "Unknown",
"latitude": t.latitude,
"longitude": t.longitude,
"speed": t.speed,
"heading": t.heading,
"timestamp": t.timestamp,
"vehicle_type": user.driver_profile.vehicle_type if user and user.driver_profile else "unknown",
"license_plate": user.driver_profile.license_plate if user and user.driver_profile else "unknown"
}
return list(latest_units.values())

116
backend/app/api/users.py Normal file
View File

@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select
from uuid import UUID
from app.core.database import get_session
from app.models.user import User
from app.api.deps import get_current_admin
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("/search")
async def search_users(
email: str = Query(..., description="Email to search for"),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Search for users by email (Admin only)."""
statement = select(User).where(User.email.contains(email))
users = session.exec(statement).all()
# Clean response (don't send hashed passwords)
return [
{
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"role": user.role,
"is_verified": user.is_verified,
"created_at": user.created_at
} for user in users
]
@router.get("/{user_id}")
async def get_user_details(
user_id: UUID,
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Get detailed user info including driver profile (Admin only)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
result = {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"role": user.role,
"is_active": user.is_active,
"is_verified": user.is_verified,
"created_at": user.created_at,
"driver_profile": None
}
if user.driver_profile:
dp = user.driver_profile
result["driver_profile"] = {
"cedula": dp.cedula,
"vehicle_type": dp.vehicle_type,
"license_plate": dp.license_plate,
"cooperative_name": dp.cooperative_name,
"photo_url": dp.photo_url,
"vehicle_photo_url": dp.vehicle_photo_url,
"shift": dp.shift,
"payment_methods": dp.payment_methods,
"speaks_english": dp.speaks_english
}
return result
@router.get("/pending-drivers")
async def get_pending_drivers(
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""List drivers waiting for verification (Admin only)."""
# Find users with DRIVER role who are NOT verified
from app.models.user import UserRole
statement = select(User).where(User.role == UserRole.DRIVER, User.is_verified.is_(False))
return [
{
"id": driver.id,
"email": driver.email,
"full_name": driver.full_name,
"created_at": driver.created_at,
"driver_profile": {
"cedula": driver.driver_profile.cedula,
"vehicle_type": driver.driver_profile.vehicle_type,
"license_plate": driver.driver_profile.license_plate,
"cooperative_name": driver.driver_profile.cooperative_name,
"shift": driver.driver_profile.shift,
"payment_methods": driver.driver_profile.payment_methods,
"speaks_english": driver.driver_profile.speaks_english
} if driver.driver_profile else None
} for driver in session.exec(statement).all()
]
@router.post("/{user_id}/verify")
async def verify_user(
user_id: UUID,
is_verified: bool = Query(..., description="True to approve, False to stay unverified/reject"),
session: Session = Depends(get_session),
_: bool = Depends(get_current_admin)
):
"""Approve or Reject a user verification (Admin only)."""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.is_verified = is_verified
session.add(user)
session.commit()
session.refresh(user)
return {"id": user.id, "email": user.email, "is_verified": user.is_verified}