Compare commits

..

No commits in common. "d909006a0185c4ad254411df7823bc135765ceac" and "aa89041d4ca43e10922b4d609524bc282e491fc8" have entirely different histories.

72 changed files with 11 additions and 14832 deletions

BIN
.coverage

Binary file not shown.

View file

@ -1,17 +0,0 @@
# Configuración de ejemplo para GestionTablets
# COPIA este archivo a .env y ajusta los valores según tu entorno
# Configuración de Flask
FLASK_APP=app.py
FLASK_ENV=development
# Clave secreta para sesiones y CSRF
# GENERA UNA NUEVA CON: openssl rand -hex 32
# O en Python: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=cambiar_esta_clave_por_una_segura_en_produccion
# Configuración de la base de datos
DATABASE=tablets.db
# Puerto del servidor (solo para desarrollo)
PORT=5000

46
.gitignore vendored
View file

@ -1,46 +0,0 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info/
# Virtual environments
.venv/
# Database files
*.db
*.db-journal
# Environment files
.env
.env.local
.env.*.local
# IDE/Editor
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Python
.python-version
# Project-specific
notes_development/
.vibe/
# Logs and temporary files
*.log
*.tmp
# Test coverage
.htmlcov/
.coverage
# Local configuration

View file

@ -1,25 +0,0 @@
# Changelog
## [2025-12-01] - Student Data Import Implementation Initial Release
- **Added** new `students` table schema with fields: `id`, `order_number`, `cial_code`, `nif_nie_passport`, `registration_number`, `file_number`, `first_name`, `last_name`, `full_name`, `birth_date`, `gender`, `study_group`, `created_at`, `updated_at`.
- **Added** indexes on `cial_code`, `nif_nie_passport`, `(last_name, first_name)`, `birth_date`, `gender`, `study_group`.
- **Added** `assigned_to_student` foreign key column to `tablets` and `non_loanable_devices` tables.
- **Added** `student_id` foreign key to `loans` table.
- **Implemented** CSV import script (`scripts/import_students.py`) for ISO-8859-1 (Latin-1) encoded CSV files with automatic UTF-8 handling, name field splitting, date conversion (DD/MM/YYYY → YYYY-MM-DD), discard "Estudio" field, duplicate checking, progress reporting, and dry-run support.
- **Created** database migration script (`scripts/migrate_database.py`) with backup, schema creation, index creation, foreign key addition, and consistency verification.
- **Updated** Flask application (`app.py`):
- Added new routes: `/students`, `/add_student`, `/student/<id>`, `/edit_student/<id>`, `/delete_student/<id>`, `/import_students`.
- Modified loan routes to optionally associate tablet loans with students.
- Added student model definitions and relationships.
- Added indexes and validation for new student fields.
- **Added** Jinja2 frontend templates:
- `templates/students.html` list/search/student details with filters and pagination.
- `templates/add_student.html` form for adding new students.
- `templates/edit_student.html` form for editing existing students.
- `templates/student_detail.html` view student details including loan and device history.
- `templates/import_students.html` CSV import interface with instructions.
- Updated `base.html` to include Students navigation link.
- Updated `loan_tablet.html` with student selection dropdown.
- **Updated** `requirements.txt` to include Flask, Flask-Babel, python-dotenv, pytest, pytest-cov, waitress.
- **Created** documentation file `docs/DATABASE_SCHEMA_SPECIFICATION.md` with detailed schema definition, import rules, and migration path.

View file

@ -1,82 +0,0 @@
# Contributing Guide
## Git Workflow
### Branch Strategy
| Branch Type | Naming | Purpose |
|-------------|--------|---------|
| `main` | `main` | Production-ready code |
| `develop` | `develop` | Integration branch for features |
| Feature | `feature/<name>` | New functionality |
| Hotfix | `hotfix/<name>` | Urgent bug fixes |
### Workflow
```bash
# Clone repository
git clone <repository-url>
cd GestionTablets
# Setup develop branch
git checkout -b develop
# Create feature branch
git checkout -b feature/my-feature
git push origin feature/my-feature
# Make changes, commit
git add .
git commit -m "feat: add my feature"
git push origin feature/my-feature
# Create Pull Request to develop
```
### Commit Message Format
```
type(scope): description
[optional body]
[optional footer]
```
**Types**:
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `refactor`: Code refactoring
- `chore`: Maintenance tasks
- `test`: Test-related changes
**Examples**:
- `feat(loans): add user loans view page`
- `fix(tablet): validate serial number uniqueness`
- `docs: update README with database schema`
- `refactor(app): extract loan logic to service`
### Pull Request Process
1. Target `develop` branch (or `main` for hotfixes)
2. Include clear description of changes
3. Reference related issues
4. Wait for code review
5. Squash merge preferred
### Reverting Changes
```bash
# Discard unstaged changes
git checkout -- file.txt
# Unstage file
git reset HEAD file.txt
# Revert to previous commit (safe)
git revert HEAD
# Hard reset to commit (destructive)
git reset --hard <commit-hash>
```

View file

@ -1,2 +0,0 @@
Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio
1,"PERIQUITA DE LOS PALOTES, JUANITA",24/08/2009,B19L64119K,24587243H,4847,,M,1º FPB BÁSICA,1º CFGB Servicios Socioculturales y a la Comunidad - Actividades Domésticas y Limpieza de edificios (LOMLOE-LOOIFP)
1 Nº Or. Apellidos, Nombre Fecha Nac. C.I.A.L. NIF/NIE/Pas. Registro Expediente Sexo Grupo Estudio
2 1 PERIQUITA DE LOS PALOTES, JUANITA 24/08/2009 B19L64119K 24587243H 4847 M 1º FPB BÁSICA 1º CFGB Servicios Socioculturales y a la Comunidad - Actividades Domésticas y Limpieza de edificios (LOMLOE-LOOIFP)

View file

@ -1,442 +0,0 @@
# Student Data Import Implementation Summary
## Overview
This document summarizes the implementation of the student data import functionality for the GestionTablets project. The implementation includes a new database schema for students, CSV import scripts, updated application code, and new frontend templates.
## Files Created
### 1. Database Schema Specification
**File:** `docs/DATABASE_SCHEMA_SPECIFICATION.md`
- Complete SQLite database schema specification
- Detailed field descriptions and data types
- Index definitions for optimal query performance
- Data import specifications and validation rules
- API endpoint definitions
- Migration path from existing database
### 2. CSV Import Script
**File:** `scripts/import_students.py`
Features:
- Reads CSV files encoded in ISO-8859-1 (Latin-1)
- Converts encoding to UTF-8 automatically
- Splits "Apellidos, Nombre" field into separate "last_name" and "first_name" fields
- Converts date format from DD/MM/YYYY to YYYY-MM-DD
- Discards the "Estudio" field as per requirements
- Validates all required fields (CIAL code, first name, last name)
- Handles duplicates gracefully
- Provides detailed statistics and error reporting
- Supports dry-run mode for testing
Usage:
```bash
# Dry run (validate without importing)
python3 scripts/import_students.py Datos_programa.csv --dry-run --verbose
# Actual import
python3 scripts/import_students.py Datos_programa.csv --verbose
```
### 3. Database Migration Script
**File:** `scripts/migrate_database.py`
Features:
- Creates backup of existing database before migration
- Creates new `students` table with all required fields and indexes
- Adds `assigned_to_student` column to `tablets` and `non_loanable_devices` tables
- Adds `student_id` column to `loans` table
- Adds timestamp columns (`created_at`, `updated_at`) to existing tables
- Verifies migration success
- Supports dry-run mode
Usage:
```bash
# Dry run
python3 scripts/migrate_database.py --dry-run
# Actual migration
python3 scripts/migrate_database.py
```
## Files Modified
### 1. Application Code
**File:** `app.py`
Changes:
- Added `students` table creation in `init_db()` function
- Added indexes for students table
- Updated `non_loanable_devices` table to include `assigned_to_student` field
- Added new routes:
- `/students` - List all students with search, filter, and pagination
- `/add_student` - Add new student (GET/POST)
- `/student/<id>` - View student details
- `/edit_student/<id>` - Edit student (GET/POST)
- `/delete_student/<id>` - Delete student
- `/import_students` - CSV import interface (GET/POST)
- Updated `loan_tablet` route to support optional student association
- Added student selection to loan form
### 2. Base Template
**File:** `templates/base.html`
Changes:
- Added "Students" link to navigation menu
### 3. Loan Tablet Template
**File:** `templates/loan_tablet.html`
Changes:
- Updated to use Bootstrap 5 classes (matching other templates)
- Added student selection dropdown with search functionality
- Improved form layout and styling
### 4. Requirements
**File:** `requirements.txt`
Updated with all necessary dependencies:
- Flask==2.3.3
- Flask-Babel==2.0.0
- python-dotenv==1.0.0
- pytest==7.4.0
- pytest-cov==4.1.0
- waitress==2.1.2
## Files Created (Templates)
### 1. Students List Template
**File:** `templates/students.html`
Features:
- Displays all students in a paginated table
- Search functionality (by name, CIAL, NIF, etc.)
- Filter by gender and study group
- Responsive design
- Action buttons for view, edit, delete
### 2. Add Student Template
**File:** `templates/add_student.html`
Features:
- Form for adding new students
- All required fields with validation
- Organized in logical sections (Identification, Personal Info, Study Info)
- Responsive design
### 3. Student Detail Template
**File:** `templates/student_detail.html`
Features:
- Displays complete student information
- Shows assigned tablets and other devices
- Shows loan history
- Breadcrumbs for navigation
- Edit and delete buttons
### 4. Edit Student Template
**File:** `templates/edit_student.html`
Features:
- Form for editing existing students
- Pre-populated with current student data
- Same validation as add form
- Breadcrumbs for navigation
### 5. Import Students Template
**File:** `templates/import_students.html`
Features:
- File upload form for CSV import
- Instructions and requirements
- Sample data format preview
- Clear visual design
## Database Schema
### New Table: students
```sql
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number INTEGER,
cial_code TEXT UNIQUE NOT NULL,
nif_nie_passport TEXT UNIQUE,
registration_number INTEGER,
file_number TEXT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
full_name TEXT NOT NULL,
birth_date TEXT,
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
study_group TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
);
```
### Indexes Created
- `idx_students_cial` - On cial_code
- `idx_students_nif` - On nif_nie_passport
- `idx_students_name` - On (last_name, first_name)
- `idx_students_birth_date` - On birth_date
- `idx_students_gender` - On gender
- `idx_students_group` - On study_group
- `idx_tablets_student` - On tablets.assigned_to_student
- `idx_non_loanable_student` - On non_loanable_devices.assigned_to_student
- `idx_loans_student` - On loans.student_id
### Modified Tables
1. **tablets**: Added `assigned_to_student` column (foreign key to students)
2. **non_loanable_devices**: Added `assigned_to_student` column (foreign key to students)
3. **loans**: Added `student_id` column (foreign key to students)
4. All tables: Added `created_at` and `updated_at` timestamp columns
## CSV Import Process
### Field Mapping
| CSV Field | Database Field | Transformation |
|-----------|----------------|----------------|
| Nº Or. | order_number | Integer conversion |
| Apellidos, Nombre | last_name, first_name | Split on ", " |
| Apellidos, Nombre | full_name | Keep as-is |
| Fecha Nac. | birth_date | DD/MM/YYYY → YYYY-MM-DD |
| C.I.A.L. | cial_code | Direct mapping |
| NIF/NIE/Pas. | nif_nie_passport | Direct mapping |
| Registro | registration_number | Integer conversion |
| Expediente | file_number | Direct mapping (nullable) |
| Sexo | gender | Direct mapping (M/F/O) |
| Grupo | study_group | Direct mapping |
| Estudio | - | **DISCARDED** |
### Validation Rules
1. **Required Fields**: cial_code, first_name, last_name
2. **Unique Fields**: cial_code, nif_nie_passport (if provided)
3. **Date Format**: birth_date must be valid YYYY-MM-DD
4. **Gender**: Must be 'M', 'F', or 'O'
## Testing
### Import Script Test Results
```
$ python3 scripts/import_students.py Datos_programa.csv --dry-run --verbose
Processing row 1/1:
Raw: {'nº or.': '1', 'apellidos, nombre': 'PERIQUITA DE LOS PALOTES, JUANITA', ...}
Processed: {'order_number': 1, 'cial_code': 'B19L64119K', ...}
[DRY RUN] Would import: PERIQUITA DE LOS PALOTES, JUANITA (B19L64119K)
IMPORT STATISTICS
Total rows in CSV: 1
Rows processed: 1
Valid rows: 1
Invalid rows: 0
```
### Actual Import Test
```
$ python3 scripts/import_students.py Datos_programa.csv --verbose
Processing row 1/1:
Inserted student ID: 1
IMPORT STATISTICS
Total rows in CSV: 1
Rows processed: 1
Valid rows: 1
Invalid rows: 0
Rows imported: 1
Duplicates skipped: 0
Errors: 0
```
### Database Verification
```sql
SELECT * FROM students;
-- Returns: 1 row with all fields correctly populated
```
## New Features
### 1. Student Management
- List all students with pagination
- Search and filter students
- Add new students
- View student details
- Edit student information
- Delete students (with safety checks)
- Import students from CSV
### 2. Enhanced Loan Management
- Loan tablets to students directly
- Associate loans with both staff users and students
- View student loan history
### 3. Device Assignment
- Assign tablets to students
- Assign non-loanable devices to students
- View all devices assigned to a student
## Usage Examples
### Import Students from CSV
```bash
# Import from file
python3 scripts/import_students.py path/to/data.csv
# Import with custom database
python3 scripts/import_students.py data.csv --database mydb.db
# Dry run to validate
python3 scripts/import_students.py data.csv --dry-run
# Skip errors and continue
python3 scripts/import_students.py data.csv --skip-errors
```
### Web Interface
1. Navigate to `/students` to view all students
2. Click "Add Student" to add a new student manually
3. Click "Import CSV" to upload a CSV file
4. Click on a student to view their details, assigned devices, and loan history
5. When loaning a tablet, optionally select a student to associate with the loan
## Migration Instructions
### For Existing Installations
1. **Backup your database**:
```bash
cp tablets.db tablets.db.backup
```
2. **Run the migration script**:
```bash
python3 scripts/migrate_database.py
```
3. **Import your CSV data**:
```bash
python3 scripts/import_students.py Datos_programa.csv
```
4. **Start the application**:
```bash
python3 app.py
```
### For New Installations
1. **Initialize the database**:
```bash
python3 app.py # This will create the database with all tables
```
2. **Import your CSV data**:
```bash
python3 scripts/import_students.py Datos_programa.csv
```
3. **Start the application**:
```bash
python3 app.py
```
## File Structure
```
GestionTablets/
├── app.py # Updated with student routes
├── scripts/
│ ├── import_students.py # CSV import script
│ └── migrate_database.py # Database migration script
├── templates/
│ ├── base.html # Updated with Students link
│ ├── loan_tablet.html # Updated with student selection
│ ├── students.html # New: Student list
│ ├── add_student.html # New: Add student form
│ ├── edit_student.html # New: Edit student form
│ ├── student_detail.html # New: Student details
│ └── import_students.html # New: CSV import interface
├── docs/
│ └── DATABASE_SCHEMA_SPECIFICATION.md # New: Schema documentation
├── requirements.txt # Updated dependencies
└── Datos_programa.csv # Sample CSV file
```
## Next Steps
1. **Test the application**: Run the app and verify all student functionality works
2. **Add translations**: Update translation files for new student-related strings
3. **Add more CSV files**: Test with additional CSV files to ensure robustness
4. **Performance testing**: Test with large CSV files (1000+ rows)
5. **Error handling**: Test edge cases (empty files, malformed data, etc.)
## Notes
- The implementation preserves all existing functionality
- The new student features are fully integrated with the existing tablet and loan management
- All templates use consistent styling (Bootstrap 5 classes)
- The import script handles encoding conversion automatically
- The database migration is safe and creates backups automatically
## Roadmap
- **Español (Spanish)** — Default language for this internal tool
- **English** — Deprecated; available for legacy users but not actively maintained
### Frontend Revamp (High Priority)
**Goal**: Improve mobile readability and standardize UI across all templates
#### 1. Database Results Display (Main Content Area)
- **Current issue**: Tables overflow on mobile, inconsistent styling across templates
- **Solution**:
- Responsive tables with horizontal scroll on mobile
- Card-based layouts for mobile (show data as stacked cards instead of tables)
- Standardize column widths and spacing
- Improve readability of data cells (better contrast, padding)
#### 2. CSS Framework Standardization
- **Current issue**: Mixed Bootstrap 5, Tailwind, and plain CSS across templates
- **Solution**:
- Standardize all templates on Tailwind CSS (already in use for `index.html`)
- Remove Bootstrap and Font Awesome dependencies
- Create reusable component classes for buttons, forms, cards
#### 3. Mobile Navigation
- **Current issue**: Horizontal navigation bar with 8+ links doesn't collapse on mobile
- **Solution**:
- Implement hamburger menu for mobile devices
- Responsive breakpoints for different screen sizes
#### 4. Form Responsiveness
- **Current issue**: Forms use Bootstrap grid classes, inconsistent spacing
- **Solution**:
- Tailwind responsive grid for form layouts
- Consistent input styling and validation feedback
- Mobile-friendly touch targets
#### 5. Dark Mode Enhancement
- **Current issue**: Incomplete dark mode coverage, some elements still have light backgrounds
- **Solution**:
- Comprehensive dark mode CSS variables
- Test all pages in dark mode
- Ensure proper contrast ratios
### Implementation Approach
- **Phase 1**: Fix database results tables (mobile scroll, card alternative)
- **Phase 2**: Standardize CSS framework (remove Bootstrap, use Tailwind only)
- **Phase 3**: Responsive navigation and forms
- **Phase 4**: Dark mode polish and accessibility improvements

View file

@ -1,354 +0,0 @@
# Instalación y Configuración - GestionTablets
**Versión:** 1.0
**Fecha:** 24 de junio de 2026
**Idioma:** Español
---
## 📋 Requisitos Previos
Antes de instalar GestionTablets, asegúrate de que tu sistema cumple con los siguientes requisitos:
### Sistema Operativo
- **Linux** (recomendado: Debian 12+, Ubuntu 22.04+)
- **macOS** (11+)
- **Windows** (10+ con WSL2 recomendado)
### Dependencias del Sistema
| Requisito | Versión Mínima | Cómo instalar (Debian/Ubuntu) | Notas |
|-----------|----------------|--------------------------------|-------|
| Python | 3.13.0 | `sudo apt update && sudo apt install python3.13` | **Obligatorio** |
| Git | 2.30+ | `sudo apt install git` | Para clonar el repositorio |
| curl | - | `sudo apt install curl` | Para instalar `uv` |
| SQLite3 | 3.35+ | `sudo apt install sqlite3` | Incluido en Python |
> ⚠️ **NOTA IMPORTANTE:** Este proyecto **requiere Python 3.13+** debido al uso de `uv` como gestor de dependencias.
---
## 🚀 Instalación Rápida
Ejecuta estos comandos en tu terminal:
```bash
# 1. Clonar el repositorio
git clone http://hq.ijuanes.ovh:3333/ijuanes/GestionTablets.git
cd GestionTablets
# 2. Ejecutar el script de configuración (requiere permisos de ejecución)
chmod +x setup.sh
./setup.sh
```
El script `setup.sh` se encargará de:
- Instalar `uv` si no está presente
- Crear un entorno virtual aislado
- Instalar todas las dependencias Python
- Inicializar la base de datos
- Configurar las variables de entorno básicas
---
## 🔧 Instalación Manual (sin setup.sh)
Si prefieres instalar manualmente, sigue estos pasos:
### 1. Instalar uv (gestor de paquetes de Python)
```bash
# Instalar uv (Astral)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Añadir uv al PATH (para la sesión actual)
export PATH="$HOME/.local/bin:$PATH"
# Verificar instalación
uv --version
```
> **Nota:** Si usas `zsh`, añade `export PATH="$HOME/.local/bin:$PATH"` a tu `~/.zshrc`.
> Si usas `bash`, añádelo a tu `~/.bashrc` o `~/.bash_profile`.
### 2. Crear entorno virtual
```bash
# Crear entorno virtual con uv
uv venv .venv
# Activar el entorno virtual
source .venv/bin/activate
```
### 3. Instalar dependencias
```bash
# Instalar dependencias desde requirements.txt
uv pip install -r requirements.txt
```
### 4. Inicializar la base de datos
```bash
# Inicializar la base de datos SQLite
python3 -c "from app import init_db; init_db(); print('✅ Base de datos inicializada')"
```
### 5. Configurar variables de entorno
Crea un archivo `.env` en el directorio raíz del proyecto:
```bash
# Copiar el archivo de ejemplo (si existe)
cp .env.example .env
# O crear manualmente
cat > .env << EOF
# Configuración de Flask
FLASK_APP=app.py
FLASK_ENV=production
# Clave secreta (generar una nueva con: openssl rand -hex 32)
SECRET_KEY=tu_clave_secreta_aqui_cambiarla
# Base de datos (opcional, por defecto: tablets.db)
DATABASE=tablets.db
EOF
```
> ⚠️ **IMPORTANTE:** Genera una clave secreta segura:
> ```bash
> openssl rand -hex 32
> ```
---
## 🏃‍♂️ Ejecución
### Desarrollo
```bash
# Activar el entorno virtual (si no lo está)
source .venv/bin/activate
# Ejecutar la aplicación en modo desarrollo
python3 app.py
```
La aplicación estará disponible en: `http://localhost:5000`
### Producción
Para producción, se recomienda usar **Waitress** (ya incluido en `requirements.txt`):
```bash
# Activar el entorno virtual
source .venv/bin/activate
# Ejecutar con Waitress (más robusto que el servidor de desarrollo)
waitress-serve --port=8080 app:app
```
La aplicación estará disponible en: `http://localhost:8080`
> **Nota:** Para producción real, considera:
> - Usar un proxy inverso (Nginx, Apache)
> - Configurar HTTPS
> - Establecer un dominio/IP estática
---
## 📦 Actualización
Para actualizar a la última versión:
```bash
# Desde el directorio del proyecto
git pull origin develop
# Actualizar dependencias (opcional, si requirements.txt ha cambiado)
source .venv/bin/activate
uv pip install -r requirements.txt --upgrade
# Reiniciar la aplicación
# (Ctrl+C para detener, luego vuelve a ejecutar)
```
---
## 🔄 Migración de Base de Datos
Si ya tienes una base de datos existente y necesitas migrar a la nueva estructura (con soporte para alumnos):
```bash
# Hacer backup de la base de datos actual
cp tablets.db tablets.db.backup
# Ejecutar el script de migración
python3 scripts/migrate_database.py
# Importar datos de alumnos (si tienes un CSV)
python3 scripts/import_students.py Datos_programa.csv --verbose
```
> **Nota:** El script de migración crea automáticamente un backup antes de realizar cambios.
---
## 🧪 Ejecución de Pruebas
Para verificar que todo funciona correctamente:
```bash
# Activar el entorno virtual
source .venv/bin/activate
# Ejecutar todas las pruebas
python3 -m pytest
# Ejecutar con cobertura de código
python3 -m pytest --cov=app --cov-report=term
# Ejecutar pruebas específicas
python3 -m pytest tests/test_import.py -v
```
Deberías ver: `46 passed, 1 warning in X.XXs`
---
## 📂 Estructura del Proyecto
```
GestionTablets/
├── app.py # Aplicación Flask principal
├── requirements.txt # Dependencias Python
├── setup.sh # Script de configuración automática
├── INSTALL.md # Este documento
├── .env.example # Plantilla de variables de entorno
├── .gitignore # Archivos ignorados por Git
├── tablets.db # Base de datos SQLite (generada)
├── docs/
│ └── DATABASE_SCHEMA_SPECIFICATION.md # Esquema de la base de datos
├── scripts/
│ ├── import_students.py # Importación de alumnos desde CSV
│ └── migrate_database.py # Migración de base de datos
├── templates/ # Plantillas HTML (Jinja2)
│ ├── base.html # Plantilla base
│ ├── index.html # Página principal
│ ├── students.html # Lista de alumnos
│ └── ...
└── translations/ # Archivos de traducción (i18n)
```
---
## 🛠️ Solución de Problemas
### Problema: "Command 'uv' not found"
**Solución:**
```bash
# Instalar uv manualmente
curl -LsSf https://astral.sh/uv/install.sh | sh
# Añadir al PATH
export PATH="$HOME/.local/bin:$PATH"
# Verificar
uv --version
```
### Problema: "Python 3.13 not found"
**Solución:**
```bash
# Instalar Python 3.13 en Debian/Ubuntu
sudo apt update
sudo apt install python3.13 python3.13-venv python3.13-dev
# Verificar
python3.13 --version
```
### Problema: "ModuleNotFoundError: No module named 'flask'"
**Solución:**
```bash
# Asegúrate de que el entorno virtual está activado
source .venv/bin/activate
# Instalar dependencias
uv pip install -r requirements.txt
```
### Problema: "Permission denied" al ejecutar setup.sh
**Solución:**
```bash
# Dar permisos de ejecución
chmod +x setup.sh
# Ejecutar
./setup.sh
```
### Problema: La base de datos no se inicializa
**Solución:**
```bash
# Forzar inicialización manual
python3 -c "from app import init_db; init_db()"
```
---
## 📝 Variables de Entorno
| Variable | Valor por Defecto | Descripción | Requerida |
|----------|-------------------|-------------|-----------|
| `FLASK_APP` | `app.py` | Archivo principal de Flask | No |
| `FLASK_ENV` | `production` | Entorno de Flask (`development` o `production`) | No |
| `SECRET_KEY` | - | Clave secreta para sesiones (¡genera una nueva!) | **Sí** |
| `DATABASE` | `tablets.db` | Ruta a la base de datos SQLite | No |
| `PORT` | `5000` | Puerto del servidor (solo para desarrollo) | No |
---
## 🔒 Seguridad
### Clave Secreta
- **Nunca** uses la clave secreta por defecto en producción.
- Genera una nueva con: `openssl rand -hex 32`
- Guárdala en `.env` (este archivo **no** debe comitearse a Git)
### Base de Datos
- El archivo `tablets.db` contiene todos los datos del sistema.
- **Haz backups regulares:** `cp tablets.db tablets.db.backup`
- No expongas el archivo públicamente.
### Acceso a la Aplicación
- Actualmente, la aplicación no tiene autenticación.
- **Recomendación para producción:**
- Usa un proxy inverso con autenticación (Nginx + Basic Auth)
- O implementa autenticación en Flask (ver propuestas de mejora)
---
## 📞 Soporte
Si encuentras problemas:
1. **Revisa esta documentación** (INSTALL.md)
2. **Consulta los logs** de la aplicación
3. **Verifica los requisitos** (Python 3.13+, uv, etc.)
4. **Contacta al administrador** del sistema (ijuanes)
---
## 📚 Documentación Adicional
- [DOCUMENTACION_RECAPITULACION.md](DOCUMENTACION_RECAPITULACION.md) - Recapitulación técnica completa
- [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) - Resumen de implementación
- [docs/DATABASE_SCHEMA_SPECIFICATION.md](docs/DATABASE_SCHEMA_SPECIFICATION.md) - Esquema detallado de la base de datos

9
LICENSE Normal file
View file

@ -0,0 +1,9 @@
MIT License
Copyright (c) 2026 ijuanes
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

205
README.md
View file

@ -1,204 +1,3 @@
# Tablet Lending and Return Management System # GestionTablets
A simple SQLite-based system for managing tablet lending and returns. Gestión de las Tablets del IES Schamann (préstamo y devolución).
## Features
- **Tablet Management**: Add, track, and manage tablets in inventory
- **User Management**: Register users who can borrow tablets
- **Loan Tracking**: Track which tablets are loaned to which users
- **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` - **DEPRECATED** - Interactive command-line application (limited functionality, use `app.py` instead)
- `test_app.py` - Test script that demonstrates functionality
- `tablets.db` - SQLite database (created automatically, includes non_loanable_devices table)
- `app.py` - **Recommended** - Full-featured web version (requires Flask, includes all features)
## Quick Start
### 1. Run the test to see it in action
```bash
python3 test_app.py
```
This will:
- Create the database
- Add sample tablets and users
- Demonstrate loan and return operations
- Show the complete workflow
### 2. Run the web application (Recommended)
```bash
python3 app.py
```
This provides a full-featured web interface for:
- Adding tablets (with notes field)
- Adding users (with email and phone)
- Loaning tablets
- Returning tablets
- Viewing inventory and loan status
- User loans view with search
- Non-loanable devices management
- Project management with markdown notes
### 3. Database Structure
The system uses three main tables with a **many-to-many relationship** between users and tablets:
**tablets**
- `id`: Primary key
- `brand`: Tablet brand
- `model`: Tablet model
- `serial_number`: Unique serial number
- `status`: 'available' or 'loaned'
**users**
- `id`: Primary key
- `name`: User's name
- `identification`: Unique identification
**loans** (junction table)
- `id`: Primary key
- `tablet_id`: Foreign key to tablets
- `user_id`: Foreign key to users
- `loan_date`: When the tablet was loaned
- `return_date`: When the tablet was returned (NULL if still active)
- `status`: 'active' or 'returned'
> **Note**: The loans table enables a many-to-many relationship. One user can loan **multiple tablets** (one-to-many from user to loans), and each tablet can be loaned to different users over time. The `status` field on tablets ensures a device can only be loaned to one user at a time.
## Requirements
- Python 3.x (built-in sqlite3 module)
- No additional dependencies needed for the command-line version
- Flask required for web versions (optional)
## Usage Examples
### Adding a tablet
```
python3 -c "
import sqlite3
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('INSERT INTO tablets (brand, model, serial_number, status) VALUES (?, ?, ?, ?)',
('Microsoft', 'Surface Pro', 'SN004', 'available'))
conn.commit()
conn.close()
print('Tablet added!')
"
```
### Adding a user
```
python3 -c "
import sqlite3
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('INSERT INTO users (name, identification) VALUES (?, ?)',
('Alice Brown', 'ID004'))
conn.commit()
conn.close()
print('User added!')
"
```
### Querying available tablets
```
python3 -c "
import sqlite3
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('SELECT brand, model, serial_number FROM tablets WHERE status = ?', ('available',))
for row in cursor.fetchall():
print(f'{row[0]} {row[1]} ({row[2]})')
conn.close()
"
```
## Web Version (Optional)
If you want to use the web interface:
1. Install Flask:
```bash
pip install flask
```
2. Run the web application:
```bash
python3 app.py
```
3. Open your browser to: http://localhost:5000
### Web Features
- **Home**: View available tablets and active loans
- **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
To backup your data:
```bash
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.

View file

@ -1,221 +0,0 @@
# 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í.

View file

