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

113
.cursor/rules/main.mdc Normal file
View File

@ -0,0 +1,113 @@
# SIBU Project Rules and Conventions
## Project Structure
This is a full-stack transportation application:
- **Backend**: FastAPI + SQLModel + Postgres (in `backend/` folder) using uv
- **Frontend**: Vue3 + Vite + Shadcn (in `frontend/` folder) using Bun
## Package Management
### Backend
- **Always use `uv` for package management**
- **NEVER edit `pyproject.toml` manually** - use `uv add <package>` instead
- Execute backend with: `uv run fastapi dev app/main.py` (development) or `uv run fastapi run app/main.py` (production)
- Run migrations with: `uv run alembic upgrade head`
### Frontend
- **Always use `bun` for package management**
- **NEVER edit `package.json` manually** - use `bun add <package>` instead
- Run development server: `bun run dev`
- Build for production: `bun run build`
## Backend Conventions
### Configuration
- Use `pydantic-settings` BaseSettings class in `app/core/config.py` for all environment variable management
- Environment files: `.env.development` and `.env.production`
- Database URL format: `postgresql+asyncpg://localhost:5432/sibu_dev`
### Code Style
- Use Python type hints for all function parameters and return types
- Use SQLModel for database models
- Use Pydantic schemas for request/response validation
- Follow FastAPI best practices for API routes
- Use dependency injection for database sessions
### Project Structure
```
backend/
├── app/
│ ├── api/ # API route handlers
│ ├── core/ # Configuration and database
│ ├── models/ # SQLModel models
│ ├── schemas/ # Pydantic schemas
│ └── services/ # Business logic
├── alembic/ # Database migrations
└── pyproject.toml # Managed by uv
```
## Frontend Conventions
### Code Style
- Use Vue3 Composition API with `<script setup>`
- Use TypeScript for type safety
- Use Pinia for state management
- Use Vue Router for navigation
- Import paths should use `@/` alias for `src/` directory
### Project Structure
```
frontend/
src/
├── components/ # Reusable Vue components
├── views/ # Main screen components
├── services/ # API client services
├── stores/ # Pinia stores
├── types/ # TypeScript type definitions
├── composables/ # Vue composables
├── utils/ # Helper functions
└── router/ # Vue Router configuration
```
### Environment Variables
- Frontend env files: `.env.development` and `.env.production`
- All env vars must be prefixed with `VITE_` to be accessible in the app
- Default API URL for development: `http://localhost:8000`
- API URL will change for production
## Google Maps Integration
- Use Google Maps JavaScript API (development mode initially)
- API key should be stored in `.env` files
- Use `@googlemaps/js-api-loader` package for loading the API
## Database
- Use PostgreSQL with asyncpg driver
- Use SQLModel for ORM (combines SQLAlchemy + Pydantic)
- Use Alembic for migrations
- Database connection managed through `app/core/database.py`
## API Design
- RESTful API endpoints
- Use `/api/` prefix for all API routes
- Response models using Pydantic schemas
- Proper error handling and HTTP status codes
## General Guidelines
- Always use environment variables for configuration
- Never commit `.env` files (use `.env.example` as template)
- Use TypeScript/Python type hints for better code quality
- Follow the service layer pattern for business logic
- Use composables for reusable Vue logic
- Keep components small and focused
- Write clear, descriptive commit messages
## Testing
- Backend: Use FastAPI's TestClient for API testing
- Frontend: Use Vue Test Utils for component testing
- Always test critical paths before deploying

View File

@ -0,0 +1,57 @@
name: Deploy to Coolify
run-name: ${{ gitea.actor }} is executing Deploy to Coolify 🚀
on:
workflow_dispatch:
push:
branches:
- main
jobs:
quality-assurance:
name: Quality Assurance
uses: ./.gitea/workflows/quality_assurance.yaml
deploy:
name: Deploy to Coolify
runs-on: ubuntu-latest
needs: quality-assurance
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to Coolify
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
COOLIFY_WEBHOOK_URL: ${{ secrets.COOLIFY_WEBHOOK_URL }}
COOLIFY_UI_WEBHOOK_URL: ${{ secrets.COOLIFY_UI_WEBHOOK_URL }}
COOLIFY_API_TOKEN: ${{ secrets.COOLIFY_API_TOKEN }}
run: |
echo "Deploying to Coolify..."
if [ -z "$COOLIFY_WEBHOOK_URL" ]; then
echo "Error: COOLIFY_WEBHOOK_URL environment variable not set"
echo "Please set COOLIFY_WEBHOOK_URL in your environment or .env file"
exit 1
fi
echo "Triggering Coolify deployment via webhook..."
curl -X POST "$COOLIFY_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
-d '{"source": "gitea", "branch": "main", "commit": "'$(git rev-parse HEAD)'"}' \
--silent --show-error
curl -X POST "$COOLIFY_UI_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $COOLIFY_API_TOKEN" \
-d '{"source": "gitea", "branch": "main", "commit": "'$(git rev-parse HEAD)'"}' \
--silent --show-error
echo "Coolify deployment triggered successfully! UI deployment triggered successfully!"
# Step to run if deploy fails
- name: Notify on Deploy Failure
if: failure()
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
run: |
curl --location "https://api.telegram.org/bot$TG_BOT_TOKEN/sendMessage" \
--header 'Content-Type: application/json' \
--data '{"chat_id": "-1002299691117", "message_thread_id": 7, "text": "[*Vertex CRM*](https://crm.vertexdc.com) Deploy to Coolify has FAILED", "parse_mode": "MarkdownV2"}'

View File

@ -0,0 +1,42 @@
name: Quality Assurance
run-name: ${{ gitea.actor }} is executing Quality Assurance 🚀
on:
# Disabled push events, because it hinders the deployment process
# push:
# branches-ignore:
# - main # Allow push events on all branches except main
pull_request: {}
workflow_call: # This allows other workflows to call this workflow
jobs:
Quality-Assurance:
name: Quality Assurance
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Dependencies
run: |
sudo apt update
sudo apt install -y libpq-dev postgresql
- name: Set up Bun
uses: oven-sh/setup-bun@v2
- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: 3.12
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Install Python Dependencies
run: |
make install
- name: Lint with black
run: |
make lint-backend
- run: echo "🍏 This job's status is ${{ job.status }}."

46
.gitignore vendored Normal file
View File

@ -0,0 +1,46 @@
# SIBU Root .gitignore
# Python / FastAPI
**/__pycache__/
**/*.py[cod]
**/*$py.class
**/.pytest_cache/
**/*.venv
**/venv/
**/env/
**/.env
**/instance/
**/.pdm-python
**/.pdm-build/
**/.ruff_cache/
# Node / Frontend
**/node_modules/
**/dist/
**/dist-ssr/
**/*.local
**/npm-debug.log*
**/yarn-debug.log*
**/yarn-error.log*
**/pnpm-debug.log*
**/lerna-debug.log*
# OS / Editor
.DS_Store
Thumbs.db
.vscode/
!.vscode/extensions.json
.idea/
*.swp
*.swo
*.suo
*.ntvs*
*.njsproj
*.sln
# UV (Backend dependency manager)
backend/.python-version
backend/.uv
**/uploads
**/dev-dist

163
Makefile Normal file
View File

@ -0,0 +1,163 @@
.PHONY: help install-backend install-frontend dev-backend dev-frontend dev migrate-up migrate-down migrate-create setup clean seed
# Default target
.DEFAULT_GOAL := help
help: ## Show this help message
@echo 'Usage: make [target]'
@echo ''
@echo 'Available targets:'
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-20s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
# Installation targets
install-backend: ## Install backend dependencies
@echo "Installing backend dependencies..."
cd backend && uv sync
FRONTEND_MANAGER ?= bun
install-frontend: ## Install frontend dependencies
@echo "Installing frontend dependencies..."
cd frontend && $(FRONTEND_MANAGER) install
install: install-backend install-frontend ## Install all dependencies
# Development server targets
dev-backend: ## Run backend development server
@echo "Starting backend server..."
cd backend && uv run fastapi dev app/main.py
dev-frontend: ## Run frontend development server
@echo "Starting frontend server..."
cd frontend && $(FRONTEND_MANAGER) run dev
dev: ## Run both backend and frontend servers concurrently
@echo "Starting both backend and frontend servers..."
@make -j2 dev-backend dev-frontend
# Migration targets
migrate-create: ## Create a new migration (usage: make migrate-create NAME=migration_name)
@if [ -z "$(NAME)" ]; then \
echo "Error: NAME is required. Usage: make migrate-create NAME=migration_name"; \
exit 1; \
fi
@echo "Creating migration: $(NAME)..."
cd backend && uv run alembic revision --autogenerate -m "$(NAME)"
migrate-up: ## Apply all pending migrations
@echo "Applying migrations..."
cd backend && uv run alembic upgrade head
migrate-down: ## Rollback last migration
@echo "Rolling back last migration..."
cd backend && uv run alembic downgrade -1
migrate-history: ## Show migration history
@echo "Migration history:"
cd backend && uv run alembic history
migrate-current: ## Show current migration version
@echo "Current migration version:"
cd backend && uv run alembic current
# Setup target
setup: install ## Install dependencies and setup project
@echo "Setup complete! Don't forget to:"
@echo " 1. Configure backend/.env.development with your DATABASE_URL"
@echo " 2. Configure frontend/.env.development with your VITE_API_URL and VITE_GOOGLE_MAPS_API_KEY"
@echo " 3. Run 'make migrate-up' to apply database migrations"
# Build targets
build-backend: ## Build backend for production
@echo "Building backend..."
cd backend && uv build
build-frontend: ## Build frontend for production
@echo "Building frontend..."
cd frontend && $(FRONTEND_MANAGER) run build
build: build-backend build-frontend ## Build both backend and frontend
# Production server targets
run-backend: ## Run backend production server
@echo "Starting backend production server..."
cd backend && uv run fastapi run app/main.py
# Utility targets
clean-backend: ## Clean backend cache and temporary files
@echo "Cleaning backend..."
cd backend && find . -type d -name __pycache__ -exec rm -r {} + 2>/dev/null || true
cd backend && find . -type f -name "*.pyc" -delete
cd backend && rm -rf .pytest_cache .mypy_cache .coverage htmlcov 2>/dev/null || true
clean-frontend: ## Clean frontend build artifacts
@echo "Cleaning frontend..."
cd frontend && rm -rf dist node_modules/.vite 2>/dev/null || true
clean: clean-backend clean-frontend ## Clean all build artifacts
# Database targets
db-init: ## Initialize database (create tables)
@echo "Initializing database..."
cd backend && uv run python -c "from app.core.database import init_db; init_db()"
db-reset: ## Reset database (drop all tables and recreate)
@echo "Resetting database..."
@echo "⚠️ This will drop all tables and data!"
@read -p "Are you sure? [y/N] " -n 1 -r; \
echo; \
if [[ $$REPLY =~ ^[Yy]$$ ]]; then \
cd backend && uv run python -c "from sqlalchemy import text; from app.core.database import engine; from sqlmodel import Session; session = Session(engine); session.exec(text('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;')); session.commit(); session.close()"; \
echo "Database reset complete. Run 'make migrate-up' to recreate tables."; \
else \
echo "Cancelled."; \
fi
db-reset-alembic: ## Reset alembic version table (use when rebuilding migrations)
@echo "Resetting alembic version table..."
cd backend && uv run python -c "from sqlalchemy import text; from app.core.database import engine; from sqlmodel import Session; session = Session(engine); session.exec(text('DROP TABLE IF EXISTS alembic_version;')); session.commit(); session.close()"
@echo "Alembic version table reset. You can now create a new migration."
seed: ## Seed database with initial data
@echo "Seeding database..."
cd backend && uv run python -m app.core.seed
import-coordinates: ## Import bus stop coordinates from Supabase (usage: make import-coordinates ROUTE="Boquete>David" or make import-coordinates ALL=true)
@echo "Importing coordinates from Supabase..."
@if [ "$(ALL)" = "true" ]; then \
cd backend && uv run python -m app.core.import_supabase_coordinates --all; \
else \
cd backend && uv run python -m app.core.import_supabase_coordinates "$(ROUTE)"; \
fi
##Probar los lint
# Format and lint targets
format-backend: ## Format backend code
@echo "Formatting backend code..."
cd backend && uv run ruff format .
lint-backend: install-backend ## Lint backend code
@echo "Linting backend code..."
cd backend && uv run ruff check . --fix
format-frontend: ## Format frontend code
@echo "Formatting frontend code..."
cd frontend && $(FRONTEND_MANAGER) run format || echo "No format script found"
lint-frontend: install-frontend ## Lint frontend code
@echo "Linting frontend code..."
cd frontend && $(FRONTEND_MANAGER) run build
# cd frontend && $(FRONTEND_MANAGER) run lint || echo "No lint script found"
lint: lint-backend lint-frontend ## Lint all code
# Test targets
test-backend: ## Run backend tests
@echo "Running backend tests..."
cd backend && uv run pytest || echo "No tests found"
test-frontend: ## Run frontend tests
@echo "Running frontend tests..."
cd frontend && $(FRONTEND_MANAGER) run test || echo "No tests found"
test: test-backend test-frontend ## Run all tests

