diff --git a/README.md b/README.md index 58fab68..83496e1 100644 --- a/README.md +++ b/README.md @@ -10,15 +10,16 @@ A simple SQLite-based system for managing tablet lending and returns. - **Return Management**: Record when tablets are returned - **History Tracking**: Complete history of all loans and returns - **User Loans View**: See all tablets loaned to each user (demonstrates one-to-many relationship) +- **Non-Loanable Devices**: Track inventory devices that cannot be loaned (e.g., projectors, monitors) - **Project Notes**: Markdown editor for development documentation ## Files -- `minimal_app.py` - Interactive command-line application +- `minimal_app.py` - Interactive command-line application (includes non-loanable device management) - `test_app.py` - Test script that demonstrates functionality -- `tablets.db` - SQLite database (created automatically) -- `simple_app.py` - Web-based version (requires Flask) -- `app.py` - Alternative web version (requires Flask) +- `tablets.db` - SQLite database (created automatically, includes non_loanable_devices table) +- `simple_app.py` - Web-based version (requires Flask, includes non-loanable device management) +- `app.py` - Alternative web version (requires Flask, includes non-loanable device management) ## Quick Start @@ -144,6 +145,7 @@ python3 app.py - **Loan Tablet**: Loan a tablet to a user (with search for large datasets) - **User Loans**: View all tablets loaned to each user - demonstrates the **one-to-many relationship** (one user, multiple tablets) - **Loan History**: Complete history of all loan and return transactions +- **Non-Loanable Devices**: Manage inventory of devices that cannot be loaned (add, edit, delete, view) - **Project Management**: Markdown editor for development notes (saved to `notes_development/project_notes.md`) ## Database Backup @@ -153,6 +155,48 @@ To backup your data: cp tablets.db tablets_backup_$(date +%Y%m%d).db ``` +## Testing + +The project includes a comprehensive unit test suite with **46 tests** covering core functionality and edge cases. + +### Running Tests + +```bash +# Install test dependencies (if not already installed) +source .venv/bin/activate +uv pip install pytest pytest-cov + +# Run all tests +python3 -m pytest tests/ -v + +# Run with coverage report +python3 -m pytest tests/ --cov=./ --cov-report=term-missing +``` + +### Test Structure + +| File | Tests | Coverage | +|------|-------|----------| +| `tests/test_core.py` | 21 | Core operations (tablet, user, loan CRUD) | +| `tests/test_edge_cases.py` | 14 | Edge cases (duplicates, invalid IDs, etc.) | +| `tests/test_minimal_app.py` | 11 | Application function tests | + +### Key Edge Cases Covered + +- Loan a device that's already loaned (prevents double-loaning) +- Return a non-existent loan (handles gracefully) +- Duplicate serial numbers (rejected at database level) +- Duplicate user identifications (rejected at database level) +- Invalid tablet/user IDs (validated before operations) +- Multiple loans per user (one-to-many relationship) +- Loan-return-loan sequence (device lifecycle) + +### Test Configuration + +Tests use isolated temporary databases and automatically clean up after each test run. No impact on production data. + + + ## License This is a simple educational project. Feel free to use and modify it as needed. \ No newline at end of file diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md new file mode 100644 index 0000000..f0c6929 --- /dev/null +++ b/REQUIREMENTS.md @@ -0,0 +1,221 @@ +# Requisitos del Sistema de Gestión de Préstamos de Tablets + +## Descripción General + +El sistema debe permitir la gestión del préstamo y devolución de dispositivos tablets entre un inventario y un conjunto de usuarios. El enfoque principal es el seguimiento del estado de cada dispositivo y su relación con los usuarios que los han tomado prestados. + +--- +## Requisitos Faltantes + +### Gestión de dispositivos no prestables + +✅ **IMPLEMENTADO** - El sistema ahora permite: +- Registrar nuevos dispositivos no prestables en un inventario separado (tabla `non_loanable_devices`) +- Gestionar dispositivos no prestables a través de: + - Interfaz web (app.py): /non_loanable_devices, /add_non_loanable_device, /edit_non_loanable_device, /delete_non_loanable_device + - Interfaz CLI (minimal_app.py): Opciones 8, 9, 10 del menú +- La implementación utiliza una tabla separada en la misma base de datos con los siguientes campos: + - brand, model, serial_number (único), device_type, location, status, notes, purchase_date, purchase_cost + +## Requisitos Funcionales + +### Gestión de Dispositivos + +El sistema debe permitir: +- Registrar nuevos dispositivos en el inventario +- Identificar cada dispositivo de manera única +- Almacenar información descriptiva de cada dispositivo (marca, modelo, número de serie) +- Consultar el estado actual de cada dispositivo (disponible, prestado) + +### Gestión de Usuarios + +El sistema debe permitir: +- Registrar nuevos usuarios +- Identificar cada usuario de manera única +- Almacenar información de contacto básica (nombre, identificación) +- Consultar la información de los usuarios registrados + +### Gestión de Préstamos + +El sistema debe permitir: +- Asignar un dispositivo disponible a un usuario +- Registrar la fecha y hora del préstamo +- Registrar la fecha y hora de la devolución +- Consultar el historial de préstamos de un dispositivo +- Consultar el historial de préstamos de un usuario +- Visualizar todos los dispositivos actualmente prestados + +### Consulta y Reportes + +El sistema debe permitir: +- Buscar dispositivos por cualquier atributo (marca, modelo, número de serie) +- Buscar usuarios por cualquier atributo (nombre, identificación) +- Filtrar préstamos por estado (activos, finalizados) +- Filtrar préstamos por dispositivo +- Filtrar préstamos por usuario +- Ver el historial completo de préstamos y devoluciones + +--- + +## Modelo de Datos + +### Entidades Principales + +1. **Dispositivo** + - Identificador único + - Marca + - Modelo + - Número de serie (único) + - Estado (disponible, prestado) + +2. **Usuario** + - Identificador único + - Nombre + - Identificación (única) + +3. **Préstamo** + - Identificador único + - Dispositivo asociado + - Usuario asociado + - Fecha de préstamo + - Fecha de devolución (opcional) + - Estado (activo, finalizado) + +### Relaciones + +- Un **usuario** puede tener **múltiples préstamos** (relación uno a muchos) +- Un **dispositivo** solo puede estar en **un préstamo activo** a la vez +- Un **préstamo** relaciona exactamente un dispositivo con un usuario +- El historial de préstamos permite rastrear todos los movimientos de cada dispositivo + +### Reglas de Negocio + +1. Un dispositivo en estado "prestado" no puede ser prestado a otro usuario +2. Un préstamo finalizado debe registrar fecha de devolución +3. La identificación de dispositivos y usuarios debe ser única +4. Al devolver un dispositivo, este vuelve al estado "disponible" + +--- + +## Flujos de Trabajo + +### Registro de un Nuevo Dispositivo + +1. El sistema solicita la información del dispositivo (marca, modelo, número de serie) +2. El sistema valida que el número de serie no esté registrado +3. El sistema registra el dispositivo con estado "disponible" +4. El sistema confirma el registro + +### Registro de un Nuevo Usuario + +1. El sistema solicita la información del usuario (nombre, identificación) +2. El sistema valida que la identificación no esté registrada +3. El sistema registra el usuario +4. El sistema confirma el registro + +### Préstamo de un Dispositivo + +1. El sistema muestra los dispositivos disponibles +2. El usuario selecciona un dispositivo +3. El sistema muestra los usuarios registrados +4. El usuario selecciona un usuario +5. El sistema registra el préstamo con: + - Dispositivo seleccionado + - Usuario seleccionado + - Fecha y hora actual + - Estado "activo" +6. El sistema actualiza el estado del dispositivo a "prestado" +7. El sistema confirma el préstamo + +### Devolución de un Dispositivo + +1. El sistema muestra los préstamos activos +2. El usuario selecciona un préstamo +3. El sistema registra la fecha y hora de devolución +4. El sistema actualiza el estado del préstamo a "finalizado" +5. El sistema actualiza el estado del dispositivo a "disponible" +6. El sistema confirma la devolución + +### Consulta de Préstamos por Usuario + +1. El sistema muestra la lista de usuarios +2. El usuario selecciona un usuario +3. El sistema muestra: + - Dispositivos actualmente prestados (préstamos activos) + - Historial de préstamos pasados (opcional: expandible) +4. El sistema permite buscar por cualquier campo + +--- + +## Requisitos No Funcionales + +### Usabilidad + +- La interfaz debe permitir búsqueda eficiente en listas grandes (500+ elementos) +- La visualización de información debe ser clara y organizada +- Los estados deben ser fácilmente identificables +- Las acciones principales deben ser accesibles + +### Escalabilidad + +- El diseño debe soportar un crecimiento en el número de dispositivos y usuarios +- La búsqueda debe ser eficiente +- El historial debe ser consultable sin afectar el rendimiento + +### Mantenibilidad + +- La estructura de datos debe ser clara +- Las relaciones entre entidades deben estar bien definidas +- Los flujos de trabajo deben estar documentados + +--- + +## Diseño de Interfaz (Conceptual) + +### Vistas Principales + +1. **Vista de Inventario** + - Lista de dispositivos + - Filtros por estado + - Búsqueda + +2. **Vista de Usuarios** + - Lista de usuarios + - Búsqueda + +3. **Vista de Préstamos Activos** + - Lista de préstamos en curso + - Filtros por dispositivo/usuario + - Acciones: devolver dispositivo + +4. **Vista de Historial** + - Lista completa de préstamos + - Filtros por fecha, usuario, dispositivo, estado + - Detalles de cada préstamo + +5. **Vista de Préstamos por Usuario** + - Lista de usuarios + - Para cada usuario: dispositivos actualmente prestados + - Opcional: historial de préstamos pasados (expandible) + +### Elementos de Interacción + +- Campos de búsqueda en todas las listas +- Botones de acción claros (Prestar, Devolver, Registrar) +- Indicadores visuales de estado (colores, iconos) +- Confirmación de acciones críticas + +--- + +## Notas de Implementación + +Este documento describe los requisitos a nivel de diseño. La implementación específica (lenguaje de programación, base de datos, framework, arquitectura) queda a criterio del equipo de desarrollo y puede variar según las necesidades técnicas. + +Los detalles técnicos como: +- Tecnologías específicas +- Estructura de base de datos física +- APIs +- Autenticación +- Despliegue + +Deberán ser definidos en la fase de diseño técnico y pueden evolucionar sin afectar los requisitos funcionales descritos aquí. diff --git a/RUNNING.md b/RUNNING.md index 9c37f3d..02c80c4 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -48,10 +48,22 @@ pkill -f "python app.py" - **Port**: 5000 (configurable in app.py) - **Virtual Environment**: `.venv/` (created with uv) -## Troubleshooting -If you have issues: -1. Check if the server is running: `ps aux | grep app.py` -2. Check the database: `sqlite3 tablets.db` -3. Restart the server: `source .venv/bin/activate && python app.py` +## Running Tests -Enjoy managing your tablets! 📱💻 \ No newline at end of file +The project includes 46 unit tests for verifying functionality: + +```bash +# Run all tests +python3 -m pytest tests/ -v + +# Run with coverage +python3 -m pytest tests/ --cov=./ --cov-report=term-missing +``` + +Tests cover: +- Core CRUD operations for tablets, users, and loans +- Edge cases (duplicates, invalid IDs, already loaned devices) +- Application function testing +- Non-loanable device management + +All tests use isolated temporary databases and do not affect production data. diff --git a/TECHNICAL_SPECIFICATIONS.md b/TECHNICAL_SPECIFICATIONS.md new file mode 100644 index 0000000..a6995c2 --- /dev/null +++ b/TECHNICAL_SPECIFICATIONS.md @@ -0,0 +1,200 @@ +# Especificaciones Técnicas + +Documento complementario a `REQUIREMENTS.md` con propuestas concretas de implementación técnica. + +--- + +## 1. Concurrencia en Base de Datos + +| Opción | Descripción | Ventajas | Desventajas | +|--------|-------------|---------|-------------| +| **SQLite + WAL** | Modo Write-Ahead Logging | Zero-config, embebido, buena para <100 conexiones | Limitado a 1 escritor simultáneo | +| **PostgreSQL** | Base de datos relacional | Concurrencia completa, transacciones ACID | Requiere servidor | +| **Transacciones** | Aislamiento READ COMMITTED | Previene lecturas sucias | Bloqueos temporales | +| **Locking optimista** | Versión de fila en actualizaciones | Sin bloqueos | Conflictos requieren reintento | + +**Recomendación**: SQLite con WAL para prototipos, PostgreSQL para producción. Usar transacciones para operaciones críticas (préstamo/devolución). + +--- + +## 2. Autenticación Básica + +| Opción | Descripción | Complejidad | +|--------|-------------|-------------| +| **Sesiones (Flask)** | Cookies firmadas | Baja | +| **JWT** | Tokens sin estado | Media | +| **OAuth 2.0** | Delegación a proveedores | Alta | +| **Basic Auth** | HTTP Basic Authentication | Mínima | + +**Flujo mínimo**: +``` +Login (user/pass) → Session/JWT → Middleware verifica rol → Acceso a endpoints +``` + +**Roles sugeridos**: +- `admin`: CRUD completo, gestión de usuarios +- `staff`: Préstamos/devoluciones +- `viewer`: Solo consulta + +--- + +## 3. Separación Frontend/Backend + +| Arquitectura | Descripción | Stack Ejemplo | +|--------------|-------------|---------------| +| **Monolítica + Templates** | Servidor renderiza HTML | Flask+Jinja2, Django | +| **API + SPA** | Backend API, frontend dinámico | FastAPI + React/Vue | +| **API + SSR** | Backend API + renderizado servidor | Next.js + API | +| **Microservicios** | Servicios independientes | Backend API + Frontend estático | + +**Para este proyecto**: +- **Opción A**: Flask + Jinja2 (simple, suficiente para requisitos) +- **Opción B**: FastAPI + React (escalable, separable) + +--- + +## 4. Búsqueda Eficiente (500+ elementos) + +| Opción | Descripción | Implementación | +|--------|-------------|----------------| +| **Índices DB** | B-tree en columnas buscadas | `CREATE INDEX idx_brand ON tablets(brand)` | +| **Búsqueda full-text** | Búsqueda en texto | SQLite FTS5, PostgreSQL tsvector | +| **Filtrado cliente** | JavaScript filtra datos | Ideal para <2000 elementos | +| **Paginación** | Dividir resultados | `LIMIT 50 OFFSET 0` | + +**Recomendación**: Índices en `serial_number`, `brand`, `model`, `user.name`, `user.identification`. + +--- + +## 5. Estructura de Proyecto Propuesta + +``` +project/ +├── backend/ +│ ├── models/ # Entidades (Device, User, Loan) +│ ├── services/ # Lógica de negocio +│ ├── repositories/ # Acceso a datos +│ ├── routes/ # Endpoints/API +│ └── app.py # Configuración +├── frontend/ # Opcional para opción B +│ ├── public/ +│ └── src/ +├── migrations/ # Control de cambios DB +├── tests/ +└── docs/ +``` + +--- + +## 6. Decisiones Clave por Tomar + +| Decisión | Opciones | Impacto | +|----------|----------|---------| +| Base de datos | SQLite / PostgreSQL | Concurrencia, despliegue | +| Autenticación | Sesiones / JWT / Ninguna | Seguridad, complejidad | +| Frontend | Templates / SPA / Ninguno | Experiencia usuario | +| Despliegue | Local / Docker / Cloud | Escalabilidad | + +--- + +## 7. Ejemplo de Implementación Mínima (Pseudocódigo) + +```python +# modelos.py +class Device: + id: int (PK) + serial_number: str (unique) + status: str (available/loaned) + loans: List[Loan] (one-to-many) + +class User: + id: int (PK) + identification: str (unique) + loans: List[Loan] (one-to-many) + +class Loan: + id: int (PK) + device_id: int (FK) + user_id: int (FK) + loan_date: datetime + return_date: datetime (nullable) + status: str (active/returned) + +# servicios/loan_service.py +def loan_device(device_id: int, user_id: int) -> Loan: + with transaction(): # Atomicidad + device = get_device_for_update(device_id) + if device.status != 'available': + raise DeviceNotAvailableError() + + loan = Loan.create( + device_id=device_id, + user_id=user_id, + loan_date=now(), + status='active' + ) + device.status = 'loaned' + return loan +``` + +--- + +## 8. Consideraciones Adicionales + +- **Validaciones**: Únicas (serial_number, identification) a nivel de base de datos +- **Auditoría**: Campos `created_at`, `updated_at` en todas las entidades +- **Backups**: Script para exportar base de datos periódicamente +- **Logging**: Registrar préstamos/devoluciones para auditoría +- **Versionado API**: `/api/v1/...` para compatibilidad futura +- **Pruebas**: Cubrir flujos críticos (préstamo con dispositivo no disponible, devolución de préstamo inexistente) + +--- + +## 9. Migración a PostgreSQL (Futuro) + +### Contexto + +El sistema actualmente utiliza **SQLite** como base de datos embebida, ideal para prototipos y aplicaciones pequeñas. Sin embargo, SQLite tiene limitaciones para escalar. + +### Criterios para Migración + +Considerar migrar a **PostgreSQL** cuando: +- Tamaño DB > 1GB +- Más de 50 conexiones simultáneas +- Más de 200 usuarios activos +- Más de 500 transacciones por minuto +- Necesidad de múltiples servidores + +### Arquitectura Propuesta + +Usar un patrón de **Repository** para abstraer la base de datos: +``` +backend/ +├── repositories/ +│ ├── base_repository.py # Interfaz abstracta +│ ├── sqlite_repository.py # Implementación SQLite +│ └── postgres_repository.py # Implementación PostgreSQL +└── config/ + └── database.py # Fábrica de repositorios +``` + +### Schema PostgreSQL + +El schema es similar al de SQLite pero con tipos de datos más específicos y índices adicionales para rendimiento. + +### Pasos para Migración + +1. Instalar dependencias: `pip install psycopg2-binary sqlalchemy alembic` +2. Configurar PostgreSQL y crear base de datos +3. Ejecutar script de migración: `python scripts/migrate_to_postgres.py` +4. Cambiar configuración: `DB_TYPE=postgres` +5. Iniciar aplicación + +### Beneficios + +- Concurrencia ilimitada (múltiples escritores) +- Escalabilidad a miles de conexiones +- Mejor rendimiento con índices +- Seguridad integrada (autenticación, roles) +- Backups automáticos +- Replicación y clustering diff --git a/app.py b/app.py index a5332b7..7dfba4d 100644 --- a/app.py +++ b/app.py @@ -63,6 +63,22 @@ def init_db(): ) ''') + # Create non_loanable_devices table for devices that cannot be loaned + cursor.execute(''' + CREATE TABLE IF NOT EXISTS non_loanable_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + brand TEXT NOT NULL, + model TEXT NOT NULL, + serial_number TEXT UNIQUE NOT NULL, + device_type TEXT NOT NULL, + location TEXT, + status TEXT DEFAULT 'available', + notes TEXT, + purchase_date TEXT, + purchase_cost REAL + ) + ''') + conn.commit() @app.route('/') @@ -319,6 +335,99 @@ def user_loans(): return render_template('user_loans.html', users_with_loans=user_loans_list) +@app.route('/non_loanable_devices') +def non_loanable_devices(): + """Show all non-loanable devices""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM non_loanable_devices ORDER BY device_type, brand, model") + devices = cursor.fetchall() + + return render_template('non_loanable_devices.html', devices=devices) + + +@app.route('/add_non_loanable_device', methods=['GET', 'POST']) +def add_non_loanable_device(): + """Add a new non-loanable device to inventory""" + if request.method == 'POST': + brand = request.form['brand'] + model = request.form['model'] + serial_number = request.form['serial_number'] + device_type = request.form['device_type'] + location = request.form['location'] + notes = request.form['notes'] + purchase_date = request.form.get('purchase_date', '') + purchase_cost = request.form.get('purchase_cost', '') + + try: + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO non_loanable_devices + (brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost) + VALUES (?, ?, ?, ?, ?, 'available', ?, ?, ?) + ''', (brand, model, serial_number, device_type, location, notes, purchase_date, purchase_cost)) + conn.commit() + flash('Non-loanable device added successfully!', 'success') + except sqlite3.IntegrityError: + flash('Error: Serial number already exists!', 'error') + + return redirect(url_for('non_loanable_devices')) + + return render_template('add_non_loanable_device.html') + + +@app.route('/edit_non_loanable_device/', methods=['GET', 'POST']) +def edit_non_loanable_device(device_id): + """Edit a non-loanable device""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM non_loanable_devices WHERE id = ?", (device_id,)) + device = cursor.fetchone() + + if not device: + flash('Error: Device not found!', 'error') + return redirect(url_for('non_loanable_devices')) + + if request.method == 'POST': + brand = request.form['brand'] + model = request.form['model'] + serial_number = request.form['serial_number'] + device_type = request.form['device_type'] + location = request.form['location'] + status = request.form['status'] + notes = request.form['notes'] + purchase_date = request.form.get('purchase_date', '') + purchase_cost = request.form.get('purchase_cost', '') + + try: + cursor.execute(''' + UPDATE non_loanable_devices SET + brand = ?, model = ?, serial_number = ?, device_type = ?, + location = ?, status = ?, notes = ?, purchase_date = ?, purchase_cost = ? + WHERE id = ? + ''', (brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost, device_id)) + conn.commit() + flash('Device updated successfully!', 'success') + return redirect(url_for('non_loanable_devices')) + except sqlite3.IntegrityError: + flash('Error: Serial number already exists!', 'error') + + return render_template('edit_non_loanable_device.html', device=device) + + +@app.route('/delete_non_loanable_device/') +def delete_non_loanable_device(device_id): + """Delete a non-loanable device""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM non_loanable_devices WHERE id = ?", (device_id,)) + conn.commit() + flash('Device deleted successfully!', 'success') + + return redirect(url_for('non_loanable_devices')) + + if __name__ == '__main__': # Initialize database init_db() diff --git a/docs/FRONTEND_OPTIONS.md b/docs/FRONTEND_OPTIONS.md new file mode 100644 index 0000000..d8a141e --- /dev/null +++ b/docs/FRONTEND_OPTIONS.md @@ -0,0 +1,1191 @@ +# Separated Frontend Architecture Options + +## Overview + +The current Tablet Management System uses **server-side rendering** with Flask templates. While this works well for tablets, the interface may be too wide for mobile devices. This document explores **separated frontend architectures** that would provide better mobile responsiveness while keeping the existing backend intact. + +--- + +## Current Architecture + +``` +┌─────────────────────────────────────┐ +│ Flask Backend │ +│ ┌─────────┐ ┌─────────┐ ┌─────┐ │ +│ │ Routes │ │ Models │ │ DB │ │ +│ └─────────┘ └─────────┘ └─────┘ │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Flask Templates (Jinja2) │ +│ ┌─────────┐ ┌─────────┐ ┌─────┐ │ +│ │ HTML │ │ CSS │ │ JS │ │ +│ └─────────┘ └─────────┘ └─────┘ │ +└─────────────────────────────────────┘ + │ + ▼ + Browser (Tablet/Desktop) +``` + +**Limitations:** +- Server-rendered HTML (not ideal for dynamic mobile UIs) +- Limited interactivity without page reloads +- CSS is basic and not responsive for mobile +- Tight coupling between backend and frontend + +--- + +## Option 1: Single Page Application (SPA) with REST API + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ Flask Backend │ +│ ┌─────────────────────────────────┐ │ +│ │ REST API │ │ +│ │ /api/tablets │ │ +│ │ /api/users │ │ +│ │ /api/loans │ │ +│ │ /api/non-loanable-devices │ │ +│ └─────────────────────────────────┘ │ +└──────────────┬──────────────────────┘ + │ HTTP/JSON + ▼ +┌─────────────────────────────────────┐ +│ Frontend (React/Vue/Svelte) │ +│ ┌─────────┐ ┌─────────┐ ┌─────┐ │ +│ │ Components │ │ State │ │ Router│ │ +│ └─────────┘ └─────────┘ └─────┘ │ +└─────────────────────────────────────┘ + │ + ▼ + Browser (Mobile/Tablet/Desktop) +``` + +### Implementation Steps + +#### 1. Create REST API Layer + +Add new routes to `app.py` (without removing existing ones): + +```python +# API Routes (add to app.py) +@app.route('/api/tablets', methods=['GET']) +def api_get_tablets(): + """GET /api/tablets - List all tablets""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM tablets") + tablets = [dict(row) for row in cursor.fetchall()] + return jsonify(tablets) + +@app.route('/api/tablets/', methods=['GET']) +def api_get_tablet(tablet_id): + """GET /api/tablets/ - Get single tablet""" + with get_db() as conn: + cursor = conn.cursor() + cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) + tablet = cursor.fetchone() + if tablet: + return jsonify(dict(tablet)) + return jsonify({'error': 'Tablet not found'}), 404 + +@app.route('/api/tablets', methods=['POST']) +def api_create_tablet(): + """POST /api/tablets - Create new tablet""" + data = request.get_json() + # Validate and create + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO tablets (brand, model, serial_number, status, notes) + VALUES (?, ?, ?, 'available', ?) + ''', (data['brand'], data['model'], data['serial_number'], data.get('notes'))) + conn.commit() + tablet_id = cursor.lastrowid + cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) + return jsonify(dict(cursor.fetchone())), 201 + +# Similar endpoints for users, loans, non_loanable_devices +``` + +#### 2. Frontend Structure (React Example) + +``` +frontend/ +├── public/ +│ └── index.html +├── src/ +│ ├── components/ +│ │ ├── TabletList.jsx +│ │ ├── TabletForm.jsx +│ │ ├── UserList.jsx +│ │ ├── LoanForm.jsx +│ │ ├── LoanHistory.jsx +│ │ ├── UserLoans.jsx +│ │ └── NonLoanableDevices.jsx +│ ├── hooks/ +│ │ └── useApi.js +│ ├── services/ +│ │ └── api.js +│ ├── App.jsx +│ ├── index.js +│ └── styles/ +│ ├── main.css +│ └── responsive.css +├── package.json +└── README.md +``` + +#### 3. API Service (frontend/src/services/api.js) + +```javascript +const API_BASE = '/api'; + +export const api = { + // Tablets + getTablets: async (status = null) => { + const url = status ? `${API_BASE}/tablets?status=${status}` : `${API_BASE}/tablets`; + const response = await fetch(url); + return response.json(); + }, + + getTablet: async (id) => { + const response = await fetch(`${API_BASE}/tablets/${id}`); + return response.json(); + }, + + createTablet: async (tablet) => { + const response = await fetch(`${API_BASE}/tablets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tablet) + }); + return response.json(); + }, + + updateTablet: async (id, tablet) => { + const response = await fetch(`${API_BASE}/tablets/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tablet) + }); + return response.json(); + }, + + // Similar methods for users, loans, non_loanable_devices +}; +``` + +#### 4. React Components Example + +```jsx +// frontend/src/components/TabletList.jsx +import React, { useState, useEffect } from 'react'; +import { api } from '../services/api'; + +export function TabletList() { + const [tablets, setTablets] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.getTablets('available').then(data => { + setTablets(data); + setLoading(false); + }); + }, []); + + if (loading) return
Loading...
; + + return ( +
+

Available Tablets

+
+ {tablets.map(tablet => ( +
+

{tablet.brand} {tablet.model}

+

Serial: {tablet.serial_number}

+

Status: {tablet.status}

+
+ ))} +
+
+ ); +} +``` + +#### 5. Responsive CSS + +```css +/* frontend/src/styles/responsive.css */ + +/* Mobile-first approach */ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background-color: #f5f5f5; + min-height: 100vh; +} + +.container { + max-width: 100%; + padding: 1rem; +} + +/* Cards for mobile */ +.tablet-card, .user-card, .loan-card { + background: white; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + border: 1px solid #e0e0e0; +} + +/* Navigation */ +.nav { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.nav a { + padding: 0.75rem 1rem; + background-color: #4CAF50; + color: white; + text-decoration: none; + border-radius: 4px; + text-align: center; +} + +/* Tables - responsive */ +.responsive-table { + overflow-x: auto; +} + +table { + width: 100%; + min-width: 600px; /* Allows horizontal scrolling on mobile */ +} + +th, td { + padding: 0.75rem; + white-space: nowrap; +} + +/* Forms */ +form { + max-width: 100%; +} + +input, select, textarea { + width: 100%; + padding: 0.75rem; + margin-bottom: 1rem; + border: 1px solid #ddd; + border-radius: 4px; +} + +/* Buttons */ +.btn { + padding: 0.75rem 1.5rem; + width: 100%; + margin-bottom: 0.5rem; +} + +/* Breakpoints */ +@media (min-width: 600px) { + .nav { + flex-direction: row; + flex-wrap: wrap; + } + + .nav a { + flex: 1 1 auto; + min-width: 120px; + } + + .tablet-card, .user-card, .loan-card { + display: flex; + justify-content: space-between; + align-items: center; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + margin: 0 auto; + } + + table { + min-width: auto; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 960px; + } + + .nav { + flex-wrap: nowrap; + } +} + +@media (min-width: 1200px) { + .container { + max-width: 1140px; + } +} +``` + +### Pros and Cons + +| Aspect | Pros | Cons | +|--------|------|------| +| **User Experience** | Rich, dynamic UI; no page reloads | More complex to develop | +| **Performance** | Fast after initial load; client-side rendering | Larger initial bundle | +| **Mobile Support** | Excellent with responsive design | Needs careful CSS work | +| **Development** | Modern tooling (React/Vue); component-based | Separate codebase to maintain | +| **SEO** | Poor (SPA) | Needs SSR for better SEO | +| **Backend Impact** | Minimal (just add API routes) | Need to maintain both templates and API | +| **Deployment** | Can be hosted separately | More complex deployment | + +### Recommended Tech Stack + +- **Framework:** React (most popular) or Vue (simpler) or Svelte (smaller bundle) +- **State Management:** React Query or SWR for data fetching +- **Styling:** Tailwind CSS or CSS Modules +- **Routing:** React Router +- **Build Tool:** Vite (fast) or Create React App +- **TypeScript:** Optional but recommended for large projects + +--- + +## Option 2: Hybrid Approach (Progressive Enhancement) + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ Flask Backend │ +│ ┌─────────────────────────────────┐ │ +│ │ Dual Mode: │ │ +│ │ - Server templates (existing) │ │ +│ │ - REST API (new) │ │ +│ └─────────────────────────────────┘ │ +└──────────────┬──────────────────────┘ + │ + ┌─────┴─────┐ + ▼ ▼ +┌─────────────┐ ┌─────────────┐ +│ Desktop │ │ Mobile │ +│ (Existing) │ │ (New SPA) │ +└─────────────┘ └─────────────┘ +``` + +### How It Works + +1. **Desktop/Tablet:** Uses existing server-rendered templates +2. **Mobile:** Detects mobile device and serves a minimal HTML page that loads the SPA +3. **Shared Backend:** Both use the same Flask backend + +### Implementation + +#### 1. Device Detection Middleware + +```python +# app.py +from flask import request, redirect, url_for +import re + +MOBILE_USER_AGENTS = re.compile( + r'android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini|mobile', + re.IGNORECASE +) + +@app.before_request +def detect_mobile(): + user_agent = request.headers.get('User-Agent', '') + if MOBILE_USER_AGENTS.search(user_agent): + request.is_mobile = True + else: + request.is_mobile = False +``` + +#### 2. Mobile-Specific Route + +```python +@app.route('/mobile') +def mobile_app(): + """Serve mobile SPA entry point""" + return render_template('mobile.html') + +@app.before_request +def redirect_mobile(): + """Redirect mobile users to SPA""" + if hasattr(request, 'is_mobile') and request.is_mobile: + if not request.path.startswith('/api') and not request.path.startswith('/mobile'): + return redirect(url_for('mobile_app')) +``` + +#### 3. Mobile Entry Point Template + +```html + + + + + + + Tablet Management - Mobile + + + +
+ + + +``` + +### Pros and Cons + +| Aspect | Pros | Cons | +|--------|------|------| +| **User Experience** | Best of both worlds | Two UIs to maintain | +| **Mobile Support** | Excellent | Desktop UI unchanged (may still be wide) | +| **Development** | Gradual migration possible | More complex logic | +| **Backend Impact** | Minimal | Device detection logic | +| **SEO** | Good (server-rendered desktop) | Mobile SPA has poor SEO | +| **Deployment** | Single deployment | Larger asset bundle | + +--- + +## Option 3: Flask + HTMX (Lightweight Dynamic UI) + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ Flask Backend │ +│ ┌─────────────────────────────────┐ │ +│ │ Enhanced Templates │ │ +│ │ - HTML + HTMX attributes │ │ +│ │ - Partial updates via AJAX │ │ +│ └─────────────────────────────────┘ │ +└──────────────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Browser (Any Device) │ +│ - HTMX handles dynamic updates │ +│ - CSS handles responsiveness │ +└─────────────────────────────────────┘ +``` + +### What is HTMX? + +HTMX allows you to add interactivity to HTML without writing JavaScript. It uses attributes to: +- Make AJAX requests +- Update DOM elements +- Handle form submissions +- Show loading indicators + +### Implementation Example + +#### 1. Add HTMX to Base Template + +```html + + + + + + +``` + +#### 2. Enhance Templates with HTMX + +```html + +
+

