diff --git a/README.md b/README.md index 0ded615..4a75b92 100644 --- a/README.md +++ b/README.md @@ -15,10 +15,11 @@ A simple SQLite-based system for managing tablet lending and returns. ## Files -- `minimal_app.py` - **DEPRECATED** - Interactive command-line application (limited functionality, use `app.py` instead) +- `minimal_app.py` - Interactive command-line application (includes non-loanable device management) - `test_app.py` - Test script that demonstrates functionality - `tablets.db` - SQLite database (created automatically, includes non_loanable_devices table) -- `app.py` - **Recommended** - Full-featured web version (requires Flask, includes all features) +- `simple_app.py` - Web-based version (requires Flask, includes non-loanable device management) +- `app.py` - Alternative web version (requires Flask, includes non-loanable device management) ## Quick Start @@ -34,21 +35,18 @@ This will: - Demonstrate loan and return operations - Show the complete workflow -### 2. Run the web application (Recommended) +### 2. Run the interactive application ```bash -python3 app.py +python3 minimal_app.py ``` -This provides a full-featured web interface for: -- Adding tablets (with notes field) -- Adding users (with email and phone) +This provides a menu-driven interface for: +- Adding tablets +- Adding users - 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 @@ -157,47 +155,16 @@ To backup your data: cp tablets.db tablets_backup_$(date +%Y%m%d).db ``` -## Testing +## Version Control -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. +This project uses Git for version control with the following structure: +- `master` - Production-ready releases +- `develop` - Integration branch for features +- `feature/*` - Individual feature branches +- `hotfix/*` - Urgent bug fixes +See `CONTRIBUTING.md` for detailed workflow and commit conventions. ## License diff --git a/RUNNING.md b/RUNNING.md index 02c80c4..9c37f3d 100644 --- a/RUNNING.md +++ b/RUNNING.md @@ -48,22 +48,10 @@ pkill -f "python app.py" - **Port**: 5000 (configurable in app.py) - **Virtual Environment**: `.venv/` (created with uv) -## Running Tests +## Troubleshooting +If you have issues: +1. Check if the server is running: `ps aux | grep app.py` +2. Check the database: `sqlite3 tablets.db` +3. Restart the server: `source .venv/bin/activate && python app.py` -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. +Enjoy managing your tablets! πŸ“±πŸ’» \ No newline at end of file diff --git a/TECHNICAL_SPECIFICATIONS.md b/TECHNICAL_SPECIFICATIONS.md index a6995c2..8a17aef 100644 --- a/TECHNICAL_SPECIFICATIONS.md +++ b/TECHNICAL_SPECIFICATIONS.md @@ -147,54 +147,3 @@ def loan_device(device_id: int, user_id: int) -> Loan: - **Logging**: Registrar prΓ©stamos/devoluciones para auditorΓ­a - **Versionado API**: `/api/v1/...` para compatibilidad futura - **Pruebas**: Cubrir flujos crΓ­ticos (prΓ©stamo con dispositivo no disponible, devoluciΓ³n de prΓ©stamo inexistente) - ---- - -## 9. MigraciΓ³n a PostgreSQL (Futuro) - -### Contexto - -El sistema actualmente utiliza **SQLite** como base de datos embebida, ideal para prototipos y aplicaciones pequeΓ±as. Sin embargo, SQLite tiene limitaciones para escalar. - -### Criterios para MigraciΓ³n - -Considerar migrar a **PostgreSQL** cuando: -- TamaΓ±o DB > 1GB -- MΓ‘s de 50 conexiones simultΓ‘neas -- MΓ‘s de 200 usuarios activos -- MΓ‘s de 500 transacciones por minuto -- Necesidad de mΓΊltiples servidores - -### Arquitectura Propuesta - -Usar un patrΓ³n de **Repository** para abstraer la base de datos: -``` -backend/ -β”œβ”€β”€ repositories/ -β”‚ β”œβ”€β”€ base_repository.py # Interfaz abstracta -β”‚ β”œβ”€β”€ sqlite_repository.py # ImplementaciΓ³n SQLite -β”‚ └── postgres_repository.py # ImplementaciΓ³n PostgreSQL -└── config/ - └── database.py # FΓ‘brica de repositorios -``` - -### Schema PostgreSQL - -El schema es similar al de SQLite pero con tipos de datos mΓ‘s especΓ­ficos y Γ­ndices adicionales para rendimiento. - -### Pasos para MigraciΓ³n - -1. Instalar dependencias: `pip install psycopg2-binary sqlalchemy alembic` -2. Configurar PostgreSQL y crear base de datos -3. Ejecutar script de migraciΓ³n: `python scripts/migrate_to_postgres.py` -4. Cambiar configuraciΓ³n: `DB_TYPE=postgres` -5. Iniciar aplicaciΓ³n - -### Beneficios - -- Concurrencia ilimitada (mΓΊltiples escritores) -- Escalabilidad a miles de conexiones -- Mejor rendimiento con Γ­ndices -- Seguridad integrada (autenticaciΓ³n, roles) -- Backups automΓ‘ticos -- ReplicaciΓ³n y clustering diff --git a/docs/CLI_VS_WEB.md b/docs/CLI_VS_WEB.md deleted file mode 100644 index f9eb00c..0000000 --- a/docs/CLI_VS_WEB.md +++ /dev/null @@ -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/` | 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/` | `edit_non_loanable_device.html` | -| Delete Non-Loanable Device | βœ… | `/delete_non_loanable_device/` | 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. diff --git a/docs/FRONTEND_OPTIONS.md b/docs/FRONTEND_OPTIONS.md deleted file mode 100644 index d8a141e..0000000 --- a/docs/FRONTEND_OPTIONS.md +++ /dev/null @@ -1,1191 +0,0 @@ -# Separated Frontend Architecture Options - -## Overview - -The current Tablet Management System uses **server-side rendering** with Flask templates. While this works well for tablets, the interface may be too wide for mobile devices. This document explores **separated frontend architectures** that would provide better mobile responsiveness while keeping the existing backend intact. - ---- - -## Current Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Flask Backend β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Routes β”‚ β”‚ Models β”‚ β”‚ DB β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Flask Templates (Jinja2) β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ HTML β”‚ β”‚ CSS β”‚ β”‚ JS β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - Browser (Tablet/Desktop) -``` - -**Limitations:** -- Server-rendered HTML (not ideal for dynamic mobile UIs) -- Limited interactivity without page reloads -- CSS is basic and not responsive for mobile -- Tight coupling between backend and frontend - ---- - -## Option 1: Single Page Application (SPA) with REST API - -### Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Flask Backend β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ REST API β”‚ β”‚ -β”‚ β”‚ /api/tablets β”‚ β”‚ -β”‚ β”‚ /api/users β”‚ β”‚ -β”‚ β”‚ /api/loans β”‚ β”‚ -β”‚ β”‚ /api/non-loanable-devices β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ HTTP/JSON - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Frontend (React/Vue/Svelte) β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Components β”‚ β”‚ State β”‚ β”‚ Routerβ”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - Browser (Mobile/Tablet/Desktop) -``` - -### Implementation Steps - -#### 1. Create REST API Layer - -Add new routes to `app.py` (without removing existing ones): - -```python -# API Routes (add to app.py) -@app.route('/api/tablets', methods=['GET']) -def api_get_tablets(): - """GET /api/tablets - List all tablets""" - with get_db() as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tablets") - tablets = [dict(row) for row in cursor.fetchall()] - return jsonify(tablets) - -@app.route('/api/tablets/', methods=['GET']) -def api_get_tablet(tablet_id): - """GET /api/tablets/ - Get single tablet""" - with get_db() as conn: - cursor = conn.cursor() - cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) - tablet = cursor.fetchone() - if tablet: - return jsonify(dict(tablet)) - return jsonify({'error': 'Tablet not found'}), 404 - -@app.route('/api/tablets', methods=['POST']) -def api_create_tablet(): - """POST /api/tablets - Create new tablet""" - data = request.get_json() - # Validate and create - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO tablets (brand, model, serial_number, status, notes) - VALUES (?, ?, ?, 'available', ?) - ''', (data['brand'], data['model'], data['serial_number'], data.get('notes'))) - conn.commit() - tablet_id = cursor.lastrowid - cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,)) - return jsonify(dict(cursor.fetchone())), 201 - -# Similar endpoints for users, loans, non_loanable_devices -``` - -#### 2. Frontend Structure (React Example) - -``` -frontend/ -β”œβ”€β”€ public/ -β”‚ └── index.html -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”œβ”€β”€ TabletList.jsx -β”‚ β”‚ β”œβ”€β”€ TabletForm.jsx -β”‚ β”‚ β”œβ”€β”€ UserList.jsx -β”‚ β”‚ β”œβ”€β”€ LoanForm.jsx -β”‚ β”‚ β”œβ”€β”€ LoanHistory.jsx -β”‚ β”‚ β”œβ”€β”€ UserLoans.jsx -β”‚ β”‚ └── NonLoanableDevices.jsx -β”‚ β”œβ”€β”€ hooks/ -β”‚ β”‚ └── useApi.js -β”‚ β”œβ”€β”€ services/ -β”‚ β”‚ └── api.js -β”‚ β”œβ”€β”€ App.jsx -β”‚ β”œβ”€β”€ index.js -β”‚ └── styles/ -β”‚ β”œβ”€β”€ main.css -β”‚ └── responsive.css -β”œβ”€β”€ package.json -└── README.md -``` - -#### 3. API Service (frontend/src/services/api.js) - -```javascript -const API_BASE = '/api'; - -export const api = { - // Tablets - getTablets: async (status = null) => { - const url = status ? `${API_BASE}/tablets?status=${status}` : `${API_BASE}/tablets`; - const response = await fetch(url); - return response.json(); - }, - - getTablet: async (id) => { - const response = await fetch(`${API_BASE}/tablets/${id}`); - return response.json(); - }, - - createTablet: async (tablet) => { - const response = await fetch(`${API_BASE}/tablets`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tablet) - }); - return response.json(); - }, - - updateTablet: async (id, tablet) => { - const response = await fetch(`${API_BASE}/tablets/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(tablet) - }); - return response.json(); - }, - - // Similar methods for users, loans, non_loanable_devices -}; -``` - -#### 4. React Components Example - -```jsx -// frontend/src/components/TabletList.jsx -import React, { useState, useEffect } from 'react'; -import { api } from '../services/api'; - -export function TabletList() { - const [tablets, setTablets] = useState([]); - const [loading, setLoading] = useState(true); - - useEffect(() => { - api.getTablets('available').then(data => { - setTablets(data); - setLoading(false); - }); - }, []); - - if (loading) return
Loading...
; - - return ( -
-

Available Tablets

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

{tablet.brand} {tablet.model}

-

Serial: {tablet.serial_number}

-

Status: {tablet.status}

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

Available Tablets

- - - - - -
- {% for tablet in available_tablets %} -
-

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

-

Serial: {{ tablet.serial_number }}

- -
- {% endfor %} -
-
- - -
-``` - -#### 3. Add HTMX Endpoints - -```python -@app.route('/api/tablets/search') -def search_tablets(): - query = request.args.get('search', '') - with get_db() as conn: - cursor = conn.cursor() - cursor.execute(""" - SELECT * FROM tablets - WHERE brand LIKE ? OR model LIKE ? OR serial_number LIKE ? - """, (f'%{query}%', f'%{query}%', f'%{query}%')) - tablets = cursor.fetchall() - return render_template('partials/tablet_list.html', tablets=tablets) - -@app.route('/api/tablets//loan', methods=['POST']) -def loan_tablet_htmx(tablet_id): - # Get user from form - user_id = request.form.get('user_id') - # Loan logic... - return render_template('partials/loan_form.html', tablet_id=tablet_id) -``` - -#### 4. Responsive CSS - -```css -/* Add to base.html or separate CSS file */ - -/* Mobile-first responsive design */ -.tablet-card, .user-card, .loan-card { - background: white; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); -} - -.nav { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.nav a { - padding: 0.75rem; - text-align: center; -} - -@media (min-width: 600px) { - .nav { - flex-direction: row; - flex-wrap: wrap; - } - - .tablet-card { - display: flex; - justify-content: space-between; - } -} - -@media (min-width: 768px) { - .container { - max-width: 720px; - margin: 0 auto; - } -} - -@media (min-width: 1024px) { - .container { - max-width: 960px; - } - - .nav { - flex-wrap: nowrap; - } -} -``` - -### Pros and Cons - -| Aspect | Pros | Cons | -|--------|------|------| -| **User Experience** | Dynamic updates without full page reloads | Less powerful than full SPA | -| **Mobile Support** | Good with responsive CSS | Still limited by server rendering | -| **Development** | Minimal changes to existing code | Need to learn HTMX | -| **Backend Impact** | Very minimal (just add endpoints) | More routes to maintain | -| **SEO** | Excellent (server-rendered) | Best of all options | -| **Deployment** | No changes needed | Simple | -| **Bundle Size** | Tiny (~14KB for HTMX) | No build step | - ---- - -## Option 4: Mobile App (Native or Cross-Platform) - -### Architecture - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Flask Backend β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ REST API β”‚ β”‚ -β”‚ β”‚ (Same as Option 1) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ HTTP/JSON - β–Ό -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Mobile App β”‚ -β”‚ (React Native / Flutter / Capacitor)β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - Mobile Device -``` - -### Implementation Options - -#### A. React Native (JavaScript) - -```javascript -// App.js -import React from 'react'; -import { View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native'; - -const API_BASE = 'http://your-server:5000/api'; - -export default function App() { - const [tablets, setTablets] = React.useState([]); - - React.useEffect(() => { - fetch(`${API_BASE}/tablets`) - .then(res => res.json()) - .then(data => setTablets(data)); - }, []); - - return ( - - Tablet Management - item.id.toString()} - renderItem={({item}) => ( - - {item.brand} {item.model} - SN: {item.serial_number} - Status: {item.status} - - )} - /> - - ); -} - -const styles = StyleSheet.create({ - container: { - flex: 1, - padding: 20, - backgroundColor: '#f5f5f5', - }, - title: { - fontSize: 24, - fontWeight: 'bold', - marginBottom: 20, - textAlign: 'center', - }, - card: { - backgroundColor: 'white', - padding: 15, - borderRadius: 8, - marginBottom: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - }, - brand: { - fontSize: 18, - fontWeight: '600', - }, - serial: { - fontSize: 14, - color: '#666', - }, - status: { - fontSize: 14, - color: '#4CAF50', - }, -}); -``` - -#### B. Flutter (Dart) - -```dart -// main.dart -import 'package:flutter/material.dart'; -import 'package:http/http.dart' as http; -import 'dart:convert'; - -void main() => runApp(MyApp()); - -class MyApp extends StatelessWidget { - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'Tablet Management', - home: TabletListScreen(), - ); - } -} - -class TabletListScreen extends StatefulWidget { - @override - _TabletListScreenState createState() => _TabletListScreenState(); -} - -class _TabletListScreenState extends State { - List tablets = []; - - @override - void initState() { - super.initState(); - fetchTablets(); - } - - Future fetchTablets() async { - final response = await http.get(Uri.parse('http://your-server:5000/api/tablets')); - if (response.statusCode == 200) { - setState(() { - tablets = json.decode(response.body); - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text('Tablet Management')), - body: ListView.builder( - itemCount: tablets.length, - itemBuilder: (context, index) { - final tablet = tablets[index]; - return Card( - child: ListTile( - title: Text('${tablet['brand']} ${tablet['model']}'), - subtitle: Text('SN: ${tablet['serial_number']}'), - trailing: Text(tablet['status']), - ), - ); - }, - ), - ); - } -} -``` - -#### C. Capacitor (Web App as Mobile App) - -Use your existing web app (Option 1 SPA) and wrap it with Capacitor: - -```bash -# Install Capacitor -npm install @capacitor/core @capacitor/cli -npx cap init - -# Add platforms -npm install @capacitor/android @capacitor/ios -npx cap add android -npx cap add ios - -# Build and sync -npm run build -npx cap sync -npx cap open android # or ios -``` - -### Pros and Cons - -| Aspect | React Native | Flutter | Capacitor | -|--------|--------------|---------|-----------| -| **Language** | JavaScript | Dart | JavaScript | -| **Performance** | Native | Native | WebView | -| **Code Reuse** | ~80% with web | ~50% with web | ~100% with web | -| **Learning Curve** | Medium (if know React) | High (new language) | Low (web devs) | -| **Access to Native** | Good | Excellent | Limited | -| **Bundle Size** | Medium | Large | Small | -| **Offline Support** | Yes | Yes | Yes | - ---- - -## Comparison Matrix - -| Feature | Current | SPA (Option 1) | Hybrid (Option 2) | HTMX (Option 3) | Mobile App (Option 4) | -|---------|---------|---------------|------------------|----------------|----------------------| -| **Mobile Friendly** | ❌ No | βœ… Yes | βœ… Yes | ⚠️ Partial | βœ… Yes | -| **Desktop Friendly** | βœ… Yes | βœ… Yes | βœ… Yes | βœ… Yes | ❌ No | -| **Tablet Friendly** | βœ… Yes | βœ… Yes | βœ… Yes | βœ… Yes | βœ… Yes | -| **Development Effort** | N/A | High | Medium | Low | High | -| **Backend Changes** | N/A | Low | Low | Very Low | Low (API only) | -| **Learning Curve** | N/A | Medium | Medium | Low | High | -| **SEO** | βœ… Good | ❌ Poor | βœ… Good | βœ… Good | ❌ Poor | -| **Offline Support** | ❌ No | βœ… Yes | ❌ No | ❌ No | βœ… Yes | -| **Performance** | ⚠️ OK | βœ… Good | ⚠️ OK | βœ… Good | βœ… Excellent | -| **Deployment** | Simple | Complex | Medium | Simple | Complex | -| **Maintenance** | Simple | Medium | Complex | Simple | Medium | - ---- - -## Recommendations - -### For Immediate Improvement (Low Effort) - -**Choose: Option 3 (HTMX)** - -- Minimal code changes -- No new build process -- Progressive enhancement -- Good mobile support with responsive CSS -- Keeps existing server rendering - -### For Best User Experience (Medium Effort) - -**Choose: Option 1 (SPA with REST API)** - -- Modern, dynamic UI -- Excellent mobile support -- Can be deployed separately -- Backend changes are minimal (just add API routes) - -### For Native Mobile Experience (High Effort) - -**Choose: Option 4 (Mobile App)** - -- Best mobile UX -- Offline capabilities -- Native device features (camera, etc.) -- Requires separate mobile development - -### For Gradual Migration - -**Choose: Option 2 (Hybrid)** - -- Start with mobile SPA -- Keep desktop as-is -- Migrate desktop later if needed -- Minimal risk - ---- - -## Implementation Roadmap - -### Phase 1: Quick Win (1-2 days) - -1. Add responsive CSS to existing templates -2. Add viewport meta tag -3. Test on mobile devices - -**Result:** Better mobile experience with minimal changes - -### Phase 2: Enhanced Interactivity (3-5 days) - -1. Add HTMX to templates -2. Create partial templates for updates -3. Add new API endpoints for HTMX -4. Test all interactions - -**Result:** Dynamic UI without full SPA complexity - -### Phase 3: Full SPA (1-2 weeks) - -1. Set up React/Vue project -2. Create API layer in Flask -3. Build frontend components -4. Add responsive design -5. Test on all devices -6. Deploy frontend separately - -**Result:** Modern, mobile-first web application - -### Phase 4: Mobile App (2-4 weeks) - -1. Choose framework (React Native/Flutter) -2. Set up mobile project -3. Connect to existing API -4. Build mobile-specific UI -5. Add offline support -6. Test on devices -7. Publish to app stores - -**Result:** Native mobile application - ---- - -## File Structure for Separated Frontend - -If you choose Option 1 (SPA), here's the recommended structure: - -``` -GestionTablets/ -β”œβ”€β”€ backend/ # Existing Flask backend -β”‚ β”œβ”€β”€ app.py # Flask app + API routes -β”‚ β”œβ”€β”€ templates/ # Existing templates (keep for now) -β”‚ β”œβ”€β”€ static/ # Static files -β”‚ └── ... -β”‚ -β”œβ”€β”€ frontend/ # NEW: Separated frontend -β”‚ β”œβ”€β”€ public/ -β”‚ β”‚ └── index.html -β”‚ β”œβ”€β”€ src/ -β”‚ β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”‚ β”œβ”€β”€ common/ -β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Button.jsx -β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Card.jsx -β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€ Modal.jsx -β”‚ β”‚ β”‚ β”‚ └── Table.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ TabletList.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ TabletForm.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ UserList.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ UserForm.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ LoanList.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ LoanForm.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ LoanHistory.jsx -β”‚ β”‚ β”‚ β”œβ”€β”€ UserLoans.jsx -β”‚ β”‚ β”‚ └── NonLoanableDevices.jsx -β”‚ β”‚ β”œβ”€β”€ hooks/ -β”‚ β”‚ β”‚ β”œβ”€β”€ useTablets.js -β”‚ β”‚ β”‚ β”œβ”€β”€ useUsers.js -β”‚ β”‚ β”‚ β”œβ”€β”€ useLoans.js -β”‚ β”‚ β”‚ └── useApi.js -β”‚ β”‚ β”œβ”€β”€ services/ -β”‚ β”‚ β”‚ └── api.js -β”‚ β”‚ β”œβ”€β”€ utils/ -β”‚ β”‚ β”‚ β”œβ”€β”€ formatters.js -β”‚ β”‚ β”‚ └── validators.js -β”‚ β”‚ β”œβ”€β”€ App.jsx -β”‚ β”‚ β”œβ”€β”€ App.css -β”‚ β”‚ β”œβ”€β”€ index.js -β”‚ β”‚ └── index.css -β”‚ β”œβ”€β”€ package.json -β”‚ β”œβ”€β”€ vite.config.js -β”‚ └── README.md -β”‚ -β”œβ”€β”€ docs/ # Documentation -β”‚ β”œβ”€β”€ MIGRATION_TO_POSTGRES.md -β”‚ └── FRONTEND_OPTIONS.md # This document -β”‚ -β”œβ”€β”€ scripts/ # Utility scripts -β”‚ └── migrate_to_postgres.py -β”‚ -β”œβ”€β”€ .gitignore -β”œβ”€β”€ README.md -β”œβ”€β”€ pyproject.toml -└── docker-compose.yml -``` - ---- - -## API Endpoints Needed - -For any separated frontend, you'll need these API endpoints: - -### Tablets -- `GET /api/tablets` - List all tablets -- `GET /api/tablets?status=available` - Filter by status -- `GET /api/tablets/` - Get single tablet -- `POST /api/tablets` - Create tablet -- `PUT /api/tablets/` - Update tablet -- `DELETE /api/tablets/` - Delete tablet -- `GET /api/tablets/search?q=query` - Search tablets - -### Users -- `GET /api/users` - List all users -- `GET /api/users/` - Get single user -- `POST /api/users` - Create user -- `PUT /api/users/` - Update user -- `DELETE /api/users/` - Delete user -- `GET /api/users/search?q=query` - Search users - -### Loans -- `GET /api/loans` - List all loans -- `GET /api/loans?status=active` - Filter by status -- `GET /api/loans/` - Get single loan -- `POST /api/loans` - Create loan -- `PUT /api/loans//return` - Return tablet -- `GET /api/loans/user/` - Get loans by user -- `GET /api/loans/tablet/` - Get loans by tablet - -### Non-Loanable Devices -- `GET /api/non-loanable-devices` - List all -- `GET /api/non-loanable-devices/` - Get single device -- `POST /api/non-loanable-devices` - Create device -- `PUT /api/non-loanable-devices/` - Update device -- `DELETE /api/non-loanable-devices/` - Delete device - -### Statistics -- `GET /api/stats` - Get dashboard statistics - ---- - -## Responsive Design Guidelines - -### Breakpoints - -```css -/* Mobile-first approach */ -:root { - --breakpoint-xs: 0px; - --breakpoint-sm: 576px; - --breakpoint-md: 768px; - --breakpoint-lg: 992px; - --breakpoint-xl: 1200px; -} - -/* Usage */ -@media (min-width: 576px) { /* Small devices (landscape phones) */ } -@media (min-width: 768px) { /* Medium devices (tablets) */ } -@media (min-width: 992px) { /* Large devices (desktops) */ } -@media (min-width: 1200px) { /* Extra large devices */ } -``` - -### Mobile-First Principles - -1. **Start with mobile** - Design for smallest screen first -2. **Progressive enhancement** - Add features for larger screens -3. **Touch targets** - Minimum 48x48px for touch elements -4. **Font sizes** - Minimum 16px for readability -5. **Spacing** - Adequate padding for touch -6. **Navigation** - Bottom navigation for mobile, top for desktop -7. **Forms** - Large, easy-to-use inputs -8. **Tables** - Consider cards instead of tables on mobile - -### Touch Target Sizes - -| Element | Minimum Size | Recommended Size | -|---------|--------------|------------------| -| Buttons | 48x48px | 56x56px | -| Form inputs | 48px height | 56px height | -| List items | 48px height | 64px height | -| Checkboxes/Radios | 24x24px | 32x32px | - ---- - -## Deployment Options - -### Option A: Separate Servers - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Backend Server │────▢│ Frontend Server β”‚ -β”‚ (Flask) β”‚ β”‚ (Nginx/Apache) β”‚ -β”‚ :5000 β”‚ β”‚ :80/:443 β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ - β–Ό β–Ό - API Requests Static Files -``` - -**Pros:** Separate scaling, independent deployment -**Cons:** More complex setup, CORS configuration - -### Option B: Same Server, Different Routes - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Flask Server β”‚ -β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ /api/* β†’ Backend routes β”‚ β”‚ -β”‚ β”‚ /* β†’ Frontend (SPA) β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ - β–Ό - Nginx (reverse proxy) - β”‚ - β–Ό - Client -``` - -**Pros:** Simpler deployment, no CORS issues -**Cons:** Backend serves static files - -### Option C: Docker Compose - -```yaml -# docker-compose.yml -version: '3.8' - -services: - backend: - build: ./backend - ports: - - "5000:5000" - environment: - - FLASK_ENV=production - restart: unless-stopped - - frontend: - build: ./frontend - ports: - - "80:80" - - "443:443" - depends_on: - - backend - restart: unless-stopped - - nginx: - image: nginx:alpine - ports: - - "80:80" - - "443:443" - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - depends_on: - - backend - - frontend - restart: unless-stopped -``` - ---- - -## Conclusion - -For the Tablet Management System, I recommend the following approach: - -### Short Term (1-2 days) -Start with **Option 3 (HTMX)** to add dynamic updates and responsive CSS to the existing templates. This provides: -- Immediate mobile improvements -- Minimal code changes -- No new dependencies (just HTMX) -- Progressive enhancement - -### Medium Term (1-2 weeks) -Migrate to **Option 1 (SPA with REST API)** for: -- Better mobile experience -- Modern development workflow -- Separate frontend deployment -- Easier to maintain long-term - -### Long Term (Optional) -Consider **Option 4 (Mobile App)** if: -- Users need offline access -- Need native device features -- Want app store presence - -The current backend (Flask + SQLite) can remain **completely unchanged** for all these options. You only need to add API endpoints, which don't affect the existing template-based functionality. diff --git a/docs/MIGRATION_TO_POSTGRES.md b/docs/MIGRATION_TO_POSTGRES.md deleted file mode 100644 index 740f8b1..0000000 --- a/docs/MIGRATION_TO_POSTGRES.md +++ /dev/null @@ -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. diff --git a/docs/RESPONSIVE_CSS.md b/docs/RESPONSIVE_CSS.md deleted file mode 100644 index 739a915..0000000 --- a/docs/RESPONSIVE_CSS.md +++ /dev/null @@ -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:

Tablet Management System

-# After:

{{ _('Tablet Management System') }}

-``` -- **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 %} -

{{ m.trans('Tablet Management System') }}

-``` -- **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 `` 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 `` Tag -**Issue:** After adding responsive CSS to `base.html`, the closing `` tag was accidentally omitted, causing the main page to render as blank. - -**Fix:** Added `` 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 `` 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 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 -``` diff --git a/minimal_app.py b/minimal_app.py index 1f7183c..381a26d 100644 --- a/minimal_app.py +++ b/minimal_app.py @@ -1,23 +1,7 @@ #!/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. +Minimal Tablet Lending and Return Management System +Using only built-in Python modules """ import sqlite3 @@ -289,17 +273,10 @@ def delete_non_loanable_device(device_id): 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("=== Tablet Lending and Return Management System ===") print("Using SQLite database: tablets.db") - print("NOTE: This CLI has limited functionality. Use web interface for full features.") while True: print("\nMenu:") diff --git a/pyproject.toml b/pyproject.toml index 08df5da..e409926 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,33 +5,3 @@ description = "Add your description here" readme = "README.md" requires-python = ">=3.14" dependencies = [] - -[tool.pytest.ini_options] -testpaths = ["tests"] -python_files = "test_*.py" -python_classes = "Test*" -python_functions = "test_*" -verbose = 1 -addopts = "-v" - -[tool.coverage.run] -source = ["."] -omit = [ - "*/tests/*", - "*/.venv/*", - "*/__pycache__/*", - "*/.git/*", - "*/templates/*", -] - -[tool.coverage.report] -exclude_lines = [ - "pragma: no cover", - "def __repr__", - "raise NotImplementedError", - "if __name__ == .__main__.:", - "if TYPE_CHECKING:", -] - -[tool.coverage.html] -directory = "htmlcov" diff --git a/templates/base.html b/templates/base.html index 3382ed4..e93f5f7 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,7 +10,6 @@ margin: 0; padding: 20px; background-color: #f5f5f5; - overflow-x: hidden; /* Prevent horizontal overflow on zoom */ } .container { max-width: 1200px; @@ -124,182 +123,6 @@ textarea { height: 100px; } - - /* ============================================ - RESPONSIVE DESIGN - Mobile First - Added for internal technical staff mobile access - ============================================ */ - - /* Mobile-first base styles */ - .container { - max-width: 100%; - padding: 1rem; - margin: 0 auto; - overflow: hidden; /* Prevent horizontal overflow */ - } - - /* Navigation - stack vertically on mobile */ - .nav { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1rem; - flex-wrap: wrap; - } - .nav a { - padding: 0.75rem 1rem; - text-align: center; - white-space: nowrap; - flex: 1 1 auto; - min-width: 120px; - } - - /* Tables - responsive with horizontal scroll */ - .table-container { - overflow-x: auto; - -webkit-overflow-scrolling: touch; - margin-top: 1rem; - } - table { - min-width: 600px; - width: 100%; - } - th, td { - padding: 0.75rem; - white-space: nowrap; - } - - /* Forms - full width on mobile */ - form { - max-width: 100%; - } - input[type="text"], - input[type="email"], - input[type="password"], - input[type="number"], - textarea, - select { - width: 100%; - padding: 0.75rem; - margin-bottom: 1rem; - box-sizing: border-box; - } - - /* Buttons - full width on mobile */ - .btn { - padding: 0.75rem 1.5rem; - width: 100%; - margin-bottom: 0.5rem; - display: block; - } - .btn:last-child { - margin-bottom: 0; - } - - /* Cards for mobile display */ - .tablet-card, - .user-card, - .loan-card { - background: white; - border-radius: 8px; - padding: 1rem; - margin-bottom: 1rem; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); - border: 1px solid #e0e0e0; - } - - /* Flash messages */ - .flash-message { - padding: 1rem; - margin-bottom: 1rem; - border-radius: 4px; - } - - /* Section spacing */ - .section { - margin-bottom: 1.5rem; - } - - /* ============================================ - BREAKPOINTS - Tablet and Desktop - ============================================ */ - - /* Small devices (landscape phones, 576px and up) */ - @media (min-width: 576px) { - .container { - max-width: 540px; - } - .nav { - flex-direction: row; - flex-wrap: wrap; - } - .nav a { - flex: 1 1 auto; - min-width: 120px; - } - .btn { - width: auto; - display: inline-block; - margin-bottom: 0; - margin-right: 0.5rem; - } - .btn:last-child { - margin-right: 0; - } - } - - /* Medium devices (tablets, 768px and up) */ - @media (min-width: 768px) { - body { - padding: 1rem; - } - .container { - max-width: 720px; - padding: 1.5rem; - } - table { - min-width: auto; - } - .table-container { - overflow-x: visible; - } - } - - /* Large devices (desktops, 992px and up) */ - @media (min-width: 992px) { - .container { - max-width: 960px; - } - .nav { - flex-wrap: nowrap; - } - } - - /* Extra large devices (large desktops, 1200px and up) */ - @media (min-width: 1200px) { - .container { - max-width: 1140px; - } - } - - /* Print styles */ - @media print { - .nav, - .btn, - .flash-message { - display: none !important; - } - body { - background: white; - padding: 0; - } - .container { - box-shadow: none; - border: none; - max-width: 100%; - padding: 0; - } - } diff --git a/templates/history.html b/templates/history.html index 061ffbd..8a2fe7a 100644 --- a/templates/history.html +++ b/templates/history.html @@ -4,32 +4,30 @@

Loan History

{% if loans %} -
- - +
+ + + + + + + + + + + + {% for loan in loans %} - - - - - - + + + + + + - - - {% for loan in loans %} - - - - - - - - - {% endfor %} - -
TabletSerial NumberBorrowerLoan DateReturn DateStatus
TabletSerial NumberBorrowerLoan DateReturn DateStatus{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.name }}{{ loan.loan_date }}{{ loan.return_date or '-' }}{{ loan.status }}
{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.name }}{{ loan.loan_date }}{{ loan.return_date or '-' }}{{ loan.status }}
-
+ {% endfor %} + + {% else %}

No loan history available.

{% endif %} diff --git a/templates/index.html b/templates/index.html index f946ad8..8dfcdb6 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,28 +4,26 @@

Available Tablets

{% if available_tablets %} -
- - +
+ + + + + + + + + + {% for tablet in available_tablets %} - - - - + + + + - - - {% for tablet in available_tablets %} - - - - - - - {% endfor %} - -
BrandModelSerial NumberNotes
BrandModelSerial NumberNotes{{ tablet.brand }}{{ tablet.model }}{{ tablet.serial_number }}{{ tablet.notes or '-' }}
{{ tablet.brand }}{{ tablet.model }}{{ tablet.serial_number }}{{ tablet.notes or '-' }}
-
+ {% endfor %} + + {% else %}

No available tablets.

{% endif %} @@ -34,32 +32,30 @@

Active Loans

{% if active_loans %} -
- - +
+ + + + + + + + + + + {% for loan in active_loans %} - - - - - + + + + + - - - {% for loan in active_loans %} - - - - - - - - {% endfor %} - -
TabletSerial NumberBorrowerLoan DateActions
TabletSerial NumberBorrowerLoan DateActions{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.name }}{{ loan.loan_date }} + Return +
{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.name }}{{ loan.loan_date }} - Return -
-
+ {% endfor %} + + {% else %}

No active loans.

{% endif %} diff --git a/templates/non_loanable_devices.html b/templates/non_loanable_devices.html index 990d433..a27e6ac 100644 --- a/templates/non_loanable_devices.html +++ b/templates/non_loanable_devices.html @@ -7,37 +7,35 @@ Add Non-Loanable Device {% if devices %} -
- - +
+ + + + + + + + + + + + + {% for device in devices %} - - - - - - - + + + + + + + - - - {% for device in devices %} - - - - - - - - - - {% endfor %} - -
TypeBrandModelSerial NumberLocationStatusActions
TypeBrandModelSerial NumberLocationStatusActions{{ device.device_type }}{{ device.brand }}{{ device.model }}{{ device.serial_number }}{{ device.location or '-' }}{{ device.status }} + Edit + Delete +
{{ device.device_type }}{{ device.brand }}{{ device.model }}{{ device.serial_number }}{{ device.location or '-' }}{{ device.status }} - Edit - Delete -
-
+ {% endfor %} + + {% else %}

No non-loanable devices registered.

{% endif %} diff --git a/templates/project_management.html b/templates/project_management.html index 597babd..7ca8005 100644 --- a/templates/project_management.html +++ b/templates/project_management.html @@ -186,28 +186,11 @@ border: 1px solid #ddd; padding: 8px; } - - /* ============================================ - RESPONSIVE DESIGN FOR PROJECT MANAGEMENT - ============================================ */ + @media (max-width: 768px) { .editor-row { flex-direction: column; } - .markdown-editor, - .markdown-preview { - height: 350px; - } - } - - @media (max-width: 480px) { - .editor-controls { - flex-direction: column; - gap: 0.5rem; - } - .editor-controls button { - width: 100%; - } } {% endblock %} diff --git a/templates/user_loans.html b/templates/user_loans.html index 6fddf9c..07fac86 100644 --- a/templates/user_loans.html +++ b/templates/user_loans.html @@ -33,32 +33,30 @@ {% if active_loans %}

Current Loans

-
- - - - - - - +
TabletSerial NumberLoan DateStatus
+ + + + + + + + + + {% for loan in active_loans %} + + + + + - - - {% for loan in active_loans %} - - - - - - - {% endfor %} - -
TabletSerial NumberLoan DateStatus
{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.loan_date }} + + {{ loan.status }} + +
{{ loan.brand }} {{ loan.model }}{{ loan.serial_number }}{{ loan.loan_date }} - - {{ loan.status }} - -
-
+ {% endfor %} + +
{% endif %} @@ -70,34 +68,32 @@
{% endif %} diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index c27920d..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Unit tests for Tablet Management System -""" diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 56b549d..0000000 --- a/tests/conftest.py +++ /dev/null @@ -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 diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index b9c84e0..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -pytest==8.3.2 -pytest-cov==5.0.0 diff --git a/tests/test_core.py b/tests/test_core.py deleted file mode 100644 index d8ddac2..0000000 --- a/tests/test_core.py +++ /dev/null @@ -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' diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py deleted file mode 100644 index 13a6efb..0000000 --- a/tests/test_edge_cases.py +++ /dev/null @@ -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 diff --git a/tests/test_minimal_app.py b/tests/test_minimal_app.py deleted file mode 100644 index 0de44ff..0000000 --- a/tests/test_minimal_app.py +++ /dev/null @@ -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