24
PENDING_FOR_TOMORROW.md Normal file
View File

@ -0,0 +1,24 @@
# Plan de Trabajo - SIBU (Continuación)
## 🎯 Avances de Hoy (2026-01-28)
* **Estabilidad del Inicio:** Se solucionó el bloqueo de la "Loading Screen" con un timeout de seguridad y correcciones de enrutamiento/roles.
* **Refactorización Completa del Panel de Promotor:**
* Nuevo diseño de cabecera centrado y moderno.
* Contadores de estadísticas dinámicos en la barra de acciones.
* **Subida de Imágenes Real:** Implementado soporte para subir fotos de negocios desde el dispositivo (sustituyendo URLs).
* Alineación de tablas corregida (headers y celdas centradas).
* Categorías actualizadas: "Área Turística" y "Viajes de Turismo".
* **Gestión de Versiones:** Commit y Tag `promos-admin` creados y pusheados a GitHub.
## 🔜 Pendiente para Mañana
1. **Visualización en Mapa:** Hacer que los negocios con promociones activas aparezcan como pines especiales o destacados en el mapa principal.
2. **Dashboard del Conductor:**
* Verificar la transmisión de ubicación en tiempo real (Telemetry).
* Asegurar que el estado "In Service" sea persistente y visible para los usuarios.
3. **Sistema de Notificaciones:** Implementar avisos cuando un transporte favorito esté cerca (Punto 4 original).
4. **Admin Verification:** Mejorar la visualización de documentos en el panel de administración para agilizar validaciones.
## 🛠️ Notas Técnicas
* Las imágenes de negocios se guardan en `backend/uploads/businesses/`.
* El frontend usa la constante `API_URL` de `apiClient.ts` para resolver las rutas de imágenes.
* Se configuró el `.gitignore` para no subir archivos de la carpeta `uploads`.

219
README.md Normal file
View File

@ -0,0 +1,219 @@
# SIBU - Sistema de Transporte
A full-stack public transportation application for tracking bus routes, schedules, and providing transportation services information.
## Project Structure
```
sibu/
├── backend/ # FastAPI backend (uv)
├── frontend/ # Vue3 frontend (Bun)
├── old/ # Archived Flutter app
└── .cursor/ # Cursor IDE rules
```
## Tech Stack
### Backend
- **FastAPI** - Modern Python web framework
- **SQLModel** - SQL database ORM (combines SQLAlchemy + Pydantic)
- **PostgreSQL** - Database
- **uv** - Fast Python package manager
- **Alembic** - Database migrations
### Frontend
- **Vue 3** - Progressive JavaScript framework
- **Vite** - Fast build tool
- **TypeScript** - Type-safe JavaScript
- **Pinia** - State management
- **Vue Router** - Client-side routing
- **Bun** - Fast JavaScript runtime and package manager
- **Google Maps API** - Map integration
## Getting Started
### Prerequisites
- Python 3.13+ (for backend)
- uv (Python package manager)
- Node.js 18+ or Bun (for frontend)
- PostgreSQL database
- Make (for using Makefile commands)
### Quick Start with Makefile
The easiest way to get started is using the provided Makefile:
1. Install all dependencies:
```bash
make setup
```
2. Set up environment variables:
```bash
# Backend: Copy and edit .env.development
cd backend && cp .env.example .env.development
# Edit backend/.env.development with your database URL
# Frontend: Copy and edit .env.development
cd frontend && cp .env.example .env.development
# Edit frontend/.env.development with your API URL and Google Maps key
```
3. Apply database migrations:
```bash
make migrate-up
```
4. Run both backend and frontend:
```bash
make dev
```
Or run them separately:
```bash
make dev-backend # Backend only (http://localhost:8000)
make dev-frontend # Frontend only (http://localhost:5173)
```
### Manual Setup
#### Backend Setup
1. Navigate to backend directory:
```bash
cd backend
```
2. Install dependencies:
```bash
uv sync
```
3. Set up environment variables:
```bash
# Copy and edit .env.development
cp .env.example .env.development
# Edit .env.development with your database URL
```
4. Run database migrations:
```bash
uv run alembic upgrade head
```
5. Start the development server:
```bash
uv run fastapi dev app/main.py
```
The API will be available at `http://localhost:8000`
#### Frontend Setup
1. Navigate to frontend directory:
```bash
cd frontend
```
2. Install dependencies:
```bash
bun install
```
3. Set up environment variables:
```bash
# Copy and edit .env.development
cp .env.example .env.development
# Edit .env.development with your API URL and Google Maps key
```
4. Start the development server:
```bash
bun run dev
```
The app will be available at `http://localhost:5173`
## Environment Variables
### Backend (.env.development)
```
DATABASE_URL=postgresql+asyncpg://localhost:5432/sibu_dev
ENVIRONMENT=development
DEBUG=true
```
### Frontend (.env.development)
```
VITE_API_URL=http://localhost:8000
VITE_GOOGLE_MAPS_API_KEY=your-google-maps-api-key-here
```
## API Endpoints
- `GET /api/routes` - Get all routes
- `GET /api/routes/{id}` - Get route by ID
- `GET /api/routes/{id}/stops` - Get stops for a route
- `GET /api/bus-stops` - Get all bus stops
- `GET /api/bus-stops/{id}` - Get bus stop by ID
- `GET /api/bus-stops/{id}/routes` - Get routes for a bus stop
- `GET /api/schedules` - Get schedules (query params: route_id, stop_id)
- `GET /api/coupons` - Get coupons
- `GET /api/taxis` - Get taxis
## Development
### Makefile Commands
The project includes a Makefile with convenient commands:
**Installation:**
- `make install` - Install all dependencies
- `make install-backend` - Install backend dependencies only
- `make install-frontend` - Install frontend dependencies only
- `make setup` - Full setup (install + instructions)
**Development:**
- `make dev` - Run both backend and frontend concurrently
- `make dev-backend` - Run backend development server
- `make dev-frontend` - Run frontend development server
**Migrations:**
- `make migrate-up` - Apply all pending migrations
- `make migrate-down` - Rollback last migration
- `make migrate-create NAME=migration_name` - Create a new migration
- `make migrate-history` - Show migration history
- `make migrate-current` - Show current migration version
**Building:**
- `make build` - Build both backend and frontend
- `make build-backend` - Build backend only
- `make build-frontend` - Build frontend only
**Other:**
- `make clean` - Clean all build artifacts
- `make test` - Run all tests
- `make help` - Show all available commands
### Manual Commands
#### Backend Commands
- `uv add <package>` - Add a dependency
- `uv run fastapi dev app/main.py` - Run development server
- `uv run fastapi run app/main.py` - Run production server
- `uv run alembic upgrade head` - Run database migrations
- `uv run alembic revision --autogenerate -m "name"` - Create migration
#### Frontend Commands
- `bun add <package>` - Add a dependency
- `bun run dev` - Run development server
- `bun run build` - Build for production
## Project Conventions
See `.cursor/rules/main.mdc` for detailed coding conventions and best practices.
## License
[Your License Here]

6
backend/.env.development Normal file
View File

@ -0,0 +1,6 @@
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/sibu
ENVIRONMENT=production
DEBUG=false
RUN_SEEDERS=false
SECRET_KEY=sibu-secret-key-for-dev-12345
ADMIN_PASSWORD=admin

43
backend/Dockerfile Normal file
View File

@ -0,0 +1,43 @@
# Use Python 3.13 slim image as base
FROM python:3.13-slim as base
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
curl \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Create a non-root user
RUN useradd -m -u 1000 appuser && \
chown -R appuser:appuser /app
# Copy dependency files
COPY pyproject.toml uv.lock ./
# Install dependencies
RUN uv sync --frozen --no-dev
# Copy application code
COPY --chown=appuser:appuser . .
# Ensure entrypoint script is executable
RUN chmod +x /app/docker-entrypoint.sh
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 8000
# Set entrypoint
ENTRYPOINT ["/app/docker-entrypoint.sh"]
# Default command
CMD ["uv", "run", "fastapi", "run", "app/main.py", "--host", "0.0.0.0", "--port", "8000"]

92
backend/alembic.ini Normal file
View File

@ -0,0 +1,92 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python-dateutil library that can be
# installed by adding `alembic[tz]` to the pip requirements
# string value is passed to dateutil.tz.gettz()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc files to
# be detected as revisions in the versions/ directory
# pyc_files = false
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

95
backend/alembic/env.py Normal file
View File