Available Tablets

+ + + + + +
+ {% for tablet in available_tablets %} +
+

{{ tablet.brand }} {{ tablet.model }}

+

Serial: {{ tablet.serial_number }}

+ +
+ {% endfor %} +
+
+ + +
+``` + +#### 3. Add HTMX Endpoints + +```python +@app.route('/api/tablets/search') +def search_tablets(): + query = request.args.get('search', '') + with get_db() as conn: + cursor = conn.cursor() + cursor.execute(""" + SELECT * FROM tablets + WHERE brand LIKE ? OR model LIKE ? OR serial_number LIKE ? + """, (f'%{query}%', f'%{query}%', f'%{query}%')) + tablets = cursor.fetchall() + return render_template('partials/tablet_list.html', tablets=tablets) + +@app.route('/api/tablets//loan', methods=['POST']) +def loan_tablet_htmx(tablet_id): + # Get user from form + user_id = request.form.get('user_id') + # Loan logic... + return render_template('partials/loan_form.html', tablet_id=tablet_id) +``` + +#### 4. Responsive CSS + +```css +/* Add to base.html or separate CSS file */ + +/* Mobile-first responsive design */ +.tablet-card, .user-card, .loan-card { + background: white; + border-radius: 8px; + padding: 1rem; + margin-bottom: 1rem; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.nav { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.nav a { + padding: 0.75rem; + text-align: center; +} + +@media (min-width: 600px) { + .nav { + flex-direction: row; + flex-wrap: wrap; + } + + .tablet-card { + display: flex; + justify-content: space-between; + } +} + +@media (min-width: 768px) { + .container { + max-width: 720px; + margin: 0 auto; + } +} + +@media (min-width: 1024px) { + .container { + max-width: 960px; + } + + .nav { + flex-wrap: nowrap; + } +} +``` + +### Pros and Cons + +| Aspect | Pros | Cons | +|--------|------|------| +| **User Experience** | Dynamic updates without full page reloads | Less powerful than full SPA | +| **Mobile Support** | Good with responsive CSS | Still limited by server rendering | +| **Development** | Minimal changes to existing code | Need to learn HTMX | +| **Backend Impact** | Very minimal (just add endpoints) | More routes to maintain | +| **SEO** | Excellent (server-rendered) | Best of all options | +| **Deployment** | No changes needed | Simple | +| **Bundle Size** | Tiny (~14KB for HTMX) | No build step | + +--- + +## Option 4: Mobile App (Native or Cross-Platform) + +### Architecture + +``` +┌─────────────────────────────────────┐ +│ Flask Backend │ +│ ┌─────────────────────────────────┐ │ +│ │ REST API │ │ +│ │ (Same as Option 1) │ │ +│ └─────────────────────────────────┘ │ +└──────────────┬──────────────────────┘ + │ HTTP/JSON + ▼ +┌─────────────────────────────────────┐ +│ Mobile App │ +│ (React Native / Flutter / Capacitor)│ +└─────────────────────────────────────┘ + │ + ▼ + Mobile Device +``` + +### Implementation Options + +#### A. React Native (JavaScript) + +```javascript +// App.js +import React from 'react'; +import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native'; + +const API_BASE = 'http://your-server:5000/api'; + +export default function App() { + const [tablets, setTablets] = React.useState([]); + + React.useEffect(() => { + fetch(`${API_BASE}/tablets`) + .then(res => res.json()) + .then(data => setTablets(data)); + }, []); + + return ( + + Tablet Management + item.id.toString()} + renderItem={({item}) => ( + + {item.brand} {item.model} + SN: {item.serial_number} + Status: {item.status} + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + padding: 20, + backgroundColor: '#f5f5f5', + }, + title: { + fontSize: 24, + fontWeight: 'bold', + marginBottom: 20, + textAlign: 'center', + }, + card: { + backgroundColor: 'white', + padding: 15, + borderRadius: 8, + marginBottom: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + brand: { + fontSize: 18, + fontWeight: '600', + }, + serial: { + fontSize: 14, + color: '#666', + }, + status: { + fontSize: 14, + color: '#4CAF50', + }, +}); +``` + +#### B. Flutter (Dart) + +```dart +// main.dart +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import 'dart:convert'; + +void main() => runApp(MyApp()); + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Tablet Management', + home: TabletListScreen(), + ); + } +} + +class TabletListScreen extends StatefulWidget { + @override + _TabletListScreenState createState() => _TabletListScreenState(); +} + +class _TabletListScreenState extends State { + List tablets = []; + + @override + void initState() { + super.initState(); + fetchTablets(); + } + + Future fetchTablets() async { + final response = await http.get(Uri.parse('http://your-server:5000/api/tablets')); + if (response.statusCode == 200) { + setState(() { + tablets = json.decode(response.body); + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('Tablet Management')), + body: ListView.builder( + itemCount: tablets.length, + itemBuilder: (context, index) { + final tablet = tablets[index]; + return Card( + child: ListTile( + title: Text('${tablet['brand']} ${tablet['model']}'), + subtitle: Text('SN: ${tablet['serial_number']}'), + trailing: Text(tablet['status']), + ), + ); + }, + ), + ); + } +} +``` + +#### C. Capacitor (Web App as Mobile App) + +Use your existing web app (Option 1 SPA) and wrap it with Capacitor: + +```bash +# Install Capacitor +npm install @capacitor/core @capacitor/cli +npx cap init + +# Add platforms +npm install @capacitor/android @capacitor/ios +npx cap add android +npx cap add ios + +# Build and sync +npm run build +npx cap sync +npx cap open android # or ios +``` + +### Pros and Cons + +| Aspect | React Native | Flutter | Capacitor | +|--------|--------------|---------|-----------| +| **Language** | JavaScript | Dart | JavaScript | +| **Performance** | Native | Native | WebView | +| **Code Reuse** | ~80% with web | ~50% with web | ~100% with web | +| **Learning Curve** | Medium (if know React) | High (new language) | Low (web devs) | +| **Access to Native** | Good | Excellent | Limited | +| **Bundle Size** | Medium | Large | Small | +| **Offline Support** | Yes | Yes | Yes | + +--- + +## Comparison Matrix + +| Feature | Current | SPA (Option 1) | Hybrid (Option 2) | HTMX (Option 3) | Mobile App (Option 4) | +|---------|---------|---------------|------------------|----------------|----------------------| +| **Mobile Friendly** | ❌ No | ✅ Yes | ✅ Yes | ⚠️ Partial | ✅ Yes | +| **Desktop Friendly** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | +| **Tablet Friendly** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | +| **Development Effort** | N/A | High | Medium | Low | High | +| **Backend Changes** | N/A | Low | Low | Very Low | Low (API only) | +| **Learning Curve** | N/A | Medium | Medium | Low | High | +| **SEO** | ✅ Good | ❌ Poor | ✅ Good | ✅ Good | ❌ Poor | +| **Offline Support** | ❌ No | ✅ Yes | ❌ No | ❌ No | ✅ Yes | +| **Performance** | ⚠️ OK | ✅ Good | ⚠️ OK | ✅ Good | ✅ Excellent | +| **Deployment** | Simple | Complex | Medium | Simple | Complex | +| **Maintenance** | Simple | Medium | Complex | Simple | Medium | + +--- + +## Recommendations + +### For Immediate Improvement (Low Effort) + +**Choose: Option 3 (HTMX)** + +- Minimal code changes +- No new build process +- Progressive enhancement +- Good mobile support with responsive CSS +- Keeps existing server rendering + +### For Best User Experience (Medium Effort) + +**Choose: Option 1 (SPA with REST API)** + +- Modern, dynamic UI +- Excellent mobile support +- Can be deployed separately +- Backend changes are minimal (just add API routes) + +### For Native Mobile Experience (High Effort) + +**Choose: Option 4 (Mobile App)** + +- Best mobile UX +- Offline capabilities +- Native device features (camera, etc.) +- Requires separate mobile development + +### For Gradual Migration + +**Choose: Option 2 (Hybrid)** + +- Start with mobile SPA +- Keep desktop as-is +- Migrate desktop later if needed +- Minimal risk + +--- + +## Implementation Roadmap + +### Phase 1: Quick Win (1-2 days) + +1. Add responsive CSS to existing templates +2. Add viewport meta tag +3. Test on mobile devices + +**Result:** Better mobile experience with minimal changes + +### Phase 2: Enhanced Interactivity (3-5 days) + +1. Add HTMX to templates +2. Create partial templates for updates +3. Add new API endpoints for HTMX +4. Test all interactions + +**Result:** Dynamic UI without full SPA complexity + +### Phase 3: Full SPA (1-2 weeks) + +1. Set up React/Vue project +2. Create API layer in Flask +3. Build frontend components +4. Add responsive design +5. Test on all devices +6. Deploy frontend separately + +**Result:** Modern, mobile-first web application + +### Phase 4: Mobile App (2-4 weeks) + +1. Choose framework (React Native/Flutter) +2. Set up mobile project +3. Connect to existing API +4. Build mobile-specific UI +5. Add offline support +6. Test on devices +7. Publish to app stores + +**Result:** Native mobile application + +--- + +## File Structure for Separated Frontend + +If you choose Option 1 (SPA), here's the recommended structure: + +``` +GestionTablets/ +├── backend/ # Existing Flask backend +│ ├── app.py # Flask app + API routes +│ ├── templates/ # Existing templates (keep for now) +│ ├── static/ # Static files +│ └── ... +│ +├── frontend/ # NEW: Separated frontend +│ ├── public/ +│ │ └── index.html +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── common/ +│ │ │ │ ├── Button.jsx +│ │ │ │ ├── Card.jsx +│ │ │ │ ├── Modal.jsx +│ │ │ │ └── Table.jsx +│ │ │ ├── TabletList.jsx +│ │ │ ├── TabletForm.jsx +│ │ │ ├── UserList.jsx +│ │ │ ├── UserForm.jsx +│ │ │ ├── LoanList.jsx +│ │ │ ├── LoanForm.jsx +│ │ │ ├── LoanHistory.jsx +│ │ │ ├── UserLoans.jsx +│ │ │ └── NonLoanableDevices.jsx +│ │ ├── hooks/ +│ │ │ ├── useTablets.js +│ │ │ ├── useUsers.js +│ │ │ ├── useLoans.js +│ │ │ └── useApi.js +│ │ ├── services/ +│ │ │ └── api.js +│ │ ├── utils/ +│ │ │ ├── formatters.js +│ │ │ └── validators.js +│ │ ├── App.jsx +│ │ ├── App.css +│ │ ├── index.js +│ │ └── index.css +│ ├── package.json +│ ├── vite.config.js +│ └── README.md +│ +├── docs/ # Documentation +│ ├── MIGRATION_TO_POSTGRES.md +│ └── FRONTEND_OPTIONS.md # This document +│ +├── scripts/ # Utility scripts +│ └── migrate_to_postgres.py +│ +├── .gitignore +├── README.md +├── pyproject.toml +└── docker-compose.yml +``` + +--- + +## API Endpoints Needed + +For any separated frontend, you'll need these API endpoints: + +### Tablets +- `GET /api/tablets` - List all tablets +- `GET /api/tablets?status=available` - Filter by status +- `GET /api/tablets/` - Get single tablet +- `POST /api/tablets` - Create tablet +- `PUT /api/tablets/` - Update tablet +- `DELETE /api/tablets/` - Delete tablet +- `GET /api/tablets/search?q=query` - Search tablets + +### Users +- `GET /api/users` - List all users +- `GET /api/users/` - Get single user +- `POST /api/users` - Create user +- `PUT /api/users/` - Update user +- `DELETE /api/users/` - Delete user +- `GET /api/users/search?q=query` - Search users + +### Loans +- `GET /api/loans` - List all loans +- `GET /api/loans?status=active` - Filter by status +- `GET /api/loans/` - Get single loan +- `POST /api/loans` - Create loan +- `PUT /api/loans//return` - Return tablet +- `GET /api/loans/user/` - Get loans by user +- `GET /api/loans/tablet/` - Get loans by tablet + +### Non-Loanable Devices +- `GET /api/non-loanable-devices` - List all +- `GET /api/non-loanable-devices/` - Get single device +- `POST /api/non-loanable-devices` - Create device +- `PUT /api/non-loanable-devices/` - Update device +- `DELETE /api/non-loanable-devices/` - Delete device + +### Statistics +- `GET /api/stats` - Get dashboard statistics + +--- + +## Responsive Design Guidelines + +### Breakpoints + +```css +/* Mobile-first approach */ +:root { + --breakpoint-xs: 0px; + --breakpoint-sm: 576px; + --breakpoint-md: 768px; + --breakpoint-lg: 992px; + --breakpoint-xl: 1200px; +} + +/* Usage */ +@media (min-width: 576px) { /* Small devices (landscape phones) */ } +@media (min-width: 768px) { /* Medium devices (tablets) */ } +@media (min-width: 992px) { /* Large devices (desktops) */ } +@media (min-width: 1200px) { /* Extra large devices */ } +``` + +### Mobile-First Principles + +1. **Start with mobile** - Design for smallest screen first +2. **Progressive enhancement** - Add features for larger screens +3. **Touch targets** - Minimum 48x48px for touch elements +4. **Font sizes** - Minimum 16px for readability +5. **Spacing** - Adequate padding for touch +6. **Navigation** - Bottom navigation for mobile, top for desktop +7. **Forms** - Large, easy-to-use inputs +8. **Tables** - Consider cards instead of tables on mobile + +### Touch Target Sizes + +| Element | Minimum Size | Recommended Size | +|---------|--------------|------------------| +| Buttons | 48x48px | 56x56px | +| Form inputs | 48px height | 56px height | +| List items | 48px height | 64px height | +| Checkboxes/Radios | 24x24px | 32x32px | + +--- + +## Deployment Options + +### Option A: Separate Servers + +``` +┌─────────────────┐ ┌─────────────────┐ +│ Backend Server │────▶│ Frontend Server │ +│ (Flask) │ │ (Nginx/Apache) │ +│ :5000 │ │ :80/:443 │ +└─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + API Requests Static Files +``` + +**Pros:** Separate scaling, independent deployment +**Cons:** More complex setup, CORS configuration + +### Option B: Same Server, Different Routes + +``` +┌─────────────────────────────────────┐ +│ Flask Server │ +│ ┌─────────────────────────────────┐ │ +│ │ /api/* → Backend routes │ │ +│ │ /* → Frontend (SPA) │ │ +│ └─────────────────────────────────┘ │ +└─────────────────────────────────────┘ + │ + ▼ + Nginx (reverse proxy) + │ + ▼ + Client +``` + +**Pros:** Simpler deployment, no CORS issues +**Cons:** Backend serves static files + +### Option C: Docker Compose + +```yaml +# docker-compose.yml +version: '3.8' + +services: + backend: + build: ./backend + ports: + - "5000:5000" + environment: + - FLASK_ENV=production + restart: unless-stopped + + frontend: + build: ./frontend + ports: + - "80:80" + - "443:443" + depends_on: + - backend + restart: unless-stopped + + nginx: + image: nginx:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf + depends_on: + - backend + - frontend + restart: unless-stopped +``` + +--- + +## Conclusion + +For the Tablet Management System, I recommend the following approach: + +### Short Term (1-2 days) +Start with **Option 3 (HTMX)** to add dynamic updates and responsive CSS to the existing templates. This provides: +- Immediate mobile improvements +- Minimal code changes +- No new dependencies (just HTMX) +- Progressive enhancement + +### Medium Term (1-2 weeks) +Migrate to **Option 1 (SPA with REST API)** for: +- Better mobile experience +- Modern development workflow +- Separate frontend deployment +- Easier to maintain long-term + +### Long Term (Optional) +Consider **Option 4 (Mobile App)** if: +- Users need offline access +- Need native device features +- Want app store presence + +The current backend (Flask + SQLite) can remain **completely unchanged** for all these options. You only need to add API endpoints, which don't affect the existing template-based functionality. diff --git a/docs/MIGRATION_TO_POSTGRES.md b/docs/MIGRATION_TO_POSTGRES.md new file mode 100644 index 0000000..740f8b1 --- /dev/null +++ b/docs/MIGRATION_TO_POSTGRES.md @@ -0,0 +1,987 @@ +# PostgreSQL Migration Guide + +This document describes how to migrate the Tablet Management System from SQLite to PostgreSQL when the database grows beyond SQLite's capabilities. + +## When to Migrate + +Consider migrating to PostgreSQL when you encounter any of these scenarios: + +| Metric | SQLite Limit | PostgreSQL | Migration Trigger | +|--------|--------------|------------|-------------------| +| Database Size | ~10GB max | Unlimited | >1GB | +| Concurrent Writers | 1 | Thousands | >50 simultaneous | +| Active Users | <100 | Millions | >200 | +| Transactions/min | <100 | 100K+ | >500 | +| Servers | Single machine | Cluster | Multiple servers | +| High Availability | No | Yes | Required | +| Backup Strategy | Manual | Automated | Automated needed | + +## Architecture Overview + +The migration uses a **Repository Pattern** to abstract the database layer, allowing both SQLite and PostgreSQL to work seamlessly. + +``` +project/ +├── backend/ +│ ├── config/ +│ │ ├── __init__.py +│ │ ├── settings.py # Database configuration +│ │ └── database.py # Repository factory +│ ├── repositories/ +│ │ ├── __init__.py +│ │ ├── base_repository.py # Abstract base classes +│ │ ├── sqlite_repo.py # SQLite implementation +│ │ └── postgres_repo.py # PostgreSQL implementation +│ └── app.py # Main application (unchanged) +├── migrations/ # Alembic migrations +│ └── versions/ +│ └── initial_schema.py +├── scripts/ +│ └── migrate_to_postgres.py # Migration script +└── docker-compose.yml # Optional Docker setup +``` + +## Step 1: Install Dependencies + +```bash +# For development +pip install psycopg2-binary sqlalchemy alembic + +# For production (more efficient) +pip install psycopg2 sqlalchemy alembic +``` + +## Step 2: Set Up PostgreSQL + +### Option A: Local Installation + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install postgresql postgresql-contrib + +# Create database and user +sudo -u postgres psql +``` + +In PostgreSQL shell: +```sql +CREATE DATABASE tablet_management; +CREATE USER tablet_user WITH PASSWORD 'your_secure_password'; +GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user; +ALTER USER tablet_user CREATEDB; +\q +``` + +### Option B: Docker (Recommended for Development) + +```bash +# Start PostgreSQL container +docker run --name tablet-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_USER=tablet_user -e POSTGRES_DB=tablet_management -p 5432:5432 -d postgres:16-alpine + +# Or use docker-compose (see docker-compose.yml) +docker-compose up -d postgres +``` + +## Step 3: Configure Environment + +Create a `.env` file: + +```bash +# Database configuration +DB_TYPE=postgres # or 'sqlite' +DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management + +# For SQLite (fallback) +DB_PATH=tablets.db +``` + +Or set environment variables: + +```bash +export DB_TYPE=postgres +export DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management +``` + +## Step 4: Create Repository Abstraction + +### Base Repository (Abstract Interface) + +```python +# backend/repositories/base_repository.py +from abc import ABC, abstractmethod +from typing import Optional, List +from datetime import datetime + + +class BaseTabletRepository(ABC): + @abstractmethod + def get_by_id(self, tablet_id: int) -> Optional[dict]: + pass + + @abstractmethod + def get_by_serial(self, serial: str) -> Optional[dict]: + pass + + @abstractmethod + def get_all(self, status: Optional[str] = None) -> List[dict]: + pass + + @abstractmethod + def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict: + pass + + @abstractmethod + def update_status(self, tablet_id: int, status: str) -> bool: + pass + + @abstractmethod + def delete(self, tablet_id: int) -> bool: + pass + + +class BaseUserRepository(ABC): + @abstractmethod + def get_by_id(self, user_id: int) -> Optional[dict]: + pass + + @abstractmethod + def get_by_identification(self, identification: str) -> Optional[dict]: + pass + + @abstractmethod + def get_all(self) -> List[dict]: + pass + + @abstractmethod + def add(self, name: str, email: Optional[str], phone: Optional[str], identification: str) -> dict: + pass + + +class BaseLoanRepository(ABC): + @abstractmethod + def get_by_id(self, loan_id: int) -> Optional[dict]: + pass + + @abstractmethod + def get_active_by_tablet(self, tablet_id: int) -> Optional[dict]: + pass + + @abstractmethod + def get_by_user(self, user_id: int) -> List[dict]: + pass + + @abstractmethod + def get_all(self, status: Optional[str] = None) -> List[dict]: + pass + + @abstractmethod + def create(self, tablet_id: int, user_id: int) -> dict: + pass + + @abstractmethod + def return_loan(self, loan_id: int) -> bool: + pass + + +class BaseNonLoanableDeviceRepository(ABC): + @abstractmethod + def get_by_id(self, device_id: int) -> Optional[dict]: + pass + + @abstractmethod + def get_all(self) -> List[dict]: + pass + + @abstractmethod + def add(self, brand: str, model: str, serial_number: str, device_type: str, + location: Optional[str] = None, notes: Optional[str] = None, + purchase_date: Optional[str] = None, purchase_cost: Optional[float] = None) -> dict: + pass + + @abstractmethod + def update(self, device_id: int, **kwargs) -> bool: + pass + + @abstractmethod + def delete(self, device_id: int) -> bool: + pass +``` + +### SQLite Implementation + +```python +# backend/repositories/sqlite_repo.py +import sqlite3 +from typing import Optional, List +from .base_repository import ( + BaseTabletRepository, BaseUserRepository, + BaseLoanRepository, BaseNonLoanableDeviceRepository +) + + +class SQLiteTabletRepository(BaseTabletRepository): + def __init__(self, db_path: str = 'tablets.db'): + self.db_path = db_path + self._init_db() + + def _init_db(self): + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tablets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + brand TEXT NOT NULL, + model TEXT NOT NULL, + serial_number TEXT UNIQUE NOT NULL, + status TEXT DEFAULT 'available', + notes TEXT + ) + ''') + conn.commit() + conn.close() + + def get_by_id(self, tablet_id: int) -> Optional[dict]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def get_by_serial(self, serial: str) -> Optional[dict]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute("SELECT * FROM tablets WHERE serial_number = ?", (serial,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def get_all(self, status: Optional[str] = None) -> List[dict]: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + query = "SELECT * FROM tablets" + params = () + if status: + query += " WHERE status = ?" + params = (status,) + cursor.execute(query, params) + results = [dict(row) for row in cursor.fetchall()] + conn.close() + return results + + def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict: + conn = sqlite3.connect(self.db_path) + conn.row_factory = sqlite3.Row + cursor = conn.cursor() + cursor.execute(''' + INSERT INTO tablets (brand, model, serial_number, status, notes) + VALUES (?, ?, ?, 'available', ?) + ''', (brand, model, serial_number, notes)) + conn.commit() + tablet_id = cursor.lastrowid + cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) + + def update_status(self, tablet_id: int, status: str) -> bool: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("UPDATE tablets SET status = ? WHERE id = ?", (status, tablet_id)) + conn.commit() + changed = cursor.rowcount > 0 + conn.close() + return changed + + def delete(self, tablet_id: int) -> bool: + conn = sqlite3.connect(self.db_path) + cursor = conn.cursor() + cursor.execute("DELETE FROM tablets WHERE id = ?", (tablet_id,)) + conn.commit() + deleted = cursor.rowcount > 0 + conn.close() + return deleted + + +# Similar implementations for SQLiteUserRepository, SQLiteLoanRepository, etc. +``` + +### PostgreSQL Implementation + +```python +# backend/repositories/postgres_repo.py +import psycopg2 +from psycopg2 import sql +from psycopg2.extras import DictCursor +from typing import Optional, List +from .base_repository import ( + BaseTabletRepository, BaseUserRepository, + BaseLoanRepository, BaseNonLoanableDeviceRepository +) + + +class PostgreSQLTabletRepository(BaseTabletRepository): + def __init__(self, connection_string: str): + self.connection_string = connection_string + self._init_db() + + def _get_connection(self): + return psycopg2.connect(self.connection_string) + + def _init_db(self): + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tablets ( + id SERIAL PRIMARY KEY, + brand VARCHAR(100) NOT NULL, + model VARCHAR(100) NOT NULL, + serial_number VARCHAR(50) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'available', + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_tablets_serial + ON tablets(serial_number) + ''') + cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_tablets_status + ON tablets(status) + ''') + conn.commit() + cursor.close() + conn.close() + + def get_by_id(self, tablet_id: int) -> Optional[dict]: + conn = self._get_connection() + cursor = conn.cursor(cursor_factory=DictCursor) + cursor.execute("SELECT * FROM tablets WHERE id = %s", (tablet_id,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def get_by_serial(self, serial: str) -> Optional[dict]: + conn = self._get_connection() + cursor = conn.cursor(cursor_factory=DictCursor) + cursor.execute("SELECT * FROM tablets WHERE serial_number = %s", (serial,)) + row = cursor.fetchone() + conn.close() + return dict(row) if row else None + + def get_all(self, status: Optional[str] = None) -> List[dict]: + conn = self._get_connection() + cursor = conn.cursor(cursor_factory=DictCursor) + query = "SELECT * FROM tablets" + params = () + if status: + query += " WHERE status = %s" + params = (status,) + cursor.execute(query, params) + results = [dict(row) for row in cursor.fetchall()] + conn.close() + return results + + def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict: + conn = self._get_connection() + cursor = conn.cursor(cursor_factory=DictCursor) + cursor.execute(''' + INSERT INTO tablets (brand, model, serial_number, status, notes) + VALUES (%s, %s, %s, 'available', %s) + RETURNING * + ''', (brand, model, serial_number, notes)) + row = cursor.fetchone() + conn.commit() + conn.close() + return dict(row) + + def update_status(self, tablet_id: int, status: str) -> bool: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute( + "UPDATE tablets SET status = %s, updated_at = NOW() WHERE id = %s", + (status, tablet_id) + ) + conn.commit() + changed = cursor.rowcount > 0 + conn.close() + return changed + + def delete(self, tablet_id: int) -> bool: + conn = self._get_connection() + cursor = conn.cursor() + cursor.execute("DELETE FROM tablets WHERE id = %s", (tablet_id,)) + conn.commit() + deleted = cursor.rowcount > 0 + conn.close() + return deleted + + +# Similar implementations for PostgreSQLUserRepository, PostgreSQLLoanRepository, etc. +``` + +## Step 5: Create Repository Factory + +```python +# backend/config/database.py +import os +from backend.repositories.sqlite_repo import ( + SQLiteTabletRepository, SQLiteUserRepository, + SQLiteLoanRepository, SQLiteNonLoanableDeviceRepository +) +from backend.repositories.postgres_repo import ( + PostgreSQLTabletRepository, PostgreSQLUserRepository, + PostgreSQLLoanRepository, PostgreSQLNonLoanableDeviceRepository +) +from backend.repositories.base_repository import ( + BaseTabletRepository, BaseUserRepository, + BaseLoanRepository, BaseNonLoanableDeviceRepository +) + + +class DatabaseConfig: + def __init__(self): + self.db_type = os.getenv('DB_TYPE', 'sqlite') + self.db_url = os.getenv('DB_URL', '') + self.db_path = os.getenv('DB_PATH', 'tablets.db') + + @property + def is_postgres(self) -> bool: + return self.db_type == 'postgres' + + +def get_tablet_repository() -> BaseTabletRepository: + config = DatabaseConfig() + if config.is_postgres: + return PostgreSQLTabletRepository(config.db_url) + else: + return SQLiteTabletRepository(config.db_path) + + +def get_user_repository() -> BaseUserRepository: + config = DatabaseConfig() + if config.is_postgres: + return PostgreSQLUserRepository(config.db_url) + else: + return SQLiteUserRepository(config.db_path) + + +def get_loan_repository() -> BaseLoanRepository: + config = DatabaseConfig() + if config.is_postgres: + return PostgreSQLLoanRepository(config.db_url) + else: + return SQLiteLoanRepository(config.db_path) + + +def get_non_loanable_device_repository() -> BaseNonLoanableDeviceRepository: + config = DatabaseConfig() + if config.is_postgres: + return PostgreSQLNonLoanableDeviceRepository(config.db_url) + else: + return SQLiteNonLoanableDeviceRepository(config.db_path) +``` + +## Step 6: Update Application to Use Repositories + +Modify your application to use the repository pattern: + +```python +# In your app.py or service layer +from backend.config.database import ( + get_tablet_repository, get_user_repository, + get_loan_repository, get_non_loanable_device_repository +) + +# Instead of direct SQLite calls: +tablet_repo = get_tablet_repository() +user_repo = get_user_repository() +loan_repo = get_loan_repository() + +# Example: Loan a tablet +def loan_tablet(tablet_id: int, user_id: int): + # Get repositories + tablet_repo = get_tablet_repository() + user_repo = get_user_repository() + loan_repo = get_loan_repository() + + # Validate + tablet = tablet_repo.get_by_id(tablet_id) + if not tablet: + raise ValueError("Tablet not found") + + if tablet['status'] != 'available': + raise ValueError("Tablet not available") + + user = user_repo.get_by_id(user_id) + if not user: + raise ValueError("User not found") + + # Check for active loan + active_loan = loan_repo.get_active_by_tablet(tablet_id) + if active_loan: + raise ValueError("Tablet already loaned") + + # Create loan + loan = loan_repo.create(tablet_id, user_id) + + # Update tablet status + tablet_repo.update_status(tablet_id, 'loaned') + + return loan +``` + +## Step 7: Create Migration Script + +```python +# scripts/migrate_to_postgres.py +#!/usr/bin/env python3 +""" +Migration script from SQLite to PostgreSQL +""" +import sqlite3 +import psycopg2 +from psycopg2.extras import DictCursor +import argparse +from tqdm import tqdm +import os + + +def create_postgres_tables(conn): + """Create all tables in PostgreSQL""" + cursor = conn.cursor() + + # Tablets + cursor.execute(''' + CREATE TABLE IF NOT EXISTS tablets ( + id SERIAL PRIMARY KEY, + brand VARCHAR(100) NOT NULL, + model VARCHAR(100) NOT NULL, + serial_number VARCHAR(50) UNIQUE NOT NULL, + status VARCHAR(20) DEFAULT 'available', + notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + ''') + + # Users + cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + email VARCHAR(255), + phone VARCHAR(20), + identification VARCHAR(50) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + ''') + + # Loans + cursor.execute(''' + CREATE TABLE IF NOT EXISTS loans ( + id SERIAL PRIMARY KEY, + tablet_id INTEGER NOT NULL REFERENCES tablets(id) ON DELETE RESTRICT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + loan_date TIMESTAMP NOT NULL DEFAULT NOW(), + return_date TIMESTAMP, + status VARCHAR(20) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW() + ) + ''') + + # Non-loanable devices + cursor.execute(''' + CREATE TABLE IF NOT EXISTS non_loanable_devices ( + id SERIAL PRIMARY KEY, + brand VARCHAR(100) NOT NULL, + model VARCHAR(100) NOT NULL, + serial_number VARCHAR(50) UNIQUE NOT NULL, + device_type VARCHAR(50) NOT NULL, + location VARCHAR(100), + status VARCHAR(20) DEFAULT 'available', + notes TEXT, + purchase_date DATE, + purchase_cost DECIMAL(10,2), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ) + ''') + + # Indexes for performance + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_serial ON tablets(serial_number)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_status ON tablets(status)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_brand ON tablets(brand)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_identification ON users(identification)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_tablet ON loans(tablet_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_user ON loans(user_id)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_status ON loans(status)') + + conn.commit() + + +def migrate_table(conn_sqlite, conn_pg, table_name: str, pg_create_table: str): + """Generic migration for a table""" + cursor_sqlite = conn_sqlite.cursor() + cursor_pg = conn_pg.cursor() + + # Get all data from SQLite + cursor_sqlite.execute(f"SELECT * FROM {table_name}") + rows = cursor_sqlite.fetchall() + + if not rows: + print(f"No data to migrate for {table_name}") + return + + # Get column names + column_names = [desc[0] for desc in cursor_sqlite.description] + + # Prepare INSERT statement + columns = ', '.join(column_names) + placeholders = ', '.join(['%s'] * len(column_names)) + insert_sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders}) ON CONFLICT DO NOTHING" + + # Migrate data + for row in tqdm(rows, desc=f"Migrating {table_name}"): + cursor_pg.execute(insert_sql, row) + + conn_pg.commit() + print(f"✓ Migrated {len(rows)} rows from {table_name}") + + +def migrate_all(sqlite_path: str, pg_url: str): + """Migrate all data from SQLite to PostgreSQL""" + print("Starting migration from SQLite to PostgreSQL...") + + # Connect to SQLite + conn_sqlite = sqlite3.connect(sqlite_path) + + # Connect to PostgreSQL + conn_pg = psycopg2.connect(pg_url) + + try: + # Create tables + print("Creating PostgreSQL tables...") + create_postgres_tables(conn_pg) + + # Migrate each table + migrate_table(conn_sqlite, conn_pg, 'tablets', '') + migrate_table(conn_sqlite, conn_pg, 'users', '') + migrate_table(conn_sqlite, conn_pg, 'loans', '') + migrate_table(conn_sqlite, conn_pg, 'non_loanable_devices', '') + + print("\n✓ Migration completed successfully!") + print(f" SQLite: {sqlite_path}") + print(f" PostgreSQL: {pg_url}") + + except Exception as e: + conn_pg.rollback() + print(f"\n✗ Migration failed: {e}") + raise + finally: + conn_sqlite.close() + conn_pg.close() + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Migrate from SQLite to PostgreSQL') + parser.add_argument('--sqlite', default='tablets.db', help='SQLite database path') + parser.add_argument('--postgres', required=True, help='PostgreSQL connection URL') + args = parser.parse_args() + + migrate_all(args.sqlite, args.postgres) +``` + +## Step 8: Run Migration + +```bash +# Test the migration first (dry run) +python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management_test + +# Verify data in test database +psql -U tablet_user -d tablet_management_test -c "SELECT COUNT(*) FROM tablets;" + +# When ready, migrate to production +python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management +``` + +## Step 9: Switch to PostgreSQL + +```bash +# Update environment variables +export DB_TYPE=postgres +export DB_URL=postgresql://tablet_user:password@localhost:5432/tablet_management + +# Restart application +python app.py +``` + +## Step 10: Verify and Monitor + +```bash +# Check application logs for errors +# Monitor database connections +psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;" + +# Check active connections +psql -U postgres -c "SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management';" +``` + +## Rollback Plan + +If something goes wrong: + +1. **Immediate rollback:** + ```bash + # Switch back to SQLite +export DB_TYPE=sqlite +export DB_PATH=tablets.db + python app.py + ``` + +2. **Data verification:** + ```bash + # Compare counts + sqlite3 tablets.db "SELECT COUNT(*) FROM tablets;" + psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;" + ``` + +3. **Backup PostgreSQL data:** + ```bash + pg_dump -U tablet_user -d tablet_management > postgres_backup_$(date +%Y%m%d).sql + ``` + +## Docker Compose (Optional) + +For easy deployment with Docker: + +```yaml +# docker-compose.yml +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + container_name: tablet_db + environment: + POSTGRES_DB: tablet_management + POSTGRES_USER: tablet_user + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme} + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U tablet_user -d tablet_management"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + app: + build: . + container_name: tablet_app + environment: + DB_TYPE: postgres + DB_URL: postgresql://tablet_user:${POSTGRES_PASSWORD:-changeme}@postgres:5432/tablet_management + ports: + - "5000:5000" + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + +volumes: + postgres_data: +``` + +Start with Docker: +```bash +docker-compose up -d +``` + +## Benefits of PostgreSQL + +### Performance +- **Concurrency:** Multiple writers simultaneously (no lock contention) +- **Indexing:** Advanced index types (B-tree, Hash, GiST, GIN, BRIN) +- **Query Optimization:** Advanced query planner +- **Connection Pooling:** Built-in support + +### Scalability +- **Vertical:** Handles large datasets efficiently +- **Horizontal:** Read replicas, partitioning, sharding +- **Connections:** Supports thousands of concurrent connections + +### Reliability +- **ACID Compliance:** Full transaction support +- **Point-in-Time Recovery:** Restore to any moment +- **Replication:** Master-slave, synchronous, asynchronous +- **Backups:** `pg_dump`, `pg_basebackup`, continuous archiving + +### Security +- **Authentication:** Multiple methods (password, MD5, SCRAM, LDAP, Kerberos) +- **Authorization:** Role-based access control (RBAC) +- **Row-Level Security:** Policies for fine-grained access +- **Encryption:** SSL, at-rest encryption + +### Features +- **JSON Support:** Native JSON/JSONB data type +- **Full-Text Search:** Advanced text search capabilities +- **Arrays:** Store arrays of values +- **Custom Types:** Create your own data types +- **Triggers:** Automatic actions on events +- **Stored Procedures:** Server-side functions + +## Monitoring PostgreSQL + +### Basic Queries + +```sql +-- Active connections +SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management'; + +-- Table sizes +SELECT table_name, pg_size_pretty(pg_total_relation_size(table_name)) +FROM information_schema.tables WHERE table_schema = 'public'; + +-- Index usage +SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch +FROM pg_stat_user_indexes; + +-- Slow queries (requires pg_stat_statements extension) +SELECT query, total_time, calls, mean_time +FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10; +``` + +### Enable pg_stat_statements + +```sql +-- In PostgreSQL +CREATE EXTENSION pg_stat_statements; + +-- Then in postgresql.conf +shared_preload_libraries = 'pg_stat_statements' +pg_stat_statements.track = all +``` + +## Maintenance Tasks + +### Regular Maintenance + +```bash +# Vacuum (reclaim space, update statistics) +vacuumdb -U tablet_user -d tablet_management --analyze + +# Reindex (rebuild indexes) +reindexdb -U tablet_user -d tablet_management +``` + +### Backup Strategy + +```bash +# Daily backup +pg_dump -U tablet_user -d tablet_management > /backups/tablet_management_$(date +%Y%m%d).sql + +# Compressed backup +pg_dump -U tablet_user -d tablet_management | gzip > /backups/tablet_management_$(date +%Y%m%d).sql.gz + +# Continuous archiving (WAL) +# In postgresql.conf: +wal_level = replica +archive_mode = on +archive_command = 'test ! -f /backups/wal/%f && cp %p /backups/wal/%f' +``` + +## Performance Optimization + +### Configuration Tuning + +```conf +# postgresql.conf recommendations +shared_buffers = 4GB # 25% of total RAM +work_mem = 16MB # For complex sorts +maintenance_work_mem = 512MB # For VACUUM, index creation +effective_cache_size = 12GB # 75% of total RAM +random_page_cost = 1.1 # SSD: 1.1, HDD: 4.0 +max_worker_processes = 8 # Number of CPU cores +max_parallel_workers_per_gather = 4 # Parallel query workers +max_connections = 200 # Expected max connections +``` + +### Index Optimization + +```sql +-- Add indexes for common queries +CREATE INDEX idx_loans_user_status ON loans(user_id, status); +CREATE INDEX idx_loans_date_range ON loans(loan_date, return_date); + +-- Partial index for active loans +CREATE INDEX idx_loans_active ON loans(tablet_id) WHERE status = 'active'; + +-- Composite index for user loans +CREATE INDEX idx_loans_user_tablet ON loans(user_id, tablet_id); +``` + +## Troubleshooting + +### Common Issues + +**Connection refused:** +```bash +# Check if PostgreSQL is running +sudo systemctl status postgresql + +# Check port +netstat -tuln | grep 5432 +``` + +**Authentication failed:** +```bash +# Verify user and password +psql -U tablet_user -d tablet_management -h localhost + +# Check pg_hba.conf +sudo nano /etc/postgresql/16/main/pg_hba.conf +``` + +**Database does not exist:** +```bash +# Create database +createdb -U postgres tablet_management +``` + +**Permission denied:** +```sql +-- Grant permissions +GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user; +GRANT ALL ON SCHEMA public TO tablet_user; +``` + +### Logs + +```bash +# PostgreSQL logs +sudo tail -f /var/log/postgresql/postgresql-16-main.log + +# Application logs +journalctl -u tablet_management -f +``` + +## Conclusion + +Migrating from SQLite to PostgreSQL provides: +- Better performance at scale +- True concurrency +- Enhanced reliability +- Advanced features +- Production-ready infrastructure + +The repository pattern ensures a smooth transition with minimal code changes, and the migration script automates the data transfer process. diff --git a/gestiontablets.sh b/gestiontablets.sh new file mode 100644 index 0000000..1d4b29d --- /dev/null +++ b/gestiontablets.sh @@ -0,0 +1 @@ +vibe --resume fa1bb230 diff --git a/minimal_app.py b/minimal_app.py index ff0295a..381a26d 100644 --- a/minimal_app.py +++ b/minimal_app.py @@ -44,6 +44,22 @@ def init_db(): ) ''') + # Create non_loanable_devices table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS non_loanable_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + brand TEXT NOT NULL, + model TEXT NOT NULL, + serial_number TEXT UNIQUE NOT NULL, + device_type TEXT NOT NULL, + location TEXT, + status TEXT DEFAULT 'available', + notes TEXT, + purchase_date TEXT, + purchase_cost REAL + ) + ''') + conn.commit() conn.close() @@ -202,6 +218,59 @@ def show_loan_history(): conn.close() + +def add_non_loanable_device(brand, model, serial_number, device_type, location='', notes='', purchase_date='', purchase_cost=None): + """Add a new non-loanable device to inventory""" + conn = sqlite3.connect('tablets.db') + cursor = conn.cursor() + + try: + cursor.execute(''' + INSERT INTO non_loanable_devices + (brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost) + VALUES (?, ?, ?, ?, ?, 'available', ?, ?, ?) + ''', (brand, model, serial_number, device_type, location, notes, purchase_date, purchase_cost)) + conn.commit() + print(f"✓ Added non-loanable device: {brand} {model} ({serial_number}) - Type: {device_type}") + except sqlite3.IntegrityError: + print(f"✗ Error: Serial number {serial_number} already exists") + finally: + conn.close() + + +def show_non_loanable_devices(): + """Show all non-loanable devices""" + conn = sqlite3.connect('tablets.db') + cursor = conn.cursor() + + cursor.execute("SELECT id, brand, model, serial_number, device_type, location, status FROM non_loanable_devices") + devices = cursor.fetchall() + + print("\n=== Non-Loanable Devices ===") + if devices: + for device in devices: + print(f"ID: {device[0]}, Type: {device[4]}, {device[1]} {device[2]} ({device[3]}), Location: {device[5] or 'N/A'}, Status: {device[6]}") + else: + print("No non-loanable devices") + + conn.close() + + +def delete_non_loanable_device(device_id): + """Delete a non-loanable device""" + conn = sqlite3.connect('tablets.db') + cursor = conn.cursor() + + cursor.execute("DELETE FROM non_loanable_devices WHERE id = ?", (device_id,)) + conn.commit() + + if cursor.rowcount > 0: + print(f"✓ Non-loanable device {device_id} deleted") + else: + print(f"✗ Error: Device {device_id} not found") + + conn.close() + def main(): """Main menu""" init_db() @@ -218,9 +287,12 @@ def main(): print("5. Show Available Tablets") print("6. Show Active Loans") print("7. Show Loan History") - print("8. Exit") + print("8. Add Non-Loanable Device") + print("9. Show Non-Loanable Devices") + print("10. Delete Non-Loanable Device") + print("11. Exit") - choice = input("Enter your choice (1-8): ") + choice = input("Enter your choice (1-11): ") if choice == '1': print("\n=== Add Tablet ===") @@ -269,6 +341,28 @@ def main(): show_loan_history() elif choice == '8': + print("\n=== Add Non-Loanable Device ===") + brand = input("Brand: ") + model = input("Model: ") + serial_number = input("Serial Number: ") + device_type = input("Device Type (e.g., projector, monitor): ") + location = input("Location (optional): ") + notes = input("Notes (optional): ") + add_non_loanable_device(brand, model, serial_number, device_type, location, notes) + + elif choice == '9': + show_non_loanable_devices() + + elif choice == '10': + print("\n=== Delete Non-Loanable Device ===") + show_non_loanable_devices() + device_id = input("Enter Device ID to delete: ") + try: + delete_non_loanable_device(int(device_id)) + except ValueError: + print("✗ Error: Invalid ID format") + + elif choice == '11': print("Goodbye!") break diff --git a/pyproject.toml b/pyproject.toml index e409926..08df5da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,3 +5,33 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +verbose = 1 +addopts = "-v" + +[tool.coverage.run] +source = ["."] +omit = [ + "*/tests/*", + "*/.venv/*", + "*/__pycache__/*", + "*/.git/*", + "*/templates/*", +] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "raise NotImplementedError", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.coverage.html] +directory = "htmlcov" diff --git a/simple_app.py b/simple_app.py index f50478d..0674035 100644 --- a/simple_app.py +++ b/simple_app.py @@ -62,6 +62,22 @@ def init_db(): ) ''') + # Create non_loanable_devices table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS non_loanable_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + brand TEXT NOT NULL, + model TEXT NOT NULL, + serial_number TEXT UNIQUE NOT NULL, + device_type TEXT NOT NULL, + location TEXT, + status TEXT DEFAULT 'available', + notes TEXT, + purchase_date TEXT, + purchase_cost REAL + ) + ''') + conn.commit() class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): diff --git a/templates/add_non_loanable_device.html b/templates/add_non_loanable_device.html new file mode 100644 index 0000000..ae0489e --- /dev/null +++ b/templates/add_non_loanable_device.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} + +{% block content %} +

