Initial commit: SIBU 2.0 MISSION
This commit is contained in:
113
.cursor/rules/main.mdc
Normal file
113
.cursor/rules/main.mdc
Normal 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
|
||||
57
.gitea/workflows/deploy.yaml
Normal file
57
.gitea/workflows/deploy.yaml
Normal 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"}'
|
||||
42
.gitea/workflows/quality_assurance.yaml
Normal file
42
.gitea/workflows/quality_assurance.yaml
Normal 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
46
.gitignore
vendored
Normal 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
163
Makefile
Normal 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
24
PENDING_FOR_TOMORROW.md
Normal 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
219
README.md
Normal 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
6
backend/.env.development
Normal 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
43
backend/Dockerfile
Normal 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
92
backend/alembic.ini
Normal 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
95
backend/alembic/env.py
Normal 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()
|
||||
|
||||
27
backend/alembic/script.py.mako
Normal file
27
backend/alembic/script.py.mako
Normal 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"}
|
||||
|
||||
32
backend/alembic/versions/2088667c3a5f_add_search_indexes.py
Normal file
32
backend/alembic/versions/2088667c3a5f_add_search_indexes.py
Normal 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 ###
|
||||
|
||||
88
backend/alembic/versions/2f4936eb86f0_initial_schema.py
Normal file
88
backend/alembic/versions/2f4936eb86f0_initial_schema.py
Normal 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 ###
|
||||
|
||||
46
backend/alembic/versions/3fe72cd3f722_sync_shuttle_fields.py
Normal file
46
backend/alembic/versions/3fe72cd3f722_sync_shuttle_fields.py
Normal 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 ###
|
||||
|
||||
57
backend/alembic/versions/414da6754b1e_add_user_roles.py
Normal file
57
backend/alembic/versions/414da6754b1e_add_user_roles.py
Normal 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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
44
backend/alembic/versions/8a5661aac24b_add_user_coupons.py
Normal file
44
backend/alembic/versions/8a5661aac24b_add_user_coupons.py
Normal 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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
33
backend/alembic/versions/c9bb64354775_add_business_coords.py
Normal file
33
backend/alembic/versions/c9bb64354775_add_business_coords.py
Normal 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 ###
|
||||
|
||||
46
backend/alembic/versions/ceda6a5abf0e_add_telemetry_table.py
Normal file
46
backend/alembic/versions/ceda6a5abf0e_add_telemetry_table.py
Normal 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 ###
|
||||
|
||||
@ -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 ###
|
||||
|
||||
@ -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
2
backend/app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""SIBU Backend Application."""
|
||||
|
||||
2
backend/app/api/__init__.py
Normal file
2
backend/app/api/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""API routes."""
|
||||
|
||||
122
backend/app/api/analytics.py
Normal file
122
backend/app/api/analytics.py
Normal 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
|
||||
}
|
||||
233
backend/app/api/auth/__init__.py
Normal file
233
backend/app/api/auth/__init__.py
Normal 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
|
||||
91
backend/app/api/bus_stops/__init__.py
Normal file
91
backend/app/api/bus_stops/__init__.py
Normal 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}
|
||||
158
backend/app/api/businesses.py
Normal file
158
backend/app/api/businesses.py
Normal 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"}
|
||||
94
backend/app/api/coupons.py
Normal file
94
backend/app/api/coupons.py
Normal 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
76
backend/app/api/deps.py
Normal 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
|
||||
109
backend/app/api/favorites/__init__.py
Normal file
109
backend/app/api/favorites/__init__.py
Normal 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}
|
||||
96
backend/app/api/reports.py
Normal file
96
backend/app/api/reports.py
Normal 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
|
||||
)
|
||||
229
backend/app/api/routes/__init__.py
Normal file
229
backend/app/api/routes/__init__.py
Normal 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
|
||||
|
||||
86
backend/app/api/schedules/__init__.py
Normal file
86
backend/app/api/schedules/__init__.py
Normal 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}
|
||||
|
||||
161
backend/app/api/shuttles/__init__.py
Normal file
161
backend/app/api/shuttles/__init__.py
Normal 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}
|
||||
145
backend/app/api/taxis/__init__.py
Normal file
145
backend/app/api/taxis/__init__.py
Normal 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}
|
||||
84
backend/app/api/telemetry.py
Normal file
84
backend/app/api/telemetry.py
Normal 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
116
backend/app/api/users.py
Normal 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}
|
||||
2
backend/app/core/__init__.py
Normal file
2
backend/app/core/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Core configuration and utilities."""
|
||||
|
||||
46
backend/app/core/config.py
Normal file
46
backend/app/core/config.py
Normal 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()
|
||||
27
backend/app/core/database.py
Normal file
27
backend/app/core/database.py
Normal 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
|
||||
|
||||
251
backend/app/core/export_database_to_seeder.py
Normal file
251
backend/app/core/export_database_to_seeder.py
Normal 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()
|
||||
|
||||
92
backend/app/core/export_supabase_data.py
Normal file
92
backend/app/core/export_supabase_data.py
Normal 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")
|
||||
|
||||
99
backend/app/core/generate_route_stops.py
Normal file
99
backend/app/core/generate_route_stops.py
Normal 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})")
|
||||
|
||||
223
backend/app/core/import_supabase_coordinates.py
Normal file
223
backend/app/core/import_supabase_coordinates.py
Normal 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)
|
||||
|
||||
46
backend/app/core/security.py
Normal file
46
backend/app/core/security.py
Normal 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
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
70
backend/app/main.py
Normal 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}
|
||||
18
backend/app/models/__init__.py
Normal file
18
backend/app/models/__init__.py
Normal 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"]
|
||||
|
||||
19
backend/app/models/analytics.py
Normal file
19
backend/app/models/analytics.py
Normal 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)
|
||||
)
|
||||
31
backend/app/models/bus_schedule.py
Normal file
31
backend/app/models/bus_schedule.py
Normal 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()))
|
||||
|
||||
35
backend/app/models/bus_stop.py
Normal file
35
backend/app/models/bus_stop.py
Normal 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()))
|
||||
|
||||
58
backend/app/models/business.py
Normal file
58
backend/app/models/business.py
Normal 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
|
||||
79
backend/app/models/coupon.py
Normal file
79
backend/app/models/coupon.py
Normal 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
|
||||
31
backend/app/models/favorite.py
Normal file
31
backend/app/models/favorite.py
Normal 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"
|
||||
}
|
||||
}
|
||||
27
backend/app/models/report.py
Normal file
27
backend/app/models/report.py
Normal 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()
|
||||
33
backend/app/models/route.py
Normal file
33
backend/app/models/route.py
Normal 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()))
|
||||
|
||||
23
backend/app/models/route_stop.py
Normal file
23
backend/app/models/route_stop.py
Normal 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()))
|
||||
|
||||
32
backend/app/models/shuttle.py
Normal file
32
backend/app/models/shuttle.py
Normal 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()))
|
||||
26
backend/app/models/taxi.py
Normal file
26
backend/app/models/taxi.py
Normal 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()))
|
||||
35
backend/app/models/telemetry.py
Normal file
35
backend/app/models/telemetry.py
Normal 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
|
||||
59
backend/app/models/user.py
Normal file
59
backend/app/models/user.py
Normal 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")
|
||||
49
backend/app/models/user_coupon.py
Normal file
49
backend/app/models/user_coupon.py
Normal 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
|
||||
2
backend/app/schemas/__init__.py
Normal file
2
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
"""Pydantic schemas for request/response validation."""
|
||||
|
||||
53
backend/app/schemas/bus_stop.py
Normal file
53
backend/app/schemas/bus_stop.py
Normal 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
|
||||
|
||||
22
backend/app/schemas/report.py
Normal file
22
backend/app/schemas/report.py
Normal 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
|
||||
50
backend/app/schemas/route.py
Normal file
50
backend/app/schemas/route.py
Normal 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
|
||||
|
||||
21
backend/app/schemas/route_stop.py
Normal file
21
backend/app/schemas/route_stop.py
Normal 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
|
||||
45
backend/app/schemas/shuttle.py
Normal file
45
backend/app/schemas/shuttle.py
Normal 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
|
||||
61
backend/app/schemas/user.py
Normal file
61
backend/app/schemas/user.py
Normal 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
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
19
backend/check_db.py
Normal file
19
backend/check_db.py
Normal 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
25
backend/check_db_async.py
Normal 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
28
backend/create_admin.py
Normal 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()
|
||||
47
backend/create_promo_user.py
Normal file
47
backend/create_promo_user.py
Normal 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
6289
backend/database_export.json
Normal file
File diff suppressed because it is too large
Load Diff
21
backend/docker-entrypoint.sh
Normal file
21
backend/docker-entrypoint.sh
Normal 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 "$@"
|
||||
|
||||
40
backend/get_remote_users.py
Normal file
40
backend/get_remote_users.py
Normal 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()
|
||||
21
backend/get_remote_users_db.py
Normal file
21
backend/get_remote_users_db.py
Normal 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()
|
||||
43
backend/get_remote_users_urllib.py
Normal file
43
backend/get_remote_users_urllib.py
Normal 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()
|
||||
55
backend/get_remote_users_v2.py
Normal file
55
backend/get_remote_users_v2.py
Normal 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
7
backend/init_db.py
Normal 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
15
backend/inspect_db.py
Normal 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
12
backend/list_paths.py
Normal 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
6
backend/main.py
Normal file
@ -0,0 +1,6 @@
|
||||
def main():
|
||||
print("Hello from sibu-backend!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
25
backend/migrate_taxis.py
Normal file
25
backend/migrate_taxis.py
Normal 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
8
backend/parse_openapi.py
Normal 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)
|
||||
16
backend/parse_openapi_v2.py
Normal file
16
backend/parse_openapi_v2.py
Normal 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
20
backend/pyproject.toml
Normal 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",
|
||||
]
|
||||
13
backend/reset_favorites.py
Normal file
13
backend/reset_favorites.py
Normal 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
106
backend/seed_promos.py
Normal 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
84
backend/seed_shuttles.py
Normal 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
Reference in New Issue
Block a user