@ -0,0 +1,95 @@
"""Alembic environment configuration."""
from logging.config import fileConfig
from sqlmodel import SQLModel
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from sqlalchemy import text as sa_text
from alembic import context
# Import your models and database configuration
from app.core.config import settings
from app.models import * # noqa: F401, F403
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Set the sqlalchemy.url from our settings
# Convert asyncpg URL to psycopg2 for Alembic (Alembic doesn't support async)
database_url = settings.database_url.replace("+asyncpg", "+psycopg2")
config.set_main_option("sqlalchemy.url", database_url)
# add your model's MetaData object here
# for 'autogenerate' support
# Import all models to ensure they're registered
target_metadata = SQLModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
# Set search_path to public schema
connection.execute(sa_text("SET search_path TO public"))
connection.commit()
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,27 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,32 @@
"""add_search_indexes
Revision ID: 2088667c3a5f
Revises: 4c9211307d7a
Create Date: 2026-01-28 18:35:15.269274
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '2088667c3a5f'
down_revision: Union[str, None] = '4c9211307d7a'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_coupons_title'), 'coupons', ['title'], unique=False)
op.create_index(op.f('ix_users_full_name'), 'users', ['full_name'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_full_name'), table_name='users')
op.drop_index(op.f('ix_coupons_title'), table_name='coupons')
# ### end Alembic commands ###

View File

@ -0,0 +1,88 @@
"""initial_schema
Revision ID: 2f4936eb86f0
Revises:
Create Date: 2025-12-02 21:34:08.468033
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2f4936eb86f0'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('bus_stops',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('latitude', sa.Float(), nullable=False),
sa.Column('longitude', sa.Float(), nullable=False),
sa.Column('city', sa.String(), nullable=False),
sa.Column('address', sa.String(), nullable=True),
sa.Column('stop_type', sa.Enum('TERMINAL', 'REGULAR', 'EXPRESS_ONLY', name='stoptype'), nullable=False),
sa.Column('has_shelter', sa.Boolean(), nullable=False),
sa.Column('has_seating', sa.Boolean(), nullable=False),
sa.Column('is_accessible', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('routes',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('origin_city', sa.String(), nullable=False),
sa.Column('destination_city', sa.String(), nullable=False),
sa.Column('distance_km', sa.Float(), nullable=True),
sa.Column('estimated_duration_minutes', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'INACTIVE', 'MAINTENANCE', name='routestatus'), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_routes_name'), 'routes', ['name'], unique=True)
op.create_table('bus_schedules',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('route_id', sa.Uuid(), nullable=False),
sa.Column('departure_time', sa.Time(), nullable=False),
sa.Column('frequency_minutes', sa.Integer(), nullable=True),
sa.Column('schedule_type', sa.Enum('WEEKDAY', 'WEEKEND', 'HOLIDAY', name='busscheduletype'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('notes', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['route_id'], ['routes.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('route_stops',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('route_id', sa.Uuid(), nullable=False),
sa.Column('stop_id', sa.Uuid(), nullable=False),
sa.Column('stop_order', sa.Integer(), nullable=False),
sa.Column('travel_time_minutes', sa.Integer(), nullable=True),
sa.Column('is_pickup_point', sa.Boolean(), nullable=False),
sa.Column('is_dropoff_point', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['route_id'], ['routes.id'], ),
sa.ForeignKeyConstraint(['stop_id'], ['bus_stops.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('route_stops')
op.drop_table('bus_schedules')
op.drop_index(op.f('ix_routes_name'), table_name='routes')
op.drop_table('routes')
op.drop_table('bus_stops')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""Sync shuttle fields
Revision ID: 3fe72cd3f722
Revises: 5caf8ba3ed4d
Create Date: 2026-02-15 16:20:02.326548
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '3fe72cd3f722'
down_revision: Union[str, None] = '5caf8ba3ed4d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# 1. Reparar valores nulos existentes en driver_profiles antes de restringir
op.execute("UPDATE driver_profiles SET speaks_english = FALSE WHERE speaks_english IS NULL")
op.alter_column('driver_profiles', 'speaks_english',
existing_type=sa.BOOLEAN(),
nullable=False)
# 2. Asegurar campos de Shuttles (solo si no existen, usando try/except o verificando primero)
# Nota: Alembic no los detectó, pero los forzamos por si acaso
with op.get_context().autocommit_block():
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS company_name VARCHAR")
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS trip_type VARCHAR DEFAULT 'one_way'")
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS price_private_trip FLOAT")
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS departure_times VARCHAR")
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS contact_whatsapp VARCHAR")
op.execute("ALTER TABLE shuttles ADD COLUMN IF NOT EXISTS estimated_duration VARCHAR")
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('driver_profiles', 'speaks_english',
existing_type=sa.BOOLEAN(),
nullable=True)
# ### end Alembic commands ###

View File

@ -0,0 +1,57 @@
"""add_user_roles
Revision ID: 414da6754b1e
Revises: aac025d432f0
Create Date: 2026-01-27 15:26:58.408563
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '414da6754b1e'
down_revision: Union[str, None] = 'aac025d432f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('email', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('hashed_password', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('full_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('role', sa.Enum('ADMIN', 'PASSENGER', 'DRIVER', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('is_verified', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_table('driver_profiles',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('cedula', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('vehicle_type', sa.Enum('TAXI', 'BUS', name='vehicletype'), nullable=False),
sa.Column('license_plate', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('photo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('vehicle_photo_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('cooperative_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('driver_profiles')
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""add_user_profile_photo
Revision ID: 4c9211307d7a
Revises: 8a5661aac24b
Create Date: 2026-01-28 18:13:37.548321
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4c9211307d7a'
down_revision: Union[str, None] = '8a5661aac24b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('profile_photo_url', sa.String(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'profile_photo_url')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""add_speaks_english_to_driver_profile
Revision ID: 5caf8ba3ed4d
Revises: 9f2fbe81a055
Create Date: 2026-01-31 16:02:13.521306
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '5caf8ba3ed4d'
down_revision: Union[str, None] = '9f2fbe81a055'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('driver_profiles', sa.Column('shift', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('driver_profiles', sa.Column('payment_methods', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('driver_profiles', sa.Column('speaks_english', sa.Boolean(), nullable=True))
op.add_column('taxis', sa.Column('rating', sa.Float(), nullable=False, server_default=sa.text('5.0')))
op.add_column('taxis', sa.Column('english_speaking', sa.Boolean(), nullable=False, server_default=sa.text('false')))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('taxis', 'english_speaking')
op.drop_column('taxis', 'rating')
op.drop_column('driver_profiles', 'speaks_english')
op.drop_column('driver_profiles', 'payment_methods')
op.drop_column('driver_profiles', 'shift')
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
"""add_user_coupons
Revision ID: 8a5661aac24b
Revises: e5fb60c22245
Create Date: 2026-01-28 18:01:08.102678
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8a5661aac24b'
down_revision: Union[str, None] = 'e5fb60c22245'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_coupons',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('coupon_id', sa.Uuid(), nullable=False),
sa.Column('status', sa.Enum('CLAIMED', 'REDEEMED', 'EXPIRED', name='usercouponstatus'), nullable=False),
sa.Column('redemption_code', sa.String(), nullable=True),
sa.Column('claimed_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('redeemed_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['coupon_id'], ['coupons.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_user_coupons_redemption_code'), 'user_coupons', ['redemption_code'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_user_coupons_redemption_code'), table_name='user_coupons')
op.drop_table('user_coupons')
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""add_is_published_to_bus_schedule
Revision ID: 8b3e5b0a3e67
Revises: 414da6754b1e
Create Date: 2026-01-27 17:05:19.915106
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '8b3e5b0a3e67'
down_revision: Union[str, None] = '414da6754b1e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('bus_schedules', sa.Column('is_published', sa.Boolean(), nullable=True))
op.execute("UPDATE bus_schedules SET is_published = false")
op.alter_column('bus_schedules', 'is_published', nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('bus_schedules', 'is_published')
# ### end Alembic commands ###

View File

@ -0,0 +1,54 @@
"""add_coupons_and_promoter_role
Revision ID: 93054da1a687
Revises: ceda6a5abf0e
Create Date: 2026-01-28 10:41:47.113948
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '93054da1a687'
down_revision: Union[str, None] = 'ceda6a5abf0e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'promoter' to UserRole enum
# Postgres specific command to add value to existing Enum
with op.get_context().autocommit_block():
op.execute("ALTER TYPE userrole ADD VALUE IF NOT EXISTS 'promoter'")
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('coupons',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('discount_percentage', sa.Integer(), nullable=True),
sa.Column('discount_amount', sa.Float(), nullable=True),
sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('valid_from', sa.DateTime(timezone=True), nullable=True),
sa.Column('valid_until', sa.DateTime(timezone=True), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('coupons')
# ### end Alembic commands ###
# Note: Removing a value from a Postgres Enum is complex and usually not done in migrations
# unless you recreate the whole type.

View File

@ -0,0 +1,32 @@
"""add_social_media_to_coupons
Revision ID: 94b4cb57c6c1
Revises: dfbcee895f78
Create Date: 2026-01-28 11:31:47.625569
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '94b4cb57c6c1'
down_revision: Union[str, None] = 'dfbcee895f78'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('coupons', sa.Column('social_media', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('coupons', 'social_media')
# ### end Alembic commands ###

View File

@ -0,0 +1,32 @@
"""add area to business
Revision ID: 9f2fbe81a055
Revises: c9bb64354775
Create Date: 2026-01-29 13:36:34.379966
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = '9f2fbe81a055'
down_revision: Union[str, None] = 'c9bb64354775'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('businesses', sa.Column('area', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('businesses', 'area')
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""Add average_speed and stop_delay
Revision ID: aac025d432f0
Revises: bb5c406b9af5
Create Date: 2026-01-02 22:36:27.855381
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'aac025d432f0'
down_revision: Union[str, None] = 'bb5c406b9af5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('route_stops', sa.Column('stop_delay_minutes', sa.Integer(), server_default='0', nullable=False))
op.add_column('routes', sa.Column('average_speed_kmh', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('routes', 'average_speed_kmh')
op.drop_column('route_stops', 'stop_delay_minutes')
# ### end Alembic commands ###

View File

@ -0,0 +1,31 @@
"""add_stop_order_to_bus_stop
Revision ID: bb5c406b9af5
Revises: 2f4936eb86f0
Create Date: 2026-01-02 16:16:02.081811
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bb5c406b9af5'
down_revision: Union[str, None] = '2f4936eb86f0'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('bus_stops', sa.Column('stop_order', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('bus_stops', 'stop_order')
# ### end Alembic commands ###

View File

@ -0,0 +1,64 @@
"""Add taxi and favorite models
Revision ID: c88628a640b6
Revises: 8b3e5b0a3e67
Create Date: 2026-01-27 19:54:26.038491
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'c88628a640b6'
down_revision: Union[str, None] = '8b3e5b0a3e67'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('taxis',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('owner_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('phone_number', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('license_plate', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('cooperative', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('corregimiento', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('shift', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_taxis_corregimiento'), 'taxis', ['corregimiento'], unique=False)
op.create_index(op.f('ix_taxis_license_plate'), 'taxis', ['license_plate'], unique=True)
op.create_table('favorites',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('item_type', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('item_id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_favorites_item_id'), 'favorites', ['item_id'], unique=False)
op.create_index(op.f('ix_favorites_item_type'), 'favorites', ['item_type'], unique=False)
op.create_index(op.f('ix_favorites_user_id'), 'favorites', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_favorites_user_id'), table_name='favorites')
op.drop_index(op.f('ix_favorites_item_type'), table_name='favorites')
op.drop_index(op.f('ix_favorites_item_id'), table_name='favorites')
op.drop_table('favorites')
op.drop_index(op.f('ix_taxis_license_plate'), table_name='taxis')
op.drop_index(op.f('ix_taxis_corregimiento'), table_name='taxis')
op.drop_table('taxis')
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""add_business_coords
Revision ID: c9bb64354775
Revises: 2088667c3a5f
Create Date: 2026-01-29 09:34:11.595352
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c9bb64354775'
down_revision: Union[str, None] = '2088667c3a5f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('businesses', sa.Column('latitude', sa.Float(), nullable=True))
op.add_column('businesses', sa.Column('longitude', sa.Float(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('businesses', 'longitude')
op.drop_column('businesses', 'latitude')
# ### end Alembic commands ###

View File

@ -0,0 +1,46 @@
"""add telemetry table
Revision ID: ceda6a5abf0e
Revises: c88628a640b6
Create Date: 2026-01-27 20:51:06.463716
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'ceda6a5abf0e'
down_revision: Union[str, None] = 'c88628a640b6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('telemetry',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('user_id', sa.Uuid(), nullable=False),
sa.Column('latitude', sa.Float(), nullable=False),
sa.Column('longitude', sa.Float(), nullable=False),
sa.Column('speed', sa.Float(), nullable=True),
sa.Column('heading', sa.Float(), nullable=True),
sa.Column('status', sa.Enum('ACTIVE', 'OFFLINE', 'BREAK', name='vehiclestatus'), nullable=False),
sa.Column('timestamp', sa.DateTime(), server_default=sa.text('now()'), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_telemetry_timestamp'), 'telemetry', ['timestamp'], unique=False)
op.create_index(op.f('ix_telemetry_user_id'), 'telemetry', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_telemetry_user_id'), table_name='telemetry')
op.drop_index(op.f('ix_telemetry_timestamp'), table_name='telemetry')
op.drop_table('telemetry')
# ### end Alembic commands ###

View File

@ -0,0 +1,40 @@
"""enrich_coupons_with_business_details
Revision ID: dfbcee895f78
Revises: 93054da1a687
Create Date: 2026-01-28 11:08:39.830857
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'dfbcee895f78'
down_revision: Union[str, None] = '93054da1a687'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('coupons', sa.Column('business_name', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('coupons', sa.Column('business_address', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('coupons', sa.Column('business_phone', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('coupons', sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
op.add_column('coupons', sa.Column('terms', sqlmodel.sql.sqltypes.AutoString(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('coupons', 'terms')
op.drop_column('coupons', 'image_url')
op.drop_column('coupons', 'business_phone')
op.drop_column('coupons', 'business_address')
op.drop_column('coupons', 'business_name')
# ### end Alembic commands ###

View File

@ -0,0 +1,47 @@
"""create_businesses_table_and_link_coupons
Revision ID: e5fb60c22245
Revises: 94b4cb57c6c1
Create Date: 2026-01-28 12:47:52.191359
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
import sqlmodel
# revision identifiers, used by Alembic.
revision: str = 'e5fb60c22245'
down_revision: Union[str, None] = '94b4cb57c6c1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('businesses',
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('address', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('phone', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('image_url', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('social_media', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('category', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.add_column('coupons', sa.Column('business_id', sa.Uuid(), nullable=True))
op.create_foreign_key(None, 'coupons', 'businesses', ['business_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'coupons', type_='foreignkey')
op.drop_column('coupons', 'business_id')
op.drop_table('businesses')
# ### end Alembic commands ###

2
backend/app/__init__.py Normal file
View File

@ -0,0 +1,2 @@
"""SIBU Backend Application."""

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}

View File

@ -0,0 +1,2 @@
"""Core configuration and utilities."""

View File

@ -0,0 +1,46 @@
"""Configuration settings using pydantic-settings."""
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import Literal
def get_env_file() -> str:
"""Get the appropriate env file based on ENVIRONMENT variable."""
env = os.getenv("ENVIRONMENT", "development")
env_file = f".env.{env}"
# Check if env file exists, fallback to .env.development
if not os.path.exists(env_file):
env_file = ".env.development"
return env_file
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Database
database_url: str = "postgresql+asyncpg://sibu:sibu@localhost:5432/sibu"
# Google Maps (for server-side APIs)
google_maps_api_key: str = ""
google_maps_url_signing_secret: str = ""
# Environment
environment: Literal["development", "production", "testing"] = "development"
debug: bool = False
# Security
admin_password: str = "admin" # Default for development, override in .env
secret_key: str = "insecure-secret-key-dev" # Default for development, override in .env
model_config = SettingsConfigDict(
env_file=get_env_file(),
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Global settings instance
settings = Settings()

View File

@ -0,0 +1,27 @@
"""Database connection and session management."""
from sqlmodel import SQLModel, create_engine, Session
from typing import Generator
from app.core.config import settings
# Create database engine
# Convert asyncpg URL to psycopg2 for synchronous operations
database_url = settings.database_url.replace("+asyncpg", "+psycopg2")
engine = create_engine(
database_url,
echo=settings.debug,
future=True,
)
def init_db() -> None:
"""Initialize database by creating all tables."""
SQLModel.metadata.create_all(engine)
def get_session() -> Generator[Session, None, None]:
"""Dependency for getting database session."""
with Session(engine) as session:
yield session

View File

@ -0,0 +1,251 @@
"""Export current database data and generate a seeder script."""
from sqlmodel import Session, select
from typing import Dict, Any
import json
from app.core.database import engine
from app.models.route import Route
from app.models.bus_stop import BusStop
from app.models.route_stop import RouteStop
from app.models.bus_schedule import BusSchedule
def export_all_data() -> Dict[str, Any]:
"""Export all data from the database."""
with Session(engine) as session:
# Export routes
routes = session.exec(select(Route)).all()
routes_data = []
for route in routes:
routes_data.append({
"id": str(route.id),
"name": route.name,
"description": route.description,
"origin_city": route.origin_city,
"destination_city": route.destination_city,
"distance_km": route.distance_km,
"estimated_duration_minutes": route.estimated_duration_minutes,
"status": route.status.value if route.status else "active",
})
# Export bus stops
bus_stops = session.exec(select(BusStop)).all()
bus_stops_data = []
for stop in bus_stops:
bus_stops_data.append({
"id": str(stop.id),
"name": stop.name,
"latitude": stop.latitude,
"longitude": stop.longitude,
"city": stop.city,
"address": stop.address,
"stop_type": stop.stop_type.value if stop.stop_type else "regular",
"has_shelter": stop.has_shelter,
"has_seating": stop.has_seating,
"is_accessible": stop.is_accessible,
})
# Export route stops
route_stops = session.exec(select(RouteStop)).all()
route_stops_data = []
for route_stop in route_stops:
route_stops_data.append({
"id": str(route_stop.id),
"route_id": str(route_stop.route_id),
"stop_id": str(route_stop.stop_id),
"stop_order": route_stop.stop_order,
"travel_time_minutes": route_stop.travel_time_minutes,
"is_pickup_point": route_stop.is_pickup_point,
"is_dropoff_point": route_stop.is_dropoff_point,
})
# Export bus schedules
bus_schedules = session.exec(select(BusSchedule)).all()
bus_schedules_data = []
for schedule in bus_schedules:
bus_schedules_data.append({
"id": str(schedule.id),
"route_id": str(schedule.route_id),
"departure_time": schedule.departure_time.strftime("%H:%M:%S") if schedule.departure_time else None,
"frequency_minutes": schedule.frequency_minutes,
"schedule_type": schedule.schedule_type.value if schedule.schedule_type else "weekday",
"is_active": schedule.is_active,
"notes": schedule.notes,
})
return {
"routes": routes_data,
"bus_stops": bus_stops_data,
"route_stops": route_stops_data,
"bus_schedules": bus_schedules_data,
}
def generate_seeder_code(data: Dict[str, Any]) -> str:
"""Generate Python seeder code from exported data."""
code = '''"""Database seeding script generated from current database."""
from sqlmodel import Session, select, create_engine
from uuid import UUID
from datetime import time
from app.core.config import settings
from app.models.route import Route, RouteStatus
from app.models.bus_stop import BusStop, StopType
from app.models.route_stop import RouteStop
from app.models.bus_schedule import BusSchedule, BusScheduleType
def seed_database():
"""Seed the database with exported data."""
# Use synchronous engine for seeding (replace asyncpg with psycopg2)
sync_database_url = settings.database_url.replace("+asyncpg", "+psycopg2")
sync_engine = create_engine(sync_database_url, echo=False)
with Session(sync_engine) as session:
# Check if data already exists
try:
existing_routes = session.exec(select(Route)).first()
if existing_routes:
print("Database already has data. Skipping seed.")
print("To reseed, drop the tables first or use: make db-reset")
return
except Exception as e:
# If tables don't exist yet, that's fine - we'll create the data
print(f"Note: {e}")
print("Proceeding with seed...")
'''
# Generate routes
code += " # Insert Routes\n"
code += " routes = [\n"
for route in data["routes"]:
status_enum = route["status"].upper().replace("-", "_")
code += f''' Route(
id=UUID("{route["id"]}"),
name={repr(route["name"])},
description={repr(route["description"])},
origin_city={repr(route["origin_city"])},
destination_city={repr(route["destination_city"])},
distance_km={route["distance_km"] if route["distance_km"] is not None else "None"},
estimated_duration_minutes={route["estimated_duration_minutes"] if route["estimated_duration_minutes"] is not None else "None"},
status=RouteStatus.{status_enum},
),
'''
code += " ]\n"
code += " for route in routes:\n"
code += " session.add(route)\n"
code += " session.flush() # Flush routes so we can reference them in foreign keys\n\n"
# Generate bus stops
code += " # Insert Bus Stops\n"
code += " bus_stops = [\n"
for stop in data["bus_stops"]:
stop_type_enum = stop["stop_type"].upper().replace("-", "_")
code += f''' BusStop(
id=UUID("{stop["id"]}"),
name={repr(stop["name"])},
latitude={stop["latitude"]},
longitude={stop["longitude"]},
city={repr(stop["city"])},
address={repr(stop["address"]) if stop["address"] else "None"},
stop_type=StopType.{stop_type_enum},
has_shelter={stop["has_shelter"]},
has_seating={stop["has_seating"]},
is_accessible={stop["is_accessible"]},
),
'''
code += " ]\n"
code += " for stop in bus_stops:\n"
code += " session.add(stop)\n"
code += " session.flush() # Flush stops so we can reference them in route_stops\n\n"
# Generate route stops
code += " # Insert Route Stops\n"
code += " route_stops = [\n"
for route_stop in data["route_stops"]:
code += f''' RouteStop(
route_id=UUID("{route_stop["route_id"]}"),
stop_id=UUID("{route_stop["stop_id"]}"),
stop_order={route_stop["stop_order"]},
travel_time_minutes={route_stop["travel_time_minutes"] if route_stop["travel_time_minutes"] is not None else "None"},
is_pickup_point={route_stop["is_pickup_point"]},
is_dropoff_point={route_stop["is_dropoff_point"]},
),
'''
code += " ]\n"
code += " for route_stop in route_stops:\n"
code += " session.add(route_stop)\n"
code += " session.flush() # Flush route_stops before adding schedules\n\n"
# Generate bus schedules
code += " # Insert Bus Schedules\n"
code += " bus_schedules = [\n"
for schedule in data["bus_schedules"]:
if schedule["departure_time"]:
hour, minute, second = schedule["departure_time"].split(":")
# Convert to int to remove leading zeros
hour_int = int(hour)
minute_int = int(minute)
time_str = f"time({hour_int}, {minute_int})"
else:
time_str = "None"
schedule_type_enum = schedule["schedule_type"].upper().replace("-", "_")
code += f''' BusSchedule(
route_id=UUID("{schedule["route_id"]}"),
departure_time={time_str},
frequency_minutes={schedule["frequency_minutes"] if schedule["frequency_minutes"] is not None else "None"},
schedule_type=BusScheduleType.{schedule_type_enum},
is_active={schedule["is_active"]},
notes={repr(schedule["notes"]) if schedule["notes"] else "None"},
),
'''
code += " ]\n"
code += " for schedule in bus_schedules:\n"
code += " session.add(schedule)\n\n"
code += " session.commit()\n"
code += ' print("Database seeded successfully!")\n\n'
code += '''
if __name__ == "__main__":
seed_database()
'''
return code
def main():
"""Main function to export database and generate seeder."""
print("Exporting database data...")
try:
data = export_all_data()
# Save JSON export
json_file = "database_export.json"
with open(json_file, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, default=str, ensure_ascii=False)
print(f"✅ Exported data to {json_file}")
print(f" - {len(data['routes'])} routes")
print(f" - {len(data['bus_stops'])} bus stops")
print(f" - {len(data['route_stops'])} route stops")
print(f" - {len(data['bus_schedules'])} bus schedules")
# Generate seeder code
seeder_code = generate_seeder_code(data)
seeder_file = "app/core/seed.py"
with open(seeder_file, "w", encoding="utf-8") as f:
f.write(seeder_code)
print(f"✅ Generated seeder script: {seeder_file}")
except Exception as e:
print(f"❌ Error exporting database: {e}")
raise
if __name__ == "__main__":
main()

View File

@ -0,0 +1,92 @@
"""Script to export route data from Supabase to update seed script."""
import os
from supabase import create_client, Client
from typing import Dict, Any
import json
def get_supabase_client() -> Client:
"""Create Supabase client from environment variables."""
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_ANON_KEY")
if not supabase_url or not supabase_key:
raise ValueError("SUPABASE_URL and SUPABASE_ANON_KEY must be set in environment")
return create_client(supabase_url, supabase_key)
def export_route_data(route_name: str = "Boquete>David") -> Dict[str, Any]:
"""Export route data including all stops from Supabase."""
supabase = get_supabase_client()
# Get route
route_response = supabase.table("routes").select("*").eq("name", route_name).execute()
if not route_response.data:
raise ValueError(f"Route '{route_name}' not found in Supabase")
route = route_response.data[0]
route_id = route["id"]
# Get all route stops with stop details
route_stops_response = supabase.table("route_stops").select(
"*, bus_stops(*)"
).eq("route_id", route_id).order("stop_order").execute()
route_stops = route_stops_response.data
return {
"route": route,
"route_stops": route_stops,
"total_stops": len(route_stops)
}
def export_all_routes() -> Dict[str, Any]:
"""Export all routes and their stops from Supabase."""
supabase = get_supabase_client()
# Get all routes
routes_response = supabase.table("routes").select("*").execute()
routes = routes_response.data
all_data = {}
for route in routes:
route_id = route["id"]
route_name = route["name"]
# Get all route stops
route_stops_response = supabase.table("route_stops").select(
"*, bus_stops(*)"
).eq("route_id", route_id).order("stop_order").execute()
all_data[route_name] = {
"route": route,
"route_stops": route_stops_response.data,
"total_stops": len(route_stops_response.data)
}
return all_data
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "--all":
# Export all routes
data = export_all_routes()
output_file = "supabase_export_all.json"
else:
# Export specific route
route_name = sys.argv[1] if len(sys.argv) > 1 else "Boquete>David"
data = export_route_data(route_name)
output_file = f"supabase_export_{route_name.replace('>', '_')}.json"
# Save to JSON file
with open(output_file, "w") as f:
json.dump(data, f, indent=2, default=str)
print(f"✅ Exported data to {output_file}")
if isinstance(data, dict) and "total_stops" in data:
print(f" Total stops: {data['total_stops']}")
elif isinstance(data, dict):
for route_name, route_data in data.items():
print(f" {route_name}: {route_data['total_stops']} stops")

View File

@ -0,0 +1,99 @@
"""Helper script to generate intermediate stops along a route."""
from typing import List, Tuple
def interpolate_point(
start: Tuple[float, float],
end: Tuple[float, float],
fraction: float
) -> Tuple[float, float]:
"""Interpolate a point between start and end coordinates."""
lat = start[0] + (end[0] - start[0]) * fraction
lng = start[1] + (end[1] - start[1]) * fraction
return (lat, lng)
def generate_intermediate_stops(
start_coords: Tuple[float, float],
end_coords: Tuple[float, float],
num_stops: int,
start_name: str = "Start",
end_name: str = "End",
city: str = "Route"
) -> List[dict]:
"""Generate intermediate stops along a route.
Args:
start_coords: (latitude, longitude) of start point
end_coords: (latitude, longitude) of end point
num_stops: Total number of stops to generate (including start and end)
start_name: Name of the start stop
end_name: Name of the end stop
city: City name for intermediate stops
Returns:
List of stop dictionaries with name, lat, lng, city
"""
if num_stops < 2:
return [
{"name": start_name, "lat": start_coords[0], "lng": start_coords[1], "city": city},
{"name": end_name, "lat": end_coords[0], "lng": end_coords[1], "city": city}
]
stops = []
# Add start stop
stops.append({
"name": start_name,
"lat": start_coords[0],
"lng": start_coords[1],
"city": city
})
# Generate intermediate stops
for i in range(1, num_stops - 1):
fraction = i / (num_stops - 1)
lat, lng = interpolate_point(start_coords, end_coords, fraction)
# Generate realistic stop names
stop_name = f"Parada {i}"
if i % 10 == 0:
stop_name = f"Parada Principal {i // 10}"
elif i % 5 == 0:
stop_name = f"Intersección {i // 5}"
stops.append({
"name": stop_name,
"lat": lat,
"lng": lng,
"city": city
})
# Add end stop
stops.append({
"name": end_name,
"lat": end_coords[0],
"lng": end_coords[1],
"city": city
})
return stops
# Example: Generate 61 stops for Boquete>David route
if __name__ == "__main__":
# Boquete coordinates (Terminal)
boquete_start = (8.7697, -82.4328)
# David coordinates (Terminal)
david_end = (8.4177, -82.4270)
stops = generate_intermediate_stops(
boquete_start,
david_end,
num_stops=61,
start_name="Terminal de Boquete",
end_name="Terminal de David",
city="Ruta Boquete-David"
)
print(f"Generated {len(stops)} stops:")
for i, stop in enumerate(stops, 1):
print(f"{i:2d}. {stop['name']:30s} ({stop['lat']:.6f}, {stop['lng']:.6f})")

View File

@ -0,0 +1,223 @@
"""Script to import bus stop coordinates from Supabase that follow actual roads."""
import os
from pathlib import Path
from supabase import create_client, Client
from sqlmodel import Session, select, create_engine
import sys
from app.core.config import settings, get_env_file
from app.models.bus_stop import BusStop
from app.models.route import Route
from app.models.route_stop import RouteStop
def load_env_file():
"""Load environment variables from .env.development file."""
env_file = get_env_file()
env_path = Path(env_file)
# If relative path, make it relative to backend directory
if not env_path.is_absolute():
backend_dir = Path(__file__).parent.parent.parent
env_path = backend_dir / env_file
if env_path.exists():
from dotenv import load_dotenv
load_dotenv(env_path)
print(f"✓ Loaded environment from {env_path}")
else:
print(f"⚠️ Warning: {env_path} not found, using system environment variables")
def get_supabase_client() -> Client:
"""Create Supabase client from environment variables."""
# Load .env.development file first
load_env_file()
supabase_url = os.getenv("SUPABASE_URL")
supabase_key = os.getenv("SUPABASE_ANON_KEY")
if not supabase_url or not supabase_key:
raise ValueError(
"SUPABASE_URL and SUPABASE_ANON_KEY must be set in environment or .env.development file.\n"
f"Checked file: {get_env_file()}"
)
return create_client(supabase_url, supabase_key)
def import_coordinates_from_supabase(route_name: str = "Boquete>David", supabase_route_name: str = None):
"""Import bus stop coordinates from Supabase for a specific route.
Args:
route_name: Route name in local database format (e.g., "Boquete>David")
supabase_route_name: Route name in Supabase format (e.g., "Boquete David").
If None, will try to find it automatically.
"""
supabase = get_supabase_client()
# If supabase_route_name is provided, use it directly
if supabase_route_name:
route_response = supabase.table("routes").select("*").eq("name", supabase_route_name).execute()
else:
# Get route from Supabase - try exact match first, then try variations
route_response = supabase.table("routes").select("*").eq("name", route_name).execute()
# If not found, try with different separators (Supabase uses " " em dash)
if not route_response.data:
# Try with different separators
variations = [
route_name.replace(">", " "), # Em dash (Supabase format)
route_name.replace(">", "-"), # Regular dash
route_name.replace(">", " to "), # " to "
route_name.replace(">", " -> "), # " -> "
]
for variant in variations:
route_response = supabase.table("routes").select("*").eq("name", variant).execute()
if route_response.data:
print(f"Found route with name variation: '{variant}'")
break
# If still not found, list available routes
if not route_response.data:
all_routes = supabase.table("routes").select("name").execute()
available = [r["name"] for r in all_routes.data] if all_routes.data else []
raise ValueError(
f"Route '{route_name}' not found in Supabase.\n"
f"Available routes: {', '.join(available) if available else 'None found'}"
)
supabase_route = route_response.data[0]
supabase_route_id = supabase_route["id"]
# Get all route stops with stop details from Supabase
# Old app uses 'stops' table with 'lat' and 'lng' columns, and 'seq' for order
# Try different query formats to match Supabase schema
try:
# Try the format used by old Flutter app: stops:stop_id with seq
route_stops_response = supabase.table("route_stops").select(
"seq, stops:stop_id(id, name, lat, lng)"
).eq("route_id", supabase_route_id).order("seq").execute()
except Exception as e1:
try:
# Try with stop_order instead of seq
route_stops_response = supabase.table("route_stops").select(
"stop_order, stops:stop_id(id, name, lat, lng)"
).eq("route_id", supabase_route_id).order("stop_order").execute()
except Exception as e2:
try:
# Try bus_stops table (new schema)
route_stops_response = supabase.table("route_stops").select(
"stop_order, bus_stops(*)"
).eq("route_id", supabase_route_id).order("stop_order").execute()
except Exception as e3:
raise Exception(f"Could not query Supabase: {e1}, {e2}, {e3}")
if not route_stops_response.data:
print(f"No stops found for route '{route_name}' in Supabase")
return
# Connect to local database
sync_database_url = settings.database_url.replace("+asyncpg", "+psycopg2")
sync_engine = create_engine(sync_database_url, echo=False)
with Session(sync_engine) as session:
# Find the route in local database
local_route = session.exec(select(Route).where(Route.name == route_name)).first()
if not local_route:
print(f"Route '{route_name}' not found in local database. Please create it first.")
return
print(f"Found route '{route_name}' in local database (ID: {local_route.id})")
# Get local route stops ordered by stop_order
local_route_stops = session.exec(
select(RouteStop, BusStop)
.join(BusStop, RouteStop.stop_id == BusStop.id)
.where(RouteStop.route_id == local_route.id)
.order_by(RouteStop.stop_order)
).all()
if len(local_route_stops) != len(route_stops_response.data):
print(f"⚠️ Warning: Local database has {len(local_route_stops)} stops, Supabase has {len(route_stops_response.data)} stops")
print(" Updating coordinates for matching stops...")
# Update coordinates for each stop
updated_count = 0
for i, supabase_stop_data in enumerate(route_stops_response.data):
if i >= len(local_route_stops):
break
# Try different possible field names for the stop data
supabase_stop = (
supabase_stop_data.get("bus_stops") or
supabase_stop_data.get("stops") or
supabase_stop_data # If the stop data is directly in the response
)
if not supabase_stop or not isinstance(supabase_stop, dict):
continue
# Get the local stop
local_route_stop, local_bus_stop = local_route_stops[i]
# Update coordinates from Supabase
# Supabase may use 'lat'/'lng' (old schema) or 'latitude'/'longitude' (new schema)
new_latitude = supabase_stop.get("latitude") or supabase_stop.get("lat")
new_longitude = supabase_stop.get("longitude") or supabase_stop.get("lng")
if new_latitude is None or new_longitude is None:
print(f"⚠️ Skipping stop {i+1}: missing coordinates in Supabase")
continue
# Check if coordinates are different
if abs(local_bus_stop.latitude - new_latitude) > 0.0001 or \
abs(local_bus_stop.longitude - new_longitude) > 0.0001:
local_bus_stop.latitude = new_latitude
local_bus_stop.longitude = new_longitude
session.add(local_bus_stop)
updated_count += 1
print(f"✓ Updated stop {i+1} ({local_bus_stop.name}): "
f"({new_latitude:.6f}, {new_longitude:.6f})")
session.commit()
print(f"\n✅ Successfully updated {updated_count} stops with coordinates from Supabase")
def import_all_routes():
"""Import coordinates for all routes from Supabase."""
supabase = get_supabase_client()
# Get all routes from Supabase
routes_response = supabase.table("routes").select("*").execute()
routes = routes_response.data
print(f"Found {len(routes)} routes in Supabase")
for route in routes:
supabase_route_name = route["name"] # Format: "Boquete David"
# Convert Supabase format to local format for matching
local_route_name = supabase_route_name.replace(" ", ">").replace(" - ", ">").replace(" to ", ">").replace(" -> ", ">")
print(f"\n{'='*60}")
print(f"Importing coordinates for route: {supabase_route_name}")
print(f"Looking for local route: {local_route_name}")
print(f"{'='*60}")
try:
import_coordinates_from_supabase(local_route_name, supabase_route_name=supabase_route_name)
except Exception as e:
print(f"❌ Error importing route '{supabase_route_name}': {e}")
continue
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--all":
# Import all routes
import_all_routes()
else:
# Import specific route
route_name = sys.argv[1] if len(sys.argv) > 1 else "Boquete>David"
import_coordinates_from_supabase(route_name)

View File

@ -0,0 +1,46 @@
import bcrypt
from datetime import datetime, timedelta, timezone
from typing import Any, Union, Optional
from jose import jwt
from app.core.config import settings
ALGORITHM = "HS256"
def create_access_token(
subject: Union[str, Any],
role: str,
full_name: str,
expires_delta: Optional[timedelta] = None
) -> str:
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=1440)
to_encode = {
"exp": expire,
"sub": str(subject),
"role": role,
"full_name": full_name
}
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def get_password_hash(password: str) -> str:
return bcrypt.hashpw(
password.encode('utf-8'),
bcrypt.gensalt()
).decode('utf-8')
def get_token_payload(token: str) -> dict:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
return payload
except Exception:
return {}

5900
backend/app/core/seed.py Normal file

File diff suppressed because it is too large Load Diff

70
backend/app/main.py Normal file
View File

@ -0,0 +1,70 @@
"""FastAPI application entry point."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
from app.core.config import settings
from app.api.routes import router as routes_router
from app.api.bus_stops import router as bus_stops_router
from app.api.schedules import router as schedules_router
from app.api.coupons import router as coupons_router
from app.api.taxis import router as taxis_router
from app.api.auth import router as auth_router
from app.api.users import router as users_router
from app.api.favorites import router as favorites_router
from app.api.telemetry import router as telemetry_router
from app.api.businesses import router as businesses_router
from app.api.analytics import router as analytics_router
from app.api.reports import router as reports_router
from app.api.shuttles import router as shuttles_router
app = FastAPI(
title="SIBU Transportation API",
description="API for SIBU public transportation system",
version="1.0.0",
debug=settings.debug,
)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure properly for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Ensure upload directories exist
for sub in ["profiles", "vehicles", "businesses"]:
os.makedirs(os.path.join("uploads", sub), exist_ok=True)
# Mount static files
app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
# Include routers
app.include_router(routes_router)
app.include_router(bus_stops_router)
app.include_router(schedules_router)
app.include_router(coupons_router)
app.include_router(taxis_router)
app.include_router(auth_router)
app.include_router(users_router)
app.include_router(favorites_router)
app.include_router(telemetry_router)
app.include_router(businesses_router)
app.include_router(analytics_router, prefix="/api/analytics", tags=["analytics"])
app.include_router(reports_router)
app.include_router(shuttles_router)
@app.get("/")
async def root():
"""Root endpoint."""
return {"message": "SIBU Transportation API", "version": "1.0.0"}
@app.get("/health")
async def health():
"""Health check endpoint."""
return {"status": "healthy", "environment": settings.environment}

View File

@ -0,0 +1,18 @@
"""Database models."""
from app.models.route import Route
from app.models.bus_stop import BusStop
from app.models.route_stop import RouteStop
from app.models.bus_schedule import BusSchedule
from app.models.user import User, DriverProfile
from app.models.taxi import Taxi
from app.models.favorite import Favorite
from app.models.telemetry import Telemetry
from app.models.coupon import Coupon
from app.models.business import Business
from app.models.user_coupon import UserCoupon
from app.models.analytics import AnalyticsEvent
from app.models.report import Report
from app.models.shuttle import Shuttle
__all__ = ["Route", "BusStop", "RouteStop", "BusSchedule", "User", "DriverProfile", "Taxi", "Favorite", "Telemetry", "Coupon", "Business", "UserCoupon", "AnalyticsEvent", "Report", "Shuttle"]

View File

@ -0,0 +1,19 @@
from sqlmodel import SQLModel, Field
from typing import Optional, Dict
from uuid import UUID, uuid4
from datetime import datetime
from sqlalchemy import Column, DateTime, func, JSON
class AnalyticsEvent(SQLModel, table=True):
__tablename__ = "analytics_events"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
event_name: str = Field(index=True)
user_id: Optional[UUID] = Field(default=None, index=True, foreign_key="users.id")
screen_name: Optional[str] = None
item_id: Optional[str] = None # route_id, stop_id, promo_id, etc.
properties: Optional[Dict] = Field(default_factory=dict, sa_column=Column(JSON))
timestamp: datetime = Field(
default_factory=datetime.utcnow,
sa_column=Column(DateTime(timezone=True), server_default=func.now(), index=True)
)

View File

@ -0,0 +1,31 @@
"""Bus schedule model."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime, time
from typing import Optional
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class BusScheduleType(str, Enum):
"""Schedule type enumeration."""
WEEKDAY = "weekday"
WEEKEND = "weekend"
HOLIDAY = "holiday"
class BusSchedule(SQLModel, table=True):
"""Bus schedule model."""
__tablename__ = "bus_schedules"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
route_id: UUID = Field(foreign_key="routes.id")
departure_time: time
frequency_minutes: Optional[int] = 30
schedule_type: BusScheduleType = BusScheduleType.WEEKDAY
is_active: bool = Field(default=True)
is_published: bool = Field(default=False)
notes: Optional[str] = None
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))

View File

@ -0,0 +1,35 @@
"""Bus stop model."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class StopType(str, Enum):
"""Stop type enumeration."""
TERMINAL = "terminal"
REGULAR = "regular"
EXPRESS_ONLY = "express_only"
class BusStop(SQLModel, table=True):
"""Bus stop model."""
__tablename__ = "bus_stops"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
name: str
latitude: float
longitude: float
city: str
address: Optional[str] = None
stop_type: StopType = StopType.REGULAR
has_shelter: bool = False
has_seating: bool = False
is_accessible: bool = False
stop_order: Optional[int] = None
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))
updated_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now(), onupdate=func.now()))

View File

@ -0,0 +1,58 @@
from sqlmodel import SQLModel, Field, Column, Relationship
from datetime import datetime
from typing import Optional, List, TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
if TYPE_CHECKING:
from app.models.coupon import Coupon
class Business(SQLModel, table=True):
"""Business record for local partners."""
__tablename__ = "businesses"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
name: str
address: Optional[str] = None
phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
category: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
area: Optional[str] = Field(default="Boquete")
# Relationship to coupons
coupons: List["Coupon"] = Relationship(back_populates="business")
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime(timezone=True), server_default=func.now())
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
)
class BusinessCreate(SQLModel):
name: str
address: Optional[str] = None
phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
category: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
area: Optional[str] = "Boquete"
class BusinessUpdate(SQLModel):
name: Optional[str] = None
address: Optional[str] = None
phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
category: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
area: Optional[str] = None

View File

@ -0,0 +1,79 @@
from sqlmodel import SQLModel, Field, Column, Relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
if TYPE_CHECKING:
from app.models.business import Business
class Coupon(SQLModel, table=True):
"""Coupon record for promotions."""
__tablename__ = "coupons"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
business_id: Optional[UUID] = Field(default=None, foreign_key="businesses.id")
title: str = Field(index=True)
description: Optional[str] = None
# Relationship to business
business: Optional["Business"] = Relationship(back_populates="coupons")
business_name: Optional[str] = None
business_address: Optional[str] = None
business_phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
terms: Optional[str] = None
discount_percentage: Optional[int] = None
discount_amount: Optional[float] = None
category: Optional[str] = None
valid_from: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True)
)
valid_until: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True)
)
is_active: bool = Field(default=True)
created_at: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime(timezone=True), server_default=func.now())
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column=Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
)
class CouponCreate(SQLModel):
title: str
business_id: Optional[UUID] = None
description: Optional[str] = None
business_name: Optional[str] = None
business_address: Optional[str] = None
business_phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
terms: Optional[str] = None
discount_percentage: Optional[int] = None
discount_amount: Optional[float] = None
category: Optional[str] = None
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
is_active: Optional[bool] = True
class CouponUpdate(SQLModel):
title: Optional[str] = None
business_id: Optional[UUID] = None
description: Optional[str] = None
business_name: Optional[str] = None
business_address: Optional[str] = None
business_phone: Optional[str] = None
image_url: Optional[str] = None
social_media: Optional[str] = None
terms: Optional[str] = None
discount_percentage: Optional[int] = None
discount_amount: Optional[float] = None
category: Optional[str] = None
valid_from: Optional[datetime] = None
valid_until: Optional[datetime] = None
is_active: Optional[bool] = None

View File

@ -0,0 +1,31 @@
from sqlmodel import SQLModel, Field
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
class Favorite(SQLModel, table=True):
__tablename__ = "favorites"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True)
# Type of favorite: 'coupon', 'business', 'taxi', 'route'
item_type: str = Field(index=True)
item_id: str = Field(index=True)
# Optional metadata
item_name: Optional[str] = None
item_image: Optional[str] = None
created_at: datetime = Field(default_factory=datetime.utcnow)
class Config:
json_schema_extra = {
"example": {
"user_id": "user-123",
"item_type": "coupon",
"item_id": "coupon-456",
"item_name": "50% descuento en restaurante",
"item_image": "/uploads/coupon.jpg"
}
}

View File

@ -0,0 +1,27 @@
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional, TYPE_CHECKING
if TYPE_CHECKING:
from app.models.user import User
from uuid import UUID, uuid4
from datetime import datetime
from sqlalchemy import Column, DateTime, func
from enum import Enum
class ReportStatus(str, Enum):
PENDING = "pending"
RESOLVED = "resolved"
ARCHIVED = "archived"
class Report(SQLModel, table=True):
__tablename__ = "reports"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
user_id: Optional[UUID] = Field(default=None, foreign_key="users.id")
message: str
status: ReportStatus = Field(default=ReportStatus.PENDING)
created_at: Optional[datetime] = Field(
sa_column=Column(DateTime, server_default=func.now())
)
# Relationships
user: Optional["User"] = Relationship()

View File

@ -0,0 +1,33 @@
"""Route model."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from enum import Enum
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class RouteStatus(str, Enum):
"""Route status enumeration."""
ACTIVE = "active"
INACTIVE = "inactive"
MAINTENANCE = "maintenance"
class Route(SQLModel, table=True):
"""Route model representing a bus route."""
__tablename__ = "routes"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
name: str = Field(unique=True, index=True)
description: Optional[str] = None
origin_city: str
destination_city: str
distance_km: Optional[float] = None
estimated_duration_minutes: Optional[int] = None
average_speed_kmh: Optional[float] = None
status: RouteStatus = RouteStatus.ACTIVE
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))
updated_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now(), onupdate=func.now()))

View File

@ -0,0 +1,23 @@
"""Route stop junction model."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class RouteStop(SQLModel, table=True):
"""Route stop junction table connecting routes to their stops."""
__tablename__ = "route_stops"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
route_id: UUID = Field(foreign_key="routes.id")
stop_id: UUID = Field(foreign_key="bus_stops.id")
stop_order: int
travel_time_minutes: Optional[int] = None
stop_delay_minutes: int = 0
is_pickup_point: bool = True
is_dropoff_point: bool = True
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))

View File

@ -0,0 +1,32 @@
"""Shuttle model for intercity and tourist trips."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class Shuttle(SQLModel, table=True):
"""Model representing an intercity shuttle or tourist trip."""
__tablename__ = "shuttles"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
route_name: str = Field(index=True) # e.g. "Boquete - Santa Catalina"
description: Optional[str] = None
origin: str = Field(index=True)
destination: str = Field(index=True)
vehicle_type: str # Private Bus, Private Car, Van
company_name: Optional[str] = None # e.g. "Chiriqui Transfers"
trip_type: str = "one_way" # one_way, round_trip, both
price_per_person: Optional[float] = None
price_private_trip: Optional[float] = None
estimated_duration: str # e.g. "4.5 hours"
departure_times: Optional[str] = None # e.g. "Every Day at 8:00 AM"
contact_whatsapp: str
phone_number: Optional[str] = None
english_speaking: bool = Field(default=False)
image_url: Optional[str] = None
is_active: bool = Field(default=True)
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))
updated_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now(), onupdate=func.now()))

View File

@ -0,0 +1,26 @@
"""Taxi model."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
class Taxi(SQLModel, table=True):
"""Taxi model representing an authorized taxi."""
__tablename__ = "taxis"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
owner_name: str
phone_number: str
license_plate: str = Field(unique=True, index=True)
cooperative: Optional[str] = None
corregimiento: str = Field(index=True)
shift: str = "day" # day, night, 24h
rating: float = Field(default=5.0)
english_speaking: bool = Field(default=False)
image_url: Optional[str] = None
is_active: bool = Field(default=True)
created_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now()))
updated_at: Optional[datetime] = Field(sa_column=Column(DateTime, server_default=func.now(), onupdate=func.now()))

View File

@ -0,0 +1,35 @@
"""Telemetry model for real-time tracking."""
from sqlmodel import SQLModel, Field, Column
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import DateTime, func
from enum import Enum
class VehicleStatus(str, Enum):
ACTIVE = "active"
OFFLINE = "offline"
BREAK = "break"
class Telemetry(SQLModel, table=True):
"""Telemetry record for a driver's vehicle."""
__tablename__ = "telemetry"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id", index=True)
latitude: float
longitude: float
speed: Optional[float] = None
heading: Optional[float] = None
status: VehicleStatus = Field(default=VehicleStatus.ACTIVE)
timestamp: datetime = Field(
sa_column=Column(DateTime, server_default=func.now(), index=True)
)
class TelemetryCreate(SQLModel):
latitude: float
longitude: float
speed: Optional[float] = None
heading: Optional[float] = None
status: VehicleStatus = VehicleStatus.ACTIVE

View File

@ -0,0 +1,59 @@
"""User and DriverProfile models."""
from sqlmodel import SQLModel, Field, Relationship
from typing import Optional
from enum import Enum
from uuid import UUID, uuid4
from datetime import datetime
from sqlalchemy import Column, DateTime, func
class UserRole(str, Enum):
ADMIN = "ADMIN"
PASSENGER = "PASSENGER"
DRIVER = "DRIVER"
PROMOTER = "PROMOTER"
class VehicleType(str, Enum):
TAXI = "taxi"
BUS = "bus"
class User(SQLModel, table=True):
__tablename__ = "users"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
email: str = Field(unique=True, index=True)
hashed_password: str
full_name: str = Field(index=True)
role: UserRole = Field(default=UserRole.PASSENGER)
is_active: bool = Field(default=True)
is_verified: bool = Field(default=False) # For drivers/admins verification
profile_photo_url: Optional[str] = None
created_at: Optional[datetime] = Field(
sa_column=Column(DateTime, server_default=func.now())
)
# Relationships
driver_profile: Optional["DriverProfile"] = Relationship(
back_populates="user", sa_relationship_kwargs={"uselist": False}
)
class DriverProfile(SQLModel, table=True):
__tablename__ = "driver_profiles"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id")
cedula: str
vehicle_type: VehicleType
license_plate: str
photo_url: Optional[str] = None
vehicle_photo_url: Optional[str] = None
cooperative_name: Optional[str] = None # Specifically for Bus
shift: Optional[str] = None # For Taxi schedules (e.g. "Dia,Noche")
payment_methods: Optional[str] = None # e.g. "Efectivo,Yappi"
speaks_english: bool = Field(default=False)
# Relationship
user: User = Relationship(back_populates="driver_profile")

View File

@ -0,0 +1,49 @@
"""UserCoupon model for tracking claimed coupons."""
from sqlmodel import SQLModel, Field, Relationship
from datetime import datetime
from typing import Optional, TYPE_CHECKING
from uuid import UUID, uuid4
from sqlalchemy import Column, DateTime, func, String
from enum import Enum
if TYPE_CHECKING:
from app.models.user import User
from app.models.coupon import Coupon
class UserCouponStatus(str, Enum):
CLAIMED = "claimed"
REDEEMED = "redeemed"
EXPIRED = "expired"
class UserCoupon(SQLModel, table=True):
__tablename__ = "user_coupons"
id: Optional[UUID] = Field(default_factory=uuid4, primary_key=True)
user_id: UUID = Field(foreign_key="users.id")
coupon_id: UUID = Field(foreign_key="coupons.id")
status: UserCouponStatus = Field(default=UserCouponStatus.CLAIMED)
redemption_code: str = Field(
sa_column=Column(String, unique=True, index=True)
)
claimed_at: datetime = Field(
sa_column=Column(DateTime(timezone=True), server_default=func.now())
)
redeemed_at: Optional[datetime] = Field(
sa_column=Column(DateTime(timezone=True), nullable=True)
)
# Relationships
user: "User" = Relationship()
coupon: "Coupon" = Relationship()
class UserCouponRead(SQLModel):
id: UUID
user_id: UUID
coupon_id: UUID
status: UserCouponStatus
redemption_code: str
claimed_at: datetime
redeemed_at: Optional[datetime]
coupon: Optional["Coupon"] = None

View File

@ -0,0 +1,2 @@
"""Pydantic schemas for request/response validation."""

View File

@ -0,0 +1,53 @@
"""Bus stop schemas."""
from pydantic import BaseModel, field_serializer
from typing import Optional
from datetime import datetime
from uuid import UUID
from app.models.bus_stop import StopType
class BusStopBase(BaseModel):
"""Base bus stop schema."""
name: str
latitude: float
longitude: float
city: str
address: Optional[str] = None
stop_type: StopType = StopType.REGULAR
has_shelter: bool = False
has_seating: bool = False
is_accessible: bool = False
stop_order: Optional[int] = None
class BusStopCreate(BusStopBase):
"""Schema for creating a bus stop."""
pass
class BusStopUpdate(BaseModel):
"""Schema for updating a bus stop."""
name: Optional[str] = None
latitude: Optional[float] = None
longitude: Optional[float] = None
city: Optional[str] = None
address: Optional[str] = None
stop_type: Optional[StopType] = None
has_shelter: Optional[bool] = None
has_seating: Optional[bool] = None
is_accessible: Optional[bool] = None
class BusStopResponse(BusStopBase):
"""Schema for bus stop response."""
id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@field_serializer('id')
def serialize_id(self, value: UUID) -> str:
return str(value)
class Config:
from_attributes = True

View File

@ -0,0 +1,22 @@
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.models.report import ReportStatus
class ReportCreate(BaseModel):
message: str
class ReportUpdate(BaseModel):
status: ReportStatus
class ReportResponse(BaseModel):
id: UUID
user_id: Optional[UUID] = None
user_name: Optional[str] = None
message: str
status: ReportStatus
created_at: datetime
class Config:
from_attributes = True

View File

@ -0,0 +1,50 @@
"""Route schemas."""
from pydantic import BaseModel, field_serializer
from typing import Optional
from datetime import datetime
from uuid import UUID
from app.models.route import RouteStatus
class RouteBase(BaseModel):
"""Base route schema."""
name: str
description: Optional[str] = None
origin_city: str
destination_city: str
distance_km: Optional[float] = None
estimated_duration_minutes: Optional[int] = None
average_speed_kmh: Optional[float] = None
status: RouteStatus = RouteStatus.ACTIVE
class RouteCreate(RouteBase):
"""Schema for creating a route."""
pass
class RouteUpdate(BaseModel):
"""Schema for updating a route."""
name: Optional[str] = None
description: Optional[str] = None
origin_city: Optional[str] = None
destination_city: Optional[str] = None
distance_km: Optional[float] = None
estimated_duration_minutes: Optional[int] = None
average_speed_kmh: Optional[float] = None
status: Optional[RouteStatus] = None
class RouteResponse(RouteBase):
"""Schema for route response."""
id: UUID
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@field_serializer('id')
def serialize_id(self, value: UUID) -> str:
return str(value)
class Config:
from_attributes = True

View File

@ -0,0 +1,21 @@
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
class RouteStopBase(BaseModel):
stop_id: UUID
stop_order: Optional[int] = None
travel_time_minutes: Optional[int] = None
stop_delay_minutes: int = 0
is_pickup_point: Optional[bool] = True
is_dropoff_point: Optional[bool] = True
class RouteStopCreate(RouteStopBase):
pass
class RouteStopUpdate(BaseModel):
stop_order: Optional[int] = None
travel_time_minutes: Optional[int] = None
stop_delay_minutes: Optional[int] = None
is_pickup_point: Optional[bool] = None
is_dropoff_point: Optional[bool] = None

View File

@ -0,0 +1,45 @@
"""Shuttle schemas."""
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
class ShuttleBase(BaseModel):
route_name: str
description: Optional[str] = None
origin: str
destination: str
vehicle_type: str
company_name: Optional[str] = None
trip_type: str = "one_way"
price_per_person: Optional[float] = None
price_private_trip: Optional[float] = None
estimated_duration: str
departure_times: Optional[str] = None
contact_whatsapp: str
image_url: Optional[str] = None
is_active: bool = True
class ShuttleCreate(ShuttleBase):
pass
class ShuttleUpdate(BaseModel):
route_name: Optional[str] = None
description: Optional[str] = None
origin: Optional[str] = None
destination: Optional[str] = None
vehicle_type: Optional[str] = None
company_name: Optional[str] = None
trip_type: Optional[str] = None
price_per_person: Optional[float] = None
price_private_trip: Optional[float] = None
estimated_duration: Optional[str] = None
departure_times: Optional[str] = None
contact_whatsapp: Optional[str] = None
image_url: Optional[str] = None
is_active: Optional[bool] = None
class ShuttleRead(ShuttleBase):
id: UUID
class Config:
from_attributes = True

View File

@ -0,0 +1,61 @@
"""User and Auth schemas."""
from pydantic import BaseModel, EmailStr
from typing import Optional
from uuid import UUID
from app.models.user import UserRole, VehicleType
class UserBase(BaseModel):
email: EmailStr
full_name: str
profile_photo_url: Optional[str] = None
class UserUpdate(BaseModel):
full_name: Optional[str] = None
password: Optional[str] = None
profile_photo_url: Optional[str] = None
class PassengerCreate(UserBase):
password: str
class DriverProfileBase(BaseModel):
cedula: str
vehicle_type: VehicleType
license_plate: str
cooperative_name: Optional[str] = None
class DriverCreate(UserBase):
password: str
cedula: str
vehicle_type: VehicleType
license_plate: str
cooperative_name: Optional[str] = None
# Photos will be handled via UploadFile in FastAPI, not in the Pydantic schema for JSON data
class UserResponse(UserBase):
id: UUID
role: UserRole
is_active: bool
is_verified: bool
class Config:
from_attributes = True
class LoginRequest(BaseModel):
email: str
password: str
keep_session: bool = False
class Token(BaseModel):
access_token: str
token_type: str
role: str
full_name: str
profile_photo_url: Optional[str] = None

View File

19
backend/check_db.py Normal file
View File

@ -0,0 +1,19 @@
from app.core.database import engine
from sqlmodel import Session, select
from app.models.favorite import Favorite
from app.models.user import User
def check_db():
with Session(engine) as session:
favorites = session.exec(select(Favorite)).all()
print(f"Total favorites: {len(favorites)}")
for f in favorites:
print(f"User: {f.user_id}, Type: {f.item_type}, ID: {f.item_id}, Name: {f.item_name}")
users = session.exec(select(User)).all()
print(f"Total users: {len(users)}")
for u in users:
print(f"User ID: {u.id}, Email: {u.email}, Role: {u.role}")
if __name__ == "__main__":
check_db()

25
backend/check_db_async.py Normal file
View File

@ -0,0 +1,25 @@
import asyncio
import asyncpg
async def check_db():
creds = [
("sibu", "sibu", "sibu"),
("postgres", "postgres", "postgres"),
("postgres", "postgres", "sibu"),
("postgres", "", "postgres"),
("sibu", "", "sibu"),
]
for user, pw, db in creds:
url = f"postgresql://{user}:{pw}@localhost:5432/{db}"
print(f"Testing {url}...")
try:
conn = await asyncpg.connect(url, timeout=5)
print(f"!!! SUCCESS with {url} !!!")
await conn.close()
return
except Exception as e:
print(f"Failed: {type(e).__name__}: {e}")
if __name__ == "__main__":
asyncio.run(check_db())

28
backend/create_admin.py Normal file
View File

@ -0,0 +1,28 @@
"""Script to create a default admin user."""
from sqlmodel import Session, select
from app.core.database import engine
from app.models.user import User, UserRole
from app.core.security import get_password_hash
from app.core.config import settings
def create_admin():
with Session(engine) as session:
# Check if admin already exists
admin = session.exec(select(User).where(User.email == "admin@sibu.com")).first()
if admin:
print("Admin already exists.")
return
new_admin = User(
email="admin@sibu.com",
full_name="Administrator",
hashed_password=get_password_hash(settings.admin_password),
role=UserRole.ADMIN,
is_verified=True
)
session.add(new_admin)
session.commit()
print("Admin user created successfully!")
if __name__ == "__main__":
create_admin()

View File

@ -0,0 +1,47 @@
import sys
import os
from sqlmodel import Session, select
# Add parent directory to path to import app
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.core.database import engine
from app.models.user import User, UserRole
from app.core.security import get_password_hash
def create_promo_user():
email = "promo@gmail.com"
password = "promo"
full_name = "Promociones SIBU"
with Session(engine) as session:
# Check if user already exists
statement = select(User).where(User.email == email)
user = session.exec(statement).first()
if user:
print(f"User {email} already exists.")
# Update role to PROMOTER if necessary
if user.role != UserRole.PROMOTER:
user.role = UserRole.PROMOTER
session.add(user)
session.commit()
print(f"Updated user {email} role to PROMOTER.")
return
# Create new user
new_user = User(
email=email,
hashed_password=get_password_hash(password),
full_name=full_name,
role=UserRole.PROMOTER.value,
is_active=True,
is_verified=True
)
session.add(new_user)
session.commit()
print(f"User {email} created successfully.")
if __name__ == "__main__":
create_promo_user()

6289
backend/database_export.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,21 @@
#!/bin/bash
set -e
echo "Starting application startup sequence..."
# Run migrations
echo "Running database migrations..."
uv run alembic upgrade head
# Optionally run seeders if RUN_SEEDERS environment variable is set
if [ "${RUN_SEEDERS:-false}" = "true" ]; then
echo "Running database seeders..."
uv run python -m app.core.seed
else
echo "Skipping seeders (set RUN_SEEDERS=true to enable)"
fi
# Start the application
echo "Starting FastAPI application..."
exec "$@"

View File

@ -0,0 +1,40 @@
import requests
def get_vertexdc_users():
api_url = "https://api.sibu.vertexdc.com"
login_data = {
"email": "admin@sibu.com",
"password": "admin",
"keep_session": True
}
print(f"Logging in to {api_url}/api/auth/login...")
try:
response = requests.post(f"{api_url}/api/auth/login", json=login_data)
if response.status_code != 200:
print(f"Login failed: {response.status_code} - {response.text}")
return
token_data = response.json()
token = token_data.get("access_token")
print("Login successful!")
headers = {"Authorization": f"Bearer {token}"}
# Try to search for '@' to get all users or use search with empty string if allowed
print("Fetching users via /api/users/search?email=...")
search_response = requests.get(f"{api_url}/api/users/search", params={"email": ""}, headers=headers)
if search_response.status_code == 200:
users = search_response.json()
print(f"Found {len(users)} users:")
for u in users:
print(f"- {u['full_name']} ({u['email']}) Role: {u['role']}")
else:
print(f"Search failed: {search_response.status_code} - {search_response.text}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
get_vertexdc_users()

View File

@ -0,0 +1,21 @@
from sqlmodel import Session, select, create_engine
from app.models.user import User
def get_remote_users():
# Trying the IP of api.sibu.vertexdc.com
url = "postgresql+psycopg2://sibu:xajqcG2nYUoLXCBQYYYN7U23AZp2JWZNmDwo9ivrp6RnwcUcANPfhVXy2AM7J0sm@74.208.39.48:5432/sibu"
print(f"Attempting to connect to: {url}")
try:
engine = create_engine(url, connect_args={'connect_timeout': 5})
with Session(engine) as session:
print("Connection successful! Fetching users...")
statement = select(User)
users = session.exec(statement).all()
print(f"Found {len(users)} users:")
for u in users:
print(f"- {u.full_name} ({u.email}) [Role: {u.role}]")
except Exception as e:
print(f"Connection failed: {e}")
if __name__ == "__main__":
get_remote_users()

View File

@ -0,0 +1,43 @@
import urllib.request
import json
import ssl
def get_vertexdc_users():
api_url = "https://api.sibu.vertexdc.com"
login_data = json.dumps({
"email": "admin@sibu.com",
"password": "admin",
"keep_session": True
}).encode('utf-8')
# Bypass SSL verification if needed (not recommended but for quick dev check)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
print(f"Logging in to {api_url}/api/auth/login...")
try:
req = urllib.request.Request(f"{api_url}/api/auth/login", data=login_data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, context=ctx) as response:
token_data = json.loads(response.read().decode('utf-8'))
token = token_data.get("access_token")
print("Login successful!")
headers = {"Authorization": f"Bearer {token}"}
print("Fetching users via /api/users/search?email=...")
# Search with an empty string to get all users
search_url = f"{api_url}/api/users/search?email="
req_search = urllib.request.Request(search_url, headers=headers)
with urllib.request.urlopen(req_search, context=ctx) as response:
users = json.loads(response.read().decode('utf-8'))
print(f"Found {len(users)} users:")
for u in users:
print(f"- {u['full_name']} ({u['email']}) Role: {u['role']}")
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
get_vertexdc_users()

View File

@ -0,0 +1,55 @@
import urllib.request
import json
import ssl
def get_vertexdc_users_via_api():
api_url = "https://api.sibu.vertexdc.com"
# Admin credentials the user gave me
login_data = json.dumps({
"email": "admin@sibu.com",
"password": "admin",
"keep_session": True
}).encode('utf-8')
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
print(f"Attempting login to {api_url}/api/auth/login...")
try:
req = urllib.request.Request(f"{api_url}/api/auth/login", data=login_data, headers={'Content-Type': 'application/json'})
with urllib.request.urlopen(req, context=ctx) as response:
token_data = json.loads(response.read().decode('utf-8'))
token = token_data.get("access_token")
print("Login successful!")
headers = {"Authorization": f"Bearer {token}"}
# Try different search terms to find users
search_terms = ["@", ".com", "admin", "promo", "usuario"]
all_users = {}
for term in search_terms:
print(f"Searching for users matching '{term}'...")
search_url = f"{api_url}/api/users/search?email={term}"
req_search = urllib.request.Request(search_url, headers=headers)
try:
with urllib.request.urlopen(req_search, context=ctx) as response:
users = json.loads(response.read().decode('utf-8'))
for u in users:
all_users[u['email']] = u
except Exception as e:
print(f"Search for '{term}' failed: {e}")
if all_users:
print(f"\n✅ Found {len(all_users)} users on VertexDC:")
for email, u in all_users.items():
print(f"- {u['full_name']} ({email}) [Role: {u['role']}]")
else:
print("\n❌ No users found via API search.")
except Exception as e:
print(f"Critical error: {e}")
if __name__ == "__main__":
get_vertexdc_users_via_api()

7
backend/init_db.py Normal file
View File

@ -0,0 +1,7 @@
"""Script to initialize database and create all tables."""
from app.core.database import init_db
if __name__ == "__main__":
print("Creating database tables...")
init_db()
print("Database tables created successfully!")

15
backend/inspect_db.py Normal file
View File

@ -0,0 +1,15 @@
from sqlalchemy import inspect
from app.core.database import engine
def inspect_db():
inspector = inspect(engine)
if 'favorites' in inspector.get_table_names():
print("Table 'favorites' exists.")
columns = inspector.get_columns('favorites')
for column in columns:
print(f"Column: {column['name']}, Type: {column['type']}")
else:
print("Table 'favorites' does NOT exist.")
if __name__ == "__main__":
inspect_db()

12
backend/list_paths.py Normal file
View File

@ -0,0 +1,12 @@
import json
try:
with open("vertexdc_openapi.json", "r", encoding="utf-16") as f:
data = json.load(f)
except Exception:
with open("vertexdc_openapi.json", "r", encoding="utf-8") as f:
data = json.load(f)
print("Paths found in OpenAPI:")
for path in sorted(data.get("paths", {}).keys()):
print(f"- {path}")

6
backend/main.py Normal file
View File

@ -0,0 +1,6 @@
def main():
print("Hello from sibu-backend!")
if __name__ == "__main__":
main()

25
backend/migrate_taxis.py Normal file
View File

@ -0,0 +1,25 @@
from app.core.database import engine
from sqlalchemy import text, inspect
def migrate():
with engine.connect() as conn:
inspector = inspect(engine)
columns = [c['name'] for c in inspector.get_columns('taxis')]
if 'rating' not in columns:
print("Adding column rating...")
conn.execute(text("ALTER TABLE taxis ADD COLUMN rating FLOAT DEFAULT 5.0"))
if 'english_speaking' not in columns:
print("Adding column english_speaking...")
conn.execute(text("ALTER TABLE taxis ADD COLUMN english_speaking BOOLEAN DEFAULT FALSE"))
if 'image_url' not in columns:
print("Adding column image_url...")
conn.execute(text("ALTER TABLE taxis ADD COLUMN image_url VARCHAR"))
conn.commit()
print("Migration completed!")
if __name__ == "__main__":
migrate()

8
backend/parse_openapi.py Normal file
View File

@ -0,0 +1,8 @@
import json
with open("vertexdc_openapi.json", "r") as f:
data = json.load(f)
for path in data.get("paths", {}):
if "/api/users" in path:
print(path)

View File

@ -0,0 +1,16 @@
import json
with open("vertexdc_openapi.json", "r", encoding="utf-16" if "\xFF\xFE" in open("vertexdc_openapi.json", "rb").read(2).decode("latin-1", errors="ignore") else "utf-8") as f:
try:
content = f.read()
# Clean potential BOM or leading spaces
content = content.strip()
data = json.loads(content)
for path in data.get("paths", {}):
if "/api/users" in path:
print(path)
except Exception as e:
print(f"Error parsing JSON: {e}")
# Let's print the first 100 chars to see what's wrong
f.seek(0)
print(f"Start of file: {f.read(100)!r}")

20
backend/pyproject.toml Normal file
View File

@ -0,0 +1,20 @@
[project]
name = "sibu-backend"
version = "0.1.0"
description = "Add your description here"
requires-python = ">=3.13"
dependencies = [
"alembic>=1.17.2",
"asyncpg>=0.31.0",
"bcrypt>=5.0.0",
"fastapi[standard]>=0.123.0",
"passlib>=1.7.4",
"psycopg2-binary>=2.9.11",
"pydantic-settings[dotenv]>=2.12.0",
"python-dotenv>=1.2.1",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.20",
"ruff>=0.14.10",
"sqlmodel>=0.0.27",
"supabase>=2.24.0",
]

View File

@ -0,0 +1,13 @@
from app.core.database import engine
from app.models.favorite import Favorite
def reset_favorites():
# Only drop favorites table
print("Dropping favorites table...")
Favorite.__table__.drop(engine, checkfirst=True)
print("Creating favorites table...")
Favorite.__table__.create(engine)
print("Done.")
if __name__ == "__main__":
reset_favorites()

106
backend/seed_promos.py Normal file
View File

@ -0,0 +1,106 @@
import sys
import os
from sqlmodel import Session, select
# Add parent directory to path to import app
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from app.core.database import engine
from app.models.business import Business
from app.models.coupon import Coupon
def seed_promos():
with Session(engine) as session:
# 1. Check if we already have these businesses
biz_name = "Pizzeria El Centro"
biz = session.exec(select(Business).where(Business.name == biz_name)).first()
if not biz:
biz = Business(
name=biz_name,
category="Restaurante",
address="Calle Central, David",
phone="775-1234",
latitude=8.4272,
longitude=-82.4300,
image_url="/uploads/businesses/pizzeria.jpg"
)
session.add(biz)
session.commit()
session.refresh(biz)
print(f"Created business: {biz_name}")
else:
# Update coords if needed
biz.latitude = 8.4272
biz.longitude = -82.4300
session.add(biz)
session.commit()
print(f"Updated business: {biz_name}")
# 2. Add a coupon for this business
coupon_title = "Pizzas 2x1 Martes"
coupon = session.exec(select(Coupon).where(Coupon.title == coupon_title)).first()
if not coupon:
coupon = Coupon(
title=coupon_title,
business_id=biz.id,
business_name=biz.name,
business_address=biz.address,
description="Lleva 2 pizzas al precio de 1 todos los martes.",
category="Restaurante",
discount_percentage=50,
is_active=True,
image_url=biz.image_url
)
session.add(coupon)
session.commit()
print(f"Created coupon: {coupon_title}")
# 3. Add second business
biz_name2 = "Heladeria Glacial"
biz2 = session.exec(select(Business).where(Business.name == biz_name2)).first()
if not biz2:
biz2 = Business(
name=biz_name2,
category="Restaurante",
address="Plaza Terronal, David",
phone="774-5678",
latitude=8.4350,
longitude=-82.4250,
image_url="/uploads/businesses/icecream.jpg"
)
session.add(biz2)
session.commit()
session.refresh(biz2)
print(f"Created business: {biz_name2}")
else:
biz2.latitude = 8.4350
biz2.longitude = -82.4250
session.add(biz2)
session.commit()
print(f"Updated business: {biz_name2}")
# 4. Add a coupon for second business
coupon_title2 = "30% en Helados"
coupon2 = session.exec(select(Coupon).where(Coupon.title == coupon_title2)).first()
if not coupon2:
coupon2 = Coupon(
title=coupon_title2,
business_id=biz2.id,
business_name=biz2.name,
business_address=biz2.address,
description="30% de descuento en el segundo helado.",
category="Restaurante",
discount_percentage=30,
is_active=True,
image_url=biz2.image_url
)
session.add(coupon2)
session.commit()
print(f"Created coupon: {coupon_title2}")
if __name__ == "__main__":
seed_promos()

84
backend/seed_shuttles.py Normal file
View File

@ -0,0 +1,84 @@
from app.core.database import engine, init_db
from app.models.shuttle import Shuttle
from sqlmodel import Session, select
import uuid
def seed_shuttles():
# Ensure table exists
init_db()
shuttles_data = [
{
'route_name': 'Boquete > Santa Catalina',
'origin': 'Boquete',
'destination': 'Santa Catalina',
'vehicle_type': 'Mini Van Compartida',
'company_name': 'Chiriqui Transfers',
'trip_type': 'one_way',
'price_per_person': 35.0,
'price_private_trip': 180.0,
'estimated_duration': '4.5 horas',
'departure_times': 'Todos los días 8:00 AM',
'contact_whatsapp': '+50760000000',
'description': 'Viaje directo desde el centro de Boquete hasta Santa Catalina. Ideal para surfistas y turistas.',
'is_active': True
},
{
'route_name': 'Boquete > Bocas del Toro',
'origin': 'Boquete',
'destination': 'Bocas del Toro',
'vehicle_type': 'Mini Van + Bote',
'company_name': 'Hello Panama Tours',
'trip_type': 'one_way',
'price_per_person': 30.0,
'price_private_trip': 150.0,
'estimated_duration': '3.5 horas',
'departure_times': 'Diario 8:00 AM / 12:00 PM',
'contact_whatsapp': '+50760000000',
'description': 'Incluye transporte terrestre hasta Almirante y el bote taxi hacia Isla Colón.',
'is_active': True
},
{
'route_name': 'Boquete > Las Lajas',
'origin': 'Boquete',
'destination': 'Las Lajas',
'vehicle_type': 'Vehículo Privado',
'company_name': 'Express Boquete',
'trip_type': 'both',
'price_per_person': 20.0,
'price_private_trip': 100.0,
'estimated_duration': '2 horas',
'departure_times': 'Bajo demanda',
'contact_whatsapp': '+50760000000',
'description': 'Transporte directo a la playa de Las Lajas. Regreso incluido opcional.',
'is_active': True
},
{
'route_name': 'Boquete > Puerto Armuelles',
'origin': 'Boquete',
'destination': 'Puerto Armuelles',
'vehicle_type': 'Sedán Privado',
'company_name': 'Taxi Chiriqui',
'trip_type': 'one_way',
'price_per_person': 25.0,
'price_private_trip': 120.0,
'estimated_duration': '2.5 horas',
'departure_times': 'Bajo demanda',
'contact_whatsapp': '+50760000000',
'description': 'Viaje cómodo y seguro hacia el puerto y las playas del sur.',
'is_active': True
}
]
with Session(engine) as session:
for data in shuttles_data:
statement = select(Shuttle).where(Shuttle.route_name == data['route_name'])
existing = session.exec(statement).first()
if not existing:
shuttle = Shuttle(**data)
session.add(shuttle)
session.commit()
print(f"✅ Inserted shuttles successfully!")
if __name__ == "__main__":
seed_shuttles()

Some files were not shown because too many files have changed in this diff Show More