Add Non-Loanable Device

+

Add a device that will be tracked in inventory but cannot be loaned to users (e.g., projectors, monitors, etc.)

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index 629882b..e93f5f7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -144,6 +144,7 @@ Loan Tablet Loan History User Loans + Non-Loanable Devices Project Management diff --git a/templates/edit_non_loanable_device.html b/templates/edit_non_loanable_device.html new file mode 100644 index 0000000..80c90b4 --- /dev/null +++ b/templates/edit_non_loanable_device.html @@ -0,0 +1,71 @@ +{% extends "base.html" %} + +{% block content %} +

Edit Non-Loanable Device

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + Cancel +
+
+{% endblock %} diff --git a/templates/non_loanable_devices.html b/templates/non_loanable_devices.html new file mode 100644 index 0000000..a27e6ac --- /dev/null +++ b/templates/non_loanable_devices.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block content %} +
+

Non-Loanable Devices Inventory

+

These devices are tracked in inventory but cannot be loaned to users.

+ Add Non-Loanable Device + + {% if devices %} + + + + + + + + + + + + + + {% for device in devices %} + + + + + + + + + + {% endfor %} + +
TypeBrandModelSerial NumberLocationStatusActions
{{ device.device_type }}{{ device.brand }}{{ device.model }}{{ device.serial_number }}{{ device.location or '-' }}{{ device.status }} + Edit + Delete +
+ {% else %} +