@ -1,69 +0,0 @@
# Tablet Management System - Running
**Status**: Application is running successfully!
## Access Information
- **Web Interface**: [http://localhost:5000](http://localhost:5000)
- **Port**: 5000
- **Framework**: Flask 3.1.3
- **Database**: SQLite (`tablets.db`)
## How to Use
### Web Interface
1. Open your browser to: [http://localhost:5000](http://localhost:5000)
2. Use the navigation menu to:
- **Home**: View available tablets and active loans
- **Add Tablet**: Add new tablets to inventory
- **Add User**: Register new users
- **Loan Tablet**: Loan a tablet to a user
- **Loan History**: View complete loan history
### Command Line (Alternative)
If you prefer command line:
```bash
# Run the interactive application
python3 minimal_app.py
# Run the test/demo
python3 test_app.py
```
## Current Data
The system already contains test data:
- **3 Tablets**: Samsung Galaxy Tab S7, Apple iPad Pro, Lenovo Tab P11
- **3 Users**: John Doe, Jane Smith, Bob Johnson
- **2 Loans**: One active, one returned
## Stopping the Server
To stop the Flask application:
```bash
pkill -f "python app.py"
```
## Technical Details
- **Backend**: SQLite 3
- **Frontend**: Flask with HTML/CSS
- **Port**: 5000 (configurable in app.py)
- **Virtual Environment**: `.venv/` (created with uv)
## Running Tests
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.

View file

@ -1,200 +0,0 @@
# 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

1016
app.py

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -1,81 +0,0 @@
#!/usr/bin/env python3
"""
Basic HTTP server to serve the tablet management system
"""
from http.server import SimpleHTTPRequestHandler, HTTPServer
import os
class MyHandler(SimpleHTTPRequestHandler):
def do_GET(self):
if self.path == '/':
self.path = '/index.html'
return super().do_GET()
def run_server():
port = 8080
server_address = ('', port)
httpd = HTTPServer(server_address, MyHandler)
# Create a simple index file if it doesn't exist
if not os.path.exists('index.html'):
with open('index.html', 'w') as f:
f.write('''<!DOCTYPE html>
<html>
<head>
<title>Tablet Management System</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #4CAF50; }
.info { background: #f0f0f0; padding: 20px; border-radius: 5px; }
.command { background: #e0e0e0; padding: 10px; border-radius: 3px; }
</style>
</head>
<body>
<h1>Tablet Management System</h1>
<div class="info">
<h2>Welcome to the Tablet Management System!</h2>
<p>This system is running with a SQLite backend.</p>
<h3>Available Commands:</h3>
<ul>
<li><strong>Test the system:</strong> <code class="command">python3 test_app.py</code></li>
<li><strong>Run interactive mode:</strong> <code class="command">python3 minimal_app.py</code></li>
<li><strong>Check database:</strong> <code class="command">sqlite3 tablets.db</code></li>
</ul>
<h3>Current Status:</h3>
<p> Database: tablets.db</p>
<p> Web server: Running on port 8080</p>
<p> System: Ready for use</p>
</div>
<h3>Quick Test Results:</h3>
<pre id="test-results">Running tests...</pre>
<script>
// Simple test to show the system is working
fetch('/test-db')
.then(response => response.text())
.then(data => {
document.getElementById('test-results').textContent = data;
})
.catch(error => {
document.getElementById('test-results').textContent = 'Database test: Error - ' + error;
});
</script>
</body>
</html>''')
print(f"Server running on http://localhost:{port}")
print("Serving current directory")
print("Press Ctrl+C to stop the server")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")
if __name__ == '__main__':
run_server()

View file

@ -1,20 +0,0 @@
#!/usr/bin/env python3
"""
Check if Flask is available and provide installation instructions
"""
try:
import flask
print("✓ Flask is available!")
print("You can run the web version:")
print(" python3 simple_app.py")
print("Then open: http://localhost:8000")
except ImportError:
print("✗ Flask is not installed")
print("\nTo install Flask:")
print(" pip install flask")
print("\nOr use the system package manager:")
print(" sudo apt install python3-flask")
print("\nYou can still use the command-line version:")
print(" python3 minimal_app.py")
print(" python3 test_app.py")

View file

@ -1,24 +0,0 @@
#!/usr/bin/env python3
"""
Compile .po files to .mo files for Flask-Babel
"""
import os
from pathlib import Path
def compile_translations():
translations_dir = Path('translations')
for lang_dir in translations_dir.iterdir():
if lang_dir.is_dir():
lc_messages_dir = lang_dir / 'LC_MESSAGES'
if lc_messages_dir.exists():
for po_file in lc_messages_dir.glob('*.po'):
mo_file = lc_messages_dir / f"{po_file.stem}.mo"
print(f"Compiling {po_file} -> {mo_file}")
# Use msgfmt to compile
os.system(f"msgfmt -o {mo_file} {po_file}")
print("✅ All translations compiled!")
if __name__ == '__main__':
compile_translations()

View file

@ -1,20 +0,0 @@
#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
class DebugHandler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header('Content-type', 'text/plain')
self.end_headers()
self.wfile.write(b'Hello from Tablet Management Server!')
def run():
try:
server = HTTPServer(('', 8000), DebugHandler)
print("Debug server running on port 8000")
server.serve_forever()
except Exception as e:
print(f"Error: {e}")
if __name__ == '__main__':
run()

View file

@ -1,522 +0,0 @@
# CLI vs Web Functionality Comparison
## ⚠️ DEPRECATION NOTICE
**The CLI version (`minimal_app.py`) is now DEPRECATED.**
Please use the **web interface (`app.py`)** for all operations. The web interface provides:
- Complete feature set
- Full database schema support
- Responsive mobile interface
- Better user experience
The CLI will be removed in a future version. This document is kept for historical reference.
---
## Overview
This document compares the functionality between the **DEPRECATED CLI version** (`minimal_app.py`) and the **Web version** (`app.py`) of the Tablet Management System.
## Current State
### Web Version (app.py) - ✅ RECOMMENDED
The web version is the **primary and recommended** interface for all users.
| Feature | Status | Route | Template |
|---------|--------|-------|---------|
| Add Tablet | ✅ | `/add_tablet` | `add_tablet.html` |
| Add User | ✅ | `/add_user` | `add_user.html` |
| Loan Tablet | ✅ | `/loan_tablet` | `loan_tablet.html` |
| Return Tablet | ✅ | `/return_tablet/<id>` | N/A (redirects) |
| Show Available Tablets | ✅ | `/` (index) | `index.html` |
| Show Active Loans | ✅ | `/` (index) | `index.html` |
| Loan History | ✅ | `/history` | `history.html` |
| User Loans | ✅ | `/user_loans` | `user_loans.html` |
| Add Non-Loanable Device | ✅ | `/add_non_loanable_device` | `add_non_loanable_device.html` |
| Show Non-Loanable Devices | ✅ | `/non_loanable_devices` | `non_loanable_devices.html` |
| Edit Non-Loanable Device | ✅ | `/edit_non_loanable_device/<id>` | `edit_non_loanable_device.html` |
| Delete Non-Loanable Device | ✅ | `/delete_non_loanable_device/<id>` | N/A (redirects) |
| Project Management | ✅ | `/project_management` | `project_management.html` |
### CLI Version (minimal_app.py) - ❌ DEPRECATED
**This CLI is deprecated and should not be used for new development.**
The CLI has limited functionality and does not support all database columns used by the web version.
| Feature | Status | Function |
|---------|--------|----------|
| Add Tablet | ⚠️ Partial | `add_tablet()` - Missing notes field |
| Add User | ⚠️ Partial | `add_user()` - Missing email/phone |
| Loan Tablet | ✅ | `loan_tablet()` |
| Return Tablet | ✅ | `return_tablet()` |
| Show Available Tablets | ✅ | `show_available_tablets()` |
| Show Active Loans | ✅ | `show_active_loans()` |
| Loan History | ✅ | `show_loan_history()` |
| Add Non-Loanable Device | ✅ | `add_non_loanable_device()` |
| Show Non-Loanable Devices | ✅ | `show_non_loanable_devices()` |
| Delete Non-Loanable Device | ✅ | `delete_non_loanable_device()` |
| Show Users | ✅ | `show_users()` |
| **User Loans** | ❌ Missing | N/A |
| **Edit Non-Loanable Device** | ❌ Missing | N/A |
| **Project Management** | ❌ Missing | N/A |
## Migration Path
### For Existing CLI Users
**Stop using the CLI and switch to the web interface:**
1. Run the web interface:
```bash
python3 app.py
```
2. Open your browser to: http://localhost:5000
3. The web interface uses the **same database** (`tablets.db`), so all your data is preserved.
### Database Compatibility
The CLI's database schema is **missing some columns** that the web version uses:
- `tablets.notes` - Added by web version
- `users.email` - Added by web version
- `users.phone` - Added by web version
**If you've only used the CLI:** Your database is missing these columns. The web interface will still work, but won't be able to store notes, email, or phone until the columns are added.
**To fix the database schema:**
```bash
# Run the web interface - it will add missing columns automatically
python3 app.py
```
The web version's `init_db()` will add any missing columns when it runs.
## Recommendation
**Use `app.py` (web interface) for all operations.**
The CLI (`minimal_app.py`) is deprecated and will be removed in a future version. All development and maintenance efforts should focus on the web interface.
## Missing Features in CLI
### 1. User Management
**Web:** Supports name, email, phone, identification
**CLI:** Only supports name, identification
**Missing:**
- Email field
- Phone field
### 2. Tablet Management
**Web:** Supports brand, model, serial_number, notes
**CLI:** Supports brand, model, serial_number
**Missing:**
- Notes field
### 3. Non-Loanable Devices
**Web:** Full CRUD (Create, Read, Update, Delete)
**CLI:** Only Create, Read, Delete
**Missing:**
- Edit functionality
### 4. Additional Features
**Web:** Has these features
**CLI:** Missing
- User Loans page (shows loans by user with search)
- Project Management (markdown notes editor)
- Flash messages (CLI uses print, which is fine)
## Database Schema Comparison
### Web Version Schema (app.py)
```sql
-- tablets
CREATE TABLE 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
)
-- users
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE
)
-- loans
CREATE TABLE loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
-- non_loanable_devices
CREATE TABLE 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
)
```
### CLI Version Schema (minimal_app.py)
```sql
-- tablets
CREATE TABLE tablets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
brand TEXT NOT NULL,
model TEXT NOT NULL,
serial_number TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'available'
-- MISSING: notes TEXT
)
-- users
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
identification TEXT UNIQUE
-- MISSING: email TEXT, phone TEXT
)
-- loans
CREATE TABLE loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
-- non_loanable_devices
CREATE TABLE 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
)
```
**Schema Differences:**
- `tablets` table: CLI missing `notes` column
- `users` table: CLI missing `email` and `phone` columns
## Recommendations
### Option 1: Update CLI to Match Web (Recommended)
Update `minimal_app.py` to:
1. Add `notes` field to tablets
2. Add `email` and `phone` fields to users
3. Add edit functionality for non-loanable devices
4. Add user loans view
5. Add project management (optional)
**Pros:**
- CLI has full feature parity with web
- Same database schema
- Users can use either interface
**Cons:**
- More complex CLI
- May not be needed if web is primary interface
### Option 2: Keep CLI Minimal (Current State)
Leave CLI as-is for basic operations only.
**Pros:**
- Simple, focused CLI
- Less code to maintain
**Cons:**
- Database schema mismatch
- Users can't access all features via CLI
- Confusing for users who expect same functionality
### Option 3: Create Separate Database (Not Recommended)
Use different databases for CLI and web.
**Pros:**
- Each can have optimized schema
**Cons:**
- Data duplication
- Sync issues
- Confusing for users
## Suggested Action Plan
### Priority 1: Fix Database Schema Mismatch
The CLI's `init_db()` creates tables without `notes`, `email`, and `phone` columns, but the web version expects them. This can cause issues.
**Solution:** Update `minimal_app.py` `init_db()` to match `app.py` schema.
### Priority 2: Add Missing Fields to CLI Functions
1. Update `add_tablet()` to accept and store `notes`
2. Update `add_user()` to accept and store `email` and `phone`
3. Update `show_available_tablets()` and `show_non_loanable_devices()` to display all fields
### Priority 3: Add Missing Features (Optional)
1. Add `edit_non_loanable_device()` function
2. Add `show_user_loans()` function
3. Consider adding project management (lower priority)
## Code Changes Required
### 1. Update init_db() in minimal_app.py
```python
def init_db():
"""Initialize database with required tables - MATCH WEB VERSION"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Create tablets table - ADD NOTES COLUMN
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
)
''')
# Create users table - ADD EMAIL AND PHONE COLUMNS
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE
)
''')
# loans and non_loanable_devices are already correct
# ... rest of init_db
```
### 2. Update add_tablet() Function
```python
def add_tablet(brand, model, serial_number, notes=''):
"""Add a new tablet to inventory"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status, notes)
VALUES (?, ?, ?, 'available', ?)
''', (brand, model, serial_number, notes))
conn.commit()
print(f"✓ Added tablet: {brand} {model} ({serial_number})")
if notes:
print(f" Notes: {notes}")
except sqlite3.IntegrityError:
print(f"✗ Error: Serial number {serial_number} already exists")
finally:
conn.close()
```
### 3. Update add_user() Function
```python
def add_user(name, identification, email='', phone=''):
"""Add a new user"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO users (name, email, phone, identification)
VALUES (?, ?, ?, ?)
''', (name, email, phone, identification))
conn.commit()
print(f"✓ Added user: {name} ({identification})")
if email:
print(f" Email: {email}")
if phone:
print(f" Phone: {phone}")
except sqlite3.IntegrityError:
print(f"✗ Error: Identification {identification} already exists")
finally:
conn.close()
```
### 4. Update show_available_tablets() to Display Notes
```python
def show_available_tablets():
"""Show available tablets"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute("SELECT id, brand, model, serial_number, notes FROM tablets WHERE status = 'available'")
tablets = cursor.fetchall()
print("\n=== Available Tablets ===")
if tablets:
for tablet in tablets:
notes = f" | Notes: {tablet[4]}" if tablet[4] else ""
print(f"ID: {tablet[0]}, {tablet[1]} {tablet[2]} ({tablet[3]}){notes}")
else:
print("No available tablets")
conn.close()
```
### 5. Add Missing Functions
#### Edit Non-Loanable Device
```python
def edit_non_loanable_device(device_id, **kwargs):
"""Edit a non-loanable device"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Build update query dynamically
updates = []
params = []
for key, value in kwargs.items():
if value is not None:
updates.append(f"{key} = ?")
params.append(value)
if not updates:
print("✗ No fields to update")
conn.close()
return
params.append(device_id)
query = f"UPDATE non_loanable_devices SET {', '.join(updates)} WHERE id = ?"
cursor.execute(query, params)
conn.commit()
if cursor.rowcount > 0:
print(f"✓ Non-loanable device {device_id} updated")
else:
print(f"✗ Error: Device {device_id} not found")
conn.close()
```
#### Show User Loans
```python
def show_user_loans():
"""Show loans grouped by user"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Get all users with their loans
cursor.execute('''
SELECT u.id, u.name, u.identification,
GROUP_CONCAT(l.id, ",") as loan_ids,
COUNT(l.id) as loan_count
FROM users u
LEFT JOIN loans l ON u.id = l.user_id
GROUP BY u.id, u.name, u.identification
''')
users = cursor.fetchall()
print("\n=== User Loans ===")
if users:
for user in users:
print(f"\nUser: {user[1]} ({user[2]}) - {user[3]} loans")
# Get loans for this user
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, l.loan_date, l.return_date, l.status
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
WHERE l.user_id = ?
ORDER BY l.loan_date DESC
''', (user[0],))
loans = cursor.fetchall()
for loan in loans:
return_date = loan[5] or 'Not returned'
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]})")
print(f" Loan Date: {loan[4]}, Return Date: {return_date}, Status: {loan[6]}")
else:
print("No users found")
conn.close()
```
## Migration Strategy
If you want to update the CLI to match the web version:
1. **Backup current database**
```bash
cp tablets.db tablets.db.backup
```
2. **Update minimal_app.py** with the changes above
3. **Run updated CLI**
```bash
python3 minimal_app.py
```
4. **Test all functionality**
5. **If database schema changed**, you may need to:
- Drop and recreate tables (if starting fresh)
- Or add missing columns with ALTER TABLE (if preserving data)
## Conclusion
The CLI version is **lagging behind** the web version in terms of:
- Database schema (missing columns)
- Feature completeness (missing functions)
- Field support (missing email, phone, notes)
**Recommendation:** Update the CLI to match the web version's database schema and functionality to ensure consistency and avoid confusion for users who may use both interfaces.

View file

@ -1,176 +0,0 @@
# Corporate Design Specifications
## Overview
This document defines the corporate design specifications for the Tablet Management System, including color palette, logo usage, and branding guidelines.
---
## Color Palette
### Primary Corporate Colors
The following three colors constitute the official corporate color palette:
| Color Name | Hex Code | RGB | Usage |
|------------|----------|-----|-------|
| **Cobalt Blue** | `#1338BE` | rgb(19, 56, 190) | Primary brand color, headers, navigation, main actions |
| **Tiger Orange** | `#FC6A03` | rgb(252, 106, 3) | Accents, highlights, loading indicators, secondary actions |
| **Emerald Green** | `#028A0F` | rgb(2, 138, 15) | Success states, positive feedback, confirmation |
### Color Shades
Each corporate color has been extended with a full shade range (50-900) for use in Tailwind CSS:
#### Cobalt Blue Shades
- `cobalt-50`: `#f0f5ff` (Lightest)
- `cobalt-100`: `#e0eaff`
- `cobalt-200`: `#b3c6ff`
- `cobalt-300`: `#80a0ff`
- `cobalt-400`: `#4d7aff`
- **`cobalt-500`: `#1338BE`** (Primary)
- `cobalt-600`: `#0f2a9c`
- `cobalt-700`: `#0b1f72`
- `cobalt-800`: `#081552`
- `cobalt-900`: `#060f38` (Darkest)
#### Tiger Orange Shades
- `tiger-50`: `#fff8f0` (Lightest)
- `tiger-100`: `#ffe8d6`
- `tiger-200`: `#ffccab`
- `tiger-300`: `#ffae80`
- `tiger-400`: `#ff8c55`
- **`tiger-500`: `#FC6A03`** (Primary)
- `tiger-600`: `#e05a00`
- `tiger-700`: `#b84500`
- `tiger-800`: `#8c3300`
- `tiger-900`: `#662500` (Darkest)
#### Emerald Green Shades
- `emerald-50`: `#f0fdf4` (Lightest)
- `emerald-100`: `#dcfce7`
- `emerald-200`: `#bbf7d0`
- `emerald-300`: `#86efac`
- `emerald-400`: `#4ade80`
- **`emerald-500`: `#028A0F`** (Primary)
- `emerald-600`: `#006b0c`
- `emerald-700`: `#005209`
- `emerald-800`: `#003d07`
- `emerald-900`: `#002904` (Darkest)
---
## Logo Usage
### Logo File
- **File**: `assets/Schamann.png`
- **Dimensions**: 175 x 161 pixels
- **Format**: PNG with transparency
- **Web Path**: `/static/Schamann.png`
### Display Guidelines
- The logo should be displayed at a fixed height of 40px in the header
- Use `object-contain` to maintain aspect ratio
- The logo should appear to the left of the application title
- Do not stretch or distort the logo
### Implementation
```html
<img src="/static/Schamann.png" alt="Corporate Logo" class="h-10 w-auto object-contain" style="max-height: 40px; height: 40px !important;">
```
---
## Usage Examples
### Headers and Navigation
```html
<!-- Header background -->
<header class="bg-cobalt-600 text-white shadow-lg">
<!-- Navigation background -->
<nav class="bg-cobalt-600 rounded-lg shadow-md">
<!-- Navigation hover -->
<a class="hover:bg-cobalt-700 transition-colors">
```
### Buttons and Actions
```html
<!-- Primary button (Cobalt Blue) -->
<button class="bg-cobalt-600 hover:bg-cobalt-700 text-white">
<!-- Secondary button (Tiger Orange) -->
<button class="bg-tiger-500 hover:bg-tiger-600 text-white">
<!-- Success button (Emerald Green) -->
<button class="bg-emerald-500 hover:bg-emerald-600 text-white">
```
### Status Indicators
```html
<!-- Success state -->
<div class="bg-emerald-100 text-emerald-800">Success!</div>
<!-- Warning state -->
<div class="bg-tiger-100 text-tiger-800">Warning</div>
<!-- Info state -->
<div class="bg-cobalt-100 text-cobalt-800">Info</div>
```
---
## Tailwind CSS Configuration
The corporate colors are configured in the Tailwind CSS configuration within the base template:
```javascript
tailwind.config = {
theme: {
extend: {
colors: {
cobalt: {
50: '#f0f5ff',
100: '#e0eaff',
// ... all shades
500: '#1338BE',
// ...
},
tiger: {
50: '#fff8f0',
// ... all shades
500: '#FC6A03',
// ...
},
emerald: {
50: '#f0fdf4',
// ... all shades
500: '#028A0F',
// ...
}
}
}
}
}
```
---
## Color Accessibility
All corporate colors meet WCAG AA contrast requirements when used appropriately:
- **Cobalt Blue (#1338BE)**: Use white text for backgrounds
- **Tiger Orange (#FC6A03)**: Use white text for backgrounds
- **Emerald Green (#028A0F)**: Use white text for backgrounds
For lighter shades (100-300), use dark text (gray-800 or gray-900).
---
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.0 | 2026-06-20 | Initial corporate design specifications with Cobalt Blue, Tiger Orange, and Emerald Green color palette |

View file

@ -1,322 +0,0 @@
# Database Schema Specification for Student Data Management
## Overview
This document specifies the SQLite database schema for managing student data imported from CSV files. The schema is designed to support the tablet lending system while incorporating student information from external data sources.
## Source Data Structure
The CSV file `Datos_programa.csv` has the following structure (ISO-8859-1 encoded):
```csv
Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio
```
### Field Descriptions
| Field | Type | Description | Notes |
|-------|------|-------------|-------|
| Nº Or. | Integer | Order number | Primary identifier in source |
| Apellidos, Nombre | String | Full name (Surnames, Name) | Needs splitting into separate fields |
| Fecha Nac. | Date | Birth date | Format: DD/MM/YYYY |
| C.I.A.L. | String | Internal identification code | Unique identifier |
| NIF/NIE/Pas. | String | National ID / Foreign ID / Passport | Unique identifier |
| Registro | Integer | Registration number | Internal reference |
| Expediente | String | File number | Optional, may be empty |
| Sexo | String | Gender | Single character (M/F) |
| Grupo | String | Study group | Educational level |
| Estudio | String | Study program | Full description - TO BE DISCARDED |
## Database Schema
### Table: students
The core table for storing student information.
```sql
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Source identifiers
order_number INTEGER,
cial_code TEXT UNIQUE NOT NULL,
nif_nie_passport TEXT UNIQUE,
registration_number INTEGER,
file_number TEXT,
-- Personal information
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
full_name TEXT NOT NULL,
-- Demographic data
birth_date TEXT, -- Format: YYYY-MM-DD
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
-- Study information
study_group TEXT,
-- Metadata
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
-- Constraints
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
);
```
### Table: tablets (existing)
```sql
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' CHECK(status IN ('available', 'loaned', 'maintenance')),
notes TEXT,
assigned_to_student INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_student) REFERENCES students(id)
);
```
### Table: users (existing - for staff)
```sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE,
role TEXT DEFAULT 'staff' CHECK(role IN ('admin', 'staff', 'viewer')),
password_hash TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
```
### Table: loans (existing - updated)
```sql
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
student_id INTEGER NOT NULL,
user_id INTEGER, -- Staff member who processed the loan
loan_date TEXT NOT NULL,
return_date TEXT,
due_date TEXT,
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'returned', 'overdue', 'lost')),
notes TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (tablet_id) REFERENCES tablets(id),
FOREIGN KEY (student_id) REFERENCES students(id),
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
### Table: non_loanable_devices (existing)
```sql
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,
assigned_to_student INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (assigned_to_student) REFERENCES students(id)
);
```
## Indexes
For optimal query performance, the following indexes are recommended:
```sql
-- Student indexes
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code);
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport);
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name);
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date);
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender);
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group);
-- Loan indexes
CREATE INDEX IF NOT EXISTS idx_loans_tablet ON loans(tablet_id);
CREATE INDEX IF NOT EXISTS idx_loans_student ON loans(student_id);
CREATE INDEX IF NOT EXISTS idx_loans_status ON loans(status);
CREATE INDEX IF NOT EXISTS idx_loans_date ON loans(loan_date);
CREATE INDEX IF NOT EXISTS idx_loans_due ON loans(due_date);
-- Tablet indexes
CREATE INDEX IF NOT EXISTS idx_tablets_serial ON tablets(serial_number);
CREATE INDEX IF NOT EXISTS idx_tablets_status ON tablets(status);
CREATE INDEX IF NOT EXISTS idx_tablets_brand ON tablets(brand);
CREATE INDEX IF NOT EXISTS idx_tablets_student ON tablets(assigned_to_student);
-- User indexes
CREATE INDEX IF NOT EXISTS idx_users_identification ON users(identification);
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
```
## Data Import Specification
### CSV Processing Requirements
1. **Encoding Conversion**: Convert from ISO-8859-1 to UTF-8
2. **Field Splitting**: Split "Apellidos, Nombre" into separate "last_name" and "first_name" fields
3. **Date Format Conversion**: Convert "DD/MM/YYYY" to "YYYY-MM-DD"
4. **Field Discarding**: The "Estudio" field will be discarded as per requirements
5. **Empty Field Handling**: Empty fields should be stored as NULL
### Import Mapping
| CSV Field | Database Field | Transformation |
|-----------|----------------|----------------|
| Nº Or. | order_number | Integer conversion |
| Apellidos, Nombre | last_name, first_name | Split on ", " |
| Apellidos, Nombre | full_name | Keep as-is |
| Fecha Nac. | birth_date | Date format conversion |
| C.I.A.L. | cial_code | Direct mapping |
| NIF/NIE/Pas. | nif_nie_passport | Direct mapping |
| Registro | registration_number | Integer conversion |
| Expediente | file_number | Direct mapping (may be empty) |
| Sexo | gender | Direct mapping |
| Grupo | study_group | Direct mapping |
| Estudio | - | DISCARDED |
### Validation Rules
1. **Required Fields**: cial_code, first_name, last_name
2. **Unique Fields**: cial_code, nif_nie_passport (if provided)
3. **Date Validation**: birth_date must be valid date in YYYY-MM-DD format
4. **Gender Validation**: Must be 'M', 'F', or 'O'
## API Endpoints (New/Updated)
### Student Management
```
GET /students - List all students (with pagination)
GET /students/<int:id> - Get student details
GET /students/search - Search students
POST /students - Create new student
PUT /students/<int:id> - Update student
DELETE /students/<int:id> - Delete student
POST /students/import - Import from CSV
```
### Loan Management (Updated)
```
GET /loans - List all loans
GET /loans/active - List active loans
GET /loans/student/<int:student_id> - Loans for specific student
POST /loans - Create new loan
PUT /loans/<int:id>/return - Return tablet
```
## Frontend Updates
### New Templates Required
1. **students.html** - Student list view
2. **student_detail.html** - Individual student details
3. **add_student.html** - Add new student form
4. **edit_student.html** - Edit student form
5. **import_students.html** - CSV import interface
### Updated Templates
1. **loan_tablet.html** - Add student selection alongside user selection
2. **user_loans.html** - Update to show student loans
3. **index.html** - Add student count and recent students
## Migration Path
### From Existing Database
1. Create new `students` table
2. Add `assigned_to_student` field to `tablets` and `non_loanable_devices`
3. Add `student_id` field to `loans` (nullable initially)
4. Create indexes
5. Import CSV data using import script
### Rollback Plan
1. Backup existing database before migration
2. Create migration scripts that are idempotent
3. Test migration on copy of production data
4. Provide rollback SQL scripts
## File Locations
```
project/
├── database/
│ ├── schema.sql # Complete SQL schema
│ └── migrations/
│ ├── 001_add_students_table.sql
│ ├── 002_add_student_relationships.sql
│ └── 003_add_indexes.sql
├── scripts/
│ ├── import_students.py # CSV import script
│ └── migrate_database.py # Database migration script
├── templates/
│ ├── students/
│ │ ├── list.html
│ │ ├── detail.html
│ │ ├── add.html
│ │ ├── edit.html
│ │ └── import.html
│ └── ... (existing templates)
└── app.py # Updated with new routes
```
## Testing Requirements
1. **Import Script Tests**:
- Test with valid CSV file
- Test with empty CSV file
- Test with malformed CSV file
- Test with duplicate entries
- Test encoding conversion
- Test field splitting
2. **Database Tests**:
- Test unique constraints
- Test foreign key relationships
- Test indexes performance
- Test data integrity
3. **API Tests**:
- Test all CRUD operations for students
- Test loan operations with students
- Test search and filtering
- Test pagination
## Security Considerations
1. **Data Validation**: All inputs must be validated before database operations
2. **SQL Injection**: Use parameterized queries exclusively
3. **Authentication**: Student data should only be accessible to authorized users
4. **Audit Trail**: Maintain logs of all import operations and data modifications
5. **Backup**: Regular backups of student data
## Performance Considerations
1. **Pagination**: All list endpoints should support pagination (default: 50 items per page)
2. **Caching**: Consider caching frequently accessed student data
3. **Batch Operations**: Import script should use batch inserts for performance
4. **Index Maintenance**: Regularly update statistics for query planner

File diff suppressed because it is too large Load diff

View file

@ -1,987 +0,0 @@
# 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.

View file

@ -1,520 +0,0 @@
# Responsive CSS Implementation
## Overview
Added responsive CSS to the Tablet Management System to improve mobile accessibility for internal technical staff. This addresses the issue where the interface was too wide for mobile devices.
## ⚠️ TOP PRIORITY: Spanish Translation Required
**Status:** Not started - Documentation only
**Priority:** HIGH
**Timeline:** To be determined
The entire user interface needs to be translated from English to Spanish. This includes:
### Scope of Translation
- ✅ All template text (buttons, labels, headers, messages)
- ✅ Navigation links
- ✅ Form field labels and placeholders
- ✅ Button text
- ✅ Flash messages (success/error)
- ✅ Table headers
- ✅ Help text and descriptions
- ✅ Page titles
### Files to Translate
| File | Status | Notes |
|------|--------|-------|
| `templates/base.html` | ⏳ Pending | Title, navigation, flash messages |
| `templates/index.html` | ⏳ Pending | Section headers, table headers, messages |
| `templates/add_tablet.html` | ⏳ Pending | Form labels, button |
| `templates/add_user.html` | ⏳ Pending | Form labels, button |
| `templates/loan_tablet.html` | ⏳ Pending | Form labels, button, search placeholders |
| `templates/history.html` | ⏳ Pending | Section header, table headers, messages |
| `templates/user_loans.html` | ⏳ Pending | All text content, search placeholder |
| `templates/non_loanable_devices.html` | ⏳ Pending | Section header, table headers, messages, button |
| `templates/edit_non_loanable_device.html` | ⏳ Pending | Form labels, buttons |
| `templates/project_management.html` | ⏳ Pending | All text content, buttons |
### Approach Options
#### Option 1: Direct Template Translation (Recommended for simplicity)
- Replace all English text with Spanish directly in templates
- **Pros:** Simple, fast, no dependencies
- **Cons:** Harder to maintain bilingual support
#### Option 2: Flask-Babel Integration (Recommended for future i18n)
```python
# Install: pip install flask-babel
from flask_babel import Babel, gettext as _
app = Flask(__name__)
babel = Babel(app)
# In templates:
# Before: <h1>Tablet Management System</h1>
# After: <h1>{{ _('Tablet Management System') }}</h1>
```
- **Pros:** Supports multiple languages, professional i18n
- **Cons:** More complex setup, requires extracting strings
#### Option 3: Jinja2 Macros
```html
{# macros.html #}
{% macro trans(text) %}{{ text|trans }}{% endmacro %}
{# In templates #}
{% import 'macros.html' as m %}
<h1>{{ m.trans('Tablet Management System') }}</h1>
```
- **Pros:** Reusable, clean templates
- **Cons:** Requires macro setup
### Recommended Spanish Translations
| English | Spanish |
|---------|---------|
| Tablet Management System | Sistema de Gestión de Tablets |
| Available Tablets | Tablets Disponibles |
| Active Loans | Préstamos Activos |
| Loan History | Historial de Préstamos |
| User Loans | Préstamos por Usuario |
| Non-Loanable Devices | Dispositivos No Prestables |
| Project Management | Gestión de Proyectos |
| Add Tablet | Añadir Tablet |
| Add User | Añadir Usuario |
| Loan Tablet | Prestar Tablet |
| Return | Devolver |
| Brand | Marca |
| Model | Modelo |
| Serial Number | Número de Serie |
| Notes | Notas |
| Name | Nombre |
| Email | Correo Electrónico |
| Phone | Teléfono |
| Identification | Identificación |
| Loan Date | Fecha de Préstamo |
| Return Date | Fecha de Devolución |
| Status | Estado |
| Actions | Acciones |
| Edit | Editar |
| Delete | Eliminar |
| Save | Guardar |
| Search | Buscar |
| No available tablets. | No hay tablets disponibles. |
| No active loans. | No hay préstamos activos. |
| No loan history available. | No hay historial de préstamos disponible. |
### Implementation Notes
- **Do not change code yet** - This is documentation only for now
- Consider using a translation dictionary or Flask-Babel for maintainability
- Test all translated text fits within the responsive design
- Verify character encoding supports Spanish (UTF-8 should be fine)
---
## Changes Made
### Date
June 20, 2026
### Files Modified
| File | Changes | Lines Changed |
|------|---------|---------------|
| `templates/base.html` | Added responsive CSS framework + fixed missing `</style>` tag | +173 +1 |
| `templates/index.html` | Wrapped tables in `.table-container` | +84/-84 |
| `templates/history.html` | Wrapped tables in `.table-container` | +46/-46 |
| `templates/non_loanable_devices.html` | Wrapped tables in `.table-container` | +56/-56 |
| `templates/user_loans.html` | Wrapped tables in `.table-container` | +80/-80 |
| `templates/project_management.html` | Added mobile breakpoints for editor | +19/-3 |
| `docs/RESPONSIVE_CSS.md` | **NEW** - Complete documentation | +208 |
| **Total** | | **+538 / -129** |
## Technical Details
### Approach
- **Mobile-first design**: Styles start with mobile and scale up
- **Progressive enhancement**: Works on all devices, enhances for larger screens
- **No JavaScript changes**: Pure CSS solution
- **No backend changes**: Only template modifications
- **Backward compatible**: Existing functionality preserved
### Key Features
#### 1. Responsive Breakpoints
```css
/* Mobile-first base styles */
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) { ... }
/* Medium devices (tablets, 768px and up) */
@media (min-width: 768px) { ... }
/* Large devices (desktops, 992px and up) */
@media (min-width: 992px) { ... }
/* Extra large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) { ... }
```
#### 2. Mobile Navigation
- Navigation links **stack vertically** on mobile
- Full-width buttons for easy tapping
- Horizontal layout on tablet/desktop
#### 3. Responsive Tables
- Tables wrapped in `.table-container` div
- **Horizontal scrolling** on mobile when table is too wide
- Full width on larger screens
#### 4. Form Elements
- Full-width inputs on mobile
- Proper spacing and padding
- Touch-friendly sizes (minimum 48px tap targets)
#### 5. Buttons
- Full-width on mobile
- Inline on larger screens
- Consistent styling
#### 6. Cards
- Added `.tablet-card`, `.user-card`, `.loan-card` classes
- Consistent styling for card-based layouts
- Proper spacing on all devices
#### 7. Project Management Editor
- Stacked layout on mobile (editor above preview)
- Side-by-side on tablet/desktop
- Responsive button controls
## Bug Fix
### Missing `</style>` Tag
**Issue:** After adding responsive CSS to `base.html`, the closing `</style>` tag was accidentally omitted, causing the main page to render as blank.
**Fix:** Added `</style>` tag at line 298 in `templates/base.html` (commit `28232db`).
**Symptoms:**
- Main page (index) displayed as blank
- Other pages may have had styling issues
- HTML structure was invalid
**Resolution:**
- Added missing `</style>` tag
- Verified all templates have proper structure
- Tested that pages render correctly
### Navigation Overflow on Zoom
**Issue:** Navigation buttons (Home, Add Tablet, Add User, etc.) overflow through the right margin when zooming in on the page.
**Fix:** Added overflow constraints to prevent horizontal scrolling (commit `26b77cf`).
**Changes:**
- Added `overflow-x: hidden` to `body` element
- Added `overflow: hidden` to `.container` element
- Added `flex-wrap: wrap` to `.nav` in mobile-first styles
- Added `flex: 1 1 auto` and `min-width: 120px` to `.nav a` for proper wrapping
**Result:**
- Navigation buttons now wrap properly on all screen sizes
- No horizontal overflow when zooming in
- Buttons remain usable and visible at all zoom levels
## Design Decisions
### Why This Approach?
1. **5 Internal Users**: No need for complex SPA frameworks
2. **Technical Staff**: Users understand basic UI limitations
3. **CRUD Operations**: Simple forms and lists don't need React/Vue
4. **Minimal Changes**: Pure CSS, no JavaScript modifications
5. **Fast Implementation**: Done in one session
6. **Maintainable**: Simple to understand and modify
### Why Not HTMX or SPA?
While we explored [HTMX](docs/FRONTEND_OPTIONS.md#option-3-htmx) and [SPA options](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api), for 5 internal technical users:
- **HTMX**: Would add unnecessary complexity for minimal benefit
- **SPA**: Significant overkill for the user base and use case
- **Pure CSS**: Solves the problem with minimal changes
The responsive CSS approach provides **80% of the benefit with 20% of the effort**.
## Testing
### Test Cases
| Device | Screen Size | Expected Behavior |
|--------|-------------|-------------------|
| Mobile (Portrait) | 375px | Vertical nav, full-width inputs, scrollable tables |
| Mobile (Landscape) | 667px | Vertical nav, full-width inputs, scrollable tables |
| Small Tablet | 768px | Horizontal nav (wrapped), proper spacing |
| Large Tablet | 1024px | Horizontal nav, side-by-side editor/preview |
| Desktop | 1440px | Full desktop layout |
### Manual Testing
1. Open on mobile device or use browser dev tools
2. Resize browser window to test different breakpoints
3. Verify all tables have horizontal scroll on mobile
4. Verify navigation is usable on all devices
5. Verify forms are easy to use on mobile
## Browser Compatibility
- ✅ Chrome (all versions)
- ✅ Firefox (all versions)
- ✅ Safari (all versions)
- ✅ Edge (all versions)
- ✅ Mobile browsers (iOS Safari, Chrome for Android)
## Performance Impact
- **Zero**: Pure CSS, no JavaScript overhead
- **No additional requests**: All styles inlined in templates
- **Fast rendering**: Browser-native CSS processing
## Future Considerations
If user base grows or requirements change, consider:
1. **HTMX Enhancement** (1-2 days)
- Add dynamic updates without page reloads
- See: [docs/FRONTEND_OPTIONS.md - Option 3](docs/FRONTEND_OPTIONS.md#option-3-flask--htmx-lightweight-dynamic-ui)
2. **SPA Migration** (1-2 weeks)
- Full React/Vue frontend
- See: [docs/FRONTEND_OPTIONS.md - Option 1](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api)
3. **Mobile App** (2-4 weeks)
- Native mobile experience
- See: [docs/FRONTEND_OPTIONS.md - Option 4](docs/FRONTEND_OPTIONS.md#option-4-mobile-app-native-or-cross-platform)
## Rollback Plan
If issues arise, simply revert the template changes:
```bash
git checkout HEAD -- templates/
```
All changes are isolated to template files, so rollback is trivial.
## Files Changed Summary
```
templates/
├── base.html # Main responsive CSS + bug fix
├── index.html # Table containers
├── history.html # Table containers
├── non_loanable_devices.html # Table containers
├── user_loans.html # Table containers
└── project_management.html # Editor responsiveness
docs/
└── RESPONSIVE_CSS.md # This documentation
```
## Commit Information
```
Commit 1: 927c323a6e7de1f3068d54ffdee22b8a420a1190
Author: ijuanes
Date: June 20, 2026
Message: feat(ui): add responsive CSS for mobile accessibility
Commit 2: 28232db0[...]
Author: ijuanes
Date: June 20, 2026
Message: fix(ui): add missing </style> tag in base.html
```
## Technical Details
### Approach
- **Mobile-first design**: Styles start with mobile and scale up
- **Progressive enhancement**: Works on all devices, enhances for larger screens
- **No JavaScript changes**: Pure CSS solution
- **No backend changes**: Only template modifications
- **Backward compatible**: Existing functionality preserved
### Key Features
#### 1. Responsive Breakpoints
```css
/* Mobile-first base styles */
/* Small devices (landscape phones, 576px and up) */
@media (min-width: 576px) { ... }
/* Medium devices (tablets, 768px and up) */
@media (min-width: 768px) { ... }
/* Large devices (desktops, 992px and up) */
@media (min-width: 992px) { ... }
/* Extra large devices (large desktops, 1200px and up) */
@media (min-width: 1200px) { ... }
```
#### 2. Mobile Navigation
- Navigation links **stack vertically** on mobile
- Full-width buttons for easy tapping
- Horizontal layout on tablet/desktop
#### 3. Responsive Tables
- Tables wrapped in `.table-container` div
- **Horizontal scrolling** on mobile when table is too wide
- Full width on larger screens
#### 4. Form Elements
- Full-width inputs on mobile
- Proper spacing and padding
- Touch-friendly sizes (minimum 48px tap targets)
#### 5. Buttons
- Full-width on mobile
- Inline on larger screens
- Consistent styling
#### 6. Cards
- Added `.tablet-card`, `.user-card`, `.loan-card` classes
- Consistent styling for card-based layouts
- Proper spacing on all devices
#### 7. Project Management Editor
- Stacked layout on mobile (editor above preview)
- Side-by-side on tablet/desktop
- Responsive button controls
### CSS Structure
The responsive CSS is organized in `templates/base.html` with:
1. **Mobile-first base styles** (no media query)
- Container: 100% width, 1rem padding
- Navigation: vertical stack
- Tables: horizontal scroll container
- Forms: full-width inputs
- Buttons: full-width, block display
2. **Breakpoint-specific styles**
- 576px: Container max-width 540px, nav horizontal wrap
- 768px: Container max-width 720px, proper body padding
- 992px: Container max-width 960px, nav no wrap
- 1200px: Container max-width 1140px
3. **Print styles**
- Hide navigation and buttons
- Clean layout for printing
## Design Decisions
### Why This Approach?
1. **5 Internal Users**: No need for complex SPA frameworks
2. **Technical Staff**: Users understand basic UI limitations
3. **CRUD Operations**: Simple forms and lists don't need React/Vue
4. **Minimal Changes**: Pure CSS, no JavaScript modifications
5. **Fast Implementation**: Done in one session
6. **Maintainable**: Simple to understand and modify
### Why Not HTMX or SPA?
While we explored [HTMX](docs/FRONTEND_OPTIONS.md#option-3-htmx) and [SPA options](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api), for 5 internal technical users:
- **HTMX**: Would add unnecessary complexity for minimal benefit
- **SPA**: Significant overkill for the user base and use case
- **Pure CSS**: Solves the problem with minimal changes
The responsive CSS approach provides **80% of the benefit with 20% of the effort**.
## Testing
### Test Cases
| Device | Screen Size | Expected Behavior |
|--------|-------------|-------------------|
| Mobile (Portrait) | 375px | Vertical nav, full-width inputs, scrollable tables |
| Mobile (Landscape) | 667px | Vertical nav, full-width inputs, scrollable tables |
| Small Tablet | 768px | Horizontal nav (wrapped), proper spacing |
| Large Tablet | 1024px | Horizontal nav, side-by-side editor/preview |
| Desktop | 1440px | Full desktop layout |
### Manual Testing
1. Open on mobile device or use browser dev tools
2. Resize browser window to test different breakpoints
3. Verify all tables have horizontal scroll on mobile
4. Verify navigation is usable on all devices
5. Verify forms are easy to use on mobile
## Browser Compatibility
- ✅ Chrome (all versions)
- ✅ Firefox (all versions)
- ✅ Safari (all versions)
- ✅ Edge (all versions)
- ✅ Mobile browsers (iOS Safari, Chrome for Android)
## Performance Impact
- **Zero**: Pure CSS, no JavaScript overhead
- **No additional requests**: All styles inlined in templates
- **Fast rendering**: Browser-native CSS processing
## Future Considerations
If user base grows or requirements change, consider:
1. **HTMX Enhancement** (1-2 days)
- Add dynamic updates without page reloads
- See: [docs/FRONTEND_OPTIONS.md - Option 3](docs/FRONTEND_OPTIONS.md#option-3-flask--htmx-lightweight-dynamic-ui)
2. **SPA Migration** (1-2 weeks)
- Full React/Vue frontend
- See: [docs/FRONTEND_OPTIONS.md - Option 1](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api)
3. **Mobile App** (2-4 weeks)
- Native mobile experience
- See: [docs/FRONTEND_OPTIONS.md - Option 4](docs/FRONTEND_OPTIONS.md#option-4-mobile-app-native-or-cross-platform)
## Rollback Plan
If issues arise, simply revert the template changes:
```bash
git checkout HEAD -- templates/
```
All changes are isolated to template files, so rollback is trivial.
## Files Changed Summary
```
templates/
├── base.html # Main responsive CSS
├── index.html # Table containers
├── history.html # Table containers
├── non_loanable_devices.html # Table containers
├── user_loans.html # Table containers
└── project_management.html # Editor responsiveness
```
## Commit Information
```
Commit: [SHA will be added after commit]
Author: ijuanes
Date: June 20, 2026
Message: feat(ui): add responsive CSS for mobile accessibility
- Add mobile-first responsive CSS to base.html
- Wrap all tables in .table-container for horizontal scrolling
- Add breakpoints for phones, tablets, and desktops
- Improve mobile navigation and form layouts
- Add print styles for clean printing
- No backend or JavaScript changes
```

View file

@ -1,226 +0,0 @@
#!/usr/bin/env python3
"""
Extract strings for translation and create Spanish .po files
"""
import os
import re
from pathlib import Path
# Directories
TEMPLATES_DIR = Path('templates')
TRANSLATIONS_DIR = Path('translations')
# Find all template files
def find_template_files():
template_files = []
for root, dirs, files in os.walk(TEMPLATES_DIR):
for file in files:
if file.endswith('.html'):
template_files.append(Path(root) / file)
return template_files
# Extract strings from templates
def extract_strings_from_file(filepath):
strings = set()
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# Find all _('...') and gettext('...') calls
pattern = r"[\"'](?:_|gettext)\([\"']([^\"']+)[\"']\)"
matches = re.findall(r"\{\{\s*_\('([^']+)'\)\s*\}\}", content)
matches += re.findall(r"\{\{\s*gettext\('([^']+)'\)\s*\}\}", content)
return matches
# Create .pot file
def create_pot_file(strings):
pot_content = """msgid ""
msgstr ""
"Project-Id-Version: Tablet Management System\n"
"POT-Creation-Date: 2026-06-20\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: \n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"""
for string in sorted(strings):
pot_content += f'msgid "{string}"\n'
pot_content += 'msgstr ""\n\n'
return pot_content
# Create Spanish .po file
def create_es_po_file(strings):
# Spanish translations
translations = {
# Navigation
'Tablet Management System': 'Sistema de Gestión de Tablets',
'Home': 'Inicio',
'Add Tablet': 'Añadir Tablet',
'Add User': 'Añadir Usuario',
'Loan Tablet': 'Prestar Tablet',
'History': 'Historial',
'User Loans': 'Préstamos por Usuario',
'Non-Loanable Devices': 'Dispositivos No Prestables',
'Project Management': 'Gestión de Proyectos',
# Language
'Language': 'Idioma',
'English': 'Inglés',
'Español': 'Español',
# Common
'Search': 'Buscar',
'Filter': 'Filtrar',
'Status': 'Estado',
'Actions': 'Acciones',
'Details': 'Detalles',
'View': 'Ver',
'All': 'Todos',
'Active': 'Activo',
'Inactive': 'Inactivo',
'Available': 'Disponible',
'Returned': 'Devuelto',
'Never': 'Nunca',
'N/A': 'N/D',
'Yes': '',
'No': 'No',
'Showing': 'Mostrando',
'to': 'a',
'of': 'de',
'Previous': 'Anterior',
'Next': 'Siguiente',
'Page': 'Página',
# User Loans
'User Loans': 'Préstamos por Usuario',
'Search Users': 'Buscar Usuarios',
'Loan Status': 'Estado de Préstamo',
'All Users': 'Todos los Usuarios',
'With Active Loans': 'Con Préstamos Activos',
'No Active Loans': 'Sin Préstamos Activos',
'Search by name or identification...': 'Buscar por nombre o identificación...',
'users': 'usuarios',
'No users found': 'No se encontraron usuarios',
'No users match your search for': 'Ningún usuario coincide con tu búsqueda de',
'Clear Filters': 'Limpiar Filtros',
# Table Headers
'User': 'Usuario',
'Identification': 'Identificación',
'Active Loans': 'Préstamos Activos',
'Last Loan Date': 'Fecha del Último Préstamo',
'Tablet': 'Tablet',
'Model': 'Modelo',
'Serial Number': 'Número de Serie',
'Notes': 'Notas',
'Borrower': 'Prestatario',
'Loan Date': 'Fecha de Préstamo',
'Return Date': 'Fecha de Devolución',
'Not returned': 'No devuelto',
# Buttons
'Loan': 'Prestar',
'Return': 'Devolver',
'Add': 'Añadir',
'Save': 'Guardar',
'Cancel': 'Cancelar',
'Delete': 'Eliminar',
'Edit': 'Editar',
'Update': 'Actualizar',
# Messages
'Tablet Management System - Internal Tool': 'Sistema de Gestión de Tablets - Herramienta Interna',
'No available tablets': 'No hay tablets disponibles',
'No active loans': 'No hay préstamos activos',
'No loan history for this user': 'Este usuario no tiene historial de préstamos',
# Forms
'Brand': 'Marca',
'Model': 'Modelo',
'Serial Number': 'Número de Serie',
'Name': 'Nombre',
'Email': 'Correo Electrónico',
'Phone': 'Teléfono',
'Identification': 'Identificación',
'Date': 'Fecha',
'Notes': 'Notas',
'Required field': 'Campo obligatorio',
# Status
'Active Loans': 'Préstamos Activos',
'Returned': 'Devueltos',
}
po_content = """msgid ""
msgstr ""
"Project-Id-Version: Tablet Management System\n"
"POT-Creation-Date: 2026-06-20\n"
"PO-Revision-Date: 2026-06-20\n"
"Last-Translator: Auto-generated\n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"""
for string in sorted(strings):
msgid = string
msgstr = translations.get(string, '')
po_content += f'msgid "{msgid}"\n'
po_content += f'msgstr "{msgstr}"\n\n'
return po_content
# Main
def main():
print("Extracting strings for translation...")
# Find all template files
template_files = find_template_files()
print(f"Found {len(template_files)} template files")
# Extract all strings
all_strings = set()
for filepath in template_files:
strings = extract_strings_from_file(filepath)
all_strings.update(strings)
print(f"Found {len(all_strings)} unique strings")
# Create .pot file
pot_content = create_pot_file(all_strings)
pot_path = TRANSLATIONS_DIR / 'messages.pot'
with open(pot_path, 'w', encoding='utf-8') as f:
f.write(pot_content)
print(f"Created {pot_path}")
# Create Spanish .po file
es_po_content = create_es_po_file(all_strings)
es_dir = TRANSLATIONS_DIR / 'es' / 'LC_MESSAGES'
es_dir.mkdir(parents=True, exist_ok=True)
es_po_path = es_dir / 'messages.po'
with open(es_po_path, 'w', encoding='utf-8') as f:
f.write(es_po_content)
print(f"Created {es_po_path}")
# Create English .po file
en_dir = TRANSLATIONS_DIR / 'en' / 'LC_MESSAGES'
en_dir.mkdir(parents=True, exist_ok=True)
en_po_path = en_dir / 'messages.po'
with open(en_po_path, 'w', encoding='utf-8') as f:
f.write(create_pot_file(all_strings))
print(f"Created {en_po_path}")
print("\n✅ Translation files created!")
if __name__ == '__main__':
main()

View file

@ -1 +0,0 @@
vibe --resume fa1bb230

View file

@ -1,47 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Tablet Management System</title>
<style>
body { font-family: Arial, sans-serif; margin: 40px; }
h1 { color: #4CAF50; }
.info { background: #f0f0f0; padding: 20px; border-radius: 5px; }
.command { background: #e0e0e0; padding: 10px; border-radius: 3px; }
</style>
</head>
<body>
<h1>Tablet Management System</h1>
<div class="info">
<h2>Welcome to the Tablet Management System!</h2>
<p>This system is running with a SQLite backend.</p>
<h3>Available Commands:</h3>
<ul>
<li><strong>Test the system:</strong> <code class="command">python3 test_app.py</code></li>
<li><strong>Run interactive mode:</strong> <code class="command">python3 minimal_app.py</code></li>
<li><strong>Check database:</strong> <code class="command">sqlite3 tablets.db</code></li>
</ul>
<h3>Current Status:</h3>
<p>✓ Database: tablets.db</p>
<p>✓ Web server: Running on port 8080</p>
<p>✓ System: Ready for use</p>
</div>
<h3>Quick Test Results:</h3>
<pre id="test-results">Running tests...</pre>
<script>
// Simple test to show the system is working
fetch('/test-db')
.then(response => response.text())
.then(data => {
document.getElementById('test-results').textContent = data;
})
.catch(error => {
document.getElementById('test-results').textContent = 'Database test: Error - ' + error;
});
</script>
</body>
</html>

View file

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

View file

@ -1,413 +0,0 @@
#!/usr/bin/env python3
"""
DEPRECATED - Tablet Lending and Return Management System (CLI)
This CLI version is DEPRECATED. Please use the web interface instead.
The web interface (app.py) provides full functionality including:
- Complete user management (name, email, phone, identification)
- Full tablet management with notes field
- User loans view with search
- Edit functionality for non-loanable devices
- Project management
- Responsive mobile interface
To run the web interface:
python3 app.py
Then open http://localhost:5000 in your browser.
This CLI will be removed in a future version.
"""
import sqlite3
from datetime import datetime
def init_db():
"""Initialize database with required tables"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Create tables
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'
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
identification TEXT UNIQUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
# 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()
def add_tablet(brand, model, serial_number):
"""Add a new tablet to inventory"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', (brand, model, serial_number))
conn.commit()
print(f"✓ Added tablet: {brand} {model} ({serial_number})")
except sqlite3.IntegrityError:
print(f"✗ Error: Serial number {serial_number} already exists")
finally:
conn.close()
def add_user(name, identification):
"""Add a new user"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
try:
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', (name, identification))
conn.commit()
print(f"✓ Added user: {name} ({identification})")
except sqlite3.IntegrityError:
print(f"✗ Error: Identification {identification} already exists")
finally:
conn.close()
def loan_tablet(tablet_id, user_id):
"""Loan a tablet to a user"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Check if tablet is available
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
result = cursor.fetchone()
if result and result[0] == 'available':
# Update tablet status
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
# Create loan record
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
conn.commit()
print(f"✓ Tablet {tablet_id} loaned to user {user_id}")
else:
print(f"✗ Error: Tablet {tablet_id} is not available for loan")
conn.close()
def return_tablet(loan_id):
"""Return a loaned tablet"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Get loan information
cursor.execute("SELECT tablet_id FROM loans WHERE id = ? AND status = 'active'", (loan_id,))
loan = cursor.fetchone()
if loan:
tablet_id = loan[0]
# Update loan status and return date
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
''', (return_date, loan_id))
# Update tablet status
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
conn.commit()
print(f"✓ Tablet returned for loan {loan_id}")
else:
print(f"✗ Error: Loan {loan_id} not found or already returned")
conn.close()
def show_available_tablets():
"""Show available tablets"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute("SELECT id, brand, model, serial_number FROM tablets WHERE status = 'available'")
tablets = cursor.fetchall()
print("\n=== Available Tablets ===")
if tablets:
for tablet in tablets:
print(f"ID: {tablet[0]}, {tablet[1]} {tablet[2]} ({tablet[3]})")
else:
print("No available tablets")
conn.close()
def show_active_loans():
"""Show active loans"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
WHERE l.status = 'active'
''')
loans = cursor.fetchall()
print("\n=== Active Loans ===")
if loans:
for loan in loans:
print(f"Loan ID: {loan[0]}, Tablet: {loan[1]} {loan[2]} ({loan[3]}), Borrower: {loan[4]}, Loan Date: {loan[5]}")
else:
print("No active loans")
conn.close()
def show_loan_history():
"""Show complete loan history"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
l.loan_date, l.return_date, l.status
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
ORDER BY l.loan_date DESC
''')
loans = cursor.fetchall()
print("\n=== Loan History ===")
if loans:
for loan in loans:
return_date = loan[6] or 'Not returned'
print(f"Loan ID: {loan[0]}, Tablet: {loan[1]} {loan[2]} ({loan[3]}), Borrower: {loan[4]}")
print(f" Loan Date: {loan[5]}, Return Date: {return_date}, Status: {loan[7]}")
else:
print("No 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"""
print("=" * 70)
print("DEPRECATION WARNING: This CLI is deprecated!")
print("Please use the web interface instead: python3 app.py")
print("=" * 70)
print()
init_db()
print("=== Tablet Lending and Return Management System (DEPRECATED) ===")
print("Using SQLite database: tablets.db")
print("NOTE: This CLI has limited functionality. Use web interface for full features.")
while True:
print("\nMenu:")
print("1. Add Tablet")
print("2. Add User")
print("3. Loan Tablet")
print("4. Return Tablet")
print("5. Show Available Tablets")
print("6. Show Active Loans")
print("7. Show Loan History")
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-11): ")
if choice == '1':
print("\n=== Add Tablet ===")
brand = input("Brand: ")
model = input("Model: ")
serial_number = input("Serial Number: ")
add_tablet(brand, model, serial_number)
elif choice == '2':
print("\n=== Add User ===")
name = input("Name: ")
identification = input("Identification: ")
add_user(name, identification)
elif choice == '3':
print("\n=== Loan Tablet ===")
show_available_tablets()
show_users()
tablet_id = input("Enter Tablet ID to loan: ")
user_id = input("Enter User ID: ")
try:
loan_tablet(int(tablet_id), int(user_id))
except ValueError:
print("✗ Error: Invalid ID format")
elif choice == '4':
print("\n=== Return Tablet ===")
show_active_loans()
loan_id = input("Enter Loan ID to return: ")
try:
return_tablet(int(loan_id))
except ValueError:
print("✗ Error: Invalid Loan ID format")
elif choice == '5':
show_available_tablets()
elif choice == '6':
show_active_loans()
elif choice == '7':
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
else:
print("✗ Invalid choice. Please try again.")
def show_users():
"""Show available users"""
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute("SELECT id, name, identification FROM users")
users = cursor.fetchall()
print("\n=== Available Users ===")
if users:
for user in users:
print(f"ID: {user[0]}, {user[1]} ({user[2]})")
else:
print("No users available")
conn.close()
if __name__ == '__main__':
main()

View file

@ -1,37 +0,0 @@
[project]
name = "gestiontablets"
version = "0.1.0"
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"

View file

@ -1,20 +0,0 @@
# Core dependencies
Flask==2.3.3
Flask-Babel==2.0.0
python-dotenv==1.0.0
# Database
# sqlite3 is built-in with Python
# For CSV processing
# No additional dependencies needed (csv is built-in)
# For development/testing
pytest==7.4.0
pytest-cov==4.1.0
# For running the application in production
waitress==2.1.2 # Production WSGI server
# For production deployment (optional)
# gunicorn==21.2.0 # Alternative WSGI server

View file

@ -1,703 +0,0 @@
#!/usr/bin/env python3
"""
CSV Import Script for Student Data
This script imports student data from a CSV file (ISO-8859-1 encoded) into the SQLite database.
It handles:
- Encoding conversion from ISO-8859-1 to UTF-8
- Splitting "Apellidos, Nombre" into separate fields
- Date format conversion from DD/MM/YYYY to YYYY-MM-DD
- Discarding the "Estudio" field as per requirements
- Validation and error handling
Usage:
python import_students.py <csv_file> [--database <db_file>] [--dry-run]
Examples:
python import_students.py Datos_programa.csv
python import_students.py Datos_programa.csv --database tablets.db
python import_students.py Datos_programa.csv --dry-run
"""
import argparse
import csv
import os
import re
import sqlite3
import sys
from datetime import datetime
from typing import Dict, List, Optional, Tuple
# Default database file
DEFAULT_DATABASE = 'tablets.db'
# CSV field mapping
CSV_FIELDS = [
'order_number',
'full_name',
'birth_date',
'cial_code',
'nif_nie_passport',
'registration_number',
'file_number',
'gender',
'study_group',
'study' # This will be discarded
]
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description='Import student data from CSV file to SQLite database'
)
parser.add_argument(
'csv_file',
help='Path to the CSV file to import (ISO-8859-1 encoded)'
)
parser.add_argument(
'--database', '-d',
default=DEFAULT_DATABASE,
help=f'Path to SQLite database file (default: {DEFAULT_DATABASE})'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Validate and preview data without importing'
)
parser.add_argument(
'--delimiter',
default=',',
help='CSV delimiter (default: comma)'
)
parser.add_argument(
'--quotechar',
default='"',
help='CSV quote character (default: double quote)'
)
parser.add_argument(
'--encoding',
default='iso-8859-1',
help='CSV file encoding (default: iso-8859-1)'
)
parser.add_argument(
'--skip-errors',
action='store_true',
help='Skip rows with errors instead of stopping'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show detailed processing information'
)
return parser.parse_args()
def read_csv_file(filepath: str, encoding: str, delimiter: str, quotechar: str) -> List[Dict[str, str]]:
"""Read CSV file and return list of dictionaries."""
"""
Read a CSV file with specified encoding and return its contents as a list of dictionaries.
Args:
filepath: Path to the CSV file
encoding: File encoding (e.g., 'iso-8859-1')
delimiter: CSV field delimiter
quotechar: CSV quote character
Returns:
List of dictionaries where keys are header names and values are field values
"""
if not os.path.exists(filepath):
raise FileNotFoundError(f"CSV file not found: {filepath}")
if not os.path.isfile(filepath):
raise ValueError(f"Path is not a file: {filepath}")
rows = []
with open(filepath, 'r', encoding=encoding, newline='') as csvfile:
reader = csv.DictReader(csvfile, delimiter=delimiter, quotechar=quotechar)
# Convert header names to lowercase and strip whitespace
if reader.fieldnames:
reader.fieldnames = [name.strip().lower() for name in reader.fieldnames]
for row in reader:
rows.append(row)
return rows
def split_full_name(full_name: str) -> Tuple[str, str]:
"""
Split a full name in format 'APELLIDOS, NOMBRE' into last_name and first_name.
Args:
full_name: Full name string in format 'Last Name, First Name'
Returns:
Tuple of (last_name, first_name)
"""
if not full_name or full_name.strip() == '':
return '', ''
# Remove surrounding quotes if present
full_name = full_name.strip().strip('"').strip("'")
# Split on comma followed by optional whitespace
parts = re.split(r',\s*', full_name, maxsplit=1)
if len(parts) == 2:
last_name = parts[0].strip()
first_name = parts[1].strip()
else:
# If no comma, assume it's just a name
last_name = ''
first_name = full_name.strip()
return last_name, first_name
def convert_date(date_str: str) -> Optional[str]:
"""
Convert date from DD/MM/YYYY to YYYY-MM-DD format.
Args:
date_str: Date string in DD/MM/YYYY format
Returns:
Date string in YYYY-MM-DD format, or None if invalid
"""
if not date_str or date_str.strip() == '':
return None
date_str = date_str.strip()
try:
# Parse DD/MM/YYYY
day, month, year = re.split(r'[/-]', date_str)
day = int(day)
month = int(month)
year = int(year)
# Validate date
datetime(year, month, day)
# Format as YYYY-MM-DD
return f"{year:04d}-{month:02d}-{day:02d}"
except (ValueError, IndexError):
return None
def validate_gender(gender: str) -> Optional[str]:
"""
Validate and normalize gender field.
Args:
gender: Gender string (M, F, etc.)
Returns:
Normalized gender ('M', 'F', or 'O') or None if invalid
"""
if not gender or gender.strip() == '':
return None
gender = gender.strip().upper()
if gender in ['M', 'H', 'MALE', 'HOMBRE']:
return 'M'
elif gender in ['F', 'MUJER', 'FEMALE']:
return 'F'
elif gender in ['O', 'OTHER', 'OTRO']:
return 'O'
else:
return None
def process_row(row: Dict[str, str], verbose: bool = False) -> Optional[Dict[str, Optional[str]]]:
"""
Process a single CSV row and convert it to database format.
Args:
row: Dictionary with CSV field names as keys
verbose: Whether to print processing details
Returns:
Dictionary with processed data ready for database insertion, or None if invalid
"""
# Normalize the row keys to lowercase and remove special characters
normalized_row = {}
for key, value in row.items():
# Remove special characters and normalize
normalized_key = key.lower().replace('º', '').replace('ó', 'o').replace('á', 'a').replace('í', 'i').replace('ú', 'u').replace('ñ', 'n').replace('.', '').replace(',', '').strip()
normalized_row[normalized_key] = value
# Map CSV fields to our expected field names
mapped_row = {}
field_mapping = {
'n or': 'order_number',
'apellidos nombre': 'full_name',
'fecha nac': 'birth_date',
'cial': 'cial_code',
'nif/nie/pas': 'nif_nie_passport',
'registro': 'registration_number',
'expediente': 'file_number',
'sexo': 'gender',
'grupo': 'study_group',
'estudio': 'study'
}
for csv_key, our_key in field_mapping.items():
if csv_key in normalized_row:
mapped_row[our_key] = normalized_row[csv_key]
else:
mapped_row[our_key] = ''
# Extract and process fields
try:
# Order number
order_number = mapped_row.get('order_number', '')
if order_number.strip() == '':
order_number = None
else:
try:
order_number = int(order_number.strip())
except ValueError:
order_number = None
# Full name and split into last_name, first_name
full_name = mapped_row.get('full_name', '')
last_name, first_name = split_full_name(full_name)
# Birth date conversion
birth_date_str = mapped_row.get('birth_date', '')
birth_date = convert_date(birth_date_str)
# CIAL code
cial_code = mapped_row.get('cial_code', '').strip()
if cial_code == '':
cial_code = None
# NIF/NIE/Passport
nif_nie_passport = mapped_row.get('nif_nie_passport', '').strip()
if nif_nie_passport == '':
nif_nie_passport = None
# Registration number
registration_number = mapped_row.get('registration_number', '')
if registration_number.strip() == '':
registration_number = None
else:
try:
registration_number = int(registration_number.strip())
except ValueError:
registration_number = None
# File number
file_number = mapped_row.get('file_number', '').strip()
if file_number == '':
file_number = None
# Gender
gender = mapped_row.get('gender', '')
gender = validate_gender(gender)
# Study group
study_group = mapped_row.get('study_group', '').strip()
if study_group == '':
study_group = None
# Create result dictionary
result = {
'order_number': order_number,
'cial_code': cial_code,
'nif_nie_passport': nif_nie_passport,
'registration_number': registration_number,
'file_number': file_number,
'first_name': first_name if first_name else None,
'last_name': last_name if last_name else None,
'full_name': full_name.strip() if full_name.strip() else None,
'birth_date': birth_date,
'gender': gender,
'study_group': study_group,
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
if verbose:
print(f" Processed: {result}")
return result
except Exception as e:
print(f" Error processing row: {e}")
return None
def validate_row(data: Dict[str, Optional[str]]) -> Tuple[bool, List[str]]:
"""
Validate processed row data.
Args:
data: Processed row data
Returns:
Tuple of (is_valid, list_of_errors)
"""
errors = []
# Check required fields
if not data.get('cial_code'):
errors.append("CIAL code is required")
if not data.get('first_name'):
errors.append("First name is required")
if not data.get('last_name'):
errors.append("Last name is required")
# Check date format
birth_date = data.get('birth_date')
if birth_date:
try:
datetime.strptime(birth_date, '%Y-%m-%d')
except ValueError:
errors.append(f"Invalid birth date format: {birth_date}")
# Check gender
gender = data.get('gender')
if gender and gender not in ['M', 'F', 'O']:
errors.append(f"Invalid gender: {gender}")
return len(errors) == 0, errors
def create_students_table(conn: sqlite3.Connection) -> bool:
"""
Create the students table if it doesn't exist.
Args:
conn: SQLite database connection
Returns:
True if table was created or already exists, False on error
"""
try:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number INTEGER,
cial_code TEXT UNIQUE NOT NULL,
nif_nie_passport TEXT UNIQUE,
registration_number INTEGER,
file_number TEXT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
full_name TEXT NOT NULL,
birth_date TEXT,
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
study_group TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
)
''')
# Create indexes
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group)
''')
conn.commit()
return True
except sqlite3.Error as e:
print(f"Error creating students table: {e}")
conn.rollback()
return False
def insert_student(conn: sqlite3.Connection, data: Dict[str, Optional[str]], verbose: bool = False) -> Tuple[bool, Optional[int]]:
"""
Insert a student record into the database.
Args:
conn: SQLite database connection
data: Student data dictionary
verbose: Whether to print details
Returns:
Tuple of (success, student_id or None)
"""
try:
cursor = conn.cursor()
# Prepare SQL and parameters
sql = '''
INSERT INTO students (
order_number, cial_code, nif_nie_passport, registration_number,
file_number, first_name, last_name, full_name, birth_date,
gender, study_group, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
'''
params = (
data['order_number'],
data['cial_code'],
data['nif_nie_passport'],
data['registration_number'],
data['file_number'],
data['first_name'],
data['last_name'],
data['full_name'],
data['birth_date'],
data['gender'],
data['study_group'],
data['created_at'],
data['updated_at']
)
cursor.execute(sql, params)
student_id = cursor.lastrowid
conn.commit()
if verbose:
print(f" Inserted student ID: {student_id}")
return True, student_id
except sqlite3.IntegrityError as e:
if verbose:
print(f" Duplicate entry (skipped): {e}")
conn.rollback()
return False, None
except sqlite3.Error as e:
print(f" Database error: {e}")
conn.rollback()
return False, None
def check_duplicate(conn: sqlite3.Connection, cial_code: str, nif_nie_passport: Optional[str]) -> bool:
"""
Check if a student with the same CIAL code or NIF/NIE already exists.
Args:
conn: SQLite database connection
cial_code: CIAL code to check
nif_nie_passport: NIF/NIE/Passport to check
Returns:
True if duplicate exists, False otherwise
"""
try:
cursor = conn.cursor()
# Check by CIAL code
cursor.execute("SELECT id FROM students WHERE cial_code = ?", (cial_code,))
if cursor.fetchone():
return True
# Check by NIF/NIE if provided
if nif_nie_passport and nif_nie_passport.strip():
cursor.execute("SELECT id FROM students WHERE nif_nie_passport = ?", (nif_nie_passport,))
if cursor.fetchone():
return True
return False
except sqlite3.Error as e:
print(f"Error checking for duplicates: {e}")
return False
def import_csv(csv_file: str, database: str, dry_run: bool = False,
skip_errors: bool = False, verbose: bool = False) -> Dict[str, int]:
"""
Main import function.
Args:
csv_file: Path to CSV file
database: Path to SQLite database
dry_run: If True, validate without importing
skip_errors: If True, skip invalid rows instead of stopping
verbose: If True, print detailed information
Returns:
Dictionary with import statistics
"""
stats = {
'total_rows': 0,
'processed': 0,
'valid': 0,
'invalid': 0,
'imported': 0,
'duplicates': 0,
'errors': 0
}
# Read CSV file
try:
if verbose:
print(f"Reading CSV file: {csv_file}")
rows = read_csv_file(csv_file, encoding='iso-8859-1', delimiter=',', quotechar='"')
stats['total_rows'] = len(rows)
if verbose:
print(f"Found {len(rows)} rows in CSV file")
except Exception as e:
print(f"Error reading CSV file: {e}")
stats['errors'] += 1
return stats
# Connect to database
try:
conn = sqlite3.connect(database)
conn.execute("PRAGMA foreign_keys = ON")
if not dry_run:
# Create students table if it doesn't exist
if not create_students_table(conn):
print("Failed to create students table")
stats['errors'] += 1
return stats
except sqlite3.Error as e:
print(f"Error connecting to database: {e}")
stats['errors'] += 1
return stats
# Process each row
for i, row in enumerate(rows, 1):
if verbose:
print(f"\nProcessing row {i}/{len(rows)}:")
print(f" Raw: {row}")
# Process row
processed = process_row(row, verbose)
if processed is None:
stats['errors'] += 1
if not skip_errors:
print(f"Error processing row {i}, stopping.")
break
else:
if verbose:
print(f" Skipping row {i} due to processing error")
continue
stats['processed'] += 1
# Validate row
is_valid, errors = validate_row(processed)
if not is_valid:
stats['invalid'] += 1
if verbose:
print(f" Invalid row {i}: {errors}")
if not skip_errors:
print(f"Error validating row {i}, stopping.")
break
else:
continue
stats['valid'] += 1
if dry_run:
# Just print what would be imported
if verbose:
print(f" [DRY RUN] Would import: {processed['full_name']} ({processed['cial_code']})")
else:
# Check for duplicates
if check_duplicate(conn, processed['cial_code'], processed.get('nif_nie_passport')):
stats['duplicates'] += 1
if verbose:
print(f" Duplicate found for CIAL: {processed['cial_code']}, skipping")
continue
# Insert into database
success, student_id = insert_student(conn, processed, verbose)
if success:
stats['imported'] += 1
else:
stats['duplicates'] += 1
# Close database connection
if not dry_run:
conn.close()
return stats
def print_statistics(stats: Dict[str, int], dry_run: bool = False):
"""Print import statistics."""
print("\n" + "=" * 50)
print("IMPORT STATISTICS")
print("=" * 50)
print(f"Total rows in CSV: {stats['total_rows']}")
print(f"Rows processed: {stats['processed']}")
print(f"Valid rows: {stats['valid']}")
print(f"Invalid rows: {stats['invalid']}")
if not dry_run:
print(f"Rows imported: {stats['imported']}")
print(f"Duplicates skipped: {stats['duplicates']}")
print(f"Errors: {stats['errors']}")
if dry_run:
print("\n[DRY RUN] No data was imported to the database.")
print("=" * 50)
def main():
"""Main entry point."""
args = parse_arguments()
print(f"Importing student data from: {args.csv_file}")
print(f"Database: {args.database}")
print(f"Dry run: {args.dry_run}")
print(f"Skip errors: {args.skip_errors}")
print()
# Perform import
stats = import_csv(
csv_file=args.csv_file,
database=args.database,
dry_run=args.dry_run,
skip_errors=args.skip_errors,
verbose=args.verbose
)
# Print statistics
print_statistics(stats, args.dry_run)
# Exit with appropriate code
if stats['errors'] > 0:
sys.exit(1)
elif stats['invalid'] > 0 and not args.skip_errors:
sys.exit(1)
else:
sys.exit(0)
if __name__ == '__main__':
main()

View file

@ -1,445 +0,0 @@
#!/usr/bin/env python3
"""
Database Migration Script
This script migrates the existing SQLite database to the new schema that includes
the students table and relationships.
Usage:
python migrate_database.py [--database <db_file>] [--dry-run]
Examples:
python migrate_database.py
python migrate_database.py --database tablets.db
python migrate_database.py --dry-run
"""
import argparse
import os
import sqlite3
import sys
from datetime import datetime
DEFAULT_DATABASE = 'tablets.db'
def parse_arguments():
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description='Migrate database to new schema with students table'
)
parser.add_argument(
'--database', '-d',
default=DEFAULT_DATABASE,
help=f'Path to SQLite database file (default: {DEFAULT_DATABASE})'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Show migration steps without executing'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Show detailed migration information'
)
return parser.parse_args()
def backup_database(database_path: str) -> str:
"""
Create a backup of the database before migration.
Args:
database_path: Path to the database file
Returns:
Path to the backup file
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = f"{database_path}.backup_{timestamp}"
if os.path.exists(database_path):
import shutil
shutil.copy2(database_path, backup_path)
print(f"Created backup: {backup_path}")
return backup_path
def check_table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
"""Check if a table exists in the database."""
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
(table_name,)
)
return cursor.fetchone() is not None
def check_column_exists(conn: sqlite3.Connection, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table."""
cursor = conn.cursor()
cursor.execute(f"PRAGMA table_info({table_name})")
columns = [col[1] for col in cursor.fetchall()]
return column_name in columns
def create_students_table(conn: sqlite3.Connection) -> bool:
"""Create the students table."""
try:
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS students (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_number INTEGER,
cial_code TEXT UNIQUE NOT NULL,
nif_nie_passport TEXT UNIQUE,
registration_number INTEGER,
file_number TEXT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
full_name TEXT NOT NULL,
birth_date TEXT,
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
study_group TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
)
''')
# Create indexes
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group)
''')
conn.commit()
print("✓ Created students table")
return True
except sqlite3.Error as e:
print(f"✗ Error creating students table: {e}")
conn.rollback()
return False
def add_student_relationship_to_tablets(conn: sqlite3.Connection) -> bool:
"""Add assigned_to_student column to tablets table."""
try:
cursor = conn.cursor()
if not check_column_exists(conn, 'tablets', 'assigned_to_student'):
cursor.execute('''
ALTER TABLE tablets ADD COLUMN assigned_to_student INTEGER
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_tablets_student ON tablets(assigned_to_student)
''')
conn.commit()
print("✓ Added assigned_to_student column to tablets table")
else:
print("✓ assigned_to_student column already exists in tablets table")
return True
except sqlite3.Error as e:
print(f"✗ Error adding assigned_to_student to tablets: {e}")
conn.rollback()
return False
def add_student_relationship_to_non_loanable_devices(conn: sqlite3.Connection) -> bool:
"""Add assigned_to_student column to non_loanable_devices table."""
try:
cursor = conn.cursor()
if not check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
cursor.execute('''
ALTER TABLE non_loanable_devices ADD COLUMN assigned_to_student INTEGER
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_non_loanable_student ON non_loanable_devices(assigned_to_student)
''')
conn.commit()
print("✓ Added assigned_to_student column to non_loanable_devices table")
else:
print("✓ assigned_to_student column already exists in non_loanable_devices table")
return True
except sqlite3.Error as e:
print(f"✗ Error adding assigned_to_student to non_loanable_devices: {e}")
conn.rollback()
return False
def add_student_relationship_to_loans(conn: sqlite3.Connection) -> bool:
"""Add student_id column to loans table."""
try:
cursor = conn.cursor()
if not check_column_exists(conn, 'loans', 'student_id'):
cursor.execute('''
ALTER TABLE loans ADD COLUMN student_id INTEGER
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_loans_student ON loans(student_id)
''')
conn.commit()
print("✓ Added student_id column to loans table")
else:
print("✓ student_id column already exists in loans table")
return True
except sqlite3.Error as e:
print(f"✗ Error adding student_id to loans: {e}")
conn.rollback()
return False
def add_foreign_keys(conn: sqlite3.Connection) -> bool:
"""Add foreign key constraints to tables."""
try:
cursor = conn.cursor()
# For tablets table
if check_column_exists(conn, 'tablets', 'assigned_to_student'):
# SQLite doesn't support adding foreign keys with ALTER TABLE easily
# We'll rely on application-level enforcement
print("✓ Foreign key for tablets.assigned_to_student will be enforced at application level")
# For non_loanable_devices table
if check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
print("✓ Foreign key for non_loanable_devices.assigned_to_student will be enforced at application level")
# For loans table
if check_column_exists(conn, 'loans', 'student_id'):
print("✓ Foreign key for loans.student_id will be enforced at application level")
conn.commit()
return True
except sqlite3.Error as e:
print(f"✗ Error adding foreign keys: {e}")
conn.rollback()
return False
def add_timestamp_columns(conn: sqlite3.Connection) -> bool:
"""Add created_at and updated_at columns to existing tables if they don't exist."""
try:
cursor = conn.cursor()
tables_to_update = ['tablets', 'users', 'loans', 'non_loanable_devices']
for table in tables_to_update:
if not check_column_exists(conn, table, 'created_at'):
cursor.execute(f'''
ALTER TABLE {table} ADD COLUMN created_at TEXT
''')
# Update existing rows
cursor.execute(f'''
UPDATE {table} SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL
''')
print(f"✓ Added created_at column to {table} table")
if not check_column_exists(conn, table, 'updated_at'):
cursor.execute(f'''
ALTER TABLE {table} ADD COLUMN updated_at TEXT
''')
# Update existing rows
cursor.execute(f'''
UPDATE {table} SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL
''')
print(f"✓ Added updated_at column to {table} table")
conn.commit()
return True
except sqlite3.Error as e:
print(f"✗ Error adding timestamp columns: {e}")
conn.rollback()
return False
def migrate_database(database_path: str, dry_run: bool = False, verbose: bool = False) -> bool:
"""
Perform the database migration.
Args:
database_path: Path to the database file
dry_run: If True, show steps without executing
verbose: If True, show detailed information
Returns:
True if migration succeeded, False otherwise
"""
if dry_run:
print("[DRY RUN] Migration steps:")
else:
print("Starting database migration...")
# Create backup
backup_path = backup_database(database_path)
try:
conn = sqlite3.connect(database_path)
conn.execute("PRAGMA foreign_keys = ON")
if dry_run:
print(" 1. Would create students table")
print(" 2. Would add assigned_to_student to tablets table")
print(" 3. Would add assigned_to_student to non_loanable_devices table")
print(" 4. Would add student_id to loans table")
print(" 5. Would add timestamp columns to existing tables")
print(" 6. Would create indexes")
else:
print("\nRunning migration steps:")
# Step 1: Create students table
if not create_students_table(conn):
return False
# Step 2: Add student relationship to tablets
if not add_student_relationship_to_tablets(conn):
return False
# Step 3: Add student relationship to non_loanable_devices
if not add_student_relationship_to_non_loanable_devices(conn):
return False
# Step 4: Add student relationship to loans
if not add_student_relationship_to_loans(conn):
return False
# Step 5: Add timestamp columns
if not add_timestamp_columns(conn):
return False
# Step 6: Add foreign keys (enforced at application level)
if not add_foreign_keys(conn):
return False
conn.close()
if dry_run:
print("\n[DRY RUN] No changes were made to the database.")
else:
print("\n✓ Database migration completed successfully!")
return True
except Exception as e:
print(f"\n✗ Migration failed: {e}")
if not dry_run and 'conn' in locals():
conn.rollback()
conn.close()
return False
def verify_migration(database_path: str) -> bool:
"""Verify that the migration was successful."""
try:
conn = sqlite3.connect(database_path)
cursor = conn.cursor()
print("\nVerifying migration:")
# Check students table exists
if check_table_exists(conn, 'students'):
print("✓ students table exists")
cursor.execute("SELECT COUNT(*) FROM students")
count = cursor.fetchone()[0]
print(f" - Contains {count} students")
else:
print("✗ students table missing")
return False
# Check columns in tablets
if check_column_exists(conn, 'tablets', 'assigned_to_student'):
print("✓ tablets.assigned_to_student column exists")
else:
print("✗ tablets.assigned_to_student column missing")
return False
# Check columns in non_loanable_devices
if check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
print("✓ non_loanable_devices.assigned_to_student column exists")
else:
print("✗ non_loanable_devices.assigned_to_student column missing")
return False
# Check columns in loans
if check_column_exists(conn, 'loans', 'student_id'):
print("✓ loans.student_id column exists")
else:
print("✗ loans.student_id column missing")
return False
# Check indexes
cursor.execute("SELECT name FROM sqlite_master WHERE type='index'")
indexes = [row[0] for row in cursor.fetchall()]
required_indexes = [
'idx_students_cial', 'idx_students_nif', 'idx_students_name',
'idx_students_birth_date', 'idx_students_gender', 'idx_students_group',
'idx_tablets_student', 'idx_non_loanable_student', 'idx_loans_student'
]
for idx in required_indexes:
if idx in indexes:
print(f"✓ Index {idx} exists")
else:
print(f"✗ Index {idx} missing")
return False
conn.close()
print("\n✓ All migration verifications passed!")
return True
except Exception as e:
print(f"✗ Verification failed: {e}")
return False
def main():
"""Main entry point."""
args = parse_arguments()
print(f"Database: {args.database}")
print(f"Dry run: {args.dry_run}")
print()
# Perform migration
success = migrate_database(
database_path=args.database,
dry_run=args.dry_run,
verbose=args.verbose
)
if success and not args.dry_run:
# Verify migration
verify_migration(args.database)
# Exit with appropriate code
sys.exit(0 if success else 1)
if __name__ == '__main__':
main()

View file

@ -1,60 +0,0 @@
#!/usr/bin/env python3
"""
Setup script for Tablet Management System
"""
import subprocess
import sys
import os
def install_dependencies():
"""Install required dependencies"""
try:
# Try to install Flask using pip
subprocess.check_call([sys.executable, '-m', 'ensurepip', '--upgrade'])
subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'Flask==2.3.3'])
print("✓ Dependencies installed successfully")
return True
except subprocess.CalledProcessError as e:
print(f"✗ Failed to install dependencies: {e}")
return False
except Exception as e:
print(f"✗ Error during installation: {e}")
return False
def check_dependencies():
"""Check if required dependencies are available"""
try:
import flask
import sqlite3
print("✓ All dependencies are available")
return True
except ImportError as e:
print(f"✗ Missing dependency: {e}")
return False
def main():
print("Setting up Tablet Management System...")
# Check if dependencies are already available
if not check_dependencies():
print("Installing required dependencies...")
if not install_dependencies():
print("\nError: Could not install dependencies.")
print("Please install Flask manually:")
print(" pip install Flask==2.3.3")
print("or")
print(" python3 -m pip install Flask==2.3.3")
return False
print("\nSetup complete!")
print("\nTo run the application:")
print(" python3 app.py")
print("\nThe application will be available at: http://localhost:5000")
return True
if __name__ == '__main__':
success = main()
sys.exit(0 if success else 1)

164
setup.sh
View file

@ -1,164 +0,0 @@
#!/bin/bash
# setup.sh - Script de configuración automática para GestionTablets
# Este script instala las dependencias necesarias y configura el entorno
set -e # Salir en caso de error
# Colores para salida
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Función para imprimir mensajes
print_status() {
echo -e "${BLUE}[INFO]${NC} $1"
}
print_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
print_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
print_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Verificar que estamos en el directorio correcto
if [ ! -f "app.py" ]; then
print_error "No se encuentra app.py. Ejecuta este script desde el directorio raíz de GestionTablets."
exit 1
fi
# 1. Verificar Python 3.13+
print_status "Verificando versión de Python..."
PYTHON_VERSION=$(python3 --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1-2)
if [ "$PYTHON_VERSION" != "3.13" ]; then
print_error "Se requiere Python 3.13. Encontrado: $PYTHON_VERSION"
print_status "Instala Python 3.13 con: sudo apt install python3.13"
exit 1
fi
print_success "Python 3.13+ detectado"
# 2. Instalar uv si no está presente
print_status "Verificando uv..."
if ! command -v uv &> /dev/null; then
print_status "Instalando uv (Astral)..."
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
print_error "No se pudo instalar uv. Verifica tu conexión a internet y que curl está instalado."
exit 1
fi
# Añadir uv al PATH para esta sesión
export PATH="$HOME/.local/bin:$PATH"
print_success "uv instalado correctamente"
else
print_success "uv ya está instalado"
fi
# Verificar versión de uv
UV_VERSION=$(uv --version 2>&1)
print_status "Versión de uv: $UV_VERSION"
# 3. Crear entorno virtual
print_status "Creando entorno virtual..."
if [ -d ".venv" ]; then
print_warning "El entorno virtual .venv ya existe. Usando el existente."
else
if ! uv venv .venv; then
print_error "No se pudo crear el entorno virtual"
exit 1
fi
print_success "Entorno virtual creado"
fi
# Activar el entorno virtual
print_status "Activando entorno virtual..."
source .venv/bin/activate
# 4. Instalar dependencias
print_status "Instalando dependencias desde requirements.txt..."
if [ -f "requirements.txt" ]; then
if ! uv pip install -r requirements.txt; then
print_error "No se pudieron instalar las dependencias"
exit 1
fi
print_success "Dependencias instaladas"
else
print_error "No se encuentra requirements.txt"
exit 1
fi
# 5. Inicializar base de datos
print_status "Inicializando base de datos..."
if [ ! -f "tablets.db" ]; then
if python3 -c "from app import init_db; init_db(); print('Base de datos inicializada')" 2>&1; then
print_success "Base de datos inicializada"
else
print_warning "No se pudo inicializar la base de datos automáticamente"
print_status "Puedes inicializarla manualmente con: python3 -c \"from app import init_db; init_db()\""
fi
else
print_warning "tablets.db ya existe. No se ha sobrescrito."
fi
# 6. Configurar variables de entorno
print_status "Configurando variables de entorno..."
if [ ! -f ".env" ]; then
# Generar clave secreta
SECRET_KEY=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || echo "cambiar_esta_clave_manualmente")
cat > .env << EOF
# Configuración de Flask
FLASK_APP=app.py
FLASK_ENV=development
# Clave secreta (¡CÁMBIALA EN PRODUCCIÓN!)
SECRET_KEY=$SECRET_KEY
# Base de datos
DATABASE=tablets.db
EOF
print_success "Archivo .env creado con configuración básica"
print_warning "⚠️ REVISA el archivo .env y cambia la SECRET_KEY antes de usar en producción"
else
print_warning ".env ya existe. No se ha sobrescrito."
fi
# 7. Crear .env.example si no existe
if [ ! -f ".env.example" ]; then
cp .env .env.example 2>/dev/null || true
print_status "Creado .env.example"
fi
# 8. Verificar instalación
print_status "Verificando instalación..."
print_status "Comprobando importación de Flask..."
python3 -c "import flask; print(f'Flask versión: {flask.__version__}')" 2>&1
print_status "Comprobando importación de la aplicación..."
python3 -c "import app; print('Aplicación importada correctamente')" 2>&1
# 9. Instrucciones finales
echo ""
print_success "=========================================="
print_success "¡Configuración completada con éxito!"
print_success "=========================================="
echo ""
print_status "Para ejecutar la aplicación en desarrollo:"
echo " 1. Activa el entorno virtual: source .venv/bin/activate"
echo " 2. Ejecuta la aplicación: python3 app.py"
echo ""
print_status "La aplicación estará disponible en: http://localhost:5000"
echo ""
print_status "Para producción, usa Waitress:"
echo " waitress-serve --port=8080 app:app"
echo ""
print_warning "⚠️ NO OLVIDES:"
print_warning " - Revisar y cambiar SECRET_KEY en .env"
print_warning " - Configurar FLASK_ENV=production para producción"
print_warning " - Hacer backup de tablets.db regularmente"
echo ""

View file

@ -1,571 +0,0 @@
#!/usr/bin/env python3
"""
Simple Tablet Lending and Return Management System
Using only built-in Python modules (no Flask required)
"""
import sqlite3
import os
from datetime import datetime
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import parse_qs, urlparse
import json
# Database setup
DATABASE = 'tablets.db'
def get_db():
"""Get database connection"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialize database with required tables"""
with get_db() as conn:
cursor = conn.cursor()
# Create tablets table
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
)
''')
# Create users table
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE
)
''')
# Create loans table
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
# 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):
def _set_headers(self, content_type="text/html"):
self.send_response(200)
self.send_header('Content-type', content_type)
self.end_headers()
def _send_html(self, content):
self._set_headers()
self.wfile.write(content.encode('utf-8'))
def _send_json(self, data):
self._set_headers(content_type="application/json")
self.wfile.write(json.dumps(data).encode('utf-8'))
def _parse_form_data(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
return parse_qs(post_data.decode('utf-8'))
def do_GET(self):
parsed_path = urlparse(self.path)
if parsed_path.path == '/':
self._handle_index()
elif parsed_path.path == '/add_tablet':
self._handle_add_tablet_form()
elif parsed_path.path == '/add_user':
self._handle_add_user_form()
elif parsed_path.path == '/loan_tablet':
self._handle_loan_tablet_form()
elif parsed_path.path == '/history':
self._handle_history()
elif parsed_path.path.startswith('/return_tablet/'):
self._handle_return_tablet(parsed_path.path)
else:
self._send_html("<h1>404 Not Found</h1>")
def do_POST(self):
parsed_path = urlparse(self.path)
if parsed_path.path == '/add_tablet':
self._handle_add_tablet()
elif parsed_path.path == '/add_user':
self._handle_add_user()
elif parsed_path.path == '/loan_tablet':
self._handle_loan_tablet()
else:
self._send_html("<h1>404 Not Found</h1>")
def _handle_index(self):
with get_db() as conn:
cursor = conn.cursor()
# Get available tablets
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
available_tablets = cursor.fetchall()
# Get active loans
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
WHERE l.status = 'active'
''')
active_loans = cursor.fetchall()
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>Tablet Management System</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
h1 {{ color: #333; }}
.nav {{ margin-bottom: 20px; }}
.nav a {{ margin-right: 15px; text-decoration: none; color: #4CAF50; }}
table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; }}
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
th {{ background-color: #4CAF50; color: white; }}
.btn {{ padding: 5px 10px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 3px; }}
.btn-danger {{ background-color: #f44336; }}
</style>
</head>
<body>
<h1>Tablet Management System</h1>
<div class="nav">
<a href="/">Home</a>
<a href="/add_tablet">Add Tablet</a>
<a href="/add_user">Add User</a>
<a href="/loan_tablet">Loan Tablet</a>
<a href="/history">Loan History</a>
</div>
<h2>Available Tablets</h2>
<table>
<thead>
<tr>
<th>Brand</th>
<th>Model</th>
<th>Serial Number</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
"""
if available_tablets:
for tablet in available_tablets:
html += f"""
<tr>
<td>{tablet['brand']}</td>
<td>{tablet['model']}</td>
<td>{tablet['serial_number']}</td>
<td>{tablet['notes'] or '-'}</td>
</tr>
"""
else:
html += "<tr><td colspan='4'>No available tablets.</td></tr>"
html += """
</tbody>
</table>
<h2>Active Loans</h2>
<table>
<thead>
<tr>
<th>Tablet</th>
<th>Serial Number</th>
<th>Borrower</th>
<th>Loan Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
"""
if active_loans:
for loan in active_loans:
html += f"""
<tr>
<td>{loan['brand']} {loan['model']}</td>
<td>{loan['serial_number']}</td>
<td>{loan['name']}</td>
<td>{loan['loan_date']}</td>
<td><a href='/return_tablet/{loan['id']}' class='btn btn-danger'>Return</a></td>
</tr>
"""
else:
html += "<tr><td colspan='5'>No active loans.</td></tr>"
html += """
</tbody>
</table>
</body>
</html>
"""
self._send_html(html)
def _handle_add_tablet_form(self):
html = """
<!DOCTYPE html>
<html>
<head>
<title>Add Tablet</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; }
input, textarea { width: 100%; padding: 8px; }
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1>Add New Tablet</h1>
<form method="POST" action="/add_tablet">
<div class="form-group">
<label for="brand">Brand:</label>
<input type="text" id="brand" name="brand" required>
</div>
<div class="form-group">
<label for="model">Model:</label>
<input type="text" id="model" name="model" required>
</div>
<div class="form-group">
<label for="serial_number">Serial Number:</label>
<input type="text" id="serial_number" name="serial_number" required>
</div>
<div class="form-group">
<label for="notes">Notes:</label>
<textarea id="notes" name="notes"></textarea>
</div>
<div class="form-group">
<button type="submit" class="btn">Add Tablet</button>
</div>
</form>
</body>
</html>
"""
self._send_html(html)
def _handle_add_tablet(self):
form_data = self._parse_form_data()
brand = form_data['brand'][0]
model = form_data['model'][0]
serial_number = form_data['serial_number'][0]
notes = form_data.get('notes', [''])[0]
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status, notes)
VALUES (?, ?, ?, 'available', ?)
''', (brand, model, serial_number, notes))
conn.commit()
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
except sqlite3.IntegrityError:
self._send_html("<h1>Error: Serial number already exists!</h1><p><a href='/add_tablet'>Try again</a></p>")
def _handle_add_user_form(self):
html = """
<!DOCTYPE html>
<html>
<head>
<title>Add User</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; }
input { width: 100%; padding: 8px; }
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1>Add New User</h1>
<form method="POST" action="/add_user">
<div class="form-group">
<label for="name">Name:</label>
<input type="text" id="name" name="name" required>
</div>
<div class="form-group">
<label for="email">Email:</label>
<input type="email" id="email" name="email">
</div>
<div class="form-group">
<label for="phone">Phone:</label>
<input type="text" id="phone" name="phone">
</div>
<div class="form-group">
<label for="identification">Identification:</label>
<input type="text" id="identification" name="identification" required>
</div>
<div class="form-group">
<button type="submit" class="btn">Add User</button>
</div>
</form>
</body>
</html>
"""
self._send_html(html)
def _handle_add_user(self):
form_data = self._parse_form_data()
name = form_data['name'][0]
email = form_data.get('email', [''])[0]
phone = form_data.get('phone', [''])[0]
identification = form_data['identification'][0]
try:
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO users (name, email, phone, identification)
VALUES (?, ?, ?, ?)
''', (name, email, phone, identification))
conn.commit()
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
except sqlite3.IntegrityError:
self._send_html("<h1>Error: Identification already exists!</h1><p><a href='/add_user'>Try again</a></p>")
def _handle_loan_tablet_form(self):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
available_tablets = cursor.fetchall()
cursor.execute("SELECT * FROM users")
users = cursor.fetchall()
html = """
<!DOCTYPE html>
<html>
<head>
<title>Loan Tablet</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.form-group { margin-bottom: 15px; }
label { display: block; margin-bottom: 5px; }
select { width: 100%; padding: 8px; }
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
</style>
</head>
<body>
<h1>Loan Tablet</h1>
<form method="POST" action="/loan_tablet">
<div class="form-group">
<label for="tablet_id">Tablet:</label>
<select id="tablet_id" name="tablet_id" required>
<option value="">Select a tablet</option>
"""
for tablet in available_tablets:
html += f"<option value='{tablet['id']}'>{tablet['brand']} {tablet['model']} ({tablet['serial_number']})</option>"
html += """
</select>
</div>
<div class="form-group">
<label for="user_id">User:</label>
<select id="user_id" name="user_id" required>
<option value="">Select a user</option>
"""
for user in users:
html += f"<option value='{user['id']}'>{user['name']} ({user['identification']})</option>"
html += """
</select>
</div>
<div class="form-group">
<button type="submit" class="btn">Loan Tablet</button>
</div>
</form>
</body>
</html>
"""
self._send_html(html)
def _handle_loan_tablet(self):
form_data = self._parse_form_data()
tablet_id = form_data['tablet_id'][0]
user_id = form_data['user_id'][0]
with get_db() as conn:
cursor = conn.cursor()
# Update tablet status
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
# Create loan record
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
conn.commit()
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
def _handle_return_tablet(self, path):
loan_id = path.split('/')[-1]
with get_db() as conn:
cursor = conn.cursor()
# Get loan information
cursor.execute("SELECT tablet_id FROM loans WHERE id = ?", (loan_id,))
loan = cursor.fetchone()
if loan:
tablet_id = loan['tablet_id']
# Update loan status and return date
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
''', (return_date, loan_id))
# Update tablet status
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
conn.commit()
self.send_response(303)
self.send_header('Location', '/')
self.end_headers()
def _handle_history(self):
with get_db() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
l.loan_date, l.return_date, l.status
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
ORDER BY l.loan_date DESC
''')
loans = cursor.fetchall()
html = """
<!DOCTYPE html>
<html>
<head>
<title>Loan History</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #333; }
.nav { margin-bottom: 20px; }
.nav a { margin-right: 15px; text-decoration: none; color: #4CAF50; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #4CAF50; color: white; }
</style>
</head>
<body>
<h1>Loan History</h1>
<div class="nav">
<a href="/">Home</a>
<a href="/add_tablet">Add Tablet</a>
<a href="/add_user">Add User</a>
<a href="/loan_tablet">Loan Tablet</a>
<a href="/history">Loan History</a>
</div>
<table>
<thead>
<tr>
<th>Tablet</th>
<th>Serial Number</th>
<th>Borrower</th>
<th>Loan Date</th>
<th>Return Date</th>
<th>Status</th>
</tr>
</thead>
<tbody>
"""
if loans:
for loan in loans:
html += f"""
<tr>
<td>{loan['brand']} {loan['model']}</td>
<td>{loan['serial_number']}</td>
<td>{loan['name']}</td>
<td>{loan['loan_date']}</td>
<td>{loan['return_date'] or '-'}</td>
<td>{loan['status']}</td>
</tr>
"""
else:
html += "<tr><td colspan='6'>No loan history available.</td></tr>"
html += """
</tbody>
</table>
</body>
</html>
"""
self._send_html(html)
def run_server():
"""Run the HTTP server"""
server_address = ('', 8000)
httpd = HTTPServer(server_address, SimpleHTTPRequestHandler)
print(f"Server running on http://localhost:8000")
print("Press Ctrl+C to stop the server")
httpd.serve_forever()
if __name__ == '__main__':
# Initialize database
init_db()
# Run the server
run_server()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

View file

@ -1,106 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add Non-Loanable Device') }}</h1>
<p class="text-gray-600 mt-1">{{ _('Add a device that will be tracked in inventory but cannot be loaned to users (e.g., projectors, monitors, etc.)') }}</p>
</div>
{# Device Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ _('Device Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="/add_non_loanable_device" class="space-y-6">
<div>
<label for="device_type" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Device Type') }} *</label>
<select id="device_type" name="device_type" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<option value="">{{ _('Select device type...') }}</option>
<option value="projector">{{ _('Projector') }}</option>
<option value="monitor">{{ _('Monitor') }}</option>
<option value="laptop">{{ _('Laptop (non-loanable)') }}</option>
<option value="printer">{{ _('Printer') }}</option>
<option value="camera">{{ _('Camera') }}</option>
<option value="audio">{{ _('Audio Equipment') }}</option>
<option value="network">{{ _('Network Equipment') }}</option>
<option value="other">{{ _('Other') }}</option>
</select>
</div>
<div>
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
<input type="text" id="brand" name="brand" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., Epson, Samsung, Dell') }}">
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
<input type="text" id="model" name="model" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., PowerLite 2250U, UltraSharp U2723QE') }}">
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
<input type="text" id="serial_number" name="serial_number" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Unique serial number') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Each device must have a unique serial number') }}</p>
</div>
<div>
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Location') }}</label>
<input type="text" id="location" name="location"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., Room 101, Lab A, Storage') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Optional location where the device is stored') }}</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Date') }}</label>
<input type="date" id="purchase_date" name="purchase_date"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
</div>
<div>
<label for="purchase_cost" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Cost') }}</label>
<input type="number" id="purchase_cost" name="purchase_cost" step="0.01"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('0.00') }}">
</div>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
<textarea id="notes" name="notes" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Optional notes about the device (condition, accessories, etc.)') }}"></textarea>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="/non_loanable_devices"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ _('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ _('Add Device') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,143 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Add Student') }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Add Student') }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Register a new student in the system') }}</p>
</div>
{# Student Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="{{ url_for('add_student') }}" class="space-y-6">
{# Identification Section #}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Identification') }}</h3>
<div>
<label for="cial_code" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CIAL Code') }} *</label>
<input type="text" id="cial_code" name="cial_code" required autofocus
value="{{ request.form.cial_code if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Unique internal identification code') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Unique internal identification code') }}</p>
</div>
<div>
<label for="nif_nie_passport" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('NIF/NIE/Passport') }}</label>
<input type="text" id="nif_nie_passport" name="nif_nie_passport"
value="{{ request.form.nif_nie_passport if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('National ID, Foreign ID, or Passport number') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('National ID, Foreign ID, or Passport number') }}</p>
</div>
<div>
<label for="registration_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Registration Number') }}</label>
<input type="number" id="registration_number" name="registration_number"
value="{{ request.form.registration_number if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional registration number') }}">
</div>
<div>
<label for="file_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('File Number') }}</label>
<input type="text" id="file_number" name="file_number"
value="{{ request.form.file_number if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional file number') }}">
</div>
<div>
<label for="order_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Order Number') }}</label>
<input type="number" id="order_number" name="order_number"
value="{{ request.form.order_number if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional order number') }}">
</div>
</div>
{# Personal Information Section #}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Personal Information') }}</h3>
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('First Name') }} *</label>
<input type="text" id="first_name" name="first_name" required
value="{{ request.form.first_name if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Student first name') }}">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Last Name') }} *</label>
<input type="text" id="last_name" name="last_name" required
value="{{ request.form.last_name if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Student last name') }}">
</div>
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Full Name') }} *</label>
<input type="text" id="full_name" name="full_name" required
value="{{ request.form.full_name if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Complete name') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Full name for display purposes') }}</p>
</div>
<div>
<label for="birth_date" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Birth Date') }}</label>
<input type="date" id="birth_date" name="birth_date"
value="{{ request.form.birth_date if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
</div>
<div>
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
<select id="gender" name="gender"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<option value="">{{ gettext('Select gender') }}</option>
<option value="M" {% if request.form.gender == 'M' %}selected{% endif %}>M</option>
<option value="F" {% if request.form.gender == 'F' %}selected{% endif %}>F</option>
</select>
</div>
<div>
<label for="study_group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
<input type="text" id="study_group" name="study_group"
value="{{ request.form.study_group if request.method == 'POST' else '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('e.g., 2º ESO A, 3º ESO B') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Class or study group') }}</p>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="{{ url_for('list_students') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ gettext('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ gettext('Add Student') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,67 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add New Tablet') }}</h1>
<p class="text-gray-600 mt-1">{{ _('Register a new tablet in the system') }}</p>
</div>
{# Tablet Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ _('Tablet Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="/add_tablet" class="space-y-6">
<div>
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
<input type="text" id="brand" name="brand" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., Apple, Samsung, Lenovo') }}">
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
<input type="text" id="model" name="model" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., iPad 10th Gen, Galaxy Tab A8') }}">
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
<input type="text" id="serial_number" name="serial_number" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Unique serial number') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Each tablet must have a unique serial number') }}</p>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
<textarea id="notes" name="notes" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Optional notes about the tablet (condition, accessories, etc.)') }}"></textarea>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="/"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ _('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ _('Add Tablet') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,69 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add New User') }}</h1>
<p class="text-gray-600 mt-1">{{ _('Register a new staff user in the system') }}</p>
</div>
{# User Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ _('User Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="/add_user" class="space-y-6">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Name') }} *</label>
<input type="text" id="name" name="name" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Full name') }}">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Email') }}</label>
<input type="email" id="email" name="email"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('user@example.com') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Optional email address') }}</p>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Phone') }}</label>
<input type="text" id="phone" name="phone"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Phone number') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Optional phone number') }}</p>
</div>
<div>
<label for="identification" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Identification') }} *</label>
<input type="text" id="identification" name="identification" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Employee ID, badge number, etc.') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Unique identification for the staff member') }}</p>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="/"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ _('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
{{ _('Add User') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,651 +0,0 @@
<!DOCTYPE html>
<html lang="{{ language or 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ _('Tablet Management System') }}</title>
<!-- Tailwind CSS via CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-1y8c6Y8XkZ7Q1Y+v5vYpX+g5Z4XhY+6eZ6X9p6Z1cM5Xf+Z7x2J6FQ0w9Xc3YfXMgZ6e5Z7+6f5Q==" crossorigin="anonymous" referrerpolicy="no-referrer" />
<!-- Custom styles -->
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
}
/* HTMX loading spinner */
.htmx-indicator {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0,0,0,.3);
border-radius: 50%;
border-top-color: #FC6A03;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Hide HTMX indicator by default */
.htmx-indicator {
opacity: 0;
transition: opacity 200ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
/* Dark mode styles */
body.dark-mode {
background-color: #1a1a2e;
color: #e0e0e0;
}
body.dark-mode .bg-gray-50 {
background-color: #1a1a2e;
}
body.dark-mode .bg-white {
background-color: #16213e;
}
body.dark-mode .text-gray-900,
body.dark-mode .text-gray-800,
body.dark-mode .text-gray-700 {
color: #e0e0e0;
}
body.dark-mode .text-gray-600 {
color: #b0b0b0;
}
body.dark-mode .text-gray-500 {
color: #9ca3af;
}
body.dark-mode th.text-gray-500 {
color: #d1d5db;
}
body.dark-mode .border-gray-200 {
border-color: #374151;
}
body.dark-mode .border-gray-300 {
border-color: #4b5563;
}
body.dark-mode input,
body.dark-mode select,
body.dark-mode textarea {
background-color: #1f2937;
border-color: #374151;
color: #e0e0e0;
}
body.dark-mode input:focus,
body.dark-mode select:focus,
body.dark-mode textarea:focus {
border-color: #4d7aff;
ring-color: #4d7aff;
}
body.dark-mode .bg-green-100 {
background-color: #064e3b;
color: #a7f3d0;
}
body.dark-mode .bg-red-100 {
background-color: #7f1d1d;
color: #fecaca;
}
body.dark-mode .bg-yellow-100 {
background-color: #78350f;
color: #fde68a;
}
body.dark-mode .bg-blue-100 {
background-color: #1e3a8a;
color: #bfdbfe;
}
body.dark-mode .bg-cobalt-600 {
background-color: #0b1f72;
}
body.dark-mode .bg-cobalt-700 {
background-color: #081552;
}
body.dark-mode table {
background-color: #16213e;
}
body.dark-mode th {
background-color: #1f2937;
color: #e0e0e0;
}
body.dark-mode td {
border-color: #374151;
}
body.dark-mode tr:hover {
background-color: #1f2937;
}
body.dark-mode .shadow-md,
body.dark-mode .shadow-lg {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
}
body.dark-mode code,
body.dark-mode .bg-gray-100 {
background-color: #374151;
color: #e0e0e0;
}
/* Dark mode for responsive tables */
body.dark-mode .table-responsive {
background-color: #16213e;
}
body.dark-mode .table-responsive thead {
background-color: #1f2937;
}
body.dark-mode .table-responsive th {
background-color: #1f2937;
color: #d1d5db;
border-bottom-color: #374151;
}
body.dark-mode .table-responsive td {
color: #e0e0e0;
border-bottom-color: #374151;
}
body.dark-mode .table-responsive tbody tr:hover {
background-color: #1f2937;
}
/* Dark mode for mobile cards */
body.dark-mode .mobile-card {
background-color: #16213e;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
}
body.dark-mode .mobile-card .card-label {
color: #9ca3af;
}
body.dark-mode .mobile-card .card-value {
color: #e0e0e0;
}
body.dark-mode .mobile-card .card-row {
border-bottom-color: #374151;
}
/* Dark mode for form elements */
body.dark-mode input[type="text"],
body.dark-mode input[type="email"],
body.dark-mode input[type="number"],
body.dark-mode input[type="date"],
body.dark-mode input[type="file"],
body.dark-mode select,
body.dark-mode textarea {
background-color: #1f2937;
border-color: #374151;
color: #e0e0e0;
}
body.dark-mode input::placeholder,
body.dark-mode textarea::placeholder {
color: #6b7280;
}
/* Dark mode for buttons */
body.dark-mode .bg-gray-200 {
background-color: #374151;
color: #e0e0e0;
}
body.dark-mode .bg-gray-200:hover {
background-color: #4b5563;
}
body.dark-mode .hover\:bg-gray-300:hover {
background-color: #4b5563;
}
/* Dark mode for footer */
body.dark-mode footer {
border-color: #374151;
}
body.dark-mode footer p {
color: #9ca3af;
}
/* Dark mode for breadcrumb */
body.dark-mode .text-cobalt-600 {
color: #80a0ff;
}
body.dark-mode .hover\:text-cobalt-800:hover {
color: #b3c6ff;
}
/* Dark mode for prose (markdown preview) */
body.dark-mode .prose {
color: #e0e0e0;
}
body.dark-mode .prose h1,
body.dark-mode .prose h2,
body.dark-mode .prose h3 {
color: #ffffff;
}
body.dark-mode .prose a {
color: #80a0ff;
}
body.dark-mode .prose code {
background-color: #374151;
color: #e0e0e0;
}
body.dark-mode .prose pre {
background-color: #1f2937;
border-color: #374151;
}
body.dark-mode .prose blockquote {
border-color: #374151;
color: #b0b0b0;
}
/* Responsive Table Styles */
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.table-responsive {
width: 100%;
border-collapse: collapse;
background-color: white;
}
.table-responsive thead {
background-color: #f9fafb;
}
.table-responsive th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
border-bottom: 2px solid #e5e7eb;
}
.table-responsive td {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #374151;
border-bottom: 1px solid #e5e7eb;
white-space: nowrap;
}
.table-responsive tbody tr:hover {
background-color: #f9fafb;
}
/* Mobile Card Layout */
@media (max-width: 640px) {
.table-card-mobile {
display: none;
}
.mobile-cards {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.mobile-card {
background-color: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
}
.mobile-card .card-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
border-bottom: 1px solid #f3f4f6;
}
.mobile-card .card-row:last-child {
border-bottom: none;
}
.mobile-card .card-label {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mobile-card .card-value {
font-size: 0.875rem;
color: #374151;
text-align: right;
}
}
</style>
<!-- Tailwind config for custom corporate colors -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#ecfdf5',
100: '#d1fae5',
200: '#a7f3d0',
300: '#6ee7b7',
400: '#34d399',
500: '#10b981',
600: '#059669',
700: '#047857',
800: '#065f46',
900: '#064e3b',
},
cobalt: {
50: '#f0f5ff',
100: '#e0eaff',
200: '#b3c6ff',
300: '#80a0ff',
400: '#4d7aff',
500: '#1338BE',
600: '#0f2a9c',
700: '#0b1f72',
800: '#081552',
900: '#060f38',
},
tiger: {
50: '#fff8f0',
100: '#ffe8d6',
200: '#ffccab',
300: '#ffae80',
400: '#ff8c55',
500: '#FC6A03',
600: '#e05a00',
700: '#b84500',
800: '#8c3300',
900: '#662500',
},
emerald: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#028A0F',
600: '#006b0c',
700: '#005209',
800: '#003d07',
900: '#002904',
}
}
}
}
}
</script>
</head>
<body class="bg-gray-50 text-gray-900">
<!-- Skip to main content -->
<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>
<!-- Container -->
<div class="min-h-screen">
<!-- Header -->
<header class="bg-cobalt-600 text-white shadow-lg">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo / Title -->
<div class="flex items-center gap-3">
<img src="/static/Schamann.png" alt="Corporate Logo" class="h-10 w-auto object-contain" style="max-height: 40px; height: 40px !important;">
<h1 class="text-xl font-bold">{{ _('Tablet Management System') }}</h1>
</div>
<!-- Desktop Controls: Language + Theme Toggle -->
<div class="hidden sm:flex items-center gap-2">
<span class="text-sm opacity-80">{{ _('Language') }}:</span>
{% for lang_code, lang_name in config['LANGUAGES'].items() %}
<a
href="{{ url_for('set_language', lang=lang_code) }}"
class="px-2 py-1 text-sm rounded hover:bg-white/20 transition-colors
{% if language == lang_code %}bg-white/30 font-medium{% endif %}"
>
{{ lang_name }}
</a>
{% endfor %}
<!-- Theme Toggle -->
<a href="#" id="theme-toggle" class="text-white hover:text-cobalt-200 ml-2" title="{{ _('Toggle dark mode') }}">
<span class="icon-moon">🌙</span>
<span class="icon-sun" style="display:none;">☀️</span>
</a>
</div>
<!-- Mobile Menu Button -->
<button id="mobile-menu-btn" class="sm:hidden text-white hover:text-cobalt-200 focus:outline-none"
aria-label="Toggle navigation menu" aria-expanded="false">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path id="menu-icon-hamburger" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
<path id="menu-icon-close" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<!-- Mobile Navigation Menu (hidden by default) -->
<div id="mobile-menu" class="sm:hidden hidden">
<div class="px-4 py-3 space-y-2 bg-cobalt-700">
<!-- Mobile Language Switcher -->
<div class="flex items-center gap-2 pb-2 border-b border-cobalt-600 mb-2">
<span class="text-sm opacity-80">{{ _('Language') }}:</span>
{% for lang_code, lang_name in config['LANGUAGES'].items() %}
<a
href="{{ url_for('set_language', lang=lang_code) }}"
class="px-2 py-1 text-sm rounded hover:bg-white/20 transition-colors
{% if language == lang_code %}bg-white/30 font-medium{% endif %}"
>
{{ lang_name }}
</a>
{% endfor %}
<!-- Mobile Theme Toggle -->
<a href="#" id="theme-toggle-mobile" class="text-white hover:text-cobalt-200 ml-2" title="{{ _('Toggle dark mode') }}">
<span class="icon-moon">🌙</span>
<span class="icon-sun" style="display:none;">☀️</span>
</a>
</div>
<!-- Mobile Navigation Links -->
<a href="/" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Home') }}
</a>
<a href="/add_tablet" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Add Tablet') }}
</a>
<a href="/add_user" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Add User') }}
</a>
<a href="/loan_tablet" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Loan Tablet') }}
</a>
<a href="/history" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('History') }}
</a>
<a href="/user_loans" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('User Loans') }}
</a>
<a href="/students" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Students') }}
</a>
<a href="/non_loanable_devices" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Non-Loanable Devices') }}
</a>
<a href="/project_management" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
{{ _('Project Management') }}
</a>
</div>
</div>
</header>
<!-- Main Content -->
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Flash Messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<div class="mb-6 space-y-3">
{% for category, message in messages %}
<div class="p-4 rounded-lg
{% if category == 'success' %}bg-green-100 text-green-800
{% elif category == 'error' %}bg-red-100 text-red-800
{% elif category == 'warning' %}bg-yellow-100 text-yellow-800
{% else %}bg-blue-100 text-blue-800{% endif %}">
<div class="flex items-center gap-2">
{% if category == 'success' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M5 13l4 4L19 7"></path>
</svg>
{% elif category == 'error' %}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{% endif %}
<span>{{ _(message) }}</span>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endwith %}
<!-- Navigation (Desktop only) -->
<nav class="hidden sm:block bg-cobalt-600 rounded-lg shadow-md mb-6" aria-label="Main navigation">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-wrap justify-center sm:justify-start gap-1 sm:gap-2 py-3">
<a href="/"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Home') }}
</a>
<a href="/add_tablet"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Add Tablet') }}
</a>
<a href="/add_user"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Add User') }}
</a>
<a href="/loan_tablet"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Loan Tablet') }}
</a>
<a href="/history"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('History') }}
</a>
<a href="/user_loans"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('User Loans') }}
</a>
<a href="/students"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Students') }}
</a>
<a href="/non_loanable_devices"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Non-Loanable Devices') }}
</a>
<a href="/project_management"
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
{{ _('Project Management') }}
</a>
</div>
</div>
</nav>
<!-- Page Content -->
{% block content %}{% endblock %}
</main>
<!-- Footer -->
<footer class="mt-12 py-6 border-t border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500">
<p>{{ _('Tablet Management System - Internal Tool') }}</p>
</div>
</footer>
</div>
<!-- Theme Toggle Script -->
<script>
// Mobile menu toggle
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
const mobileMenu = document.getElementById('mobile-menu');
const menuIconHamburger = document.getElementById('menu-icon-hamburger');
const menuIconClose = document.getElementById('menu-icon-close');
if (mobileMenuBtn && mobileMenu) {
mobileMenuBtn.addEventListener('click', function() {
const isOpen = mobileMenu.classList.toggle('hidden');
mobileMenuBtn.setAttribute('aria-expanded', !isOpen);
// Toggle icons
menuIconHamburger.classList.toggle('hidden');
menuIconClose.classList.toggle('hidden');
});
// Close menu when clicking a link
mobileMenu.querySelectorAll('a').forEach(link => {
link.addEventListener('click', function() {
mobileMenu.classList.add('hidden');
mobileMenuBtn.setAttribute('aria-expanded', false);
menuIconHamburger.classList.remove('hidden');
menuIconClose.classList.add('hidden');
});
});
}
// Check for saved theme preference or default to light mode
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
document.body.classList.add('dark-mode');
updateToggleIcon(true);
} else if (savedTheme !== 'light') {
// Invalid value - clear and default to light
localStorage.removeItem('theme');
}
// Theme toggle for desktop
const themeToggle = document.getElementById('theme-toggle');
if (themeToggle) {
themeToggle.addEventListener('click', function(e) {
e.preventDefault();
const isDark = document.body.classList.toggle('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateToggleIcon(isDark);
});
}
// Theme toggle for mobile
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
if (themeToggleMobile) {
themeToggleMobile.addEventListener('click', function(e) {
e.preventDefault();
const isDark = document.body.classList.toggle('dark-mode');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
updateToggleIcon(isDark);
});
}
function updateToggleIcon(isDark) {
// Update all theme toggle icons (desktop and mobile)
document.querySelectorAll('.icon-moon').forEach(icon => {
icon.style.display = isDark ? 'none' : 'inline';
});
document.querySelectorAll('.icon-sun').forEach(icon => {
icon.style.display = isDark ? 'inline' : 'none';
});
}
</script>
</body>
</html>

View file

@ -1,206 +0,0 @@
{# templates/components/user_loans_results.html #}
{%- if users %}
{# Results Table Card #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-green-600 text-white">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
{{ _('User') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
{{ _('Identification') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
{{ _('Active Loans') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
{{ _('Last Loan Date') }}
</th>
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
{{ _('Actions') }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{%- for user in users %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ user.name }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
{{ user.identification or _('N/A') }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{% if user.loan_count and user.loan_count > 0 %}bg-green-100 text-green-800
{% else %}bg-gray-100 text-gray-800
{% endif %}">
{{ user.loan_count or 0 }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ user.last_loan_date or _('Never') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
<a href="/user_loans/{{ user.id }}"
class="text-green-600 hover:text-green-800 font-medium">
{{ _('View Details') }}
</a>
</td>
</tr>
{%- endfor %}
</tbody>
</table>
</div>
{# Pagination #}
{%- if total_pages > 1 %}
<nav class="flex items-center justify-between pt-4" aria-label="Table navigation">
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-500">
{{ _('Showing') }} <span class="font-medium text-gray-900">{{ (page - 1) * per_page + 1 }}</span>
{{ _('to') }} <span class="font-medium text-gray-900">{{ min(page * per_page, total_users) }}</span>
{{ _('of') }} <span class="font-medium text-gray-900">{{ total_users }}</span> {{ _('users') }}
</span>
</div>
<div class="flex items-center space-x-2">
{# Previous Button #}
{%- if page > 1 %}
<a
href="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700"
>
{{ _('Previous') }}
</a>
{%- else %}
<span class="px-3 py-2 ml-0 leading-tight text-gray-300 bg-white border border-gray-300 rounded-l-lg cursor-not-allowed">
{{ _('Previous') }}
</span>
{%- endif %}
{# Page Numbers #}
{%- if total_pages <= 7 %}
{%- for p in range(1, total_pages + 1) %}
{%- if p == page %}
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
{{ p }}
</span>
{%- else %}
<a
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
>
{{ p }}
</a>
{%- endif %}
{%- endfor %}
{%- else %}
{# Show first page #}
<a
href="/user_loans?page=1&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page=1&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
>
1
</a>
{%- if page > 3 %}
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
{%- endif %}
{# Show pages around current #}
{%- for p in range(max(2, page - 2), min(total_pages - 1, page + 2) + 1) %}
{%- if p == page %}
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
{{ p }}
</span>
{%- else %}
<a
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
>
{{ p }}
</a>
{%- endif %}
{%- endfor %}
{%- if page < total_pages - 2 %}
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
{%- endif %}
{# Show last page #}
<a
href="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
>
{{ total_pages }}
</a>
{%- endif %}
{# Next Button #}
{%- if page < total_pages %}
<a
href="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
hx-get="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status']"
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700"
>
{{ _('Next') }}
</a>
{%- else %}
<span class="px-3 py-2 leading-tight text-gray-300 bg-white border border-gray-300 rounded-r-lg cursor-not-allowed">
{{ _('Next') }}
</span>
{%- endif %}
</div>
</nav>
{%- endif %}
{%- else %}
{# No Results #}
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ _('No users found') }}</h3>
<p class="text-gray-500 mb-4">
{% if search %}
{{ _('No users match your search for') }} "{{ search }}"
{% elif status == 'with_loans' %}
{{ _('No users have active loans') }}
{% elif status == 'no_loans' %}
{{ _('All users have at least one active loan') }}
{% else %}
{{ _('There are no users in the system yet') }}
{% endif %}
</p>
<a href="/user_loans"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 transition-colors">
{{ _('Clear Filters') }}
</a>
</div>
{%- endif %}

View file

@ -1,116 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Edit Non-Loanable Device') }}</h1>
<p class="text-gray-600 mt-1">{{ _('Update device information') }}</p>
</div>
{# Device Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ _('Device Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="/edit_non_loanable_device/{{ device.id }}" class="space-y-6">
<div>
<label for="device_type" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Device Type') }} *</label>
<select id="device_type" name="device_type" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<option value="projector" {% if device.device_type == 'projector' %}selected{% endif %}>{{ _('Projector') }}</option>
<option value="monitor" {% if device.device_type == 'monitor' %}selected{% endif %}>{{ _('Monitor') }}</option>
<option value="laptop" {% if device.device_type == 'laptop' %}selected{% endif %}>{{ _('Laptop (non-loanable)') }}</option>
<option value="printer" {% if device.device_type == 'printer' %}selected{% endif %}>{{ _('Printer') }}</option>
<option value="camera" {% if device.device_type == 'camera' %}selected{% endif %}>{{ _('Camera') }}</option>
<option value="audio" {% if device.device_type == 'audio' %}selected{% endif %}>{{ _('Audio Equipment') }}</option>
<option value="network" {% if device.device_type == 'network' %}selected{% endif %}>{{ _('Network Equipment') }}</option>
<option value="other" {% if device.device_type == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
</select>
</div>
<div>
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
<input type="text" id="brand" name="brand" value="{{ device.brand }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., Epson, Samsung, Dell') }}">
</div>
<div>
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
<input type="text" id="model" name="model" value="{{ device.model }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., PowerLite 2250U, UltraSharp U2723QE') }}">
</div>
<div>
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
<input type="text" id="serial_number" name="serial_number" value="{{ device.serial_number }}" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Unique serial number') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Each device must have a unique serial number') }}</p>
</div>
<div>
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Location') }}</label>
<input type="text" id="location" name="location" value="{{ device.location or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('e.g., Room 101, Lab A, Storage') }}">
<p class="mt-1 text-sm text-gray-500">{{ _('Optional location where the device is stored') }}</p>
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Status') }} *</label>
<select id="status" name="status" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<option value="available" {% if device.status == 'available' %}selected{% endif %}>{{ _('Available') }}</option>
<option value="in_use" {% if device.status == 'in_use' %}selected{% endif %}>{{ _('In Use') }}</option>
<option value="maintenance" {% if device.status == 'maintenance' %}selected{% endif %}>{{ _('Maintenance') }}</option>
<option value="retired" {% if device.status == 'retired' %}selected{% endif %}>{{ _('Retired') }}</option>
</select>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Date') }}</label>
<input type="date" id="purchase_date" name="purchase_date" value="{{ device.purchase_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
</div>
<div>
<label for="purchase_cost" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Cost') }}</label>
<input type="number" id="purchase_cost" name="purchase_cost" step="0.01" value="{{ device.purchase_cost or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('0.00') }}">
</div>
</div>
<div>
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
<textarea id="notes" name="notes" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ _('Optional notes about the device (condition, accessories, etc.)') }}">{{ device.notes or '' }}</textarea>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="/non_loanable_devices"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ _('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ _('Update Device') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,168 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Edit Student') }} - {{ student.full_name }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Breadcrumb #}
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<a href="{{ url_for('list_students') }}" class="text-cobalt-600 hover:text-cobalt-800">{{ gettext('Students') }}</a>
</li>
<li>
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
</li>
<li>
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800">{{ student.full_name }}</a>
</li>
<li>
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
</li>
<li>
<span class="text-gray-500">{{ gettext('Edit') }}</span>
</li>
</ol>
</nav>
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Edit Student') }}: {{ student.full_name }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Update student information') }}</p>
</div>
{# Student Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="{{ url_for('edit_student', student_id=student.id) }}" class="space-y-6">
{# Identification Section #}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Identification') }}</h3>
<div>
<label for="cial_code" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CIAL Code') }} *</label>
<input type="text" id="cial_code" name="cial_code" required autofocus
value="{{ request.form.cial_code if request.method == 'POST' else student.cial_code }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Unique internal identification code') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Unique internal identification code') }}</p>
</div>
<div>
<label for="nif_nie_passport" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('NIF/NIE/Passport') }}</label>
<input type="text" id="nif_nie_passport" name="nif_nie_passport"
value="{{ request.form.nif_nie_passport if request.method == 'POST' else (student.nif_nie_passport or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('National ID, Foreign ID, or Passport number') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('National ID, Foreign ID, or Passport number') }}</p>
</div>
<div>
<label for="registration_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Registration Number') }}</label>
<input type="number" id="registration_number" name="registration_number"
value="{{ request.form.registration_number if request.method == 'POST' else (student.registration_number or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional registration number') }}">
</div>
<div>
<label for="file_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('File Number') }}</label>
<input type="text" id="file_number" name="file_number"
value="{{ request.form.file_number if request.method == 'POST' else (student.file_number or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional file number') }}">
</div>
<div>
<label for="order_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Order Number') }}</label>
<input type="number" id="order_number" name="order_number"
value="{{ request.form.order_number if request.method == 'POST' else (student.order_number or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Optional order number') }}">
</div>
</div>
{# Personal Information Section #}
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Personal Information') }}</h3>
<div>
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('First Name') }} *</label>
<input type="text" id="first_name" name="first_name" required
value="{{ request.form.first_name if request.method == 'POST' else student.first_name }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Student first name') }}">
</div>
<div>
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Last Name') }} *</label>
<input type="text" id="last_name" name="last_name" required
value="{{ request.form.last_name if request.method == 'POST' else student.last_name }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Student last name') }}">
</div>
<div>
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Full Name') }} *</label>
<input type="text" id="full_name" name="full_name" required
value="{{ request.form.full_name if request.method == 'POST' else student.full_name }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('Complete name') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Full name for display purposes') }}</p>
</div>
<div>
<label for="birth_date" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Birth Date') }}</label>
<input type="date" id="birth_date" name="birth_date"
value="{{ request.form.birth_date if request.method == 'POST' else (student.birth_date or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
</div>
<div>
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
<select id="gender" name="gender"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<option value="">{{ gettext('Select gender') }}</option>
<option value="M" {% if request.form.gender == 'M' or student.gender == 'M' %}selected{% endif %}>M</option>
<option value="F" {% if request.form.gender == 'F' or student.gender == 'F' %}selected{% endif %}>F</option>
</select>
</div>
<div>
<label for="study_group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
<input type="text" id="study_group" name="study_group"
value="{{ request.form.study_group if request.method == 'POST' else (student.study_group or '') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
placeholder="{{ gettext('e.g., 2º ESO A, 3º ESO B') }}">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Class or study group') }}</p>
</div>
</div>
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="{{ url_for('student_detail', student_id=student.id) }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ gettext('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ gettext('Save Changes') }}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,139 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
{# Page Header #}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Loan History') }}</h1>
<p class="text-gray-600 mt-1">{{ _('View all past and current tablet loans') }}</p>
</div>
<a href="/"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
{{ _('Back to Dashboard') }}
</a>
</div>
{% if loans %}
{# Desktop Table (hidden on mobile) #}
<div class="table-container table-card-mobile">
<table class="table-responsive">
<thead>
<tr>
<th>{{ _('Tablet') }}</th>
<th>{{ _('Serial Number') }}</th>
<th>{{ _('Borrower') }}</th>
<th>{{ _('Loan Date') }}</th>
<th>{{ _('Return Date') }}</th>
<th>{{ _('Status') }}</th>
</tr>
</thead>
<tbody>
{% for loan in loans %}
<tr>
<td>
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
</td>
<td>
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
</td>
<td>
<div class="font-medium text-gray-900">{{ loan.name }}</div>
<div class="text-sm text-gray-500">{{ loan.identification }}</div>
</td>
<td class="text-sm text-gray-500">
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
<br>
<span class="text-xs">{{ loan.loan_date[11:16] if loan.loan_date else '' }}</span>
</td>
<td class="text-sm text-gray-500">
{% if loan.return_date %}
{{ loan.return_date[:10] }}
<br>
<span class="text-xs">{{ loan.return_date[11:16] }}</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if loan.status == 'active' %}bg-green-100 text-green-800
{% elif loan.status == 'returned' %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ loan.status }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Mobile Cards (hidden on desktop) #}
<div class="mobile-cards hidden">
{% for loan in loans %}
<div class="mobile-card">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
<div class="text-sm text-gray-500">{{ loan.name }}</div>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if loan.status == 'active' %}bg-green-100 text-green-800
{% elif loan.status == 'returned' %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ loan.status }}
</span>
</div>
<div class="space-y-2">
<div class="card-row">
<span class="card-label">{{ _('Serial Number') }}</span>
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
</div>
<div class="card-row">
<span class="card-label">{{ _('Borrower') }}</span>
<div class="card-value text-right">
<div>{{ loan.name }}</div>
<div class="text-xs text-gray-500">{{ loan.identification }}</div>
</div>
</div>
<div class="card-row">
<span class="card-label">{{ _('Loan Date') }}</span>
<span class="card-value">{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}</span>
</div>
{% if loan.return_date %}
<div class="card-row">
<span class="card-label">{{ _('Return Date') }}</span>
<span class="card-value">{{ loan.return_date[:10] }}</span>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-gray-500">{{ _('No loan history available.') }}</p>
<a href="/loan_tablet"
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
{{ _('Create a Loan') }}
</a>
</div>
{% endif %}
</div>
<script>
// Show mobile cards on small screens
if (window.innerWidth <= 640) {
document.querySelector('.table-card-mobile').style.display = 'none';
document.querySelector('.mobile-cards').style.display = 'flex';
}
</script>
{% endblock %}

View file

@ -1,157 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Import Students from CSV') }}{% endblock %}
{% block content %}
<div class="max-w-4xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Import Students from CSV') }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Bulk import student data from a CSV file') }}</p>
</div>
{# Instructions Card #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Instructions') }}</h2>
</div>
<div class="p-6">
<ul class="space-y-2 text-gray-700">
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('Upload a CSV file with student data') }}
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('The file should be encoded in ISO-8859-1 (Latin-1)') }}
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('Required columns: Apellidos, Nombre (will be split), C.I.A.L., Fecha Nac.') }}
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('Optional columns: NIF/NIE/Pas., Registro, Expediente, Sexo, Grupo') }}
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('The "Estudio" column will be discarded') }}
</li>
<li class="flex items-start gap-2">
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ gettext('Duplicate students (by CIAL code or NIF/NIE) will be skipped') }}
</li>
</ul>
<div class="mt-4 p-3 bg-gray-50 rounded-lg">
<p class="text-sm text-gray-600">
{{ gettext('Example CSV format:') }}<br>
<code class="text-xs bg-gray-200 px-2 py-1 rounded">Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio</code>
</p>
</div>
</div>
</div>
{# Upload Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-green-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Upload CSV File') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="{{ url_for('import_students') }}" enctype="multipart/form-data" class="space-y-4">
<div>
<label for="csv_file" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CSV File') }} *</label>
<input type="file" id="csv_file" name="csv_file" accept=".csv" required
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select a CSV file to upload') }}</p>
</div>
<div class="flex justify-end gap-3">
<a href="{{ url_for('list_students') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ gettext('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
{{ gettext('Import Students') }}
</button>
</div>
</form>
</div>
</div>
{# Sample Data Preview #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-gray-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Sample Data Format') }}</h2>
</div>
<div class="p-6">
<div class="table-container">
<table class="table-responsive">
<thead>
<tr>
<th>{{ gettext('Nº Or.') }}</th>
<th>{{ gettext('Apellidos, Nombre') }}</th>
<th>{{ gettext('Fecha Nac.') }}</th>
<th>{{ gettext('C.I.A.L.') }}</th>
<th>{{ gettext('NIF/NIE/Pas.') }}</th>
<th>{{ gettext('Registro') }}</th>
<th>{{ gettext('Expediente') }}</th>
<th>{{ gettext('Sexo') }}</th>
<th>{{ gettext('Grupo') }}</th>
<th class="text-gray-400 line-through">{{ gettext('Estudio') }}</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
<td>García López, María</td>
<td>15/03/2005</td>
<td>12345678A</td>
<td>12345678A</td>
<td>REG001</td>
<td>EXP001</td>
<td>F</td>
<td>2º ESO A</td>
<td class="text-gray-400 line-through">ESO</td>
</tr>
<tr>
<td>2</td>
<td>Martínez Ruiz, Carlos</td>
<td>22/07/2004</td>
<td>87654321B</td>
<td>87654321B</td>
<td>REG002</td>
<td>EXP002</td>
<td>M</td>
<td>3º ESO B</td>
<td class="text-gray-400 line-through">ESO</td>
</tr>
</tbody>
</table>
</div>
<p class="mt-3 text-sm text-gray-500">
<span class="text-gray-400 line-through">{{ gettext('Estudio') }}</span>
{{ gettext('column will be discarded during import') }}
</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,233 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-8">
{# Available Tablets Section #}
<div class="bg-white rounded-lg shadow">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
{{ _('Available Tablets') }}
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
{{ available_tablets|length }}
</span>
</h2>
</div>
{% if available_tablets %}
{# Desktop Table (hidden on mobile) #}
<div class="table-container table-card-mobile">
<table class="table-responsive">
<thead>
<tr>
<th>{{ _('Brand') }}</th>
<th>{{ _('Model') }}</th>
<th>{{ _('Serial Number') }}</th>
<th>{{ _('Notes') }}</th>
<th class="text-right">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for tablet in available_tablets %}
<tr>
<td>
<div class="font-medium text-gray-900">{{ tablet.brand }}</div>
</td>
<td class="text-gray-500">
{{ tablet.model }}
</td>
<td>
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ tablet.serial_number }}</code>
</td>
<td class="text-gray-500">
{{ tablet.notes or '-' }}
</td>
<td class="text-right">
<a href="/loan_tablet?tablet_id={{ tablet.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ _('Loan') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Mobile Cards (hidden on desktop) #}
<div class="mobile-cards hidden p-6">
{% for tablet in available_tablets %}
<div class="mobile-card">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-medium text-gray-900">{{ tablet.brand }}</div>
<div class="text-sm text-gray-500">{{ tablet.model }}</div>
</div>
<a href="/loan_tablet?tablet_id={{ tablet.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ _('Loan') }}
</a>
</div>
<div class="space-y-2">
<div class="card-row">
<span class="card-label">{{ _('Serial Number') }}</span>
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ tablet.serial_number }}</code>
</div>
{% if tablet.notes %}
<div class="card-row">
<span class="card-label">{{ _('Notes') }}</span>
<span class="card-value text-sm">{{ tablet.notes }}</span>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
</svg>
<p>{{ _('No available tablets') }}</p>
<a href="/add_tablet"
class="mt-4 inline-block px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
{{ _('Add Tablet') }}
</a>
</div>
{% endif %}
</div>
{# Active Loans Section #}
<div class="bg-white rounded-lg shadow">
<div class="p-6 border-b border-gray-200">
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{{ _('Active Loans') }}
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
{{ active_loans|length }}
</span>
</h2>
</div>
{% if active_loans %}
{# Desktop Table (hidden on mobile) #}
<div class="table-container table-card-mobile">
<table class="table-responsive">
<thead>
<tr>
<th>{{ _('Tablet') }}</th>
<th>{{ _('Serial Number') }}</th>
<th>{{ _('Borrower') }}</th>
<th>{{ _('Loan Date') }}</th>
<th class="text-right">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for loan in active_loans %}
<tr>
<td>
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
</td>
<td>
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
</td>
<td>
<div class="font-medium text-gray-900">{{ loan.name }}</div>
<div class="text-sm text-gray-500">{{ loan.identification }}</div>
</td>
<td class="text-sm text-gray-500">
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
<br>
<span class="text-xs">{{ loan.loan_date[11:16] if loan.loan_date else '' }}</span>
</td>
<td class="text-right">
<a href="/return_tablet/{{ loan.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
{{ _('Return') }}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Mobile Cards (hidden on desktop) #}
<div class="mobile-cards hidden p-6">
{% for loan in active_loans %}
<div class="mobile-card">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
<div class="text-sm text-gray-500">{{ loan.name }}</div>
</div>
<a href="/return_tablet/{{ loan.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 5l7 7-7 7"></path>
</svg>
{{ _('Return') }}
</a>
</div>
<div class="space-y-2">
<div class="card-row">
<span class="card-label">{{ _('Serial Number') }}</span>
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
</div>
<div class="card-row">
<span class="card-label">{{ _('Borrower') }}</span>
<div class="card-value text-right">
<div>{{ loan.name }}</div>
<div class="text-xs text-gray-500">{{ loan.identification }}</div>
</div>
</div>
<div class="card-row">
<span class="card-label">{{ _('Loan Date') }}</span>
<span class="card-value">{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="p-8 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p>{{ _('No active loans') }}</p>
</div>
{% endif %}
</div>
</div>
<script>
// Show mobile cards on small screens
if (window.innerWidth <= 640) {
document.querySelectorAll('.table-card-mobile').forEach(el => el.style.display = 'none');
document.querySelectorAll('.mobile-cards').forEach(el => el.style.display = 'flex');
}
</script>
{% endblock %}

View file

@ -1,122 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Loan Tablet') }}{% endblock %}
{% block content %}
<div class="max-w-2xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Loan Tablet') }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Loan a tablet to a user or student') }}</p>
</div>
{# Loan Form #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Loan Information') }}</h2>
</div>
<div class="p-6">
<form method="POST" action="/loan_tablet" class="space-y-6">
{# Tablet Selection #}
<div>
<label for="tablet_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Tablet') }} *</label>
<input type="text" id="tablet_search"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
placeholder="{{ gettext('Search tablets (brand, model, or serial)...') }}"
onkeyup="filterDropdown('tablet_search', 'tablet_id')">
<select id="tablet_id" name="tablet_id" required size="5"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
style="max-height: 200px; overflow-y: auto;">
<option value="">{{ gettext('Select a tablet') }}</option>
{% for tablet in available_tablets %}
<option value="{{ tablet.id }}"
data-search="{{ tablet.brand|lower }} {{ tablet.model|lower }} {{ tablet.serial_number|lower }}">
{{ tablet.brand }} {{ tablet.model }} ({{ tablet.serial_number }})
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select which tablet to loan') }}</p>
</div>
{# User Selection (Staff) #}
<div>
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Staff User') }} *</label>
<input type="text" id="user_search"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
placeholder="{{ gettext('Search users (name or ID)...') }}"
onkeyup="filterDropdown('user_search', 'user_id')">
<select id="user_id" name="user_id" required size="5"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
style="max-height: 200px; overflow-y: auto;">
<option value="">{{ gettext('Select a user') }}</option>
{% for user in users %}
<option value="{{ user.id }}"
data-search="{{ user.name|lower }} {{ user.identification|lower }}">
{{ user.name }} ({{ user.identification }})
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select the staff member processing the loan') }}</p>
</div>
{# Student Selection (Optional) #}
<div>
<label for="student_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Student (Optional)') }}</label>
<input type="text" id="student_search"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
placeholder="{{ gettext('Search students (name, CIAL, or NIF)...') }}"
onkeyup="filterDropdown('student_search', 'student_id')">
<select id="student_id" name="student_id" size="5"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
style="max-height: 200px; overflow-y: auto;">
<option value="">{{ gettext('Select a student (optional)') }}</option>
{% for student in students %}
<option value="{{ student.id }}"
data-search="{{ student.last_name|lower }} {{ student.first_name|lower }} {{ student.cial_code|lower }} {{ student.nif_nie_passport|lower }}">
{{ student.last_name }}, {{ student.first_name }} ({{ student.cial_code }})
</option>
{% endfor %}
</select>
<p class="mt-1 text-sm text-gray-500">{{ gettext('Optionally associate this loan with a student') }}</p>
</div>
{# Form Actions #}
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
<a href="/"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ gettext('Cancel') }}
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ gettext('Loan Tablet') }}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function filterDropdown(searchId, selectId) {
const search = document.getElementById(searchId).value.toLowerCase();
const select = document.getElementById(selectId);
const options = select.options;
for (let i = 1; i < options.length; i++) {
const searchText = options[i].getAttribute('data-search') || '';
options[i].style.display = searchText.includes(search) ? '' : 'none';
}
// Show first visible option if search is empty, otherwise keep selection
if (search === '') {
select.selectedIndex = 0;
}
}
</script>
{% endblock %}

View file

@ -1,149 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
{# Page Header #}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Non-Loanable Devices Inventory') }}</h1>
<p class="text-gray-600 mt-1">{{ _('These devices are tracked in inventory but cannot be loaned to users.') }}</p>
</div>
<a href="/add_non_loanable_device"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ _('Add Non-Loanable Device') }}
</a>
</div>
{% if devices %}
{# Desktop Table (hidden on mobile) #}
<div class="table-container table-card-mobile">
<table class="table-responsive">
<thead>
<tr>
<th>{{ _('Type') }}</th>
<th>{{ _('Brand') }}</th>
<th>{{ _('Model') }}</th>
<th>{{ _('Serial Number') }}</th>
<th>{{ _('Location') }}</th>
<th>{{ _('Status') }}</th>
<th class="text-right">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
<tr>
<td>{{ device.device_type }}</td>
<td>{{ device.brand }}</td>
<td>{{ device.model }}</td>
<td>
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ device.serial_number }}</code>
</td>
<td>{{ device.location or '-' }}</td>
<td>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if device.status == 'available' %}bg-green-100 text-green-800
{% elif device.status == 'maintenance' %}bg-yellow-100 text-yellow-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ device.status }}
</span>
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<a href="/edit_non_loanable_device/{{ device.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
{{ _('Edit') }}
</a>
<a href="/delete_non_loanable_device/{{ device.id }}"
onclick="return confirm('{{ _("%") }}')"
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
{{ _('Delete') }}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Mobile Cards (hidden on desktop) #}
<div class="mobile-cards hidden">
{% for device in devices %}
<div class="mobile-card">
<div class="flex justify-between items-start mb-3">
<div>
<div class="font-medium text-gray-900">{{ device.brand }} {{ device.model }}</div>
<div class="text-sm text-gray-500">{{ device.device_type }}</div>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
{% if device.status == 'available' %}bg-green-100 text-green-800
{% elif device.status == 'maintenance' %}bg-yellow-100 text-yellow-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ device.status }}
</span>
</div>
<div class="space-y-2">
<div class="card-row">
<span class="card-label">{{ _('Serial Number') }}</span>
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ device.serial_number }}</code>
</div>
{% if device.location %}
<div class="card-row">
<span class="card-label">{{ _('Location') }}</span>
<span class="card-value">{{ device.location }}</span>
</div>
{% endif %}
</div>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
<a href="/edit_non_loanable_device/{{ device.id }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
{{ _('Edit') }}
</a>
<a href="/delete_non_loanable_device/{{ device.id }}"
onclick="return confirm('{{ _("%") }}')"
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
{{ _('Delete') }}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
</svg>
<p class="text-gray-500">{{ _('No non-loanable devices registered.') }}</p>
<a href="/add_non_loanable_device"
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
{{ _('Add Device') }}
</a>
</div>
{% endif %}
</div>
<script>
// Show mobile cards on small screens
if (window.innerWidth <= 640) {
document.querySelector('.table-card-mobile').style.display = 'none';
document.querySelector('.mobile-cards').style.display = 'flex';
}
</script>
{% endblock %}

View file

@ -1,122 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-6xl mx-auto space-y-6">
{# Page Header #}
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ _('Project Management - Development Notes') }}</h1>
<p class="text-gray-600 mt-1">{{ _('Internal development notes and project documentation') }}</p>
</div>
{# Editor Section #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4 flex justify-between items-center">
<h2 class="text-lg font-semibold text-white">{{ _('Editor') }}</h2>
<div class="flex gap-2">
<button onclick="saveNotes()"
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
{{ _('Save Notes') }}
</button>
<button onclick="loadNotes()"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
{{ _('Reload') }}
</button>
</div>
</div>
<div class="p-6">
<div id="status" class="text-sm text-gray-500 italic mb-4"></div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold text-gray-800 mb-3">{{ _('Editor') }}</h3>
<textarea id="markdown-editor" rows="20"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 font-mono text-sm"
placeholder="{{ _('Write your markdown notes here...') }}"></textarea>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-800 mb-3">{{ _('Preview') }}</h3>
<div id="markdown-preview"
class="w-full min-h-[400px] p-4 border border-gray-300 rounded-md bg-gray-50 overflow-y-auto prose prose-sm max-w-none"></div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script>
const NOTES_FILE = 'notes_development/project_notes.md';
// Initialize: load and render notes
document.addEventListener('DOMContentLoaded', function() {
loadNotes();
// Set up live preview
const editor = document.getElementById('markdown-editor');
const preview = document.getElementById('markdown-preview');
editor.addEventListener('input', function() {
preview.innerHTML = marked.parse(editor.value);
});
});
function loadNotes() {
fetch('/get_notes/' + NOTES_FILE)
.then(response => {
if (!response.ok) {
throw new Error('File not found, using default');
}
return response.json();
})
.then(data => {
document.getElementById('markdown-editor').value = data.content;
document.getElementById('markdown-preview').innerHTML = marked.parse(data.content);
showStatus('{{ _("Notes loaded successfully") }}');
})
.catch(error => {
console.error('Error loading notes:', error);
showStatus('{{ _("Error loading notes") }}', true);
});
}
function saveNotes() {
const content = document.getElementById('markdown-editor').value;
fetch('/save_notes/' + NOTES_FILE, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ content: content })
})
.then(response => response.json())
.then(data => {
if (data.success) {
showStatus('{{ _("Notes saved successfully") }}');
} else {
showStatus('{{ _("Error saving notes") }}: ' + data.error, true);
}
})
.catch(error => {
console.error('Error saving notes:', error);
showStatus('{{ _("Error saving notes") }}', true);
});
}
function showStatus(message, isError = false) {
const status = document.getElementById('status');
status.textContent = message;
status.className = 'text-sm italic ' + (isError ? 'text-red-600' : 'text-green-600');
// Clear status after 3 seconds
setTimeout(() => {
status.textContent = '';
status.className = 'text-sm text-gray-500 italic';
}, 3000);
}
</script>
{% endblock %}

View file

@ -1,179 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Student Details') }} - {{ student.full_name }}{% endblock %}
{% block content %}
<div class="space-y-6">
{# Breadcrumb #}
<nav class="flex" aria-label="Breadcrumb">
<ol class="flex items-center space-x-2">
<li>
<a href="{{ url_for('list_students') }}" class="text-cobalt-600 hover:text-cobalt-800">{{ gettext('Students') }}</a>
</li>
<li>
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
</li>
<li>
<span class="text-gray-500">{{ student.full_name }}</span>
</li>
</ol>
</nav>
{# Student Header #}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ student.full_name }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Student Details') }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('edit_student', student_id=student.id) }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
{{ gettext('Edit') }}
</a>
<a href="{{ url_for('delete_student', student_id=student.id) }}"
onclick="return confirm('{{ gettext('Are you sure you want to delete this student?') }}')"
class="inline-flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
{{ gettext('Delete') }}
</a>
</div>
</div>
{# Student Information and Assigned Devices #}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
{# Student Information Card #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-cobalt-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
</div>
<div class="p-6">
<dl class="space-y-4">
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('ID') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.id }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('CIAL Code') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">
<code class="bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('NIF/NIE/Passport') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.nif_nie_passport or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Registration Number') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.registration_number or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('File Number') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.file_number or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Order Number') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.order_number or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Full Name') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.full_name }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('First Name') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.first_name }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Last Name') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.last_name }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Birth Date') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.birth_date or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Gender') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.gender or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Study Group') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.study_group or '-' }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Created') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.created_at }}</dd>
</div>
<div class="flex flex-col sm:flex-row sm:items-center">
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Updated') }}:</dt>
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.updated_at }}</dd>
</div>
</dl>
</div>
</div>
{# Assigned Devices Card #}
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="bg-green-600 px-6 py-4">
<h2 class="text-lg font-semibold text-white">{{ gettext('Assigned Devices') }}</h2>
</div>
<div class="p-6">
{# Assigned Tablets #}
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">{{ gettext('Tablets') }}</h3>
{% if assigned_tablets %}
<div class="space-y-3 mb-6">
{% for tablet in assigned_tablets %}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900">{{ tablet.brand }} {{ tablet.model }}</div>
<div class="text-sm text-gray-500">
{{ gettext('Serial:') }} {{ tablet.serial_number }}
</div>
<div class="text-sm text-gray-500">
{{ gettext('Status:') }}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{% if tablet.status == 'available' %}bg-green-100 text-green-800
{% elif tablet.status == 'loaned' %}bg-yellow-100 text-yellow-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ tablet.status }}
</span>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 text-sm mb-6">{{ gettext('No tablets assigned.') }}</p>
{% endif %}
{# Assigned Non-Loanable Devices #}
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">{{ gettext('Non-Loanable Devices') }}</h3>
{% if assigned_devices %}
<div class="space-y-3">
{% for device in assigned_devices %}
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900">{{ device.brand }} {{ device.model }}</div>
<div class="text-sm text-gray-500">
{{ gettext('Serial:') }} {{ device.serial_number }}
</div>
<div class="text-sm text-gray-500">
{{ gettext('Type:') }} {{ device.device_type }}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 text-sm">{{ gettext('No non-loanable devices assigned.') }}</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,201 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ gettext('Students') }}{% endblock %}
{% block content %}
<div class="space-y-6">
{# Page Header #}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Students') }}</h1>
<p class="text-gray-600 mt-1">{{ gettext('Total students:') }} {{ total_students }}</p>
</div>
<div class="flex gap-2">
<a href="{{ url_for('add_student') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
{{ gettext('Add Student') }}
</a>
<a href="{{ url_for('import_students') }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
</svg>
{{ gettext('Import CSV') }}
</a>
</div>
</div>
{# Search and Filter Form #}
<div class="bg-white rounded-lg shadow p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ gettext('Search & Filter') }}</h2>
<form method="get" action="{{ url_for('list_students') }}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Search') }}</label>
<input type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
id="search" name="search" value="{{ search }}" placeholder="{{ gettext('Name, CIAL, NIF...') }}">
</div>
<div>
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
<select class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500" id="gender" name="gender">
<option value="">{{ gettext('All') }}</option>
{% for g in genders %}
<option value="{{ g }}" {% if gender == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
<select class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500" id="group" name="group">
<option value="">{{ gettext('All') }}</option>
{% for g in groups %}
<option value="{{ g }}" {% if group == g %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
</div>
<div class="flex items-end gap-2">
<button type="submit" class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
{{ gettext('Search') }}
</button>
<a href="{{ url_for('list_students') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ gettext('Clear') }}
</a>
</div>
</form>
</div>
{% if students %}
{# Desktop Table (hidden on mobile) #}
<div class="table-container table-card-mobile">
<table class="table-responsive">
<thead>
<tr>
<th>{{ gettext('ID') }}</th>
<th>{{ gettext('Name') }}</th>
<th>{{ gettext('CIAL Code') }}</th>
<th>{{ gettext('NIF/NIE') }}</th>
<th>{{ gettext('Gender') }}</th>
<th>{{ gettext('Study Group') }}</th>
<th>{{ gettext('Birth Date') }}</th>
<th class="text-right">{{ gettext('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for student in students %}
<tr>
<td>{{ student.id }}</td>
<td>
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800 font-medium">
{{ student.last_name }}, {{ student.first_name }}
</a>
</td>
<td>
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
</td>
<td>{{ student.nif_nie_passport }}</td>
<td>{{ student.gender }}</td>
<td>{{ student.study_group }}</td>
<td class="text-sm text-gray-500">{{ student.birth_date }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<a href="{{ url_for('student_detail', student_id=student.id) }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
{{ gettext('View') }}
</a>
<a href="{{ url_for('edit_student', student_id=student.id) }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-lg text-sm hover:bg-yellow-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
{{ gettext('Edit') }}
</a>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Mobile Cards (hidden on desktop) #}
<div class="mobile-cards hidden">
{% for student in students %}
<div class="mobile-card">
<div class="flex justify-between items-start mb-3">
<div>
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800 font-medium">
{{ student.last_name }}, {{ student.first_name }}
</a>
<div class="text-sm text-gray-500">{{ student.study_group }} | {{ student.gender }}</div>
</div>
</div>
<div class="space-y-2">
<div class="card-row">
<span class="card-label">{{ gettext('CIAL Code') }}</span>
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
</div>
<div class="card-row">
<span class="card-label">{{ gettext('NIF/NIE') }}</span>
<span class="card-value">{{ student.nif_nie_passport }}</span>
</div>
<div class="card-row">
<span class="card-label">{{ gettext('Birth Date') }}</span>
<span class="card-value">{{ student.birth_date }}</span>
</div>
</div>
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
<a href="{{ url_for('student_detail', student_id=student.id) }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
</svg>
{{ gettext('View') }}
</a>
<a href="{{ url_for('edit_student', student_id=student.id) }}"
class="inline-flex items-center gap-1 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-lg text-sm hover:bg-yellow-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
{{ gettext('Edit') }}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="bg-white rounded-lg shadow p-8 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zM12 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
<p class="text-gray-500">{{ gettext('No students found.') }}</p>
<a href="{{ url_for('add_student') }}"
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
{{ gettext('Add Student') }}
</a>
</div>
{% endif %}
</div>
<script>
// Show mobile cards on small screens
if (window.innerWidth <= 640) {
document.querySelector('.table-card-mobile').style.display = 'none';
document.querySelector('.mobile-cards').style.display = 'flex';
}
</script>
{% endblock %}

View file

@ -1,122 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h2 class="text-xl font-semibold text-gray-800">{{ _('User Loans') }}</h2>
<div class="text-sm text-gray-500">
<span id="result-count" hx-swap-oob="true">{{ total_users }} {{ _('users') }}</span>
</div>
</div>
<!-- Search and Filters Card -->
<div class="bg-white rounded-lg shadow p-4">
<form id="search-form"
hx-get="/user_loans"
hx-target="#results-container"
hx-swap="innerHTML"
hx-include="[name='search'], [name='status'], [name='page']"
class="space-y-4">
<!-- Search Row -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Search Input -->
<div class="md:col-span-2">
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
{{ _('Search Users') }}
</label>
<div class="relative">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
<input
type="text"
id="search"
name="search"
value="{{ search or '' }}"
placeholder="{{ _('Search by name or identification...') }}"
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
>
</div>
</div>
<!-- Status Filter -->
<div>
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
{{ _('Loan Status') }}
</label>
<select
id="status"
name="status"
class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
>
<option value="" {% if not status %}selected{% endif %}>{{ _('All Users') }}</option>
<option value="with_loans" {% if status == 'with_loans' %}selected{% endif %}>{{ _('With Active Loans') }}</option>
<option value="no_loans" {% if status == 'no_loans' %}selected{% endif %}>{{ _('No Active Loans') }}</option>
</select>
</div>
</div>
<!-- Submit Button -->
<button type="submit"
class="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center justify-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
{{ _('Search') }}
</button>
</form>
</div>
<!-- Loading Indicator -->
<div id="loading" class="htmx-indicator hidden"></div>
<!-- Results Container -->
<div id="results-container">
{% include 'components/user_loans_results.html' %}
</div>
</div>
<!-- HTMX Script to handle URL updates -->
<script>
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.requestConfig.method === 'get' && evt.detail.successful) {
// Update URL without reload
const url = new URL(window.location);
const form = document.getElementById('search-form');
if (form) {
const formData = new FormData(form);
for (let [key, value] of formData.entries()) {
if (value) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
window.history.pushState({}, '', url);
}
}
});
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const form = document.getElementById('search-form');
if (form) {
for (let [key, value] of urlParams.entries()) {
const input = form.querySelector(`[name="${key}"]`);
if (input) {
if (input.type === 'checkbox' || input.type === 'radio') {
input.checked = input.value === value;
} else {
input.value = value;
}
}
}
}
});
</script>
{% endblock %}

View file

@ -1,126 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<!-- Back Navigation -->
<div class="flex items-center gap-4">
<a href="/user_loans"
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 19l-7-7 7-7"></path>
</svg>
{{ _('Back to All Users') }}
</a>
</div>
<!-- User Header -->
<div class="bg-white rounded-lg shadow p-6">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-shrink-0">
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
<span class="text-2xl font-bold text-green-600">{{ user.name[:1].upper() }}</span>
</div>
</div>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-900">{{ user.name }}</h1>
<p class="text-gray-500">
{{ _('ID') }}: {{ user.id }} | {{ _('Identification') }}: {{ user.identification or _('N/A') }}
</p>
<div class="flex gap-4 mt-2">
<div class="flex items-center gap-2">
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
<span class="text-sm text-gray-600">{{ active_count }} {{ _('Active Loans') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-3 h-3 bg-gray-400 rounded-full"></span>
<span class="text-sm text-gray-600">{{ returned_count }} {{ _('Returned') }}</span>
</div>
</div>
</div>
<div class="flex gap-2">
{% if user.email %}
<a href="mailto:{{ user.email }}"
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
{{ _('Email') }}
</a>
{% endif %}
{% if user.phone %}
<a href="tel:{{ user.phone }}"
class="px-3 py-1 bg-purple-100 text-purple-700 rounded-lg text-sm hover:bg-purple-200">
{{ _('Call') }}
</a>
{% endif %}
</div>
</div>
</div>
<!-- Loan History -->
<div class="bg-white rounded-lg shadow">
<div class="p-6 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-800">{{ _('Loan History') }}</h2>
</div>
{% if loans %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ _('Tablet') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ _('Loan Date') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ _('Return Date') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{{ _('Status') }}
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for loan in loans %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
<div class="text-sm text-gray-500">{{ loan.serial_number }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
{{ loan.loan_date[11:16] if loan.loan_date else '' }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{% if loan.return_date %}
{{ loan.return_date[:10] }} {{ loan.return_date[11:16] }}
{% else %}
<span class="text-gray-400 italic">{{ _('Not returned') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
{% if loan.status == 'active' %}bg-green-100 text-green-800
{% elif loan.status == 'returned' %}bg-gray-100 text-gray-800
{% else %}bg-yellow-100 text-yellow-800
{% endif %}">
{{ loan.status|title }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-8 text-center text-gray-500">
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p>{{ _('No loan history for this user') }}</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View file

@ -1,210 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the Tablet Management System
"""
import sqlite3
from datetime import datetime
def test_tablet_management():
"""Test the tablet management system functionality"""
# Initialize database
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
# Create tables
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'
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
identification TEXT UNIQUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
conn.commit()
print("=== Testing Tablet Management System ===")
# Test 1: Add tablets
print("\n1. Adding tablets...")
tablets = [
("Samsung", "Galaxy Tab S7", "SN001"),
("Apple", "iPad Pro", "SN002"),
("Lenovo", "Tab P11", "SN003")
]
for brand, model, serial in tablets:
try:
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', (brand, model, serial))
print(f" ✓ Added: {brand} {model} ({serial})")
except sqlite3.IntegrityError:
print(f" ✗ Duplicate: {serial}")
conn.commit()
# Test 2: Add users
print("\n2. Adding users...")
users = [
("John Doe", "ID001"),
("Jane Smith", "ID002"),
("Bob Johnson", "ID003")
]
for name, identification in users:
try:
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', (name, identification))
print(f" ✓ Added: {name} ({identification})")
except sqlite3.IntegrityError:
print(f" ✗ Duplicate: {identification}")
conn.commit()
# Test 3: Show available tablets
print("\n3. Available tablets:")
cursor.execute("SELECT id, brand, model, serial_number FROM tablets WHERE status = 'available'")
available_tablets = cursor.fetchall()
for tablet in available_tablets:
print(f" ID {tablet[0]}: {tablet[1]} {tablet[2]} ({tablet[3]})")
# Test 4: Loan tablets
print("\n4. Loaning tablets...")
# Loan tablet 1 to user 1
tablet_id = 1
user_id = 1
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
print(f" ✓ Loaned tablet {tablet_id} to user {user_id}")
# Loan tablet 2 to user 2
tablet_id = 2
user_id = 2
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
print(f" ✓ Loaned tablet {tablet_id} to user {user_id}")
conn.commit()
# Test 5: Show active loans
print("\n5. Active loans:")
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
WHERE l.status = 'active'
''')
active_loans = cursor.fetchall()
for loan in active_loans:
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]}) to {loan[4]} on {loan[5]}")
# Test 6: Return a tablet
print("\n6. Returning tablet...")
loan_id = 1
cursor.execute("SELECT tablet_id FROM loans WHERE id = ?", (loan_id,))
loan = cursor.fetchone()
if loan:
tablet_id = loan[0]
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
''', (return_date, loan_id))
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
print(f" ✓ Returned tablet for loan {loan_id}")
conn.commit()
# Test 7: Show loan history
print("\n7. Complete loan history:")
cursor.execute('''
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
l.loan_date, l.return_date, l.status
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
ORDER BY l.loan_date DESC
''')
loans = cursor.fetchall()
for loan in loans:
return_date = loan[6] or 'Not returned'
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]}) to {loan[4]}")
print(f" Loan: {loan[5]}, Return: {return_date}, Status: {loan[7]}")
# Test 8: Show final status
print("\n8. Final status:")
# Available tablets
cursor.execute("SELECT COUNT(*) FROM tablets WHERE status = 'available'")
available_count = cursor.fetchone()[0]
print(f" Available tablets: {available_count}")
# Loaned tablets
cursor.execute("SELECT COUNT(*) FROM tablets WHERE status = 'loaned'")
loaned_count = cursor.fetchone()[0]
print(f" Loaned tablets: {loaned_count}")
# Active loans
cursor.execute("SELECT COUNT(*) FROM loans WHERE status = 'active'")
active_loans_count = cursor.fetchone()[0]
print(f" Active loans: {active_loans_count}")
# Returned loans
cursor.execute("SELECT COUNT(*) FROM loans WHERE status = 'returned'")
returned_loans_count = cursor.fetchone()[0]
print(f" Returned loans: {returned_loans_count}")
conn.close()
print("\n=== Test completed successfully! ===")
print("\nYou can now run the interactive application:")
print(" python3 minimal_app.py")
print("\nOr use the database directly with SQLite:")
print(" sqlite3 tablets.db")
if __name__ == '__main__':
test_tablet_management()

View file

@ -1,3 +0,0 @@
"""
Unit tests for Tablet Management System
"""

View file

@ -1,181 +0,0 @@
"""
Pytest configuration and fixtures for Tablet Management System tests
"""
import pytest
import sqlite3
import os
import sys
from datetime import datetime
# Add project root to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@pytest.fixture
def test_db_path():
"""Path to test database"""
return 'test_tablets.db'
@pytest.fixture
def init_test_db(test_db_path):
"""Initialize a fresh test database with schema"""
# Remove existing test database if it exists
if os.path.exists(test_db_path):
os.remove(test_db_path)
conn = sqlite3.connect(test_db_path)
cursor = conn.cursor()
# Create tables
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
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
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()
yield test_db_path
# Cleanup: remove test database
if os.path.exists(test_db_path):
os.remove(test_db_path)
@pytest.fixture
def db_conn(init_test_db):
"""Get a database connection to the test database"""
conn = sqlite3.connect(init_test_db)
conn.row_factory = sqlite3.Row
yield conn
conn.close()
@pytest.fixture
def sample_tablets(db_conn):
"""Insert sample tablets into test database"""
cursor = db_conn.cursor()
tablets = [
('Samsung', 'Galaxy Tab S7', 'SN001', 'available'),
('Apple', 'iPad Pro', 'SN002', 'available'),
('Lenovo', 'Tab P11', 'SN003', 'available'),
('Microsoft', 'Surface Pro', 'SN004', 'loaned'),
]
for brand, model, serial, status in tablets:
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, ?)
''', (brand, model, serial, status))
db_conn.commit()
return tablets
@pytest.fixture
def sample_users(db_conn):
"""Insert sample users into test database"""
cursor = db_conn.cursor()
users = [
('John Doe', 'john@example.com', '1234567890', 'ID001'),
('Jane Smith', 'jane@example.com', '0987654321', 'ID002'),
('Bob Johnson', 'bob@example.com', '5551234567', 'ID003'),
]
for name, email, phone, identification in users:
cursor.execute('''
INSERT INTO users (name, email, phone, identification)
VALUES (?, ?, ?, ?)
''', (name, email, phone, identification))
db_conn.commit()
return users
@pytest.fixture
def sample_loans(db_conn, sample_tablets, sample_users):
"""Insert sample loans into test database"""
cursor = db_conn.cursor()
# Get tablet and user IDs
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'SN004'")
loaned_tablet = cursor.fetchone()
cursor.execute("SELECT id FROM users WHERE identification = 'ID001'")
user1 = cursor.fetchone()
cursor.execute("SELECT id FROM users WHERE identification = 'ID002'")
user2 = cursor.fetchone()
if loaned_tablet and user1:
# Active loan for SN004 to user1
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (loaned_tablet['id'], user1['id'], loan_date))
if user2:
# Returned loan for SN001 to user2
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, return_date, status)
VALUES (?, ?, ?, ?, 'returned')
''', (1, user2['id'], loan_date, return_date))
db_conn.commit()
@pytest.fixture
def populated_db(db_conn, sample_tablets, sample_users, sample_loans):
"""Database with all sample data loaded"""
return db_conn

View file

@ -1,2 +0,0 @@
pytest==8.3.2
pytest-cov==5.0.0

View file

@ -1,481 +0,0 @@
"""
Unit tests for core tablet management functions
Tests loan logic, validation, and database operations
"""
import pytest
import sqlite3
from datetime import datetime
import sys
import os
# Import the functions from minimal_app (they work with any db connection)
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def get_tablet_count(conn):
"""Helper to get tablet count"""
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM tablets")
return cursor.fetchone()[0]
def get_user_count(conn):
"""Helper to get user count"""
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM users")
return cursor.fetchone()[0]
def get_loan_count(conn):
"""Helper to get loan count"""
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM loans")
return cursor.fetchone()[0]
class TestTabletOperations:
"""Tests for tablet CRUD operations"""
def test_add_tablet_success(self, db_conn):
"""Test adding a new tablet successfully"""
cursor = db_conn.cursor()
# Add a tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('TestBrand', 'TestModel', 'TEST001'))
db_conn.commit()
# Verify it was added
cursor.execute("SELECT * FROM tablets WHERE serial_number = 'TEST001'")
tablet = cursor.fetchone()
assert tablet is not None
assert tablet['brand'] == 'TestBrand'
assert tablet['model'] == 'TestModel'
assert tablet['serial_number'] == 'TEST001'
assert tablet['status'] == 'available'
def test_add_tablet_duplicate_serial(self, db_conn):
"""Test that duplicate serial numbers are rejected"""
cursor = db_conn.cursor()
# Add first tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand1', 'Model1', 'DUP001'))
db_conn.commit()
# Try to add duplicate
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand2', 'Model2', 'DUP001'))
db_conn.commit()
def test_tablet_status_update(self, db_conn):
"""Test updating tablet status"""
cursor = db_conn.cursor()
# Add a tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'STATUS001'))
db_conn.commit()
# Update status
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE serial_number = 'STATUS001'")
db_conn.commit()
# Verify update
cursor.execute("SELECT status FROM tablets WHERE serial_number = 'STATUS001'")
status = cursor.fetchone()['status']
assert status == 'loaned'
class TestUserOperations:
"""Tests for user CRUD operations"""
def test_add_user_success(self, db_conn):
"""Test adding a new user successfully"""
cursor = db_conn.cursor()
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('Test User', 'TESTID001'))
db_conn.commit()
cursor.execute("SELECT * FROM users WHERE identification = 'TESTID001'")
user = cursor.fetchone()
assert user is not None
assert user['name'] == 'Test User'
assert user['identification'] == 'TESTID001'
def test_add_user_duplicate_identification(self, db_conn):
"""Test that duplicate user identifications are rejected"""
cursor = db_conn.cursor()
# Add first user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User1', 'DUPID001'))
db_conn.commit()
# Try to add duplicate
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User2', 'DUPID001'))
db_conn.commit()
def test_user_with_contact_info(self, db_conn):
"""Test adding user with email and phone"""
cursor = db_conn.cursor()
cursor.execute('''
INSERT INTO users (name, email, phone, identification)
VALUES (?, ?, ?, ?)
''', ('Contact User', 'test@email.com', '1234567890', 'CONTACT001'))
db_conn.commit()
cursor.execute("SELECT * FROM users WHERE identification = 'CONTACT001'")
user = cursor.fetchone()
assert user['email'] == 'test@email.com'
assert user['phone'] == '1234567890'
class TestLoanOperations:
"""Tests for loan operations - the core business logic"""
def test_loan_tablet_success(self, populated_db):
"""Test loaning an available tablet to a user"""
cursor = populated_db.cursor()
# Get an available tablet and user
cursor.execute("SELECT id FROM tablets WHERE status = 'available' LIMIT 1")
tablet = cursor.fetchone()
cursor.execute("SELECT id FROM users LIMIT 1")
user = cursor.fetchone()
assert tablet is not None
assert user is not None
tablet_id = tablet['id']
user_id = user['id']
# Loan the tablet
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
populated_db.commit()
# Verify tablet status changed
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
status = cursor.fetchone()['status']
assert status == 'loaned'
# Verify loan was created
cursor.execute("SELECT * FROM loans WHERE tablet_id = ? AND user_id = ?", (tablet_id, user_id))
loan = cursor.fetchone()
assert loan is not None
assert loan['status'] == 'active'
assert loan['return_date'] is None
def test_loan_already_loaned_tablet(self, populated_db):
"""Test that loaning an already loaned tablet fails gracefully"""
cursor = populated_db.cursor()
# Get a loaned tablet (SN004 should be loaned from sample data)
cursor.execute("SELECT id FROM tablets WHERE status = 'loaned' LIMIT 1")
tablet = cursor.fetchone()
cursor.execute("SELECT id FROM users LIMIT 1")
user = cursor.fetchone()
if tablet and user:
tablet_id = tablet['id']
user_id = user['id']
# Try to loan it again (should check status first)
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
status = cursor.fetchone()['status']
# This should be 'loaned', so we shouldn't be able to loan it
assert status == 'loaned'
# The application logic should prevent this
# In the actual app, this would be checked before inserting
# Here we verify the status is still loaned
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
final_status = cursor.fetchone()['status']
assert final_status == 'loaned'
def test_return_tablet_success(self, populated_db):
"""Test returning a loaned tablet"""
cursor = populated_db.cursor()
# Get an active loan
cursor.execute("SELECT * FROM loans WHERE status = 'active' LIMIT 1")
loan = cursor.fetchone()
if loan:
loan_id = loan['id']
tablet_id = loan['tablet_id']
# Return the tablet
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
''', (return_date, loan_id))
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
populated_db.commit()
# Verify loan status changed
cursor.execute("SELECT status, return_date FROM loans WHERE id = ?", (loan_id,))
updated_loan = cursor.fetchone()
assert updated_loan['status'] == 'returned'
assert updated_loan['return_date'] is not None
# Verify tablet status changed
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
tablet_status = cursor.fetchone()['status']
assert tablet_status == 'available'
def test_return_nonexistent_loan(self, db_conn):
"""Test returning a loan that doesn't exist"""
cursor = db_conn.cursor()
# Try to return a non-existent loan
cursor.execute("SELECT tablet_id FROM loans WHERE id = ? AND status = 'active'", (9999,))
loan = cursor.fetchone()
# Should be None since loan doesn't exist
assert loan is None
class TestEdgeCases:
"""Tests for edge cases and error conditions"""
def test_loan_to_nonexistent_user(self, db_conn):
"""Test loaning to a user that doesn't exist"""
cursor = db_conn.cursor()
# Add a tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'EDGE001'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'EDGE001'")
tablet = cursor.fetchone()
# Try to loan to non-existent user (ID 9999)
# Note: SQLite doesn't enforce foreign keys by default unless we enable it
# The application should validate this at the application level
# For now, we verify that the user doesn't exist
cursor.execute("SELECT id FROM users WHERE id = 9999")
user = cursor.fetchone()
assert user is None # User doesn't exist
# In a real app with FK enforcement, this would raise IntegrityError
# For SQLite without FK enforcement, we just verify the user check
def test_loan_nonexistent_tablet(self, db_conn):
"""Test loaning a tablet that doesn't exist"""
cursor = db_conn.cursor()
# Add a user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('Test User', 'EDGEID001'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'EDGEID001'")
user = cursor.fetchone()
# Try to loan non-existent tablet (ID 9999)
# Note: SQLite doesn't enforce foreign keys by default unless we enable it
# The application should validate this at the application level
# For now, we verify that the tablet doesn't exist
cursor.execute("SELECT id FROM tablets WHERE id = 9999")
tablet_check = cursor.fetchone()
assert tablet_check is None # Tablet doesn't exist
# In a real app with FK enforcement, this would raise IntegrityError
# For SQLite without FK enforcement, we just verify the tablet check
def test_empty_database_operations(self, db_conn):
"""Test operations on empty database"""
cursor = db_conn.cursor()
# Query empty tables
cursor.execute("SELECT COUNT(*) FROM tablets")
tablet_count = cursor.fetchone()[0]
assert tablet_count == 0
cursor.execute("SELECT COUNT(*) FROM users")
user_count = cursor.fetchone()[0]
assert user_count == 0
cursor.execute("SELECT COUNT(*) FROM loans")
loan_count = cursor.fetchone()[0]
assert loan_count == 0
def test_multiple_loans_same_user(self, db_conn):
"""Test that one user can have multiple loans (one-to-many relationship)"""
cursor = db_conn.cursor()
# Add user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('MultiLoan User', 'MULTI001'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'MULTI001'")
user = cursor.fetchone()
# Add multiple tablets
for i in range(3):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', (f'Brand{i}', f'Model{i}', f'MULTI{i:03d}'))
db_conn.commit()
# Loan all tablets to the same user
cursor.execute("SELECT id FROM tablets WHERE serial_number LIKE 'MULTI%'")
tablets = cursor.fetchall()
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
for tablet in tablets:
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet['id'], user['id'], loan_date))
db_conn.commit()
# Verify user has multiple loans
cursor.execute("SELECT COUNT(*) FROM loans WHERE user_id = ?", (user['id'],))
loan_count = cursor.fetchone()[0]
assert loan_count == 3
def test_serial_number_uniqueness_across_tables(self, db_conn):
"""Test that serial numbers are unique within their respective tables"""
cursor = db_conn.cursor()
# Add tablet with serial number
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'UNIQUE001'))
db_conn.commit()
# Add non-loanable device with same serial number (should be allowed - different tables)
cursor.execute('''
INSERT INTO non_loanable_devices
(brand, model, serial_number, device_type, status)
VALUES (?, ?, ?, ?, 'available')
''', ('Brand', 'Model', 'UNIQUE001', 'projector'))
db_conn.commit()
# Both should exist (different tables)
cursor.execute("SELECT COUNT(*) FROM tablets WHERE serial_number = 'UNIQUE001'")
tablet_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM non_loanable_devices WHERE serial_number = 'UNIQUE001'")
device_count = cursor.fetchone()[0]
assert tablet_count == 1
assert device_count == 1
# But duplicate within same table should fail
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand2', 'Model2', 'UNIQUE001'))
db_conn.commit()
class TestQueryOperations:
"""Tests for query and filtering operations"""
def test_query_available_tablets(self, populated_db):
"""Test querying available tablets"""
cursor = populated_db.cursor()
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
available = cursor.fetchall()
# Should have at least the sample available tablets
assert len(available) >= 3 # SN001, SN002, SN003 from sample
def test_query_loaned_tablets(self, populated_db):
"""Test querying loaned tablets"""
cursor = populated_db.cursor()
cursor.execute("SELECT * FROM tablets WHERE status = 'loaned'")
loaned = cursor.fetchall()
# Should have at least SN004 from sample data
assert len(loaned) >= 1
def test_query_active_loans(self, populated_db):
"""Test querying active loans"""
cursor = populated_db.cursor()
cursor.execute("SELECT * FROM loans WHERE status = 'active'")
active = cursor.fetchall()
# Should have at least 1 active loan from sample
assert len(active) >= 1
def test_query_returned_loans(self, populated_db):
"""Test querying returned loans"""
cursor = populated_db.cursor()
cursor.execute("SELECT * FROM loans WHERE status = 'returned'")
returned = cursor.fetchall()
# Should have at least 1 returned loan from sample
assert len(returned) >= 1
def test_query_loans_by_user(self, populated_db):
"""Test querying loans by user"""
cursor = populated_db.cursor()
cursor.execute("SELECT id FROM users LIMIT 1")
user = cursor.fetchone()
if user:
cursor.execute("SELECT * FROM loans WHERE user_id = ?", (user['id'],))
loans = cursor.fetchall()
# User should have at least 0 loans
assert isinstance(loans, list)
def test_query_tablets_by_brand(self, populated_db):
"""Test querying tablets by brand"""
cursor = populated_db.cursor()
cursor.execute("SELECT * FROM tablets WHERE brand = 'Samsung'")
samsung = cursor.fetchall()
# Should find Samsung tablet from sample data
assert len(samsung) >= 1
assert samsung[0]['brand'] == 'Samsung'

View file

@ -1,513 +0,0 @@
"""
Edge case tests for Tablet Management System
Tests critical scenarios: already loaned devices, non-existent loans, duplicates, etc.
"""
import pytest
import sqlite3
from datetime import datetime
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
class TestLoanEdgeCases:
"""Critical edge cases for loan operations"""
def test_loan_device_already_loaned(self, db_conn):
"""
CRITICAL: Test that a device already loaned cannot be loaned again
This prevents the same physical device from being loaned to multiple users
"""
cursor = db_conn.cursor()
# Add a tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'ALREADY_LOANED'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'ALREADY_LOANED'")
tablet = cursor.fetchone()
# Add a user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User1', 'USER1'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'USER1'")
user1 = cursor.fetchone()
# Add another user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User2', 'USER2'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'USER2'")
user2 = cursor.fetchone()
# Loan to first user
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet['id'], user1['id'], loan_date))
db_conn.commit()
# Verify tablet is loaned
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet['id'],))
status = cursor.fetchone()['status']
assert status == 'loaned'
# Try to loan to second user - should check status first
# In the actual application, this would be prevented by checking status
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet['id'],))
current_status = cursor.fetchone()['status']
# The application logic should prevent this
assert current_status == 'loaned'
# If we tried to loan it anyway (without checking), we'd get a constraint error
# because the tablet status is already 'loaned'
# The proper app logic checks status before allowing loan
def test_return_nonexistent_loan_id(self, db_conn):
"""
CRITICAL: Test returning a loan that doesn't exist
Should handle gracefully without crashing
"""
cursor = db_conn.cursor()
# Try to return a non-existent loan
loan_id = 99999
cursor.execute("SELECT tablet_id FROM loans WHERE id = ? AND status = 'active'", (loan_id,))
loan = cursor.fetchone()
# Should return None (no such loan)
assert loan is None
# The application should handle this by showing an error message
# rather than crashing
def test_return_already_returned_loan(self, db_conn):
"""
CRITICAL: Test returning a loan that's already been returned
Should handle gracefully
"""
cursor = db_conn.cursor()
# Add tablet and user
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'ALREADY_RETURNED'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'ALREADY_RETURNED'")
tablet = cursor.fetchone()
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User', 'RETURN_USER'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'RETURN_USER'")
user = cursor.fetchone()
# Create and return a loan
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, return_date, status)
VALUES (?, ?, ?, ?, 'returned')
''', (tablet['id'], user['id'], loan_date, return_date))
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet['id'],))
db_conn.commit()
# Get the loan ID
cursor.execute("SELECT id FROM loans WHERE tablet_id = ?", (tablet['id'],))
loan = cursor.fetchone()
loan_id = loan['id']
# Try to return it again
cursor.execute("SELECT tablet_id FROM loans WHERE id = ? AND status = 'active'", (loan_id,))
active_loan = cursor.fetchone()
# Should be None because status is 'returned', not 'active'
assert active_loan is None
def test_loan_with_invalid_tablet_id(self, db_conn):
"""
CRITICAL: Test loaning with an invalid/non-existent tablet ID
Should fail gracefully
"""
cursor = db_conn.cursor()
# Add a user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User', 'INVALID_USER'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'INVALID_USER'")
user = cursor.fetchone()
# Try to loan with invalid tablet ID
invalid_tablet_id = 99999
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Note: SQLite doesn't enforce foreign keys by default unless we enable it
# The application should validate this at the application level
# For now, we verify that the tablet doesn't exist
cursor.execute("SELECT id FROM tablets WHERE id = ?", (invalid_tablet_id,))
tablet_check = cursor.fetchone()
assert tablet_check is None # Tablet doesn't exist
# In a real app with FK enforcement, this would raise IntegrityError
# For SQLite without FK enforcement, we just verify the tablet check
def test_loan_with_invalid_user_id(self, db_conn):
"""
CRITICAL: Test loaning with an invalid/non-existent user ID
Should fail gracefully
"""
cursor = db_conn.cursor()
# Add a tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'INVALID_LOAN'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'INVALID_LOAN'")
tablet = cursor.fetchone()
# Try to loan with invalid user ID
invalid_user_id = 99999
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Note: SQLite doesn't enforce foreign keys by default unless we enable it
# The application should validate this at the application level
# For now, we verify that the user doesn't exist
cursor.execute("SELECT id FROM users WHERE id = ?", (invalid_user_id,))
user_check = cursor.fetchone()
assert user_check is None # User doesn't exist
# In a real app with FK enforcement, this would raise IntegrityError
# For SQLite without FK enforcement, we just verify the user check
class TestDuplicatePrevention:
"""Tests for preventing duplicate entries"""
def test_duplicate_tablet_serial_number(self, db_conn):
"""
CRITICAL: Test that duplicate tablet serial numbers are prevented
Serial numbers must be unique for tracking
"""
cursor = db_conn.cursor()
# Add first tablet
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand1', 'Model1', 'DUP_SERIAL'))
db_conn.commit()
# Try to add duplicate
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand2', 'Model2', 'DUP_SERIAL'))
db_conn.commit()
def test_duplicate_user_identification(self, db_conn):
"""
CRITICAL: Test that duplicate user identifications are prevented
User identifications must be unique
"""
cursor = db_conn.cursor()
# Add first user
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User1', 'DUP_ID'))
db_conn.commit()
# Try to add duplicate
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User2', 'DUP_ID'))
db_conn.commit()
def test_duplicate_non_loanable_device_serial(self, db_conn):
"""
CRITICAL: Test that duplicate non-loanable device serials are prevented
"""
cursor = db_conn.cursor()
# Add first device
cursor.execute('''
INSERT INTO non_loanable_devices
(brand, model, serial_number, device_type, status)
VALUES (?, ?, ?, ?, 'available')
''', ('Brand', 'Model', 'DUP_DEVICE_SERIAL', 'projector'))
db_conn.commit()
# Try to add duplicate
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO non_loanable_devices
(brand, model, serial_number, device_type, status)
VALUES (?, ?, ?, ?, 'available')
''', ('Brand2', 'Model2', 'DUP_DEVICE_SERIAL', 'monitor'))
db_conn.commit()
class TestDataIntegrity:
"""Tests for data integrity constraints"""
def test_foreign_key_tablet_deletion(self, db_conn):
"""
Test that deleting a tablet with active loans is handled
SQLite defaults to allowing this, but we should be aware
"""
cursor = db_conn.cursor()
# Add tablet and user
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'FK_TEST'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'FK_TEST'")
tablet = cursor.fetchone()
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User', 'FK_USER'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'FK_USER'")
user = cursor.fetchone()
# Create active loan
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet['id'], user['id'], loan_date))
db_conn.commit()
# SQLite allows this by default (no ON DELETE RESTRICT)
# In production, we might want to add CASCADE or RESTRICT
# For now, just verify the loan exists
cursor.execute("SELECT COUNT(*) FROM loans WHERE tablet_id = ?", (tablet['id'],))
loan_count = cursor.fetchone()[0]
assert loan_count == 1
def test_null_serial_number_prevention(self, db_conn):
"""
Test that NULL serial numbers are prevented
Serial numbers are required (NOT NULL constraint)
"""
cursor = db_conn.cursor()
# Try to add tablet with NULL serial number
with pytest.raises(sqlite3.IntegrityError):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', None))
db_conn.commit()
def test_null_identification_prevention(self, db_conn):
"""
Test that NULL user identifications are prevented
Identifications are required (NOT NULL constraint)
"""
cursor = db_conn.cursor()
# Try to add user with NULL identification
# Note: identification is NOT marked as NOT NULL in the schema
# This test verifies the current behavior
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User', None))
db_conn.commit()
# This should work because identification is not NOT NULL
# But in practice, we should have this constraint
cursor.execute("SELECT COUNT(*) FROM users WHERE identification IS NULL")
count = cursor.fetchone()[0]
# This will be 1, showing that NULL is currently allowed
# In production, we should add NOT NULL constraint
def test_empty_string_serial_number(self, db_conn):
"""
Test handling of empty string serial numbers
Empty strings are different from NULL
"""
cursor = db_conn.cursor()
# Add tablet with empty string serial number
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', ''))
db_conn.commit()
# This should work (empty string is allowed unless we add CHECK constraint)
cursor.execute("SELECT COUNT(*) FROM tablets WHERE serial_number = ''")
count = cursor.fetchone()[0]
assert count == 1
# In production, we might want to prevent empty strings
# with a CHECK constraint: CHECK(serial_number <> '')
class TestConcurrentScenarioSimulations:
"""Simulate scenarios that could cause issues in concurrent environments"""
def test_loan_return_loan_sequence(self, db_conn):
"""
Test the sequence: loan -> return -> loan again
This simulates a device being loaned multiple times over its lifetime
"""
cursor = db_conn.cursor()
# Add tablet and user
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', ('Brand', 'Model', 'SEQUENCE_TEST'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'SEQUENCE_TEST'")
tablet = cursor.fetchone()
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', ('User', 'SEQUENCE_USER'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = 'SEQUENCE_USER'")
user = cursor.fetchone()
# First loan
loan_date1 = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet['id'], user['id'], loan_date1))
db_conn.commit()
# Return
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("SELECT id FROM loans WHERE tablet_id = ?", (tablet['id'],))
loan1 = cursor.fetchone()
cursor.execute('''
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
''', (return_date, loan1['id']))
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet['id'],))
db_conn.commit()
# Second loan
loan_date2 = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet['id'],))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet['id'], user['id'], loan_date2))
db_conn.commit()
# Verify we have 2 loans for this tablet
cursor.execute("SELECT COUNT(*) FROM loans WHERE tablet_id = ?", (tablet['id'],))
loan_count = cursor.fetchone()[0]
assert loan_count == 2
# Verify 1 active, 1 returned
cursor.execute("SELECT COUNT(*) FROM loans WHERE tablet_id = ? AND status = 'active'", (tablet['id'],))
active_count = cursor.fetchone()[0]
cursor.execute("SELECT COUNT(*) FROM loans WHERE tablet_id = ? AND status = 'returned'", (tablet['id'],))
returned_count = cursor.fetchone()[0]
assert active_count == 1
assert returned_count == 1
def test_multiple_users_multiple_tablets(self, db_conn):
"""
Test complex scenario with multiple users and tablets
Ensures the many-to-many relationship works correctly
"""
cursor = db_conn.cursor()
# Add 3 users
users = []
for i in range(3):
cursor.execute('''
INSERT INTO users (name, identification)
VALUES (?, ?)
''', (f'User{i}', f'MULTI_USER_{i}'))
db_conn.commit()
cursor.execute("SELECT id FROM users WHERE identification = ?", (f'MULTI_USER_{i}',))
users.append(cursor.fetchone())
# Add 5 tablets
tablets = []
for i in range(5):
cursor.execute('''
INSERT INTO tablets (brand, model, serial_number, status)
VALUES (?, ?, ?, 'available')
''', (f'Brand{i}', f'Model{i}', f'MULTI_TABLET_{i}'))
db_conn.commit()
cursor.execute("SELECT id FROM tablets WHERE serial_number = ?", (f'MULTI_TABLET_{i}',))
tablets.append(cursor.fetchone())
# Loan tablets to users in a pattern
# User 0: tablets 0, 1
# User 1: tablets 2, 3
# User 2: tablet 4
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
loans_map = {
users[0]['id']: [tablets[0]['id'], tablets[1]['id']],
users[1]['id']: [tablets[2]['id'], tablets[3]['id']],
users[2]['id']: [tablets[4]['id']],
}
for user_id, tablet_ids in loans_map.items():
for tablet_id in tablet_ids:
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
db_conn.commit()
# Verify counts
cursor.execute("SELECT COUNT(*) FROM loans")
total_loans = cursor.fetchone()[0]
assert total_loans == 5 # 2 + 2 + 1
# Verify each user has correct number of loans
for user_id, expected_tablet_ids in loans_map.items():
cursor.execute("SELECT COUNT(*) FROM loans WHERE user_id = ?", (user_id,))
count = cursor.fetchone()[0]
assert count == len(expected_tablet_ids)
# Verify all loaned tablets have correct status
cursor.execute("SELECT COUNT(*) FROM tablets WHERE status = 'loaned'")
loaned_count = cursor.fetchone()[0]
assert loaned_count == 5

View file

@ -1,365 +0,0 @@
"""
Unit tests for the actual application functions from minimal_app.py
Tests the real business logic with proper imports
"""
import pytest
import sqlite3
import sys
import os
from datetime import datetime
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Import functions from minimal_app
from minimal_app import (
add_tablet, add_user, loan_tablet, return_tablet,
show_available_tablets, show_active_loans, show_loan_history,
add_non_loanable_device, show_non_loanable_devices, delete_non_loanable_device
)
@pytest.fixture
def test_db_path():
"""Path to test database"""
return 'test_minimal_app.db'
@pytest.fixture
def init_test_db(test_db_path):
"""Initialize a fresh test database with schema (same as minimal_app)"""
# Remove existing test database if it exists
if os.path.exists(test_db_path):
os.remove(test_db_path)
# Use the same init_db function from minimal_app
from minimal_app import init_db
# Temporarily rename the database
original_db = 'tablets.db'
if os.path.exists(original_db):
os.rename(original_db, f'{original_db}.backup')
try:
# Create test database
os.environ['TEST_DB'] = test_db_path
init_db()
yield test_db_path
finally:
# Cleanup
if os.path.exists(test_db_path):
os.remove(test_db_path)
if os.path.exists(f'{original_db}.backup'):
os.rename(f'{original_db}.backup', original_db)
if 'TEST_DB' in os.environ:
del os.environ['TEST_DB']
@pytest.fixture
def clean_db():
"""Fixture that ensures we have a clean database for each test"""
# This is simpler - just create a temp database for each test
import tempfile
import shutil
# Create temp directory for database
temp_dir = tempfile.mkdtemp()
db_path = os.path.join(temp_dir, 'test.db')
# Initialize database
conn = sqlite3.connect(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'
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
identification TEXT UNIQUE
)
''')
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
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()
# Temporarily replace the database
original_db = 'tablets.db'
backup_path = f'{original_db}.test_backup'
# Backup original if exists
if os.path.exists(original_db):
if os.path.exists(backup_path):
os.remove(backup_path)
os.rename(original_db, backup_path)
# Copy temp db to tablets.db location
shutil.copy(db_path, original_db)
yield original_db
# Cleanup
if os.path.exists(original_db):
os.remove(original_db)
if os.path.exists(backup_path):
os.rename(backup_path, original_db)
shutil.rmtree(temp_dir, ignore_errors=True)
class TestMinimalAppFunctions:
"""Test the actual functions from minimal_app.py"""
def test_add_tablet_function(self, clean_db):
"""Test the add_tablet function"""
add_tablet('TestBrand', 'TestModel', 'TEST_SN_001')
# Verify it was added
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM tablets WHERE serial_number = 'TEST_SN_001'")
tablet = cursor.fetchone()
conn.close()
assert tablet is not None
assert tablet[1] == 'TestBrand' # brand is index 1
assert tablet[2] == 'TestModel' # model is index 2
assert tablet[3] == 'TEST_SN_001' # serial_number is index 3
def test_add_tablet_duplicate(self, clean_db, capsys):
"""Test that duplicate serial numbers are rejected"""
add_tablet('Brand1', 'Model1', 'DUP_SN')
add_tablet('Brand2', 'Model2', 'DUP_SN')
captured = capsys.readouterr()
assert 'already exists' in captured.out
def test_add_user_function(self, clean_db):
"""Test the add_user function"""
add_user('Test User', 'TEST_ID_001')
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE identification = 'TEST_ID_001'")
user = cursor.fetchone()
conn.close()
assert user is not None
assert user[1] == 'Test User' # name is index 1
assert user[2] == 'TEST_ID_001' # identification is index 2
def test_add_user_duplicate(self, clean_db, capsys):
"""Test that duplicate user identifications are rejected"""
add_user('User1', 'DUP_ID')
add_user('User2', 'DUP_ID')
captured = capsys.readouterr()
assert 'already exists' in captured.out
def test_loan_tablet_function(self, clean_db):
"""Test the loan_tablet function"""
# Add tablet and user
add_tablet('LoanBrand', 'LoanModel', 'LOAN_SN_001')
add_user('LoanUser', 'LOAN_ID_001')
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'LOAN_SN_001'")
tablet_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM users WHERE identification = 'LOAN_ID_001'")
user_id = cursor.fetchone()[0]
conn.close()
# Loan the tablet
loan_tablet(tablet_id, user_id)
# Verify loan was created and tablet status changed
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
status = cursor.fetchone()[0]
assert status == 'loaned'
cursor.execute("SELECT * FROM loans WHERE tablet_id = ? AND user_id = ?", (tablet_id, user_id))
loan = cursor.fetchone()
assert loan is not None
assert loan[5] == 'active' # status is index 5
conn.close()
def test_loan_already_loaned_tablet(self, clean_db, capsys):
"""Test loaning a tablet that's already loaned"""
add_tablet('Brand', 'Model', 'ALREADY_LOANED_SN')
add_user('User1', 'USER1_ID')
add_user('User2', 'USER2_ID')
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'ALREADY_LOANED_SN'")
tablet_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM users WHERE identification = 'USER1_ID'")
user1_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM users WHERE identification = 'USER2_ID'")
user2_id = cursor.fetchone()[0]
conn.close()
# Loan to first user
loan_tablet(tablet_id, user1_id)
# Try to loan to second user - should fail
loan_tablet(tablet_id, user2_id)
captured = capsys.readouterr()
assert 'not available' in captured.out
def test_return_tablet_function(self, clean_db):
"""Test the return_tablet function"""
add_tablet('ReturnBrand', 'ReturnModel', 'RETURN_SN_001')
add_user('ReturnUser', 'RETURN_ID_001')
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'RETURN_SN_001'")
tablet_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM users WHERE identification = 'RETURN_ID_001'")
user_id = cursor.fetchone()[0]
conn.close()
# Loan the tablet
loan_tablet(tablet_id, user_id)
# Get loan ID
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM loans WHERE tablet_id = ?", (tablet_id,))
loan_id = cursor.fetchone()[0]
conn.close()
# Return the tablet
return_tablet(loan_id)
# Verify return
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
status = cursor.fetchone()[0]
assert status == 'available'
cursor.execute("SELECT status, return_date FROM loans WHERE id = ?", (loan_id,))
loan = cursor.fetchone()
assert loan[0] == 'returned'
assert loan[1] is not None # return_date should be set
conn.close()
def test_return_nonexistent_loan(self, clean_db, capsys):
"""Test returning a loan that doesn't exist"""
return_tablet(99999)
captured = capsys.readouterr()
assert 'not found' in captured.out or 'Loan' in captured.out
def test_show_available_tablets(self, clean_db, capsys):
"""Test showing available tablets"""
add_tablet('Avail1', 'Model1', 'AVAIL_SN_001')
add_tablet('Avail2', 'Model2', 'AVAIL_SN_002')
show_available_tablets()
captured = capsys.readouterr()
assert 'Available Tablets' in captured.out
assert 'AVAIL_SN_001' in captured.out
assert 'AVAIL_SN_002' in captured.out
def test_show_active_loans(self, clean_db, capsys):
"""Test showing active loans"""
add_tablet('LoanBrand', 'LoanModel', 'ACTIVE_LOAN_SN')
add_user('LoanUser', 'ACTIVE_LOAN_ID')
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM tablets WHERE serial_number = 'ACTIVE_LOAN_SN'")
tablet_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM users WHERE identification = 'ACTIVE_LOAN_ID'")
user_id = cursor.fetchone()[0]
conn.close()
loan_tablet(tablet_id, user_id)
show_active_loans()
captured = capsys.readouterr()
assert 'Active Loans' in captured.out
assert 'ACTIVE_LOAN_SN' in captured.out
def test_non_loanable_device_crud(self, clean_db, capsys):
"""Test CRUD operations for non-loanable devices"""
# Add
add_non_loanable_device('Projector', 'P100', 'PROJ_001', 'projector', 'Room A')
captured = capsys.readouterr()
assert 'Added non-loanable device' in captured.out
# Show
show_non_loanable_devices()
captured = capsys.readouterr()
assert 'PROJ_001' in captured.out
# Get ID for delete
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT id FROM non_loanable_devices WHERE serial_number = 'PROJ_001'")
device_id = cursor.fetchone()[0]
conn.close()
# Delete
delete_non_loanable_device(device_id)
captured = capsys.readouterr()
assert 'deleted' in captured.out
# Verify deletion
conn = sqlite3.connect(clean_db)
cursor = conn.cursor()
cursor.execute("SELECT * FROM non_loanable_devices WHERE serial_number = 'PROJ_001'")
device = cursor.fetchone()
conn.close()
assert device is None

View file

@ -1,191 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: Tablet Management System\n"
"POT-Creation-Date: 2026-06-20\n"
"PO-Revision-Date: 2026-06-20\n"
"Last-Translator: Auto-generated\n"
"Language-Team: \n"
"Language: en\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
msgid "Actions"
msgstr ""
msgid "Active Loans"
msgstr ""
msgid "Add Tablet"
msgstr ""
msgid "Add User"
msgstr ""
msgid "All Users"
msgstr ""
msgid "All users have at least one active loan"
msgstr ""
msgid "Available Tablets"
msgstr ""
msgid "Back to All Users"
msgstr ""
msgid "Borrower"
msgstr ""
msgid "Brand"
msgstr ""
msgid "Call"
msgstr ""
msgid "Clear Filters"
msgstr ""
msgid "Email"
msgstr ""
msgid "English"
msgstr ""
msgid "Español"
msgstr ""
msgid "History"
msgstr ""
msgid "Home"
msgstr ""
msgid "ID"
msgstr ""
msgid "Identification"
msgstr ""
msgid "Language"
msgstr ""
msgid "Last Loan Date"
msgstr ""
msgid "Loan"
msgstr ""
msgid "Loan Date"
msgstr ""
msgid "Loan History"
msgstr ""
msgid "Loan Status"
msgstr ""
msgid "Loan Tablet"
msgstr ""
msgid "Model"
msgstr ""
msgid "Next"
msgstr ""
msgid "No Active Loans"
msgstr ""
msgid "No active loans"
msgstr ""
msgid "No available tablets"
msgstr ""
msgid "No loan history for this user"
msgstr ""
msgid "No users found"
msgstr ""
msgid "No users have active loans"
msgstr ""
msgid "No users match your search for"
msgstr ""
msgid "Non-Loanable Devices"
msgstr ""
msgid "Notes"
msgstr ""
msgid "N/A"
msgstr ""
msgid "of"
msgstr ""
msgid "Previous"
msgstr ""
msgid "Project Management"
msgstr ""
msgid "Return"
msgstr ""
msgid "Return Date"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search by name or identification..."
msgstr ""
msgid "Search Users"
msgstr ""
msgid "Serial Number"
msgstr ""
msgid "Showing"
msgstr ""
msgid "Status"
msgstr ""
msgid "Tablet"
msgstr ""
msgid "Tablet Management System"
msgstr ""
msgid "Tablet Management System - Internal Tool"
msgstr ""
msgid "There are no users in the system yet"
msgstr ""
msgid "to"
msgstr ""
msgid "User"
msgstr ""
msgid "User Loans"
msgstr ""
msgid "users"
msgstr ""
msgid "View"
msgstr ""
msgid "View Details"
msgstr ""
msgid "With Active Loans"
msgstr ""

View file

@ -1,192 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: Tablet Management System\n"
"POT-Creation-Date: 2026-06-20\n"
"PO-Revision-Date: 2026-06-20\n"
"Last-Translator: Auto-generated\n"
"Language-Team: \n"
"Language: es\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Actions"
msgstr "Acciones"
msgid "Active Loans"
msgstr "Préstamos Activos"
msgid "Add Tablet"
msgstr "Añadir Tablet"
msgid "Add User"
msgstr "Añadir Usuario"
msgid "All Users"
msgstr "Todos los Usuarios"
msgid "All users have at least one active loan"
msgstr "Todos los usuarios tienen al menos un préstamo activo"
msgid "Available Tablets"
msgstr "Tablets Disponibles"
msgid "Back to All Users"
msgstr "Volver a Todos los Usuarios"
msgid "Borrower"
msgstr "Prestatario"
msgid "Brand"
msgstr "Marca"
msgid "Call"
msgstr "Llamar"
msgid "Clear Filters"
msgstr "Limpiar Filtros"
msgid "Email"
msgstr "Correo Electrónico"
msgid "English"
msgstr "Inglés"
msgid "Español"
msgstr "Español"
msgid "History"
msgstr "Historial"
msgid "Home"
msgstr "Inicio"
msgid "ID"
msgstr "ID"
msgid "Identification"
msgstr "Identificación"
msgid "Language"
msgstr "Idioma"
msgid "Last Loan Date"
msgstr "Fecha del Último Préstamo"
msgid "Loan"
msgstr "Prestar"
msgid "Loan Date"
msgstr "Fecha de Préstamo"
msgid "Loan History"
msgstr "Historial de Préstamos"
msgid "Loan Status"
msgstr "Estado de Préstamo"
msgid "Loan Tablet"
msgstr "Prestar Tablet"
msgid "Model"
msgstr "Modelo"
msgid "Next"
msgstr "Siguiente"
msgid "No Active Loans"
msgstr "Sin Préstamos Activos"
msgid "No active loans"
msgstr "No hay préstamos activos"
msgid "No available tablets"
msgstr "No hay tablets disponibles"
msgid "No loan history for this user"
msgstr "Este usuario no tiene historial de préstamos"
msgid "No users found"
msgstr "No se encontraron usuarios"
msgid "No users have active loans"
msgstr "No hay usuarios con préstamos activos"
msgid "No users match your search for"
msgstr "Ningún usuario coincide con tu búsqueda de"
msgid "Non-Loanable Devices"
msgstr "Dispositivos No Prestables"
msgid "Notes"
msgstr "Notas"
msgid "N/A"
msgstr "N/D"
msgid "of"
msgstr "de"
msgid "Previous"
msgstr "Anterior"
msgid "Project Management"
msgstr "Gestión de Proyectos"
msgid "Return"
msgstr "Devolver"
msgid "Return Date"
msgstr "Fecha de Devolución"
msgid "Search"
msgstr "Buscar"
msgid "Search by name or identification..."
msgstr "Buscar por nombre o identificación..."
msgid "Search Users"
msgstr "Buscar Usuarios"
msgid "Serial Number"
msgstr "Número de Serie"
msgid "Showing"
msgstr "Mostrando"
msgid "Status"
msgstr "Estado"
msgid "Tablet"
msgstr "Tablet"
msgid "Tablet Management System"
msgstr "Sistema de Gestión de Tablets"
msgid "Tablet Management System - Internal Tool"
msgstr "Sistema de Gestión de Tablets - Herramienta Interna"
msgid "There are no users in the system yet"
msgstr "Aún no hay usuarios en el sistema"
msgid "to"
msgstr "a"
msgid "User"
msgstr "Usuario"
msgid "User Loans"
msgstr "Préstamos por Usuario"
msgid "users"
msgstr "usuarios"
msgid "View"
msgstr "Ver"
msgid "View Details"
msgstr "Ver Detalles"
msgid "With Active Loans"
msgstr "Con Préstamos Activos"

View file

@ -1,195 +0,0 @@
msgid ""
msgstr ""
"Project-Id-Version: Tablet Management System
"
"POT-Creation-Date: 2026-06-20
"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE
"
"Last-Translator:
"
"Language-Team:
"
"Language: en
"
"MIME-Version: 1.0
"
"Content-Type: text/plain; charset=UTF-8
"
"Content-Transfer-Encoding: 8bit
"
msgid "Actions"
msgstr ""
msgid "Active Loans"
msgstr ""
msgid "Add Tablet"
msgstr ""
msgid "Add User"
msgstr ""
msgid "All Users"
msgstr ""
msgid "All users have at least one active loan"
msgstr ""
msgid "Available Tablets"
msgstr ""
msgid "Back to All Users"
msgstr ""
msgid "Borrower"
msgstr ""
msgid "Brand"
msgstr ""
msgid "Call"
msgstr ""
msgid "Clear Filters"
msgstr ""
msgid "Email"
msgstr ""
msgid "History"
msgstr ""
msgid "Home"
msgstr ""
msgid "ID"
msgstr ""
msgid "Identification"
msgstr ""
msgid "Language"
msgstr ""
msgid "Last Loan Date"
msgstr ""
msgid "Loan"
msgstr ""
msgid "Loan Date"
msgstr ""
msgid "Loan History"
msgstr ""
msgid "Loan Status"
msgstr ""
msgid "Loan Tablet"
msgstr ""
msgid "Model"
msgstr ""
msgid "Next"
msgstr ""
msgid "No Active Loans"
msgstr ""
msgid "No active loans"
msgstr ""
msgid "No available tablets"
msgstr ""
msgid "No loan history for this user"
msgstr ""
msgid "No users found"
msgstr ""
msgid "No users have active loans"
msgstr ""
msgid "No users match your search for"
msgstr ""
msgid "Non-Loanable Devices"
msgstr ""
msgid "Not returned"
msgstr ""
msgid "Notes"
msgstr ""
msgid "Previous"
msgstr ""
msgid "Project Management"
msgstr ""
msgid "Return"
msgstr ""
msgid "Return Date"
msgstr ""
msgid "Returned"
msgstr ""
msgid "Search"
msgstr ""
msgid "Search Users"
msgstr ""
msgid "Search by name or identification..."
msgstr ""
msgid "Serial Number"
msgstr ""
msgid "Showing"
msgstr ""
msgid "Status"
msgstr ""
msgid "Tablet"
msgstr ""
msgid "Tablet Management System"
msgstr ""
msgid "Tablet Management System - Internal Tool"
msgstr ""
msgid "There are no users in the system yet"
msgstr ""
msgid "User"
msgstr ""
msgid "User Loans"
msgstr ""
msgid "View Details"
msgstr ""
msgid "With Active Loans"
msgstr ""
msgid "of"
msgstr ""
msgid "to"
msgstr ""
msgid "users"
msgstr ""

8
uv.lock generated
View file

@ -1,8 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "gestiontablets"
version = "0.1.0"
source = { virtual = "." }

View file

@ -1,182 +0,0 @@
#!/usr/bin/env python3
"""
Working web server for tablet management system
"""
from http.server import HTTPServer, BaseHTTPRequestHandler
import sqlite3
from urllib.parse import urlparse, parse_qs
import json
class TabletHandler(BaseHTTPRequestHandler):
def _set_headers(self, status=200, content_type='text/html'):
self.send_response(status)
self.send_header('Content-type', content_type)
self.end_headers()
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == '/':
self._serve_main_page()
elif parsed.path == '/api/tablets':
self._serve_tablets()
elif parsed.path == '/api/users':
self._serve_users()
elif parsed.path == '/api/loans':
self._serve_loans()
else:
self._set_headers(404)
self.wfile.write(b'404 Not Found')
def _serve_main_page(self):
html = '''<!DOCTYPE html>
<html>
<head>
<title>Tablet Management System</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
h1 { color: #4CAF50; }
.container { max-width: 800px; margin: 0 auto; }
.section { margin-bottom: 20px; padding: 15px; background: #f5f5f5; border-radius: 5px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #4CAF50; color: white; }
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }
.btn-danger { background-color: #f44336; }
</style>
</head>
<body>
<div class="container">
<h1>Tablet Management System</h1>
<p>SQLite backend - Port 8000</p>
<div class="section">
<h2>Available Tablets</h2>
<div id="tablets"></div>
</div>
<div class="section">
<h2>Active Loans</h2>
<div id="loans"></div>
</div>
<div class="section">
<h2>System Info</h2>
<p> Database: tablets.db</p>
<p> Status: Operational</p>
<p>Port: 8000</p>
</div>
</div>
<script>
// Load data from API
fetch('/api/tablets')
.then(r => r.json())
.then(data => {
let html = '<table><thead><tr><th>ID</th><th>Brand</th><th>Model</th><th>Serial</th><th>Status</th></tr></thead><tbody>';
data.forEach(tablet => {
html += `<tr><td>${tablet.id}</td><td>${tablet.brand}</td><td>${tablet.model}</td><td>${tablet.serial_number}</td><td>${tablet.status}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('tablets').innerHTML = html;
});
fetch('/api/loans')
.then(r => r.json())
.then(data => {
let html = '<table><thead><tr><th>ID</th><th>Tablet</th><th>User</th><th>Loan Date</th><th>Status</th></tr></thead><tbody>';
data.forEach(loan => {
html += `<tr><td>${loan.id}</td><td>${loan.tablet}</td><td>${loan.user}</td><td>${loan.loan_date}</td><td>${loan.status}</td></tr>`;
});
html += '</tbody></table>';
document.getElementById('loans').innerHTML = html;
});
</script>
</body>
</html>'''
self._set_headers()
self.wfile.write(html.encode('utf-8'))
def _serve_tablets(self):
try:
conn = sqlite3.connect('tablets.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM tablets')
tablets = [dict(row) for row in cursor.fetchall()]
conn.close()
self._set_headers(content_type='application/json')
self.wfile.write(json.dumps(tablets).encode('utf-8'))
except Exception as e:
self._set_headers(500)
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
def _serve_users(self):
try:
conn = sqlite3.connect('tablets.db')
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute('SELECT * FROM users')
users = [dict(row) for row in cursor.fetchall()]
conn.close()
self._set_headers(content_type='application/json')
self.wfile.write(json.dumps(users).encode('utf-8'))
except Exception as e:
self._set_headers(500)
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
def _serve_loans(self):
try:
conn = sqlite3.connect('tablets.db')
cursor = conn.cursor()
cursor.execute('''
SELECT l.id, l.tablet_id, l.user_id, l.loan_date, l.return_date, l.status,
t.brand || ' ' || t.model as tablet,
u.name as user
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
JOIN users u ON l.user_id = u.id
''')
loans = []
for row in cursor.fetchall():
loans.append({
'id': row[0],
'tablet_id': row[1],
'user_id': row[2],
'loan_date': row[3],
'return_date': row[4],
'status': row[5],
'tablet': row[6],
'user': row[7]
})
conn.close()
self._set_headers(content_type='application/json')
self.wfile.write(json.dumps(loans).encode('utf-8'))
except Exception as e:
self._set_headers(500)
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
def run_server():
server_address = ('', 8000)
httpd = HTTPServer(server_address, TabletHandler)
print("Tablet Management System - Web Interface")
print("========================================")
print("Server running on: http://localhost:8000")
print("Database: tablets.db (SQLite)")
print("Press Ctrl+C to stop the server")
print()
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")
if __name__ == '__main__':
run_server()