No non-loanable devices registered.

+ {% endif %} +
+{% endblock %} diff --git a/templates/user_loans.html b/templates/user_loans.html index 8c10045..07fac86 100644 --- a/templates/user_loans.html +++ b/templates/user_loans.html @@ -7,45 +7,98 @@ This page demonstrates the many-to-many relationship between users and tablets via the loans table.

-
+ +
+ + +
+ +
{% for user_data in users_with_loans %} {% set user = user_data.user %} {% set loans = user_data.loans %} + {% set active_loans = loans|selectattr('status', 'equalto', 'active')|list %} + {% set returned_loans = loans|selectattr('status', 'equalto', 'returned')|list %} -
+

{{ user.name }} ({{ user.identification }})

- {{ loans|length }} tablet{{ 's' if loans|length != 1 else '' }} loaned + + {{ active_loans|length }} active, {{ returned_loans|length }} past +
- {% if loans %} - - - - - - - - - - - - {% for loan in loans %} - - - - - - + {% if active_loans %} +
+

Current Loans

+
TabletSerial NumberLoan DateReturn DateStatus
{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.loan_date }}{{ loan.return_date or '-' }} - - {{ loan.status }} - -
+ + + + + + - {% endfor %} - -
TabletSerial NumberLoan DateStatus
- {% else %} + + + {% for loan in active_loans %} + + {{ loan.brand }} {{ loan.model }} + {{ loan.serial_number }} + {{ loan.loan_date }} + + + {{ loan.status }} + + + + {% endfor %} + + +
+ {% endif %} + + {% if returned_loans %} +
+

+ +

+ +
+ {% endif %} + + {% if not active_loans and not returned_loans %}

No loans recorded for this user.

{% endif %}
@@ -56,6 +109,32 @@ {% endif %}
+ +