Compare commits
No commits in common. "aa89041d4ca43e10922b4d609524bc282e491fc8" and "d909006a0185c4ad254411df7823bc135765ceac" have entirely different histories.
aa89041d4c
...
d909006a01
72 changed files with 14832 additions and 11 deletions
BIN
.coverage
Normal file
BIN
.coverage
Normal file
Binary file not shown.
17
.env.example
Normal file
17
.env.example
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Configuración de ejemplo para GestionTablets
|
||||
# COPIA este archivo a .env y ajusta los valores según tu entorno
|
||||
|
||||
# Configuración de Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
|
||||
# Clave secreta para sesiones y CSRF
|
||||
# GENERA UNA NUEVA CON: openssl rand -hex 32
|
||||
# O en Python: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||
SECRET_KEY=cambiar_esta_clave_por_una_segura_en_produccion
|
||||
|
||||
# Configuración de la base de datos
|
||||
DATABASE=tablets.db
|
||||
|
||||
# Puerto del servidor (solo para desarrollo)
|
||||
PORT=5000
|
||||
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE/Editor
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Python
|
||||
.python-version
|
||||
|
||||
# Project-specific
|
||||
notes_development/
|
||||
.vibe/
|
||||
|
||||
# Logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
|
||||
# Test coverage
|
||||
.htmlcov/
|
||||
.coverage
|
||||
|
||||
# Local configuration
|
||||
25
CHANGELOG.md
Normal file
25
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Changelog
|
||||
|
||||
## [2025-12-01] - Student Data Import Implementation Initial Release
|
||||
|
||||
- **Added** new `students` table schema with fields: `id`, `order_number`, `cial_code`, `nif_nie_passport`, `registration_number`, `file_number`, `first_name`, `last_name`, `full_name`, `birth_date`, `gender`, `study_group`, `created_at`, `updated_at`.
|
||||
- **Added** indexes on `cial_code`, `nif_nie_passport`, `(last_name, first_name)`, `birth_date`, `gender`, `study_group`.
|
||||
- **Added** `assigned_to_student` foreign key column to `tablets` and `non_loanable_devices` tables.
|
||||
- **Added** `student_id` foreign key to `loans` table.
|
||||
- **Implemented** CSV import script (`scripts/import_students.py`) for ISO-8859-1 (Latin-1) encoded CSV files with automatic UTF-8 handling, name field splitting, date conversion (DD/MM/YYYY → YYYY-MM-DD), discard "Estudio" field, duplicate checking, progress reporting, and dry-run support.
|
||||
- **Created** database migration script (`scripts/migrate_database.py`) with backup, schema creation, index creation, foreign key addition, and consistency verification.
|
||||
- **Updated** Flask application (`app.py`):
|
||||
- Added new routes: `/students`, `/add_student`, `/student/<id>`, `/edit_student/<id>`, `/delete_student/<id>`, `/import_students`.
|
||||
- Modified loan routes to optionally associate tablet loans with students.
|
||||
- Added student model definitions and relationships.
|
||||
- Added indexes and validation for new student fields.
|
||||
- **Added** Jinja2 front‑end templates:
|
||||
- `templates/students.html` – list/search/student details with filters and pagination.
|
||||
- `templates/add_student.html` – form for adding new students.
|
||||
- `templates/edit_student.html` – form for editing existing students.
|
||||
- `templates/student_detail.html` – view student details including loan and device history.
|
||||
- `templates/import_students.html` – CSV import interface with instructions.
|
||||
- Updated `base.html` to include Students navigation link.
|
||||
- Updated `loan_tablet.html` with student selection dropdown.
|
||||
- **Updated** `requirements.txt` to include Flask, Flask-Babel, python-dotenv, pytest, pytest-cov, waitress.
|
||||
- **Created** documentation file `docs/DATABASE_SCHEMA_SPECIFICATION.md` with detailed schema definition, import rules, and migration path.
|
||||
82
CONTRIBUTING.md
Normal file
82
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
# Contributing Guide
|
||||
|
||||
## Git Workflow
|
||||
|
||||
### Branch Strategy
|
||||
|
||||
| Branch Type | Naming | Purpose |
|
||||
|-------------|--------|---------|
|
||||
| `main` | `main` | Production-ready code |
|
||||
| `develop` | `develop` | Integration branch for features |
|
||||
| Feature | `feature/<name>` | New functionality |
|
||||
| Hotfix | `hotfix/<name>` | Urgent bug fixes |
|
||||
|
||||
### Workflow
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone <repository-url>
|
||||
cd GestionTablets
|
||||
|
||||
# Setup develop branch
|
||||
git checkout -b develop
|
||||
|
||||
# Create feature branch
|
||||
git checkout -b feature/my-feature
|
||||
git push origin feature/my-feature
|
||||
|
||||
# Make changes, commit
|
||||
git add .
|
||||
git commit -m "feat: add my feature"
|
||||
git push origin feature/my-feature
|
||||
|
||||
# Create Pull Request to develop
|
||||
```
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types**:
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation changes
|
||||
- `refactor`: Code refactoring
|
||||
- `chore`: Maintenance tasks
|
||||
- `test`: Test-related changes
|
||||
|
||||
**Examples**:
|
||||
- `feat(loans): add user loans view page`
|
||||
- `fix(tablet): validate serial number uniqueness`
|
||||
- `docs: update README with database schema`
|
||||
- `refactor(app): extract loan logic to service`
|
||||
|
||||
### Pull Request Process
|
||||
|
||||
1. Target `develop` branch (or `main` for hotfixes)
|
||||
2. Include clear description of changes
|
||||
3. Reference related issues
|
||||
4. Wait for code review
|
||||
5. Squash merge preferred
|
||||
|
||||
### Reverting Changes
|
||||
|
||||
```bash
|
||||
# Discard unstaged changes
|
||||
git checkout -- file.txt
|
||||
|
||||
# Unstage file
|
||||
git reset HEAD file.txt
|
||||
|
||||
# Revert to previous commit (safe)
|
||||
git revert HEAD
|
||||
|
||||
# Hard reset to commit (destructive)
|
||||
git reset --hard <commit-hash>
|
||||
```
|
||||
2
Datos_programa.csv
Normal file
2
Datos_programa.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio
|
||||
1,"PERIQUITA DE LOS PALOTES, JUANITA",24/08/2009,B19L64119K,24587243H,4847,,M,1º FPB BÁSICA,1º CFGB Servicios Socioculturales y a la Comunidad - Actividades Domésticas y Limpieza de edificios (LOMLOE-LOOIFP)
|
||||
|
442
IMPLEMENTATION_SUMMARY.md
Normal file
442
IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
# Student Data Import Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
This document summarizes the implementation of the student data import functionality for the GestionTablets project. The implementation includes a new database schema for students, CSV import scripts, updated application code, and new frontend templates.
|
||||
|
||||
## Files Created
|
||||
|
||||
### 1. Database Schema Specification
|
||||
**File:** `docs/DATABASE_SCHEMA_SPECIFICATION.md`
|
||||
|
||||
- Complete SQLite database schema specification
|
||||
- Detailed field descriptions and data types
|
||||
- Index definitions for optimal query performance
|
||||
- Data import specifications and validation rules
|
||||
- API endpoint definitions
|
||||
- Migration path from existing database
|
||||
|
||||
### 2. CSV Import Script
|
||||
**File:** `scripts/import_students.py`
|
||||
|
||||
Features:
|
||||
- Reads CSV files encoded in ISO-8859-1 (Latin-1)
|
||||
- Converts encoding to UTF-8 automatically
|
||||
- Splits "Apellidos, Nombre" field into separate "last_name" and "first_name" fields
|
||||
- Converts date format from DD/MM/YYYY to YYYY-MM-DD
|
||||
- Discards the "Estudio" field as per requirements
|
||||
- Validates all required fields (CIAL code, first name, last name)
|
||||
- Handles duplicates gracefully
|
||||
- Provides detailed statistics and error reporting
|
||||
- Supports dry-run mode for testing
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
# Dry run (validate without importing)
|
||||
python3 scripts/import_students.py Datos_programa.csv --dry-run --verbose
|
||||
|
||||
# Actual import
|
||||
python3 scripts/import_students.py Datos_programa.csv --verbose
|
||||
```
|
||||
|
||||
### 3. Database Migration Script
|
||||
**File:** `scripts/migrate_database.py`
|
||||
|
||||
Features:
|
||||
- Creates backup of existing database before migration
|
||||
- Creates new `students` table with all required fields and indexes
|
||||
- Adds `assigned_to_student` column to `tablets` and `non_loanable_devices` tables
|
||||
- Adds `student_id` column to `loans` table
|
||||
- Adds timestamp columns (`created_at`, `updated_at`) to existing tables
|
||||
- Verifies migration success
|
||||
- Supports dry-run mode
|
||||
|
||||
Usage:
|
||||
```bash
|
||||
# Dry run
|
||||
python3 scripts/migrate_database.py --dry-run
|
||||
|
||||
# Actual migration
|
||||
python3 scripts/migrate_database.py
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. Application Code
|
||||
**File:** `app.py`
|
||||
|
||||
Changes:
|
||||
- Added `students` table creation in `init_db()` function
|
||||
- Added indexes for students table
|
||||
- Updated `non_loanable_devices` table to include `assigned_to_student` field
|
||||
- Added new routes:
|
||||
- `/students` - List all students with search, filter, and pagination
|
||||
- `/add_student` - Add new student (GET/POST)
|
||||
- `/student/<id>` - View student details
|
||||
- `/edit_student/<id>` - Edit student (GET/POST)
|
||||
- `/delete_student/<id>` - Delete student
|
||||
- `/import_students` - CSV import interface (GET/POST)
|
||||
- Updated `loan_tablet` route to support optional student association
|
||||
- Added student selection to loan form
|
||||
|
||||
### 2. Base Template
|
||||
**File:** `templates/base.html`
|
||||
|
||||
Changes:
|
||||
- Added "Students" link to navigation menu
|
||||
|
||||
### 3. Loan Tablet Template
|
||||
**File:** `templates/loan_tablet.html`
|
||||
|
||||
Changes:
|
||||
- Updated to use Bootstrap 5 classes (matching other templates)
|
||||
- Added student selection dropdown with search functionality
|
||||
- Improved form layout and styling
|
||||
|
||||
### 4. Requirements
|
||||
**File:** `requirements.txt`
|
||||
|
||||
Updated with all necessary dependencies:
|
||||
- Flask==2.3.3
|
||||
- Flask-Babel==2.0.0
|
||||
- python-dotenv==1.0.0
|
||||
- pytest==7.4.0
|
||||
- pytest-cov==4.1.0
|
||||
- waitress==2.1.2
|
||||
|
||||
## Files Created (Templates)
|
||||
|
||||
### 1. Students List Template
|
||||
**File:** `templates/students.html`
|
||||
|
||||
Features:
|
||||
- Displays all students in a paginated table
|
||||
- Search functionality (by name, CIAL, NIF, etc.)
|
||||
- Filter by gender and study group
|
||||
- Responsive design
|
||||
- Action buttons for view, edit, delete
|
||||
|
||||
### 2. Add Student Template
|
||||
**File:** `templates/add_student.html`
|
||||
|
||||
Features:
|
||||
- Form for adding new students
|
||||
- All required fields with validation
|
||||
- Organized in logical sections (Identification, Personal Info, Study Info)
|
||||
- Responsive design
|
||||
|
||||
### 3. Student Detail Template
|
||||
**File:** `templates/student_detail.html`
|
||||
|
||||
Features:
|
||||
- Displays complete student information
|
||||
- Shows assigned tablets and other devices
|
||||
- Shows loan history
|
||||
- Breadcrumbs for navigation
|
||||
- Edit and delete buttons
|
||||
|
||||
### 4. Edit Student Template
|
||||
**File:** `templates/edit_student.html`
|
||||
|
||||
Features:
|
||||
- Form for editing existing students
|
||||
- Pre-populated with current student data
|
||||
- Same validation as add form
|
||||
- Breadcrumbs for navigation
|
||||
|
||||
### 5. Import Students Template
|
||||
**File:** `templates/import_students.html`
|
||||
|
||||
Features:
|
||||
- File upload form for CSV import
|
||||
- Instructions and requirements
|
||||
- Sample data format preview
|
||||
- Clear visual design
|
||||
|
||||
## Database Schema
|
||||
|
||||
### New Table: students
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_number INTEGER,
|
||||
cial_code TEXT UNIQUE NOT NULL,
|
||||
nif_nie_passport TEXT UNIQUE,
|
||||
registration_number INTEGER,
|
||||
file_number TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
birth_date TEXT,
|
||||
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
|
||||
study_group TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
|
||||
);
|
||||
```
|
||||
|
||||
### Indexes Created
|
||||
|
||||
- `idx_students_cial` - On cial_code
|
||||
- `idx_students_nif` - On nif_nie_passport
|
||||
- `idx_students_name` - On (last_name, first_name)
|
||||
- `idx_students_birth_date` - On birth_date
|
||||
- `idx_students_gender` - On gender
|
||||
- `idx_students_group` - On study_group
|
||||
- `idx_tablets_student` - On tablets.assigned_to_student
|
||||
- `idx_non_loanable_student` - On non_loanable_devices.assigned_to_student
|
||||
- `idx_loans_student` - On loans.student_id
|
||||
|
||||
### Modified Tables
|
||||
|
||||
1. **tablets**: Added `assigned_to_student` column (foreign key to students)
|
||||
2. **non_loanable_devices**: Added `assigned_to_student` column (foreign key to students)
|
||||
3. **loans**: Added `student_id` column (foreign key to students)
|
||||
4. All tables: Added `created_at` and `updated_at` timestamp columns
|
||||
|
||||
## CSV Import Process
|
||||
|
||||
### Field Mapping
|
||||
|
||||
| CSV Field | Database Field | Transformation |
|
||||
|-----------|----------------|----------------|
|
||||
| Nº Or. | order_number | Integer conversion |
|
||||
| Apellidos, Nombre | last_name, first_name | Split on ", " |
|
||||
| Apellidos, Nombre | full_name | Keep as-is |
|
||||
| Fecha Nac. | birth_date | DD/MM/YYYY → YYYY-MM-DD |
|
||||
| C.I.A.L. | cial_code | Direct mapping |
|
||||
| NIF/NIE/Pas. | nif_nie_passport | Direct mapping |
|
||||
| Registro | registration_number | Integer conversion |
|
||||
| Expediente | file_number | Direct mapping (nullable) |
|
||||
| Sexo | gender | Direct mapping (M/F/O) |
|
||||
| Grupo | study_group | Direct mapping |
|
||||
| Estudio | - | **DISCARDED** |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
1. **Required Fields**: cial_code, first_name, last_name
|
||||
2. **Unique Fields**: cial_code, nif_nie_passport (if provided)
|
||||
3. **Date Format**: birth_date must be valid YYYY-MM-DD
|
||||
4. **Gender**: Must be 'M', 'F', or 'O'
|
||||
|
||||
## Testing
|
||||
|
||||
### Import Script Test Results
|
||||
|
||||
```
|
||||
$ python3 scripts/import_students.py Datos_programa.csv --dry-run --verbose
|
||||
|
||||
Processing row 1/1:
|
||||
Raw: {'nº or.': '1', 'apellidos, nombre': 'PERIQUITA DE LOS PALOTES, JUANITA', ...}
|
||||
Processed: {'order_number': 1, 'cial_code': 'B19L64119K', ...}
|
||||
[DRY RUN] Would import: PERIQUITA DE LOS PALOTES, JUANITA (B19L64119K)
|
||||
|
||||
IMPORT STATISTICS
|
||||
Total rows in CSV: 1
|
||||
Rows processed: 1
|
||||
Valid rows: 1
|
||||
Invalid rows: 0
|
||||
```
|
||||
|
||||
### Actual Import Test
|
||||
|
||||
```
|
||||
$ python3 scripts/import_students.py Datos_programa.csv --verbose
|
||||
|
||||
Processing row 1/1:
|
||||
Inserted student ID: 1
|
||||
|
||||
IMPORT STATISTICS
|
||||
Total rows in CSV: 1
|
||||
Rows processed: 1
|
||||
Valid rows: 1
|
||||
Invalid rows: 0
|
||||
Rows imported: 1
|
||||
Duplicates skipped: 0
|
||||
Errors: 0
|
||||
```
|
||||
|
||||
### Database Verification
|
||||
|
||||
```sql
|
||||
SELECT * FROM students;
|
||||
-- Returns: 1 row with all fields correctly populated
|
||||
```
|
||||
|
||||
## New Features
|
||||
|
||||
### 1. Student Management
|
||||
- List all students with pagination
|
||||
- Search and filter students
|
||||
- Add new students
|
||||
- View student details
|
||||
- Edit student information
|
||||
- Delete students (with safety checks)
|
||||
- Import students from CSV
|
||||
|
||||
### 2. Enhanced Loan Management
|
||||
- Loan tablets to students directly
|
||||
- Associate loans with both staff users and students
|
||||
- View student loan history
|
||||
|
||||
### 3. Device Assignment
|
||||
- Assign tablets to students
|
||||
- Assign non-loanable devices to students
|
||||
- View all devices assigned to a student
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Import Students from CSV
|
||||
|
||||
```bash
|
||||
# Import from file
|
||||
python3 scripts/import_students.py path/to/data.csv
|
||||
|
||||
# Import with custom database
|
||||
python3 scripts/import_students.py data.csv --database mydb.db
|
||||
|
||||
# Dry run to validate
|
||||
python3 scripts/import_students.py data.csv --dry-run
|
||||
|
||||
# Skip errors and continue
|
||||
python3 scripts/import_students.py data.csv --skip-errors
|
||||
```
|
||||
|
||||
### Web Interface
|
||||
|
||||
1. Navigate to `/students` to view all students
|
||||
2. Click "Add Student" to add a new student manually
|
||||
3. Click "Import CSV" to upload a CSV file
|
||||
4. Click on a student to view their details, assigned devices, and loan history
|
||||
5. When loaning a tablet, optionally select a student to associate with the loan
|
||||
|
||||
## Migration Instructions
|
||||
|
||||
### For Existing Installations
|
||||
|
||||
1. **Backup your database**:
|
||||
```bash
|
||||
cp tablets.db tablets.db.backup
|
||||
```
|
||||
|
||||
2. **Run the migration script**:
|
||||
```bash
|
||||
python3 scripts/migrate_database.py
|
||||
```
|
||||
|
||||
3. **Import your CSV data**:
|
||||
```bash
|
||||
python3 scripts/import_students.py Datos_programa.csv
|
||||
```
|
||||
|
||||
4. **Start the application**:
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
### For New Installations
|
||||
|
||||
1. **Initialize the database**:
|
||||
```bash
|
||||
python3 app.py # This will create the database with all tables
|
||||
```
|
||||
|
||||
2. **Import your CSV data**:
|
||||
```bash
|
||||
python3 scripts/import_students.py Datos_programa.csv
|
||||
```
|
||||
|
||||
3. **Start the application**:
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
GestionTablets/
|
||||
├── app.py # Updated with student routes
|
||||
├── scripts/
|
||||
│ ├── import_students.py # CSV import script
|
||||
│ └── migrate_database.py # Database migration script
|
||||
├── templates/
|
||||
│ ├── base.html # Updated with Students link
|
||||
│ ├── loan_tablet.html # Updated with student selection
|
||||
│ ├── students.html # New: Student list
|
||||
│ ├── add_student.html # New: Add student form
|
||||
│ ├── edit_student.html # New: Edit student form
|
||||
│ ├── student_detail.html # New: Student details
|
||||
│ └── import_students.html # New: CSV import interface
|
||||
├── docs/
|
||||
│ └── DATABASE_SCHEMA_SPECIFICATION.md # New: Schema documentation
|
||||
├── requirements.txt # Updated dependencies
|
||||
└── Datos_programa.csv # Sample CSV file
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test the application**: Run the app and verify all student functionality works
|
||||
2. **Add translations**: Update translation files for new student-related strings
|
||||
3. **Add more CSV files**: Test with additional CSV files to ensure robustness
|
||||
4. **Performance testing**: Test with large CSV files (1000+ rows)
|
||||
5. **Error handling**: Test edge cases (empty files, malformed data, etc.)
|
||||
|
||||
## Notes
|
||||
|
||||
- The implementation preserves all existing functionality
|
||||
- The new student features are fully integrated with the existing tablet and loan management
|
||||
- All templates use consistent styling (Bootstrap 5 classes)
|
||||
- The import script handles encoding conversion automatically
|
||||
- The database migration is safe and creates backups automatically
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Español (Spanish)** — Default language for this internal tool
|
||||
- **English** — Deprecated; available for legacy users but not actively maintained
|
||||
|
||||
### Frontend Revamp (High Priority)
|
||||
|
||||
**Goal**: Improve mobile readability and standardize UI across all templates
|
||||
|
||||
#### 1. Database Results Display (Main Content Area)
|
||||
- **Current issue**: Tables overflow on mobile, inconsistent styling across templates
|
||||
- **Solution**:
|
||||
- Responsive tables with horizontal scroll on mobile
|
||||
- Card-based layouts for mobile (show data as stacked cards instead of tables)
|
||||
- Standardize column widths and spacing
|
||||
- Improve readability of data cells (better contrast, padding)
|
||||
|
||||
#### 2. CSS Framework Standardization
|
||||
- **Current issue**: Mixed Bootstrap 5, Tailwind, and plain CSS across templates
|
||||
- **Solution**:
|
||||
- Standardize all templates on Tailwind CSS (already in use for `index.html`)
|
||||
- Remove Bootstrap and Font Awesome dependencies
|
||||
- Create reusable component classes for buttons, forms, cards
|
||||
|
||||
#### 3. Mobile Navigation
|
||||
- **Current issue**: Horizontal navigation bar with 8+ links doesn't collapse on mobile
|
||||
- **Solution**:
|
||||
- Implement hamburger menu for mobile devices
|
||||
- Responsive breakpoints for different screen sizes
|
||||
|
||||
#### 4. Form Responsiveness
|
||||
- **Current issue**: Forms use Bootstrap grid classes, inconsistent spacing
|
||||
- **Solution**:
|
||||
- Tailwind responsive grid for form layouts
|
||||
- Consistent input styling and validation feedback
|
||||
- Mobile-friendly touch targets
|
||||
|
||||
#### 5. Dark Mode Enhancement
|
||||
- **Current issue**: Incomplete dark mode coverage, some elements still have light backgrounds
|
||||
- **Solution**:
|
||||
- Comprehensive dark mode CSS variables
|
||||
- Test all pages in dark mode
|
||||
- Ensure proper contrast ratios
|
||||
|
||||
### Implementation Approach
|
||||
- **Phase 1**: Fix database results tables (mobile scroll, card alternative)
|
||||
- **Phase 2**: Standardize CSS framework (remove Bootstrap, use Tailwind only)
|
||||
- **Phase 3**: Responsive navigation and forms
|
||||
- **Phase 4**: Dark mode polish and accessibility improvements
|
||||
354
INSTALL.md
Normal file
354
INSTALL.md
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
# Instalación y Configuración - GestionTablets
|
||||
|
||||
**Versión:** 1.0
|
||||
**Fecha:** 24 de junio de 2026
|
||||
**Idioma:** Español
|
||||
|
||||
---
|
||||
|
||||
## 📋 Requisitos Previos
|
||||
|
||||
Antes de instalar GestionTablets, asegúrate de que tu sistema cumple con los siguientes requisitos:
|
||||
|
||||
### Sistema Operativo
|
||||
- **Linux** (recomendado: Debian 12+, Ubuntu 22.04+)
|
||||
- **macOS** (11+)
|
||||
- **Windows** (10+ con WSL2 recomendado)
|
||||
|
||||
### Dependencias del Sistema
|
||||
|
||||
| Requisito | Versión Mínima | Cómo instalar (Debian/Ubuntu) | Notas |
|
||||
|-----------|----------------|--------------------------------|-------|
|
||||
| Python | 3.13.0 | `sudo apt update && sudo apt install python3.13` | **Obligatorio** |
|
||||
| Git | 2.30+ | `sudo apt install git` | Para clonar el repositorio |
|
||||
| curl | - | `sudo apt install curl` | Para instalar `uv` |
|
||||
| SQLite3 | 3.35+ | `sudo apt install sqlite3` | Incluido en Python |
|
||||
|
||||
> ⚠️ **NOTA IMPORTANTE:** Este proyecto **requiere Python 3.13+** debido al uso de `uv` como gestor de dependencias.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalación Rápida
|
||||
|
||||
Ejecuta estos comandos en tu terminal:
|
||||
|
||||
```bash
|
||||
# 1. Clonar el repositorio
|
||||
git clone http://hq.ijuanes.ovh:3333/ijuanes/GestionTablets.git
|
||||
cd GestionTablets
|
||||
|
||||
# 2. Ejecutar el script de configuración (requiere permisos de ejecución)
|
||||
chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
El script `setup.sh` se encargará de:
|
||||
- Instalar `uv` si no está presente
|
||||
- Crear un entorno virtual aislado
|
||||
- Instalar todas las dependencias Python
|
||||
- Inicializar la base de datos
|
||||
- Configurar las variables de entorno básicas
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Instalación Manual (sin setup.sh)
|
||||
|
||||
Si prefieres instalar manualmente, sigue estos pasos:
|
||||
|
||||
### 1. Instalar uv (gestor de paquetes de Python)
|
||||
|
||||
```bash
|
||||
# Instalar uv (Astral)
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Añadir uv al PATH (para la sesión actual)
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Verificar instalación
|
||||
uv --version
|
||||
```
|
||||
|
||||
> **Nota:** Si usas `zsh`, añade `export PATH="$HOME/.local/bin:$PATH"` a tu `~/.zshrc`.
|
||||
> Si usas `bash`, añádelo a tu `~/.bashrc` o `~/.bash_profile`.
|
||||
|
||||
### 2. Crear entorno virtual
|
||||
|
||||
```bash
|
||||
# Crear entorno virtual con uv
|
||||
uv venv .venv
|
||||
|
||||
# Activar el entorno virtual
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. Instalar dependencias
|
||||
|
||||
```bash
|
||||
# Instalar dependencias desde requirements.txt
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. Inicializar la base de datos
|
||||
|
||||
```bash
|
||||
# Inicializar la base de datos SQLite
|
||||
python3 -c "from app import init_db; init_db(); print('✅ Base de datos inicializada')"
|
||||
```
|
||||
|
||||
### 5. Configurar variables de entorno
|
||||
|
||||
Crea un archivo `.env` en el directorio raíz del proyecto:
|
||||
|
||||
```bash
|
||||
# Copiar el archivo de ejemplo (si existe)
|
||||
cp .env.example .env
|
||||
|
||||
# O crear manualmente
|
||||
cat > .env << EOF
|
||||
# Configuración de Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=production
|
||||
|
||||
# Clave secreta (generar una nueva con: openssl rand -hex 32)
|
||||
SECRET_KEY=tu_clave_secreta_aqui_cambiarla
|
||||
|
||||
# Base de datos (opcional, por defecto: tablets.db)
|
||||
DATABASE=tablets.db
|
||||
EOF
|
||||
```
|
||||
|
||||
> ⚠️ **IMPORTANTE:** Genera una clave secreta segura:
|
||||
> ```bash
|
||||
> openssl rand -hex 32
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 🏃♂️ Ejecución
|
||||
|
||||
### Desarrollo
|
||||
|
||||
```bash
|
||||
# Activar el entorno virtual (si no lo está)
|
||||
source .venv/bin/activate
|
||||
|
||||
# Ejecutar la aplicación en modo desarrollo
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
La aplicación estará disponible en: `http://localhost:5000`
|
||||
|
||||
### Producción
|
||||
|
||||
Para producción, se recomienda usar **Waitress** (ya incluido en `requirements.txt`):
|
||||
|
||||
```bash
|
||||
# Activar el entorno virtual
|
||||
source .venv/bin/activate
|
||||
|
||||
# Ejecutar con Waitress (más robusto que el servidor de desarrollo)
|
||||
waitress-serve --port=8080 app:app
|
||||
```
|
||||
|
||||
La aplicación estará disponible en: `http://localhost:8080`
|
||||
|
||||
> **Nota:** Para producción real, considera:
|
||||
> - Usar un proxy inverso (Nginx, Apache)
|
||||
> - Configurar HTTPS
|
||||
> - Establecer un dominio/IP estática
|
||||
|
||||
---
|
||||
|
||||
## 📦 Actualización
|
||||
|
||||
Para actualizar a la última versión:
|
||||
|
||||
```bash
|
||||
# Desde el directorio del proyecto
|
||||
git pull origin develop
|
||||
|
||||
# Actualizar dependencias (opcional, si requirements.txt ha cambiado)
|
||||
source .venv/bin/activate
|
||||
uv pip install -r requirements.txt --upgrade
|
||||
|
||||
# Reiniciar la aplicación
|
||||
# (Ctrl+C para detener, luego vuelve a ejecutar)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Migración de Base de Datos
|
||||
|
||||
Si ya tienes una base de datos existente y necesitas migrar a la nueva estructura (con soporte para alumnos):
|
||||
|
||||
```bash
|
||||
# Hacer backup de la base de datos actual
|
||||
cp tablets.db tablets.db.backup
|
||||
|
||||
# Ejecutar el script de migración
|
||||
python3 scripts/migrate_database.py
|
||||
|
||||
# Importar datos de alumnos (si tienes un CSV)
|
||||
python3 scripts/import_students.py Datos_programa.csv --verbose
|
||||
```
|
||||
|
||||
> **Nota:** El script de migración crea automáticamente un backup antes de realizar cambios.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Ejecución de Pruebas
|
||||
|
||||
Para verificar que todo funciona correctamente:
|
||||
|
||||
```bash
|
||||
# Activar el entorno virtual
|
||||
source .venv/bin/activate
|
||||
|
||||
# Ejecutar todas las pruebas
|
||||
python3 -m pytest
|
||||
|
||||
# Ejecutar con cobertura de código
|
||||
python3 -m pytest --cov=app --cov-report=term
|
||||
|
||||
# Ejecutar pruebas específicas
|
||||
python3 -m pytest tests/test_import.py -v
|
||||
```
|
||||
|
||||
Deberías ver: `46 passed, 1 warning in X.XXs`
|
||||
|
||||
---
|
||||
|
||||
## 📂 Estructura del Proyecto
|
||||
|
||||
```
|
||||
GestionTablets/
|
||||
├── app.py # Aplicación Flask principal
|
||||
├── requirements.txt # Dependencias Python
|
||||
├── setup.sh # Script de configuración automática
|
||||
├── INSTALL.md # Este documento
|
||||
├── .env.example # Plantilla de variables de entorno
|
||||
├── .gitignore # Archivos ignorados por Git
|
||||
├── tablets.db # Base de datos SQLite (generada)
|
||||
├── docs/
|
||||
│ └── DATABASE_SCHEMA_SPECIFICATION.md # Esquema de la base de datos
|
||||
├── scripts/
|
||||
│ ├── import_students.py # Importación de alumnos desde CSV
|
||||
│ └── migrate_database.py # Migración de base de datos
|
||||
├── templates/ # Plantillas HTML (Jinja2)
|
||||
│ ├── base.html # Plantilla base
|
||||
│ ├── index.html # Página principal
|
||||
│ ├── students.html # Lista de alumnos
|
||||
│ └── ...
|
||||
└── translations/ # Archivos de traducción (i18n)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Solución de Problemas
|
||||
|
||||
### Problema: "Command 'uv' not found"
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Instalar uv manualmente
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Añadir al PATH
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Verificar
|
||||
uv --version
|
||||
```
|
||||
|
||||
### Problema: "Python 3.13 not found"
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Instalar Python 3.13 en Debian/Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install python3.13 python3.13-venv python3.13-dev
|
||||
|
||||
# Verificar
|
||||
python3.13 --version
|
||||
```
|
||||
|
||||
### Problema: "ModuleNotFoundError: No module named 'flask'"
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Asegúrate de que el entorno virtual está activado
|
||||
source .venv/bin/activate
|
||||
|
||||
# Instalar dependencias
|
||||
uv pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Problema: "Permission denied" al ejecutar setup.sh
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Dar permisos de ejecución
|
||||
chmod +x setup.sh
|
||||
|
||||
# Ejecutar
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
### Problema: La base de datos no se inicializa
|
||||
|
||||
**Solución:**
|
||||
```bash
|
||||
# Forzar inicialización manual
|
||||
python3 -c "from app import init_db; init_db()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Variables de Entorno
|
||||
|
||||
| Variable | Valor por Defecto | Descripción | Requerida |
|
||||
|----------|-------------------|-------------|-----------|
|
||||
| `FLASK_APP` | `app.py` | Archivo principal de Flask | No |
|
||||
| `FLASK_ENV` | `production` | Entorno de Flask (`development` o `production`) | No |
|
||||
| `SECRET_KEY` | - | Clave secreta para sesiones (¡genera una nueva!) | **Sí** |
|
||||
| `DATABASE` | `tablets.db` | Ruta a la base de datos SQLite | No |
|
||||
| `PORT` | `5000` | Puerto del servidor (solo para desarrollo) | No |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Seguridad
|
||||
|
||||
### Clave Secreta
|
||||
- **Nunca** uses la clave secreta por defecto en producción.
|
||||
- Genera una nueva con: `openssl rand -hex 32`
|
||||
- Guárdala en `.env` (este archivo **no** debe comitearse a Git)
|
||||
|
||||
### Base de Datos
|
||||
- El archivo `tablets.db` contiene todos los datos del sistema.
|
||||
- **Haz backups regulares:** `cp tablets.db tablets.db.backup`
|
||||
- No expongas el archivo públicamente.
|
||||
|
||||
### Acceso a la Aplicación
|
||||
- Actualmente, la aplicación no tiene autenticación.
|
||||
- **Recomendación para producción:**
|
||||
- Usa un proxy inverso con autenticación (Nginx + Basic Auth)
|
||||
- O implementa autenticación en Flask (ver propuestas de mejora)
|
||||
|
||||
---
|
||||
|
||||
## 📞 Soporte
|
||||
|
||||
Si encuentras problemas:
|
||||
|
||||
1. **Revisa esta documentación** (INSTALL.md)
|
||||
2. **Consulta los logs** de la aplicación
|
||||
3. **Verifica los requisitos** (Python 3.13+, uv, etc.)
|
||||
4. **Contacta al administrador** del sistema (ijuanes)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentación Adicional
|
||||
|
||||
- [DOCUMENTACION_RECAPITULACION.md](DOCUMENTACION_RECAPITULACION.md) - Recapitulación técnica completa
|
||||
- [IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md) - Resumen de implementación
|
||||
- [docs/DATABASE_SCHEMA_SPECIFICATION.md](docs/DATABASE_SCHEMA_SPECIFICATION.md) - Esquema detallado de la base de datos
|
||||
9
LICENSE
9
LICENSE
|
|
@ -1,9 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 ijuanes
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
205
README.md
205
README.md
|
|
@ -1,3 +1,204 @@
|
|||
# GestionTablets
|
||||
# Tablet Lending and Return Management System
|
||||
|
||||
Gestión de las Tablets del IES Schamann (préstamo y devolución).
|
||||
A simple SQLite-based system for managing tablet lending and returns.
|
||||
|
||||
## Features
|
||||
|
||||
- **Tablet Management**: Add, track, and manage tablets in inventory
|
||||
- **User Management**: Register users who can borrow tablets
|
||||
- **Loan Tracking**: Track which tablets are loaned to which users
|
||||
- **Return Management**: Record when tablets are returned
|
||||
- **History Tracking**: Complete history of all loans and returns
|
||||
- **User Loans View**: See all tablets loaned to each user (demonstrates one-to-many relationship)
|
||||
- **Non-Loanable Devices**: Track inventory devices that cannot be loaned (e.g., projectors, monitors)
|
||||
- **Project Notes**: Markdown editor for development documentation
|
||||
|
||||
## Files
|
||||
|
||||
- `minimal_app.py` - **DEPRECATED** - Interactive command-line application (limited functionality, use `app.py` instead)
|
||||
- `test_app.py` - Test script that demonstrates functionality
|
||||
- `tablets.db` - SQLite database (created automatically, includes non_loanable_devices table)
|
||||
- `app.py` - **Recommended** - Full-featured web version (requires Flask, includes all features)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Run the test to see it in action
|
||||
|
||||
```bash
|
||||
python3 test_app.py
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create the database
|
||||
- Add sample tablets and users
|
||||
- Demonstrate loan and return operations
|
||||
- Show the complete workflow
|
||||
|
||||
### 2. Run the web application (Recommended)
|
||||
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
This provides a full-featured web interface for:
|
||||
- Adding tablets (with notes field)
|
||||
- Adding users (with email and phone)
|
||||
- Loaning tablets
|
||||
- Returning tablets
|
||||
- Viewing inventory and loan status
|
||||
- User loans view with search
|
||||
- Non-loanable devices management
|
||||
- Project management with markdown notes
|
||||
|
||||
### 3. Database Structure
|
||||
|
||||
The system uses three main tables with a **many-to-many relationship** between users and tablets:
|
||||
|
||||
**tablets**
|
||||
- `id`: Primary key
|
||||
- `brand`: Tablet brand
|
||||
- `model`: Tablet model
|
||||
- `serial_number`: Unique serial number
|
||||
- `status`: 'available' or 'loaned'
|
||||
|
||||
**users**
|
||||
- `id`: Primary key
|
||||
- `name`: User's name
|
||||
- `identification`: Unique identification
|
||||
|
||||
**loans** (junction table)
|
||||
- `id`: Primary key
|
||||
- `tablet_id`: Foreign key to tablets
|
||||
- `user_id`: Foreign key to users
|
||||
- `loan_date`: When the tablet was loaned
|
||||
- `return_date`: When the tablet was returned (NULL if still active)
|
||||
- `status`: 'active' or 'returned'
|
||||
|
||||
> **Note**: The loans table enables a many-to-many relationship. One user can loan **multiple tablets** (one-to-many from user to loans), and each tablet can be loaned to different users over time. The `status` field on tablets ensures a device can only be loaned to one user at a time.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.x (built-in sqlite3 module)
|
||||
- No additional dependencies needed for the command-line version
|
||||
- Flask required for web versions (optional)
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Adding a tablet
|
||||
```
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO tablets (brand, model, serial_number, status) VALUES (?, ?, ?, ?)',
|
||||
('Microsoft', 'Surface Pro', 'SN004', 'available'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('Tablet added!')
|
||||
"
|
||||
```
|
||||
|
||||
### Adding a user
|
||||
```
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('INSERT INTO users (name, identification) VALUES (?, ?)',
|
||||
('Alice Brown', 'ID004'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('User added!')
|
||||
"
|
||||
```
|
||||
|
||||
### Querying available tablets
|
||||
```
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT brand, model, serial_number FROM tablets WHERE status = ?', ('available',))
|
||||
for row in cursor.fetchall():
|
||||
print(f'{row[0]} {row[1]} ({row[2]})')
|
||||
conn.close()
|
||||
"
|
||||
```
|
||||
|
||||
## Web Version (Optional)
|
||||
|
||||
If you want to use the web interface:
|
||||
|
||||
1. Install Flask:
|
||||
```bash
|
||||
pip install flask
|
||||
```
|
||||
|
||||
2. Run the web application:
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
3. Open your browser to: http://localhost:5000
|
||||
|
||||
### Web Features
|
||||
|
||||
- **Home**: View available tablets and active loans
|
||||
- **Loan Tablet**: Loan a tablet to a user (with search for large datasets)
|
||||
- **User Loans**: View all tablets loaned to each user - demonstrates the **one-to-many relationship** (one user, multiple tablets)
|
||||
- **Loan History**: Complete history of all loan and return transactions
|
||||
- **Non-Loanable Devices**: Manage inventory of devices that cannot be loaned (add, edit, delete, view)
|
||||
- **Project Management**: Markdown editor for development notes (saved to `notes_development/project_notes.md`)
|
||||
|
||||
## Database Backup
|
||||
|
||||
To backup your data:
|
||||
```bash
|
||||
cp tablets.db tablets_backup_$(date +%Y%m%d).db
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The project includes a comprehensive unit test suite with **46 tests** covering core functionality and edge cases.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Install test dependencies (if not already installed)
|
||||
source .venv/bin/activate
|
||||
uv pip install pytest pytest-cov
|
||||
|
||||
# Run all tests
|
||||
python3 -m pytest tests/ -v
|
||||
|
||||
# Run with coverage report
|
||||
python3 -m pytest tests/ --cov=./ --cov-report=term-missing
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
| File | Tests | Coverage |
|
||||
|------|-------|----------|
|
||||
| `tests/test_core.py` | 21 | Core operations (tablet, user, loan CRUD) |
|
||||
| `tests/test_edge_cases.py` | 14 | Edge cases (duplicates, invalid IDs, etc.) |
|
||||
| `tests/test_minimal_app.py` | 11 | Application function tests |
|
||||
|
||||
### Key Edge Cases Covered
|
||||
|
||||
- Loan a device that's already loaned (prevents double-loaning)
|
||||
- Return a non-existent loan (handles gracefully)
|
||||
- Duplicate serial numbers (rejected at database level)
|
||||
- Duplicate user identifications (rejected at database level)
|
||||
- Invalid tablet/user IDs (validated before operations)
|
||||
- Multiple loans per user (one-to-many relationship)
|
||||
- Loan-return-loan sequence (device lifecycle)
|
||||
|
||||
### Test Configuration
|
||||
|
||||
Tests use isolated temporary databases and automatically clean up after each test run. No impact on production data.
|
||||
|
||||
|
||||
|
||||
## License
|
||||
|
||||
This is a simple educational project. Feel free to use and modify it as needed.
|
||||
221
REQUIREMENTS.md
Normal file
221
REQUIREMENTS.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# Requisitos del Sistema de Gestión de Préstamos de Tablets
|
||||
|
||||
## Descripción General
|
||||
|
||||
El sistema debe permitir la gestión del préstamo y devolución de dispositivos tablets entre un inventario y un conjunto de usuarios. El enfoque principal es el seguimiento del estado de cada dispositivo y su relación con los usuarios que los han tomado prestados.
|
||||
|
||||
---
|
||||
## Requisitos Faltantes
|
||||
|
||||
### Gestión de dispositivos no prestables
|
||||
|
||||
✅ **IMPLEMENTADO** - El sistema ahora permite:
|
||||
- Registrar nuevos dispositivos no prestables en un inventario separado (tabla `non_loanable_devices`)
|
||||
- Gestionar dispositivos no prestables a través de:
|
||||
- Interfaz web (app.py): /non_loanable_devices, /add_non_loanable_device, /edit_non_loanable_device, /delete_non_loanable_device
|
||||
- Interfaz CLI (minimal_app.py): Opciones 8, 9, 10 del menú
|
||||
- La implementación utiliza una tabla separada en la misma base de datos con los siguientes campos:
|
||||
- brand, model, serial_number (único), device_type, location, status, notes, purchase_date, purchase_cost
|
||||
|
||||
## Requisitos Funcionales
|
||||
|
||||
### Gestión de Dispositivos
|
||||
|
||||
El sistema debe permitir:
|
||||
- Registrar nuevos dispositivos en el inventario
|
||||
- Identificar cada dispositivo de manera única
|
||||
- Almacenar información descriptiva de cada dispositivo (marca, modelo, número de serie)
|
||||
- Consultar el estado actual de cada dispositivo (disponible, prestado)
|
||||
|
||||
### Gestión de Usuarios
|
||||
|
||||
El sistema debe permitir:
|
||||
- Registrar nuevos usuarios
|
||||
- Identificar cada usuario de manera única
|
||||
- Almacenar información de contacto básica (nombre, identificación)
|
||||
- Consultar la información de los usuarios registrados
|
||||
|
||||
### Gestión de Préstamos
|
||||
|
||||
El sistema debe permitir:
|
||||
- Asignar un dispositivo disponible a un usuario
|
||||
- Registrar la fecha y hora del préstamo
|
||||
- Registrar la fecha y hora de la devolución
|
||||
- Consultar el historial de préstamos de un dispositivo
|
||||
- Consultar el historial de préstamos de un usuario
|
||||
- Visualizar todos los dispositivos actualmente prestados
|
||||
|
||||
### Consulta y Reportes
|
||||
|
||||
El sistema debe permitir:
|
||||
- Buscar dispositivos por cualquier atributo (marca, modelo, número de serie)
|
||||
- Buscar usuarios por cualquier atributo (nombre, identificación)
|
||||
- Filtrar préstamos por estado (activos, finalizados)
|
||||
- Filtrar préstamos por dispositivo
|
||||
- Filtrar préstamos por usuario
|
||||
- Ver el historial completo de préstamos y devoluciones
|
||||
|
||||
---
|
||||
|
||||
## Modelo de Datos
|
||||
|
||||
### Entidades Principales
|
||||
|
||||
1. **Dispositivo**
|
||||
- Identificador único
|
||||
- Marca
|
||||
- Modelo
|
||||
- Número de serie (único)
|
||||
- Estado (disponible, prestado)
|
||||
|
||||
2. **Usuario**
|
||||
- Identificador único
|
||||
- Nombre
|
||||
- Identificación (única)
|
||||
|
||||
3. **Préstamo**
|
||||
- Identificador único
|
||||
- Dispositivo asociado
|
||||
- Usuario asociado
|
||||
- Fecha de préstamo
|
||||
- Fecha de devolución (opcional)
|
||||
- Estado (activo, finalizado)
|
||||
|
||||
### Relaciones
|
||||
|
||||
- Un **usuario** puede tener **múltiples préstamos** (relación uno a muchos)
|
||||
- Un **dispositivo** solo puede estar en **un préstamo activo** a la vez
|
||||
- Un **préstamo** relaciona exactamente un dispositivo con un usuario
|
||||
- El historial de préstamos permite rastrear todos los movimientos de cada dispositivo
|
||||
|
||||
### Reglas de Negocio
|
||||
|
||||
1. Un dispositivo en estado "prestado" no puede ser prestado a otro usuario
|
||||
2. Un préstamo finalizado debe registrar fecha de devolución
|
||||
3. La identificación de dispositivos y usuarios debe ser única
|
||||
4. Al devolver un dispositivo, este vuelve al estado "disponible"
|
||||
|
||||
---
|
||||
|
||||
## Flujos de Trabajo
|
||||
|
||||
### Registro de un Nuevo Dispositivo
|
||||
|
||||
1. El sistema solicita la información del dispositivo (marca, modelo, número de serie)
|
||||
2. El sistema valida que el número de serie no esté registrado
|
||||
3. El sistema registra el dispositivo con estado "disponible"
|
||||
4. El sistema confirma el registro
|
||||
|
||||
### Registro de un Nuevo Usuario
|
||||
|
||||
1. El sistema solicita la información del usuario (nombre, identificación)
|
||||
2. El sistema valida que la identificación no esté registrada
|
||||
3. El sistema registra el usuario
|
||||
4. El sistema confirma el registro
|
||||
|
||||
### Préstamo de un Dispositivo
|
||||
|
||||
1. El sistema muestra los dispositivos disponibles
|
||||
2. El usuario selecciona un dispositivo
|
||||
3. El sistema muestra los usuarios registrados
|
||||
4. El usuario selecciona un usuario
|
||||
5. El sistema registra el préstamo con:
|
||||
- Dispositivo seleccionado
|
||||
- Usuario seleccionado
|
||||
- Fecha y hora actual
|
||||
- Estado "activo"
|
||||
6. El sistema actualiza el estado del dispositivo a "prestado"
|
||||
7. El sistema confirma el préstamo
|
||||
|
||||
### Devolución de un Dispositivo
|
||||
|
||||
1. El sistema muestra los préstamos activos
|
||||
2. El usuario selecciona un préstamo
|
||||
3. El sistema registra la fecha y hora de devolución
|
||||
4. El sistema actualiza el estado del préstamo a "finalizado"
|
||||
5. El sistema actualiza el estado del dispositivo a "disponible"
|
||||
6. El sistema confirma la devolución
|
||||
|
||||
### Consulta de Préstamos por Usuario
|
||||
|
||||
1. El sistema muestra la lista de usuarios
|
||||
2. El usuario selecciona un usuario
|
||||
3. El sistema muestra:
|
||||
- Dispositivos actualmente prestados (préstamos activos)
|
||||
- Historial de préstamos pasados (opcional: expandible)
|
||||
4. El sistema permite buscar por cualquier campo
|
||||
|
||||
---
|
||||
|
||||
## Requisitos No Funcionales
|
||||
|
||||
### Usabilidad
|
||||
|
||||
- La interfaz debe permitir búsqueda eficiente en listas grandes (500+ elementos)
|
||||
- La visualización de información debe ser clara y organizada
|
||||
- Los estados deben ser fácilmente identificables
|
||||
- Las acciones principales deben ser accesibles
|
||||
|
||||
### Escalabilidad
|
||||
|
||||
- El diseño debe soportar un crecimiento en el número de dispositivos y usuarios
|
||||
- La búsqueda debe ser eficiente
|
||||
- El historial debe ser consultable sin afectar el rendimiento
|
||||
|
||||
### Mantenibilidad
|
||||
|
||||
- La estructura de datos debe ser clara
|
||||
- Las relaciones entre entidades deben estar bien definidas
|
||||
- Los flujos de trabajo deben estar documentados
|
||||
|
||||
---
|
||||
|
||||
## Diseño de Interfaz (Conceptual)
|
||||
|
||||
### Vistas Principales
|
||||
|
||||
1. **Vista de Inventario**
|
||||
- Lista de dispositivos
|
||||
- Filtros por estado
|
||||
- Búsqueda
|
||||
|
||||
2. **Vista de Usuarios**
|
||||
- Lista de usuarios
|
||||
- Búsqueda
|
||||
|
||||
3. **Vista de Préstamos Activos**
|
||||
- Lista de préstamos en curso
|
||||
- Filtros por dispositivo/usuario
|
||||
- Acciones: devolver dispositivo
|
||||
|
||||
4. **Vista de Historial**
|
||||
- Lista completa de préstamos
|
||||
- Filtros por fecha, usuario, dispositivo, estado
|
||||
- Detalles de cada préstamo
|
||||
|
||||
5. **Vista de Préstamos por Usuario**
|
||||
- Lista de usuarios
|
||||
- Para cada usuario: dispositivos actualmente prestados
|
||||
- Opcional: historial de préstamos pasados (expandible)
|
||||
|
||||
### Elementos de Interacción
|
||||
|
||||
- Campos de búsqueda en todas las listas
|
||||
- Botones de acción claros (Prestar, Devolver, Registrar)
|
||||
- Indicadores visuales de estado (colores, iconos)
|
||||
- Confirmación de acciones críticas
|
||||
|
||||
---
|
||||
|
||||
## Notas de Implementación
|
||||
|
||||
Este documento describe los requisitos a nivel de diseño. La implementación específica (lenguaje de programación, base de datos, framework, arquitectura) queda a criterio del equipo de desarrollo y puede variar según las necesidades técnicas.
|
||||
|
||||
Los detalles técnicos como:
|
||||
- Tecnologías específicas
|
||||
- Estructura de base de datos física
|
||||
- APIs
|
||||
- Autenticación
|
||||
- Despliegue
|
||||
|
||||
Deberán ser definidos en la fase de diseño técnico y pueden evolucionar sin afectar los requisitos funcionales descritos aquí.
|
||||
69
RUNNING.md
Normal file
69
RUNNING.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Tablet Management System - Running
|
||||
|
||||
✅ **Status**: Application is running successfully!
|
||||
|
||||
## Access Information
|
||||
|
||||
- **Web Interface**: [http://localhost:5000](http://localhost:5000)
|
||||
- **Port**: 5000
|
||||
- **Framework**: Flask 3.1.3
|
||||
- **Database**: SQLite (`tablets.db`)
|
||||
|
||||
## How to Use
|
||||
|
||||
### Web Interface
|
||||
1. Open your browser to: [http://localhost:5000](http://localhost:5000)
|
||||
2. Use the navigation menu to:
|
||||
- **Home**: View available tablets and active loans
|
||||
- **Add Tablet**: Add new tablets to inventory
|
||||
- **Add User**: Register new users
|
||||
- **Loan Tablet**: Loan a tablet to a user
|
||||
- **Loan History**: View complete loan history
|
||||
|
||||
### Command Line (Alternative)
|
||||
If you prefer command line:
|
||||
```bash
|
||||
# Run the interactive application
|
||||
python3 minimal_app.py
|
||||
|
||||
# Run the test/demo
|
||||
python3 test_app.py
|
||||
```
|
||||
|
||||
## Current Data
|
||||
The system already contains test data:
|
||||
- **3 Tablets**: Samsung Galaxy Tab S7, Apple iPad Pro, Lenovo Tab P11
|
||||
- **3 Users**: John Doe, Jane Smith, Bob Johnson
|
||||
- **2 Loans**: One active, one returned
|
||||
|
||||
## Stopping the Server
|
||||
To stop the Flask application:
|
||||
```bash
|
||||
pkill -f "python app.py"
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
- **Backend**: SQLite 3
|
||||
- **Frontend**: Flask with HTML/CSS
|
||||
- **Port**: 5000 (configurable in app.py)
|
||||
- **Virtual Environment**: `.venv/` (created with uv)
|
||||
|
||||
## Running Tests
|
||||
|
||||
The project includes 46 unit tests for verifying functionality:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
python3 -m pytest tests/ -v
|
||||
|
||||
# Run with coverage
|
||||
python3 -m pytest tests/ --cov=./ --cov-report=term-missing
|
||||
```
|
||||
|
||||
Tests cover:
|
||||
- Core CRUD operations for tablets, users, and loans
|
||||
- Edge cases (duplicates, invalid IDs, already loaned devices)
|
||||
- Application function testing
|
||||
- Non-loanable device management
|
||||
|
||||
All tests use isolated temporary databases and do not affect production data.
|
||||
200
TECHNICAL_SPECIFICATIONS.md
Normal file
200
TECHNICAL_SPECIFICATIONS.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Especificaciones Técnicas
|
||||
|
||||
Documento complementario a `REQUIREMENTS.md` con propuestas concretas de implementación técnica.
|
||||
|
||||
---
|
||||
|
||||
## 1. Concurrencia en Base de Datos
|
||||
|
||||
| Opción | Descripción | Ventajas | Desventajas |
|
||||
|--------|-------------|---------|-------------|
|
||||
| **SQLite + WAL** | Modo Write-Ahead Logging | Zero-config, embebido, buena para <100 conexiones | Limitado a 1 escritor simultáneo |
|
||||
| **PostgreSQL** | Base de datos relacional | Concurrencia completa, transacciones ACID | Requiere servidor |
|
||||
| **Transacciones** | Aislamiento READ COMMITTED | Previene lecturas sucias | Bloqueos temporales |
|
||||
| **Locking optimista** | Versión de fila en actualizaciones | Sin bloqueos | Conflictos requieren reintento |
|
||||
|
||||
**Recomendación**: SQLite con WAL para prototipos, PostgreSQL para producción. Usar transacciones para operaciones críticas (préstamo/devolución).
|
||||
|
||||
---
|
||||
|
||||
## 2. Autenticación Básica
|
||||
|
||||
| Opción | Descripción | Complejidad |
|
||||
|--------|-------------|-------------|
|
||||
| **Sesiones (Flask)** | Cookies firmadas | Baja |
|
||||
| **JWT** | Tokens sin estado | Media |
|
||||
| **OAuth 2.0** | Delegación a proveedores | Alta |
|
||||
| **Basic Auth** | HTTP Basic Authentication | Mínima |
|
||||
|
||||
**Flujo mínimo**:
|
||||
```
|
||||
Login (user/pass) → Session/JWT → Middleware verifica rol → Acceso a endpoints
|
||||
```
|
||||
|
||||
**Roles sugeridos**:
|
||||
- `admin`: CRUD completo, gestión de usuarios
|
||||
- `staff`: Préstamos/devoluciones
|
||||
- `viewer`: Solo consulta
|
||||
|
||||
---
|
||||
|
||||
## 3. Separación Frontend/Backend
|
||||
|
||||
| Arquitectura | Descripción | Stack Ejemplo |
|
||||
|--------------|-------------|---------------|
|
||||
| **Monolítica + Templates** | Servidor renderiza HTML | Flask+Jinja2, Django |
|
||||
| **API + SPA** | Backend API, frontend dinámico | FastAPI + React/Vue |
|
||||
| **API + SSR** | Backend API + renderizado servidor | Next.js + API |
|
||||
| **Microservicios** | Servicios independientes | Backend API + Frontend estático |
|
||||
|
||||
**Para este proyecto**:
|
||||
- **Opción A**: Flask + Jinja2 (simple, suficiente para requisitos)
|
||||
- **Opción B**: FastAPI + React (escalable, separable)
|
||||
|
||||
---
|
||||
|
||||
## 4. Búsqueda Eficiente (500+ elementos)
|
||||
|
||||
| Opción | Descripción | Implementación |
|
||||
|--------|-------------|----------------|
|
||||
| **Índices DB** | B-tree en columnas buscadas | `CREATE INDEX idx_brand ON tablets(brand)` |
|
||||
| **Búsqueda full-text** | Búsqueda en texto | SQLite FTS5, PostgreSQL tsvector |
|
||||
| **Filtrado cliente** | JavaScript filtra datos | Ideal para <2000 elementos |
|
||||
| **Paginación** | Dividir resultados | `LIMIT 50 OFFSET 0` |
|
||||
|
||||
**Recomendación**: Índices en `serial_number`, `brand`, `model`, `user.name`, `user.identification`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Estructura de Proyecto Propuesta
|
||||
|
||||
```
|
||||
project/
|
||||
├── backend/
|
||||
│ ├── models/ # Entidades (Device, User, Loan)
|
||||
│ ├── services/ # Lógica de negocio
|
||||
│ ├── repositories/ # Acceso a datos
|
||||
│ ├── routes/ # Endpoints/API
|
||||
│ └── app.py # Configuración
|
||||
├── frontend/ # Opcional para opción B
|
||||
│ ├── public/
|
||||
│ └── src/
|
||||
├── migrations/ # Control de cambios DB
|
||||
├── tests/
|
||||
└── docs/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisiones Clave por Tomar
|
||||
|
||||
| Decisión | Opciones | Impacto |
|
||||
|----------|----------|---------|
|
||||
| Base de datos | SQLite / PostgreSQL | Concurrencia, despliegue |
|
||||
| Autenticación | Sesiones / JWT / Ninguna | Seguridad, complejidad |
|
||||
| Frontend | Templates / SPA / Ninguno | Experiencia usuario |
|
||||
| Despliegue | Local / Docker / Cloud | Escalabilidad |
|
||||
|
||||
---
|
||||
|
||||
## 7. Ejemplo de Implementación Mínima (Pseudocódigo)
|
||||
|
||||
```python
|
||||
# modelos.py
|
||||
class Device:
|
||||
id: int (PK)
|
||||
serial_number: str (unique)
|
||||
status: str (available/loaned)
|
||||
loans: List[Loan] (one-to-many)
|
||||
|
||||
class User:
|
||||
id: int (PK)
|
||||
identification: str (unique)
|
||||
loans: List[Loan] (one-to-many)
|
||||
|
||||
class Loan:
|
||||
id: int (PK)
|
||||
device_id: int (FK)
|
||||
user_id: int (FK)
|
||||
loan_date: datetime
|
||||
return_date: datetime (nullable)
|
||||
status: str (active/returned)
|
||||
|
||||
# servicios/loan_service.py
|
||||
def loan_device(device_id: int, user_id: int) -> Loan:
|
||||
with transaction(): # Atomicidad
|
||||
device = get_device_for_update(device_id)
|
||||
if device.status != 'available':
|
||||
raise DeviceNotAvailableError()
|
||||
|
||||
loan = Loan.create(
|
||||
device_id=device_id,
|
||||
user_id=user_id,
|
||||
loan_date=now(),
|
||||
status='active'
|
||||
)
|
||||
device.status = 'loaned'
|
||||
return loan
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Consideraciones Adicionales
|
||||
|
||||
- **Validaciones**: Únicas (serial_number, identification) a nivel de base de datos
|
||||
- **Auditoría**: Campos `created_at`, `updated_at` en todas las entidades
|
||||
- **Backups**: Script para exportar base de datos periódicamente
|
||||
- **Logging**: Registrar préstamos/devoluciones para auditoría
|
||||
- **Versionado API**: `/api/v1/...` para compatibilidad futura
|
||||
- **Pruebas**: Cubrir flujos críticos (préstamo con dispositivo no disponible, devolución de préstamo inexistente)
|
||||
|
||||
---
|
||||
|
||||
## 9. Migración a PostgreSQL (Futuro)
|
||||
|
||||
### Contexto
|
||||
|
||||
El sistema actualmente utiliza **SQLite** como base de datos embebida, ideal para prototipos y aplicaciones pequeñas. Sin embargo, SQLite tiene limitaciones para escalar.
|
||||
|
||||
### Criterios para Migración
|
||||
|
||||
Considerar migrar a **PostgreSQL** cuando:
|
||||
- Tamaño DB > 1GB
|
||||
- Más de 50 conexiones simultáneas
|
||||
- Más de 200 usuarios activos
|
||||
- Más de 500 transacciones por minuto
|
||||
- Necesidad de múltiples servidores
|
||||
|
||||
### Arquitectura Propuesta
|
||||
|
||||
Usar un patrón de **Repository** para abstraer la base de datos:
|
||||
```
|
||||
backend/
|
||||
├── repositories/
|
||||
│ ├── base_repository.py # Interfaz abstracta
|
||||
│ ├── sqlite_repository.py # Implementación SQLite
|
||||
│ └── postgres_repository.py # Implementación PostgreSQL
|
||||
└── config/
|
||||
└── database.py # Fábrica de repositorios
|
||||
```
|
||||
|
||||
### Schema PostgreSQL
|
||||
|
||||
El schema es similar al de SQLite pero con tipos de datos más específicos y índices adicionales para rendimiento.
|
||||
|
||||
### Pasos para Migración
|
||||
|
||||
1. Instalar dependencias: `pip install psycopg2-binary sqlalchemy alembic`
|
||||
2. Configurar PostgreSQL y crear base de datos
|
||||
3. Ejecutar script de migración: `python scripts/migrate_to_postgres.py`
|
||||
4. Cambiar configuración: `DB_TYPE=postgres`
|
||||
5. Iniciar aplicación
|
||||
|
||||
### Beneficios
|
||||
|
||||
- Concurrencia ilimitada (múltiples escritores)
|
||||
- Escalabilidad a miles de conexiones
|
||||
- Mejor rendimiento con índices
|
||||
- Seguridad integrada (autenticación, roles)
|
||||
- Backups automáticos
|
||||
- Replicación y clustering
|
||||
BIN
assets/Schamann.png
Normal file
BIN
assets/Schamann.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
81
basic_server.py
Normal file
81
basic_server.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Basic HTTP server to serve the tablet management system
|
||||
"""
|
||||
|
||||
from http.server import SimpleHTTPRequestHandler, HTTPServer
|
||||
import os
|
||||
|
||||
class MyHandler(SimpleHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == '/':
|
||||
self.path = '/index.html'
|
||||
return super().do_GET()
|
||||
|
||||
def run_server():
|
||||
port = 8080
|
||||
server_address = ('', port)
|
||||
httpd = HTTPServer(server_address, MyHandler)
|
||||
|
||||
# Create a simple index file if it doesn't exist
|
||||
if not os.path.exists('index.html'):
|
||||
with open('index.html', 'w') as f:
|
||||
f.write('''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tablet Management System</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
h1 { color: #4CAF50; }
|
||||
.info { background: #f0f0f0; padding: 20px; border-radius: 5px; }
|
||||
.command { background: #e0e0e0; padding: 10px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tablet Management System</h1>
|
||||
|
||||
<div class="info">
|
||||
<h2>Welcome to the Tablet Management System!</h2>
|
||||
<p>This system is running with a SQLite backend.</p>
|
||||
|
||||
<h3>Available Commands:</h3>
|
||||
<ul>
|
||||
<li><strong>Test the system:</strong> <code class="command">python3 test_app.py</code></li>
|
||||
<li><strong>Run interactive mode:</strong> <code class="command">python3 minimal_app.py</code></li>
|
||||
<li><strong>Check database:</strong> <code class="command">sqlite3 tablets.db</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Current Status:</h3>
|
||||
<p>✓ Database: tablets.db</p>
|
||||
<p>✓ Web server: Running on port 8080</p>
|
||||
<p>✓ System: Ready for use</p>
|
||||
</div>
|
||||
|
||||
<h3>Quick Test Results:</h3>
|
||||
<pre id="test-results">Running tests...</pre>
|
||||
|
||||
<script>
|
||||
// Simple test to show the system is working
|
||||
fetch('/test-db')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
document.getElementById('test-results').textContent = data;
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('test-results').textContent = 'Database test: Error - ' + error;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>''')
|
||||
|
||||
print(f"Server running on http://localhost:{port}")
|
||||
print("Serving current directory")
|
||||
print("Press Ctrl+C to stop the server")
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped")
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server()
|
||||
20
check_flask.py
Normal file
20
check_flask.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check if Flask is available and provide installation instructions
|
||||
"""
|
||||
|
||||
try:
|
||||
import flask
|
||||
print("✓ Flask is available!")
|
||||
print("You can run the web version:")
|
||||
print(" python3 simple_app.py")
|
||||
print("Then open: http://localhost:8000")
|
||||
except ImportError:
|
||||
print("✗ Flask is not installed")
|
||||
print("\nTo install Flask:")
|
||||
print(" pip install flask")
|
||||
print("\nOr use the system package manager:")
|
||||
print(" sudo apt install python3-flask")
|
||||
print("\nYou can still use the command-line version:")
|
||||
print(" python3 minimal_app.py")
|
||||
print(" python3 test_app.py")
|
||||
24
compile_translations.py
Normal file
24
compile_translations.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compile .po files to .mo files for Flask-Babel
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def compile_translations():
|
||||
translations_dir = Path('translations')
|
||||
|
||||
for lang_dir in translations_dir.iterdir():
|
||||
if lang_dir.is_dir():
|
||||
lc_messages_dir = lang_dir / 'LC_MESSAGES'
|
||||
if lc_messages_dir.exists():
|
||||
for po_file in lc_messages_dir.glob('*.po'):
|
||||
mo_file = lc_messages_dir / f"{po_file.stem}.mo"
|
||||
print(f"Compiling {po_file} -> {mo_file}")
|
||||
# Use msgfmt to compile
|
||||
os.system(f"msgfmt -o {mo_file} {po_file}")
|
||||
|
||||
print("✅ All translations compiled!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
compile_translations()
|
||||
20
debug_server.py
Normal file
20
debug_server.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
#!/usr/bin/env python3
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
|
||||
class DebugHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/plain')
|
||||
self.end_headers()
|
||||
self.wfile.write(b'Hello from Tablet Management Server!')
|
||||
|
||||
def run():
|
||||
try:
|
||||
server = HTTPServer(('', 8000), DebugHandler)
|
||||
print("Debug server running on port 8000")
|
||||
server.serve_forever()
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
run()
|
||||
522
docs/CLI_VS_WEB.md
Normal file
522
docs/CLI_VS_WEB.md
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
# CLI vs Web Functionality Comparison
|
||||
|
||||
## ⚠️ DEPRECATION NOTICE
|
||||
|
||||
**The CLI version (`minimal_app.py`) is now DEPRECATED.**
|
||||
|
||||
Please use the **web interface (`app.py`)** for all operations. The web interface provides:
|
||||
- Complete feature set
|
||||
- Full database schema support
|
||||
- Responsive mobile interface
|
||||
- Better user experience
|
||||
|
||||
The CLI will be removed in a future version. This document is kept for historical reference.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
This document compares the functionality between the **DEPRECATED CLI version** (`minimal_app.py`) and the **Web version** (`app.py`) of the Tablet Management System.
|
||||
|
||||
## Current State
|
||||
|
||||
### Web Version (app.py) - ✅ RECOMMENDED
|
||||
|
||||
The web version is the **primary and recommended** interface for all users.
|
||||
|
||||
| Feature | Status | Route | Template |
|
||||
|---------|--------|-------|---------|
|
||||
| Add Tablet | ✅ | `/add_tablet` | `add_tablet.html` |
|
||||
| Add User | ✅ | `/add_user` | `add_user.html` |
|
||||
| Loan Tablet | ✅ | `/loan_tablet` | `loan_tablet.html` |
|
||||
| Return Tablet | ✅ | `/return_tablet/<id>` | N/A (redirects) |
|
||||
| Show Available Tablets | ✅ | `/` (index) | `index.html` |
|
||||
| Show Active Loans | ✅ | `/` (index) | `index.html` |
|
||||
| Loan History | ✅ | `/history` | `history.html` |
|
||||
| User Loans | ✅ | `/user_loans` | `user_loans.html` |
|
||||
| Add Non-Loanable Device | ✅ | `/add_non_loanable_device` | `add_non_loanable_device.html` |
|
||||
| Show Non-Loanable Devices | ✅ | `/non_loanable_devices` | `non_loanable_devices.html` |
|
||||
| Edit Non-Loanable Device | ✅ | `/edit_non_loanable_device/<id>` | `edit_non_loanable_device.html` |
|
||||
| Delete Non-Loanable Device | ✅ | `/delete_non_loanable_device/<id>` | N/A (redirects) |
|
||||
| Project Management | ✅ | `/project_management` | `project_management.html` |
|
||||
|
||||
### CLI Version (minimal_app.py) - ❌ DEPRECATED
|
||||
|
||||
**This CLI is deprecated and should not be used for new development.**
|
||||
|
||||
The CLI has limited functionality and does not support all database columns used by the web version.
|
||||
|
||||
| Feature | Status | Function |
|
||||
|---------|--------|----------|
|
||||
| Add Tablet | ⚠️ Partial | `add_tablet()` - Missing notes field |
|
||||
| Add User | ⚠️ Partial | `add_user()` - Missing email/phone |
|
||||
| Loan Tablet | ✅ | `loan_tablet()` |
|
||||
| Return Tablet | ✅ | `return_tablet()` |
|
||||
| Show Available Tablets | ✅ | `show_available_tablets()` |
|
||||
| Show Active Loans | ✅ | `show_active_loans()` |
|
||||
| Loan History | ✅ | `show_loan_history()` |
|
||||
| Add Non-Loanable Device | ✅ | `add_non_loanable_device()` |
|
||||
| Show Non-Loanable Devices | ✅ | `show_non_loanable_devices()` |
|
||||
| Delete Non-Loanable Device | ✅ | `delete_non_loanable_device()` |
|
||||
| Show Users | ✅ | `show_users()` |
|
||||
| **User Loans** | ❌ Missing | N/A |
|
||||
| **Edit Non-Loanable Device** | ❌ Missing | N/A |
|
||||
| **Project Management** | ❌ Missing | N/A |
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Existing CLI Users
|
||||
|
||||
**Stop using the CLI and switch to the web interface:**
|
||||
|
||||
1. Run the web interface:
|
||||
```bash
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
2. Open your browser to: http://localhost:5000
|
||||
|
||||
3. The web interface uses the **same database** (`tablets.db`), so all your data is preserved.
|
||||
|
||||
### Database Compatibility
|
||||
|
||||
The CLI's database schema is **missing some columns** that the web version uses:
|
||||
- `tablets.notes` - Added by web version
|
||||
- `users.email` - Added by web version
|
||||
- `users.phone` - Added by web version
|
||||
|
||||
**If you've only used the CLI:** Your database is missing these columns. The web interface will still work, but won't be able to store notes, email, or phone until the columns are added.
|
||||
|
||||
**To fix the database schema:**
|
||||
```bash
|
||||
# Run the web interface - it will add missing columns automatically
|
||||
python3 app.py
|
||||
```
|
||||
|
||||
The web version's `init_db()` will add any missing columns when it runs.
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Use `app.py` (web interface) for all operations.**
|
||||
|
||||
The CLI (`minimal_app.py`) is deprecated and will be removed in a future version. All development and maintenance efforts should focus on the web interface.
|
||||
|
||||
|
||||
## Missing Features in CLI
|
||||
|
||||
### 1. User Management
|
||||
**Web:** Supports name, email, phone, identification
|
||||
**CLI:** Only supports name, identification
|
||||
|
||||
**Missing:**
|
||||
- Email field
|
||||
- Phone field
|
||||
|
||||
### 2. Tablet Management
|
||||
**Web:** Supports brand, model, serial_number, notes
|
||||
**CLI:** Supports brand, model, serial_number
|
||||
|
||||
**Missing:**
|
||||
- Notes field
|
||||
|
||||
### 3. Non-Loanable Devices
|
||||
**Web:** Full CRUD (Create, Read, Update, Delete)
|
||||
**CLI:** Only Create, Read, Delete
|
||||
|
||||
**Missing:**
|
||||
- Edit functionality
|
||||
|
||||
### 4. Additional Features
|
||||
**Web:** Has these features
|
||||
**CLI:** Missing
|
||||
|
||||
- User Loans page (shows loans by user with search)
|
||||
- Project Management (markdown notes editor)
|
||||
- Flash messages (CLI uses print, which is fine)
|
||||
|
||||
## Database Schema Comparison
|
||||
|
||||
### Web Version Schema (app.py)
|
||||
|
||||
```sql
|
||||
-- tablets
|
||||
CREATE TABLE tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT
|
||||
)
|
||||
|
||||
-- users
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
identification TEXT UNIQUE
|
||||
)
|
||||
|
||||
-- loans
|
||||
CREATE TABLE loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
|
||||
-- non_loanable_devices
|
||||
CREATE TABLE non_loanable_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL
|
||||
)
|
||||
```
|
||||
|
||||
### CLI Version Schema (minimal_app.py)
|
||||
|
||||
```sql
|
||||
-- tablets
|
||||
CREATE TABLE tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available'
|
||||
-- MISSING: notes TEXT
|
||||
)
|
||||
|
||||
-- users
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
identification TEXT UNIQUE
|
||||
-- MISSING: email TEXT, phone TEXT
|
||||
)
|
||||
|
||||
-- loans
|
||||
CREATE TABLE loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
|
||||
-- non_loanable_devices
|
||||
CREATE TABLE non_loanable_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL
|
||||
)
|
||||
```
|
||||
|
||||
**Schema Differences:**
|
||||
- `tablets` table: CLI missing `notes` column
|
||||
- `users` table: CLI missing `email` and `phone` columns
|
||||
|
||||
## Recommendations
|
||||
|
||||
### Option 1: Update CLI to Match Web (Recommended)
|
||||
|
||||
Update `minimal_app.py` to:
|
||||
1. Add `notes` field to tablets
|
||||
2. Add `email` and `phone` fields to users
|
||||
3. Add edit functionality for non-loanable devices
|
||||
4. Add user loans view
|
||||
5. Add project management (optional)
|
||||
|
||||
**Pros:**
|
||||
- CLI has full feature parity with web
|
||||
- Same database schema
|
||||
- Users can use either interface
|
||||
|
||||
**Cons:**
|
||||
- More complex CLI
|
||||
- May not be needed if web is primary interface
|
||||
|
||||
### Option 2: Keep CLI Minimal (Current State)
|
||||
|
||||
Leave CLI as-is for basic operations only.
|
||||
|
||||
**Pros:**
|
||||
- Simple, focused CLI
|
||||
- Less code to maintain
|
||||
|
||||
**Cons:**
|
||||
- Database schema mismatch
|
||||
- Users can't access all features via CLI
|
||||
- Confusing for users who expect same functionality
|
||||
|
||||
### Option 3: Create Separate Database (Not Recommended)
|
||||
|
||||
Use different databases for CLI and web.
|
||||
|
||||
**Pros:**
|
||||
- Each can have optimized schema
|
||||
|
||||
**Cons:**
|
||||
- Data duplication
|
||||
- Sync issues
|
||||
- Confusing for users
|
||||
|
||||
## Suggested Action Plan
|
||||
|
||||
### Priority 1: Fix Database Schema Mismatch
|
||||
|
||||
The CLI's `init_db()` creates tables without `notes`, `email`, and `phone` columns, but the web version expects them. This can cause issues.
|
||||
|
||||
**Solution:** Update `minimal_app.py` `init_db()` to match `app.py` schema.
|
||||
|
||||
### Priority 2: Add Missing Fields to CLI Functions
|
||||
|
||||
1. Update `add_tablet()` to accept and store `notes`
|
||||
2. Update `add_user()` to accept and store `email` and `phone`
|
||||
3. Update `show_available_tablets()` and `show_non_loanable_devices()` to display all fields
|
||||
|
||||
### Priority 3: Add Missing Features (Optional)
|
||||
|
||||
1. Add `edit_non_loanable_device()` function
|
||||
2. Add `show_user_loans()` function
|
||||
3. Consider adding project management (lower priority)
|
||||
|
||||
## Code Changes Required
|
||||
|
||||
### 1. Update init_db() in minimal_app.py
|
||||
|
||||
```python
|
||||
def init_db():
|
||||
"""Initialize database with required tables - MATCH WEB VERSION"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create tablets table - ADD NOTES COLUMN
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Create users table - ADD EMAIL AND PHONE COLUMNS
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
identification TEXT UNIQUE
|
||||
)
|
||||
''')
|
||||
|
||||
# loans and non_loanable_devices are already correct
|
||||
# ... rest of init_db
|
||||
```
|
||||
|
||||
### 2. Update add_tablet() Function
|
||||
|
||||
```python
|
||||
def add_tablet(brand, model, serial_number, notes=''):
|
||||
"""Add a new tablet to inventory"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status, notes)
|
||||
VALUES (?, ?, ?, 'available', ?)
|
||||
''', (brand, model, serial_number, notes))
|
||||
conn.commit()
|
||||
print(f"✓ Added tablet: {brand} {model} ({serial_number})")
|
||||
if notes:
|
||||
print(f" Notes: {notes}")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"✗ Error: Serial number {serial_number} already exists")
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### 3. Update add_user() Function
|
||||
|
||||
```python
|
||||
def add_user(name, identification, email='', phone=''):
|
||||
"""Add a new user"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (name, email, phone, identification)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (name, email, phone, identification))
|
||||
conn.commit()
|
||||
print(f"✓ Added user: {name} ({identification})")
|
||||
if email:
|
||||
print(f" Email: {email}")
|
||||
if phone:
|
||||
print(f" Phone: {phone}")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"✗ Error: Identification {identification} already exists")
|
||||
finally:
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### 4. Update show_available_tablets() to Display Notes
|
||||
|
||||
```python
|
||||
def show_available_tablets():
|
||||
"""Show available tablets"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id, brand, model, serial_number, notes FROM tablets WHERE status = 'available'")
|
||||
tablets = cursor.fetchall()
|
||||
|
||||
print("\n=== Available Tablets ===")
|
||||
if tablets:
|
||||
for tablet in tablets:
|
||||
notes = f" | Notes: {tablet[4]}" if tablet[4] else ""
|
||||
print(f"ID: {tablet[0]}, {tablet[1]} {tablet[2]} ({tablet[3]}){notes}")
|
||||
else:
|
||||
print("No available tablets")
|
||||
|
||||
conn.close()
|
||||
```
|
||||
|
||||
### 5. Add Missing Functions
|
||||
|
||||
#### Edit Non-Loanable Device
|
||||
|
||||
```python
|
||||
def edit_non_loanable_device(device_id, **kwargs):
|
||||
"""Edit a non-loanable device"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
params = []
|
||||
for key, value in kwargs.items():
|
||||
if value is not None:
|
||||
updates.append(f"{key} = ?")
|
||||
params.append(value)
|
||||
|
||||
if not updates:
|
||||
print("✗ No fields to update")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
params.append(device_id)
|
||||
query = f"UPDATE non_loanable_devices SET {', '.join(updates)} WHERE id = ?"
|
||||
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
print(f"✓ Non-loanable device {device_id} updated")
|
||||
else:
|
||||
print(f"✗ Error: Device {device_id} not found")
|
||||
|
||||
conn.close()
|
||||
```
|
||||
|
||||
#### Show User Loans
|
||||
|
||||
```python
|
||||
def show_user_loans():
|
||||
"""Show loans grouped by user"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get all users with their loans
|
||||
cursor.execute('''
|
||||
SELECT u.id, u.name, u.identification,
|
||||
GROUP_CONCAT(l.id, ",") as loan_ids,
|
||||
COUNT(l.id) as loan_count
|
||||
FROM users u
|
||||
LEFT JOIN loans l ON u.id = l.user_id
|
||||
GROUP BY u.id, u.name, u.identification
|
||||
''')
|
||||
users = cursor.fetchall()
|
||||
|
||||
print("\n=== User Loans ===")
|
||||
if users:
|
||||
for user in users:
|
||||
print(f"\nUser: {user[1]} ({user[2]}) - {user[3]} loans")
|
||||
|
||||
# Get loans for this user
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, l.loan_date, l.return_date, l.status
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
WHERE l.user_id = ?
|
||||
ORDER BY l.loan_date DESC
|
||||
''', (user[0],))
|
||||
loans = cursor.fetchall()
|
||||
|
||||
for loan in loans:
|
||||
return_date = loan[5] or 'Not returned'
|
||||
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]})")
|
||||
print(f" Loan Date: {loan[4]}, Return Date: {return_date}, Status: {loan[6]}")
|
||||
else:
|
||||
print("No users found")
|
||||
|
||||
conn.close()
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
If you want to update the CLI to match the web version:
|
||||
|
||||
1. **Backup current database**
|
||||
```bash
|
||||
cp tablets.db tablets.db.backup
|
||||
```
|
||||
|
||||
2. **Update minimal_app.py** with the changes above
|
||||
|
||||
3. **Run updated CLI**
|
||||
```bash
|
||||
python3 minimal_app.py
|
||||
```
|
||||
|
||||
4. **Test all functionality**
|
||||
|
||||
5. **If database schema changed**, you may need to:
|
||||
- Drop and recreate tables (if starting fresh)
|
||||
- Or add missing columns with ALTER TABLE (if preserving data)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The CLI version is **lagging behind** the web version in terms of:
|
||||
- Database schema (missing columns)
|
||||
- Feature completeness (missing functions)
|
||||
- Field support (missing email, phone, notes)
|
||||
|
||||
**Recommendation:** Update the CLI to match the web version's database schema and functionality to ensure consistency and avoid confusion for users who may use both interfaces.
|
||||
176
docs/CORPORATE_DESIGN.md
Normal file
176
docs/CORPORATE_DESIGN.md
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
# Corporate Design Specifications
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines the corporate design specifications for the Tablet Management System, including color palette, logo usage, and branding guidelines.
|
||||
|
||||
---
|
||||
|
||||
## Color Palette
|
||||
|
||||
### Primary Corporate Colors
|
||||
|
||||
The following three colors constitute the official corporate color palette:
|
||||
|
||||
| Color Name | Hex Code | RGB | Usage |
|
||||
|------------|----------|-----|-------|
|
||||
| **Cobalt Blue** | `#1338BE` | rgb(19, 56, 190) | Primary brand color, headers, navigation, main actions |
|
||||
| **Tiger Orange** | `#FC6A03` | rgb(252, 106, 3) | Accents, highlights, loading indicators, secondary actions |
|
||||
| **Emerald Green** | `#028A0F` | rgb(2, 138, 15) | Success states, positive feedback, confirmation |
|
||||
|
||||
### Color Shades
|
||||
|
||||
Each corporate color has been extended with a full shade range (50-900) for use in Tailwind CSS:
|
||||
|
||||
#### Cobalt Blue Shades
|
||||
- `cobalt-50`: `#f0f5ff` (Lightest)
|
||||
- `cobalt-100`: `#e0eaff`
|
||||
- `cobalt-200`: `#b3c6ff`
|
||||
- `cobalt-300`: `#80a0ff`
|
||||
- `cobalt-400`: `#4d7aff`
|
||||
- **`cobalt-500`: `#1338BE`** (Primary)
|
||||
- `cobalt-600`: `#0f2a9c`
|
||||
- `cobalt-700`: `#0b1f72`
|
||||
- `cobalt-800`: `#081552`
|
||||
- `cobalt-900`: `#060f38` (Darkest)
|
||||
|
||||
#### Tiger Orange Shades
|
||||
- `tiger-50`: `#fff8f0` (Lightest)
|
||||
- `tiger-100`: `#ffe8d6`
|
||||
- `tiger-200`: `#ffccab`
|
||||
- `tiger-300`: `#ffae80`
|
||||
- `tiger-400`: `#ff8c55`
|
||||
- **`tiger-500`: `#FC6A03`** (Primary)
|
||||
- `tiger-600`: `#e05a00`
|
||||
- `tiger-700`: `#b84500`
|
||||
- `tiger-800`: `#8c3300`
|
||||
- `tiger-900`: `#662500` (Darkest)
|
||||
|
||||
#### Emerald Green Shades
|
||||
- `emerald-50`: `#f0fdf4` (Lightest)
|
||||
- `emerald-100`: `#dcfce7`
|
||||
- `emerald-200`: `#bbf7d0`
|
||||
- `emerald-300`: `#86efac`
|
||||
- `emerald-400`: `#4ade80`
|
||||
- **`emerald-500`: `#028A0F`** (Primary)
|
||||
- `emerald-600`: `#006b0c`
|
||||
- `emerald-700`: `#005209`
|
||||
- `emerald-800`: `#003d07`
|
||||
- `emerald-900`: `#002904` (Darkest)
|
||||
|
||||
---
|
||||
|
||||
## Logo Usage
|
||||
|
||||
### Logo File
|
||||
- **File**: `assets/Schamann.png`
|
||||
- **Dimensions**: 175 x 161 pixels
|
||||
- **Format**: PNG with transparency
|
||||
- **Web Path**: `/static/Schamann.png`
|
||||
|
||||
### Display Guidelines
|
||||
- The logo should be displayed at a fixed height of 40px in the header
|
||||
- Use `object-contain` to maintain aspect ratio
|
||||
- The logo should appear to the left of the application title
|
||||
- Do not stretch or distort the logo
|
||||
|
||||
### Implementation
|
||||
```html
|
||||
<img src="/static/Schamann.png" alt="Corporate Logo" class="h-10 w-auto object-contain" style="max-height: 40px; height: 40px !important;">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Headers and Navigation
|
||||
```html
|
||||
<!-- Header background -->
|
||||
<header class="bg-cobalt-600 text-white shadow-lg">
|
||||
|
||||
<!-- Navigation background -->
|
||||
<nav class="bg-cobalt-600 rounded-lg shadow-md">
|
||||
|
||||
<!-- Navigation hover -->
|
||||
<a class="hover:bg-cobalt-700 transition-colors">
|
||||
```
|
||||
|
||||
### Buttons and Actions
|
||||
```html
|
||||
<!-- Primary button (Cobalt Blue) -->
|
||||
<button class="bg-cobalt-600 hover:bg-cobalt-700 text-white">
|
||||
|
||||
<!-- Secondary button (Tiger Orange) -->
|
||||
<button class="bg-tiger-500 hover:bg-tiger-600 text-white">
|
||||
|
||||
<!-- Success button (Emerald Green) -->
|
||||
<button class="bg-emerald-500 hover:bg-emerald-600 text-white">
|
||||
```
|
||||
|
||||
### Status Indicators
|
||||
```html
|
||||
<!-- Success state -->
|
||||
<div class="bg-emerald-100 text-emerald-800">Success!</div>
|
||||
|
||||
<!-- Warning state -->
|
||||
<div class="bg-tiger-100 text-tiger-800">Warning</div>
|
||||
|
||||
<!-- Info state -->
|
||||
<div class="bg-cobalt-100 text-cobalt-800">Info</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tailwind CSS Configuration
|
||||
|
||||
The corporate colors are configured in the Tailwind CSS configuration within the base template:
|
||||
|
||||
```javascript
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
cobalt: {
|
||||
50: '#f0f5ff',
|
||||
100: '#e0eaff',
|
||||
// ... all shades
|
||||
500: '#1338BE',
|
||||
// ...
|
||||
},
|
||||
tiger: {
|
||||
50: '#fff8f0',
|
||||
// ... all shades
|
||||
500: '#FC6A03',
|
||||
// ...
|
||||
},
|
||||
emerald: {
|
||||
50: '#f0fdf4',
|
||||
// ... all shades
|
||||
500: '#028A0F',
|
||||
// ...
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Color Accessibility
|
||||
|
||||
All corporate colors meet WCAG AA contrast requirements when used appropriately:
|
||||
|
||||
- **Cobalt Blue (#1338BE)**: Use white text for backgrounds
|
||||
- **Tiger Orange (#FC6A03)**: Use white text for backgrounds
|
||||
- **Emerald Green (#028A0F)**: Use white text for backgrounds
|
||||
|
||||
For lighter shades (100-300), use dark text (gray-800 or gray-900).
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.0 | 2026-06-20 | Initial corporate design specifications with Cobalt Blue, Tiger Orange, and Emerald Green color palette |
|
||||
322
docs/DATABASE_SCHEMA_SPECIFICATION.md
Normal file
322
docs/DATABASE_SCHEMA_SPECIFICATION.md
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# Database Schema Specification for Student Data Management
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the SQLite database schema for managing student data imported from CSV files. The schema is designed to support the tablet lending system while incorporating student information from external data sources.
|
||||
|
||||
## Source Data Structure
|
||||
|
||||
The CSV file `Datos_programa.csv` has the following structure (ISO-8859-1 encoded):
|
||||
|
||||
```csv
|
||||
Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
|
||||
| Field | Type | Description | Notes |
|
||||
|-------|------|-------------|-------|
|
||||
| Nº Or. | Integer | Order number | Primary identifier in source |
|
||||
| Apellidos, Nombre | String | Full name (Surnames, Name) | Needs splitting into separate fields |
|
||||
| Fecha Nac. | Date | Birth date | Format: DD/MM/YYYY |
|
||||
| C.I.A.L. | String | Internal identification code | Unique identifier |
|
||||
| NIF/NIE/Pas. | String | National ID / Foreign ID / Passport | Unique identifier |
|
||||
| Registro | Integer | Registration number | Internal reference |
|
||||
| Expediente | String | File number | Optional, may be empty |
|
||||
| Sexo | String | Gender | Single character (M/F) |
|
||||
| Grupo | String | Study group | Educational level |
|
||||
| Estudio | String | Study program | Full description - TO BE DISCARDED |
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Table: students
|
||||
|
||||
The core table for storing student information.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
-- Source identifiers
|
||||
order_number INTEGER,
|
||||
cial_code TEXT UNIQUE NOT NULL,
|
||||
nif_nie_passport TEXT UNIQUE,
|
||||
registration_number INTEGER,
|
||||
file_number TEXT,
|
||||
|
||||
-- Personal information
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
|
||||
-- Demographic data
|
||||
birth_date TEXT, -- Format: YYYY-MM-DD
|
||||
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
|
||||
|
||||
-- Study information
|
||||
study_group TEXT,
|
||||
|
||||
-- Metadata
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Constraints
|
||||
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: tablets (existing)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available' CHECK(status IN ('available', 'loaned', 'maintenance')),
|
||||
notes TEXT,
|
||||
assigned_to_student INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (assigned_to_student) REFERENCES students(id)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: users (existing - for staff)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
identification TEXT UNIQUE,
|
||||
role TEXT DEFAULT 'staff' CHECK(role IN ('admin', 'staff', 'viewer')),
|
||||
password_hash TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### Table: loans (existing - updated)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
student_id INTEGER NOT NULL,
|
||||
user_id INTEGER, -- Staff member who processed the loan
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
due_date TEXT,
|
||||
status TEXT DEFAULT 'active' CHECK(status IN ('active', 'returned', 'overdue', 'lost')),
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets(id),
|
||||
FOREIGN KEY (student_id) REFERENCES students(id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
```
|
||||
|
||||
### Table: non_loanable_devices (existing)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL,
|
||||
assigned_to_student INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (assigned_to_student) REFERENCES students(id)
|
||||
);
|
||||
```
|
||||
|
||||
## Indexes
|
||||
|
||||
For optimal query performance, the following indexes are recommended:
|
||||
|
||||
```sql
|
||||
-- Student indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code);
|
||||
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport);
|
||||
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender);
|
||||
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group);
|
||||
|
||||
-- Loan indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_tablet ON loans(tablet_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_student ON loans(student_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_status ON loans(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_date ON loans(loan_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_due ON loans(due_date);
|
||||
|
||||
-- Tablet indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_serial ON tablets(serial_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_status ON tablets(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_brand ON tablets(brand);
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_student ON tablets(assigned_to_student);
|
||||
|
||||
-- User indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_users_identification ON users(identification);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
|
||||
```
|
||||
|
||||
## Data Import Specification
|
||||
|
||||
### CSV Processing Requirements
|
||||
|
||||
1. **Encoding Conversion**: Convert from ISO-8859-1 to UTF-8
|
||||
2. **Field Splitting**: Split "Apellidos, Nombre" into separate "last_name" and "first_name" fields
|
||||
3. **Date Format Conversion**: Convert "DD/MM/YYYY" to "YYYY-MM-DD"
|
||||
4. **Field Discarding**: The "Estudio" field will be discarded as per requirements
|
||||
5. **Empty Field Handling**: Empty fields should be stored as NULL
|
||||
|
||||
### Import Mapping
|
||||
|
||||
| CSV Field | Database Field | Transformation |
|
||||
|-----------|----------------|----------------|
|
||||
| Nº Or. | order_number | Integer conversion |
|
||||
| Apellidos, Nombre | last_name, first_name | Split on ", " |
|
||||
| Apellidos, Nombre | full_name | Keep as-is |
|
||||
| Fecha Nac. | birth_date | Date format conversion |
|
||||
| C.I.A.L. | cial_code | Direct mapping |
|
||||
| NIF/NIE/Pas. | nif_nie_passport | Direct mapping |
|
||||
| Registro | registration_number | Integer conversion |
|
||||
| Expediente | file_number | Direct mapping (may be empty) |
|
||||
| Sexo | gender | Direct mapping |
|
||||
| Grupo | study_group | Direct mapping |
|
||||
| Estudio | - | DISCARDED |
|
||||
|
||||
### Validation Rules
|
||||
|
||||
1. **Required Fields**: cial_code, first_name, last_name
|
||||
2. **Unique Fields**: cial_code, nif_nie_passport (if provided)
|
||||
3. **Date Validation**: birth_date must be valid date in YYYY-MM-DD format
|
||||
4. **Gender Validation**: Must be 'M', 'F', or 'O'
|
||||
|
||||
## API Endpoints (New/Updated)
|
||||
|
||||
### Student Management
|
||||
|
||||
```
|
||||
GET /students - List all students (with pagination)
|
||||
GET /students/<int:id> - Get student details
|
||||
GET /students/search - Search students
|
||||
POST /students - Create new student
|
||||
PUT /students/<int:id> - Update student
|
||||
DELETE /students/<int:id> - Delete student
|
||||
POST /students/import - Import from CSV
|
||||
```
|
||||
|
||||
### Loan Management (Updated)
|
||||
|
||||
```
|
||||
GET /loans - List all loans
|
||||
GET /loans/active - List active loans
|
||||
GET /loans/student/<int:student_id> - Loans for specific student
|
||||
POST /loans - Create new loan
|
||||
PUT /loans/<int:id>/return - Return tablet
|
||||
```
|
||||
|
||||
## Frontend Updates
|
||||
|
||||
### New Templates Required
|
||||
|
||||
1. **students.html** - Student list view
|
||||
2. **student_detail.html** - Individual student details
|
||||
3. **add_student.html** - Add new student form
|
||||
4. **edit_student.html** - Edit student form
|
||||
5. **import_students.html** - CSV import interface
|
||||
|
||||
### Updated Templates
|
||||
|
||||
1. **loan_tablet.html** - Add student selection alongside user selection
|
||||
2. **user_loans.html** - Update to show student loans
|
||||
3. **index.html** - Add student count and recent students
|
||||
|
||||
## Migration Path
|
||||
|
||||
### From Existing Database
|
||||
|
||||
1. Create new `students` table
|
||||
2. Add `assigned_to_student` field to `tablets` and `non_loanable_devices`
|
||||
3. Add `student_id` field to `loans` (nullable initially)
|
||||
4. Create indexes
|
||||
5. Import CSV data using import script
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
1. Backup existing database before migration
|
||||
2. Create migration scripts that are idempotent
|
||||
3. Test migration on copy of production data
|
||||
4. Provide rollback SQL scripts
|
||||
|
||||
## File Locations
|
||||
|
||||
```
|
||||
project/
|
||||
├── database/
|
||||
│ ├── schema.sql # Complete SQL schema
|
||||
│ └── migrations/
|
||||
│ ├── 001_add_students_table.sql
|
||||
│ ├── 002_add_student_relationships.sql
|
||||
│ └── 003_add_indexes.sql
|
||||
├── scripts/
|
||||
│ ├── import_students.py # CSV import script
|
||||
│ └── migrate_database.py # Database migration script
|
||||
├── templates/
|
||||
│ ├── students/
|
||||
│ │ ├── list.html
|
||||
│ │ ├── detail.html
|
||||
│ │ ├── add.html
|
||||
│ │ ├── edit.html
|
||||
│ │ └── import.html
|
||||
│ └── ... (existing templates)
|
||||
└── app.py # Updated with new routes
|
||||
```
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
1. **Import Script Tests**:
|
||||
- Test with valid CSV file
|
||||
- Test with empty CSV file
|
||||
- Test with malformed CSV file
|
||||
- Test with duplicate entries
|
||||
- Test encoding conversion
|
||||
- Test field splitting
|
||||
|
||||
2. **Database Tests**:
|
||||
- Test unique constraints
|
||||
- Test foreign key relationships
|
||||
- Test indexes performance
|
||||
- Test data integrity
|
||||
|
||||
3. **API Tests**:
|
||||
- Test all CRUD operations for students
|
||||
- Test loan operations with students
|
||||
- Test search and filtering
|
||||
- Test pagination
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Data Validation**: All inputs must be validated before database operations
|
||||
2. **SQL Injection**: Use parameterized queries exclusively
|
||||
3. **Authentication**: Student data should only be accessible to authorized users
|
||||
4. **Audit Trail**: Maintain logs of all import operations and data modifications
|
||||
5. **Backup**: Regular backups of student data
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
1. **Pagination**: All list endpoints should support pagination (default: 50 items per page)
|
||||
2. **Caching**: Consider caching frequently accessed student data
|
||||
3. **Batch Operations**: Import script should use batch inserts for performance
|
||||
4. **Index Maintenance**: Regularly update statistics for query planner
|
||||
1191
docs/FRONTEND_OPTIONS.md
Normal file
1191
docs/FRONTEND_OPTIONS.md
Normal file
File diff suppressed because it is too large
Load diff
987
docs/MIGRATION_TO_POSTGRES.md
Normal file
987
docs/MIGRATION_TO_POSTGRES.md
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
# PostgreSQL Migration Guide
|
||||
|
||||
This document describes how to migrate the Tablet Management System from SQLite to PostgreSQL when the database grows beyond SQLite's capabilities.
|
||||
|
||||
## When to Migrate
|
||||
|
||||
Consider migrating to PostgreSQL when you encounter any of these scenarios:
|
||||
|
||||
| Metric | SQLite Limit | PostgreSQL | Migration Trigger |
|
||||
|--------|--------------|------------|-------------------|
|
||||
| Database Size | ~10GB max | Unlimited | >1GB |
|
||||
| Concurrent Writers | 1 | Thousands | >50 simultaneous |
|
||||
| Active Users | <100 | Millions | >200 |
|
||||
| Transactions/min | <100 | 100K+ | >500 |
|
||||
| Servers | Single machine | Cluster | Multiple servers |
|
||||
| High Availability | No | Yes | Required |
|
||||
| Backup Strategy | Manual | Automated | Automated needed |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
The migration uses a **Repository Pattern** to abstract the database layer, allowing both SQLite and PostgreSQL to work seamlessly.
|
||||
|
||||
```
|
||||
project/
|
||||
├── backend/
|
||||
│ ├── config/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── settings.py # Database configuration
|
||||
│ │ └── database.py # Repository factory
|
||||
│ ├── repositories/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── base_repository.py # Abstract base classes
|
||||
│ │ ├── sqlite_repo.py # SQLite implementation
|
||||
│ │ └── postgres_repo.py # PostgreSQL implementation
|
||||
│ └── app.py # Main application (unchanged)
|
||||
├── migrations/ # Alembic migrations
|
||||
│ └── versions/
|
||||
│ └── initial_schema.py
|
||||
├── scripts/
|
||||
│ └── migrate_to_postgres.py # Migration script
|
||||
└── docker-compose.yml # Optional Docker setup
|
||||
```
|
||||
|
||||
## Step 1: Install Dependencies
|
||||
|
||||
```bash
|
||||
# For development
|
||||
pip install psycopg2-binary sqlalchemy alembic
|
||||
|
||||
# For production (more efficient)
|
||||
pip install psycopg2 sqlalchemy alembic
|
||||
```
|
||||
|
||||
## Step 2: Set Up PostgreSQL
|
||||
|
||||
### Option A: Local Installation
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
|
||||
# Create database and user
|
||||
sudo -u postgres psql
|
||||
```
|
||||
|
||||
In PostgreSQL shell:
|
||||
```sql
|
||||
CREATE DATABASE tablet_management;
|
||||
CREATE USER tablet_user WITH PASSWORD 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user;
|
||||
ALTER USER tablet_user CREATEDB;
|
||||
\q
|
||||
```
|
||||
|
||||
### Option B: Docker (Recommended for Development)
|
||||
|
||||
```bash
|
||||
# Start PostgreSQL container
|
||||
docker run --name tablet-db -e POSTGRES_PASSWORD=your_password -e POSTGRES_USER=tablet_user -e POSTGRES_DB=tablet_management -p 5432:5432 -d postgres:16-alpine
|
||||
|
||||
# Or use docker-compose (see docker-compose.yml)
|
||||
docker-compose up -d postgres
|
||||
```
|
||||
|
||||
## Step 3: Configure Environment
|
||||
|
||||
Create a `.env` file:
|
||||
|
||||
```bash
|
||||
# Database configuration
|
||||
DB_TYPE=postgres # or 'sqlite'
|
||||
DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management
|
||||
|
||||
# For SQLite (fallback)
|
||||
DB_PATH=tablets.db
|
||||
```
|
||||
|
||||
Or set environment variables:
|
||||
|
||||
```bash
|
||||
export DB_TYPE=postgres
|
||||
export DB_URL=postgresql://tablet_user:your_password@localhost:5432/tablet_management
|
||||
```
|
||||
|
||||
## Step 4: Create Repository Abstraction
|
||||
|
||||
### Base Repository (Abstract Interface)
|
||||
|
||||
```python
|
||||
# backend/repositories/base_repository.py
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class BaseTabletRepository(ABC):
|
||||
@abstractmethod
|
||||
def get_by_id(self, tablet_id: int) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_serial(self, serial: str) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self, status: Optional[str] = None) -> List[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_status(self, tablet_id: int, status: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, tablet_id: int) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
class BaseUserRepository(ABC):
|
||||
@abstractmethod
|
||||
def get_by_id(self, user_id: int) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_identification(self, identification: str) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add(self, name: str, email: Optional[str], phone: Optional[str], identification: str) -> dict:
|
||||
pass
|
||||
|
||||
|
||||
class BaseLoanRepository(ABC):
|
||||
@abstractmethod
|
||||
def get_by_id(self, loan_id: int) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_active_by_tablet(self, tablet_id: int) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_by_user(self, user_id: int) -> List[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self, status: Optional[str] = None) -> List[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def create(self, tablet_id: int, user_id: int) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def return_loan(self, loan_id: int) -> bool:
|
||||
pass
|
||||
|
||||
|
||||
class BaseNonLoanableDeviceRepository(ABC):
|
||||
@abstractmethod
|
||||
def get_by_id(self, device_id: int) -> Optional[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_all(self) -> List[dict]:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add(self, brand: str, model: str, serial_number: str, device_type: str,
|
||||
location: Optional[str] = None, notes: Optional[str] = None,
|
||||
purchase_date: Optional[str] = None, purchase_cost: Optional[float] = None) -> dict:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update(self, device_id: int, **kwargs) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, device_id: int) -> bool:
|
||||
pass
|
||||
```
|
||||
|
||||
### SQLite Implementation
|
||||
|
||||
```python
|
||||
# backend/repositories/sqlite_repo.py
|
||||
import sqlite3
|
||||
from typing import Optional, List
|
||||
from .base_repository import (
|
||||
BaseTabletRepository, BaseUserRepository,
|
||||
BaseLoanRepository, BaseNonLoanableDeviceRepository
|
||||
)
|
||||
|
||||
|
||||
class SQLiteTabletRepository(BaseTabletRepository):
|
||||
def __init__(self, db_path: str = 'tablets.db'):
|
||||
self.db_path = db_path
|
||||
self._init_db()
|
||||
|
||||
def _init_db(self):
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_by_id(self, tablet_id: int) -> Optional[dict]:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_by_serial(self, serial: str) -> Optional[dict]:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM tablets WHERE serial_number = ?", (serial,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_all(self, status: Optional[str] = None) -> List[dict]:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
query = "SELECT * FROM tablets"
|
||||
params = ()
|
||||
if status:
|
||||
query += " WHERE status = ?"
|
||||
params = (status,)
|
||||
cursor.execute(query, params)
|
||||
results = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status, notes)
|
||||
VALUES (?, ?, ?, 'available', ?)
|
||||
''', (brand, model, serial_number, notes))
|
||||
conn.commit()
|
||||
tablet_id = cursor.lastrowid
|
||||
cursor.execute("SELECT * FROM tablets WHERE id = ?", (tablet_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row)
|
||||
|
||||
def update_status(self, tablet_id: int, status: str) -> bool:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("UPDATE tablets SET status = ? WHERE id = ?", (status, tablet_id))
|
||||
conn.commit()
|
||||
changed = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return changed
|
||||
|
||||
def delete(self, tablet_id: int) -> bool:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM tablets WHERE id = ?", (tablet_id,))
|
||||
conn.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
# Similar implementations for SQLiteUserRepository, SQLiteLoanRepository, etc.
|
||||
```
|
||||
|
||||
### PostgreSQL Implementation
|
||||
|
||||
```python
|
||||
# backend/repositories/postgres_repo.py
|
||||
import psycopg2
|
||||
from psycopg2 import sql
|
||||
from psycopg2.extras import DictCursor
|
||||
from typing import Optional, List
|
||||
from .base_repository import (
|
||||
BaseTabletRepository, BaseUserRepository,
|
||||
BaseLoanRepository, BaseNonLoanableDeviceRepository
|
||||
)
|
||||
|
||||
|
||||
class PostgreSQLTabletRepository(BaseTabletRepository):
|
||||
def __init__(self, connection_string: str):
|
||||
self.connection_string = connection_string
|
||||
self._init_db()
|
||||
|
||||
def _get_connection(self):
|
||||
return psycopg2.connect(self.connection_string)
|
||||
|
||||
def _init_db(self):
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
brand VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
serial_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'available',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_serial
|
||||
ON tablets(serial_number)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_status
|
||||
ON tablets(status)
|
||||
''')
|
||||
conn.commit()
|
||||
cursor.close()
|
||||
conn.close()
|
||||
|
||||
def get_by_id(self, tablet_id: int) -> Optional[dict]:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor(cursor_factory=DictCursor)
|
||||
cursor.execute("SELECT * FROM tablets WHERE id = %s", (tablet_id,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_by_serial(self, serial: str) -> Optional[dict]:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor(cursor_factory=DictCursor)
|
||||
cursor.execute("SELECT * FROM tablets WHERE serial_number = %s", (serial,))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_all(self, status: Optional[str] = None) -> List[dict]:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor(cursor_factory=DictCursor)
|
||||
query = "SELECT * FROM tablets"
|
||||
params = ()
|
||||
if status:
|
||||
query += " WHERE status = %s"
|
||||
params = (status,)
|
||||
cursor.execute(query, params)
|
||||
results = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
return results
|
||||
|
||||
def add(self, brand: str, model: str, serial_number: str, notes: Optional[str] = None) -> dict:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor(cursor_factory=DictCursor)
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status, notes)
|
||||
VALUES (%s, %s, %s, 'available', %s)
|
||||
RETURNING *
|
||||
''', (brand, model, serial_number, notes))
|
||||
row = cursor.fetchone()
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return dict(row)
|
||||
|
||||
def update_status(self, tablet_id: int, status: str) -> bool:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"UPDATE tablets SET status = %s, updated_at = NOW() WHERE id = %s",
|
||||
(status, tablet_id)
|
||||
)
|
||||
conn.commit()
|
||||
changed = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return changed
|
||||
|
||||
def delete(self, tablet_id: int) -> bool:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM tablets WHERE id = %s", (tablet_id,))
|
||||
conn.commit()
|
||||
deleted = cursor.rowcount > 0
|
||||
conn.close()
|
||||
return deleted
|
||||
|
||||
|
||||
# Similar implementations for PostgreSQLUserRepository, PostgreSQLLoanRepository, etc.
|
||||
```
|
||||
|
||||
## Step 5: Create Repository Factory
|
||||
|
||||
```python
|
||||
# backend/config/database.py
|
||||
import os
|
||||
from backend.repositories.sqlite_repo import (
|
||||
SQLiteTabletRepository, SQLiteUserRepository,
|
||||
SQLiteLoanRepository, SQLiteNonLoanableDeviceRepository
|
||||
)
|
||||
from backend.repositories.postgres_repo import (
|
||||
PostgreSQLTabletRepository, PostgreSQLUserRepository,
|
||||
PostgreSQLLoanRepository, PostgreSQLNonLoanableDeviceRepository
|
||||
)
|
||||
from backend.repositories.base_repository import (
|
||||
BaseTabletRepository, BaseUserRepository,
|
||||
BaseLoanRepository, BaseNonLoanableDeviceRepository
|
||||
)
|
||||
|
||||
|
||||
class DatabaseConfig:
|
||||
def __init__(self):
|
||||
self.db_type = os.getenv('DB_TYPE', 'sqlite')
|
||||
self.db_url = os.getenv('DB_URL', '')
|
||||
self.db_path = os.getenv('DB_PATH', 'tablets.db')
|
||||
|
||||
@property
|
||||
def is_postgres(self) -> bool:
|
||||
return self.db_type == 'postgres'
|
||||
|
||||
|
||||
def get_tablet_repository() -> BaseTabletRepository:
|
||||
config = DatabaseConfig()
|
||||
if config.is_postgres:
|
||||
return PostgreSQLTabletRepository(config.db_url)
|
||||
else:
|
||||
return SQLiteTabletRepository(config.db_path)
|
||||
|
||||
|
||||
def get_user_repository() -> BaseUserRepository:
|
||||
config = DatabaseConfig()
|
||||
if config.is_postgres:
|
||||
return PostgreSQLUserRepository(config.db_url)
|
||||
else:
|
||||
return SQLiteUserRepository(config.db_path)
|
||||
|
||||
|
||||
def get_loan_repository() -> BaseLoanRepository:
|
||||
config = DatabaseConfig()
|
||||
if config.is_postgres:
|
||||
return PostgreSQLLoanRepository(config.db_url)
|
||||
else:
|
||||
return SQLiteLoanRepository(config.db_path)
|
||||
|
||||
|
||||
def get_non_loanable_device_repository() -> BaseNonLoanableDeviceRepository:
|
||||
config = DatabaseConfig()
|
||||
if config.is_postgres:
|
||||
return PostgreSQLNonLoanableDeviceRepository(config.db_url)
|
||||
else:
|
||||
return SQLiteNonLoanableDeviceRepository(config.db_path)
|
||||
```
|
||||
|
||||
## Step 6: Update Application to Use Repositories
|
||||
|
||||
Modify your application to use the repository pattern:
|
||||
|
||||
```python
|
||||
# In your app.py or service layer
|
||||
from backend.config.database import (
|
||||
get_tablet_repository, get_user_repository,
|
||||
get_loan_repository, get_non_loanable_device_repository
|
||||
)
|
||||
|
||||
# Instead of direct SQLite calls:
|
||||
tablet_repo = get_tablet_repository()
|
||||
user_repo = get_user_repository()
|
||||
loan_repo = get_loan_repository()
|
||||
|
||||
# Example: Loan a tablet
|
||||
def loan_tablet(tablet_id: int, user_id: int):
|
||||
# Get repositories
|
||||
tablet_repo = get_tablet_repository()
|
||||
user_repo = get_user_repository()
|
||||
loan_repo = get_loan_repository()
|
||||
|
||||
# Validate
|
||||
tablet = tablet_repo.get_by_id(tablet_id)
|
||||
if not tablet:
|
||||
raise ValueError("Tablet not found")
|
||||
|
||||
if tablet['status'] != 'available':
|
||||
raise ValueError("Tablet not available")
|
||||
|
||||
user = user_repo.get_by_id(user_id)
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
# Check for active loan
|
||||
active_loan = loan_repo.get_active_by_tablet(tablet_id)
|
||||
if active_loan:
|
||||
raise ValueError("Tablet already loaned")
|
||||
|
||||
# Create loan
|
||||
loan = loan_repo.create(tablet_id, user_id)
|
||||
|
||||
# Update tablet status
|
||||
tablet_repo.update_status(tablet_id, 'loaned')
|
||||
|
||||
return loan
|
||||
```
|
||||
|
||||
## Step 7: Create Migration Script
|
||||
|
||||
```python
|
||||
# scripts/migrate_to_postgres.py
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script from SQLite to PostgreSQL
|
||||
"""
|
||||
import sqlite3
|
||||
import psycopg2
|
||||
from psycopg2.extras import DictCursor
|
||||
import argparse
|
||||
from tqdm import tqdm
|
||||
import os
|
||||
|
||||
|
||||
def create_postgres_tables(conn):
|
||||
"""Create all tables in PostgreSQL"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Tablets
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
brand VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
serial_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
status VARCHAR(20) DEFAULT 'available',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
''')
|
||||
|
||||
# Users
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255),
|
||||
phone VARCHAR(20),
|
||||
identification VARCHAR(50) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
''')
|
||||
|
||||
# Loans
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
tablet_id INTEGER NOT NULL REFERENCES tablets(id) ON DELETE RESTRICT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||
loan_date TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
return_date TIMESTAMP,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
''')
|
||||
|
||||
# Non-loanable devices
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
||||
id SERIAL PRIMARY KEY,
|
||||
brand VARCHAR(100) NOT NULL,
|
||||
model VARCHAR(100) NOT NULL,
|
||||
serial_number VARCHAR(50) UNIQUE NOT NULL,
|
||||
device_type VARCHAR(50) NOT NULL,
|
||||
location VARCHAR(100),
|
||||
status VARCHAR(20) DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date DATE,
|
||||
purchase_cost DECIMAL(10,2),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
''')
|
||||
|
||||
# Indexes for performance
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_serial ON tablets(serial_number)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_status ON tablets(status)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_tablets_brand ON tablets(brand)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_users_identification ON users(identification)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_tablet ON loans(tablet_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_user ON loans(user_id)')
|
||||
cursor.execute('CREATE INDEX IF NOT EXISTS idx_loans_status ON loans(status)')
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def migrate_table(conn_sqlite, conn_pg, table_name: str, pg_create_table: str):
|
||||
"""Generic migration for a table"""
|
||||
cursor_sqlite = conn_sqlite.cursor()
|
||||
cursor_pg = conn_pg.cursor()
|
||||
|
||||
# Get all data from SQLite
|
||||
cursor_sqlite.execute(f"SELECT * FROM {table_name}")
|
||||
rows = cursor_sqlite.fetchall()
|
||||
|
||||
if not rows:
|
||||
print(f"No data to migrate for {table_name}")
|
||||
return
|
||||
|
||||
# Get column names
|
||||
column_names = [desc[0] for desc in cursor_sqlite.description]
|
||||
|
||||
# Prepare INSERT statement
|
||||
columns = ', '.join(column_names)
|
||||
placeholders = ', '.join(['%s'] * len(column_names))
|
||||
insert_sql = f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
|
||||
|
||||
# Migrate data
|
||||
for row in tqdm(rows, desc=f"Migrating {table_name}"):
|
||||
cursor_pg.execute(insert_sql, row)
|
||||
|
||||
conn_pg.commit()
|
||||
print(f"✓ Migrated {len(rows)} rows from {table_name}")
|
||||
|
||||
|
||||
def migrate_all(sqlite_path: str, pg_url: str):
|
||||
"""Migrate all data from SQLite to PostgreSQL"""
|
||||
print("Starting migration from SQLite to PostgreSQL...")
|
||||
|
||||
# Connect to SQLite
|
||||
conn_sqlite = sqlite3.connect(sqlite_path)
|
||||
|
||||
# Connect to PostgreSQL
|
||||
conn_pg = psycopg2.connect(pg_url)
|
||||
|
||||
try:
|
||||
# Create tables
|
||||
print("Creating PostgreSQL tables...")
|
||||
create_postgres_tables(conn_pg)
|
||||
|
||||
# Migrate each table
|
||||
migrate_table(conn_sqlite, conn_pg, 'tablets', '')
|
||||
migrate_table(conn_sqlite, conn_pg, 'users', '')
|
||||
migrate_table(conn_sqlite, conn_pg, 'loans', '')
|
||||
migrate_table(conn_sqlite, conn_pg, 'non_loanable_devices', '')
|
||||
|
||||
print("\n✓ Migration completed successfully!")
|
||||
print(f" SQLite: {sqlite_path}")
|
||||
print(f" PostgreSQL: {pg_url}")
|
||||
|
||||
except Exception as e:
|
||||
conn_pg.rollback()
|
||||
print(f"\n✗ Migration failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
conn_sqlite.close()
|
||||
conn_pg.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description='Migrate from SQLite to PostgreSQL')
|
||||
parser.add_argument('--sqlite', default='tablets.db', help='SQLite database path')
|
||||
parser.add_argument('--postgres', required=True, help='PostgreSQL connection URL')
|
||||
args = parser.parse_args()
|
||||
|
||||
migrate_all(args.sqlite, args.postgres)
|
||||
```
|
||||
|
||||
## Step 8: Run Migration
|
||||
|
||||
```bash
|
||||
# Test the migration first (dry run)
|
||||
python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management_test
|
||||
|
||||
# Verify data in test database
|
||||
psql -U tablet_user -d tablet_management_test -c "SELECT COUNT(*) FROM tablets;"
|
||||
|
||||
# When ready, migrate to production
|
||||
python scripts/migrate_to_postgres.py --sqlite tablets.db --postgres postgresql://tablet_user:password@localhost:5432/tablet_management
|
||||
```
|
||||
|
||||
## Step 9: Switch to PostgreSQL
|
||||
|
||||
```bash
|
||||
# Update environment variables
|
||||
export DB_TYPE=postgres
|
||||
export DB_URL=postgresql://tablet_user:password@localhost:5432/tablet_management
|
||||
|
||||
# Restart application
|
||||
python app.py
|
||||
```
|
||||
|
||||
## Step 10: Verify and Monitor
|
||||
|
||||
```bash
|
||||
# Check application logs for errors
|
||||
# Monitor database connections
|
||||
psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;"
|
||||
|
||||
# Check active connections
|
||||
psql -U postgres -c "SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management';"
|
||||
```
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If something goes wrong:
|
||||
|
||||
1. **Immediate rollback:**
|
||||
```bash
|
||||
# Switch back to SQLite
|
||||
export DB_TYPE=sqlite
|
||||
export DB_PATH=tablets.db
|
||||
python app.py
|
||||
```
|
||||
|
||||
2. **Data verification:**
|
||||
```bash
|
||||
# Compare counts
|
||||
sqlite3 tablets.db "SELECT COUNT(*) FROM tablets;"
|
||||
psql -U tablet_user -d tablet_management -c "SELECT COUNT(*) FROM tablets;"
|
||||
```
|
||||
|
||||
3. **Backup PostgreSQL data:**
|
||||
```bash
|
||||
pg_dump -U tablet_user -d tablet_management > postgres_backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
## Docker Compose (Optional)
|
||||
|
||||
For easy deployment with Docker:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: tablet_db
|
||||
environment:
|
||||
POSTGRES_DB: tablet_management
|
||||
POSTGRES_USER: tablet_user
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-changeme}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tablet_user -d tablet_management"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
container_name: tablet_app
|
||||
environment:
|
||||
DB_TYPE: postgres
|
||||
DB_URL: postgresql://tablet_user:${POSTGRES_PASSWORD:-changeme}@postgres:5432/tablet_management
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
Start with Docker:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Benefits of PostgreSQL
|
||||
|
||||
### Performance
|
||||
- **Concurrency:** Multiple writers simultaneously (no lock contention)
|
||||
- **Indexing:** Advanced index types (B-tree, Hash, GiST, GIN, BRIN)
|
||||
- **Query Optimization:** Advanced query planner
|
||||
- **Connection Pooling:** Built-in support
|
||||
|
||||
### Scalability
|
||||
- **Vertical:** Handles large datasets efficiently
|
||||
- **Horizontal:** Read replicas, partitioning, sharding
|
||||
- **Connections:** Supports thousands of concurrent connections
|
||||
|
||||
### Reliability
|
||||
- **ACID Compliance:** Full transaction support
|
||||
- **Point-in-Time Recovery:** Restore to any moment
|
||||
- **Replication:** Master-slave, synchronous, asynchronous
|
||||
- **Backups:** `pg_dump`, `pg_basebackup`, continuous archiving
|
||||
|
||||
### Security
|
||||
- **Authentication:** Multiple methods (password, MD5, SCRAM, LDAP, Kerberos)
|
||||
- **Authorization:** Role-based access control (RBAC)
|
||||
- **Row-Level Security:** Policies for fine-grained access
|
||||
- **Encryption:** SSL, at-rest encryption
|
||||
|
||||
### Features
|
||||
- **JSON Support:** Native JSON/JSONB data type
|
||||
- **Full-Text Search:** Advanced text search capabilities
|
||||
- **Arrays:** Store arrays of values
|
||||
- **Custom Types:** Create your own data types
|
||||
- **Triggers:** Automatic actions on events
|
||||
- **Stored Procedures:** Server-side functions
|
||||
|
||||
## Monitoring PostgreSQL
|
||||
|
||||
### Basic Queries
|
||||
|
||||
```sql
|
||||
-- Active connections
|
||||
SELECT * FROM pg_stat_activity WHERE datname = 'tablet_management';
|
||||
|
||||
-- Table sizes
|
||||
SELECT table_name, pg_size_pretty(pg_total_relation_size(table_name))
|
||||
FROM information_schema.tables WHERE table_schema = 'public';
|
||||
|
||||
-- Index usage
|
||||
SELECT indexrelname, idx_scan, idx_tup_read, idx_tup_fetch
|
||||
FROM pg_stat_user_indexes;
|
||||
|
||||
-- Slow queries (requires pg_stat_statements extension)
|
||||
SELECT query, total_time, calls, mean_time
|
||||
FROM pg_stat_statements ORDER BY mean_time DESC LIMIT 10;
|
||||
```
|
||||
|
||||
### Enable pg_stat_statements
|
||||
|
||||
```sql
|
||||
-- In PostgreSQL
|
||||
CREATE EXTENSION pg_stat_statements;
|
||||
|
||||
-- Then in postgresql.conf
|
||||
shared_preload_libraries = 'pg_stat_statements'
|
||||
pg_stat_statements.track = all
|
||||
```
|
||||
|
||||
## Maintenance Tasks
|
||||
|
||||
### Regular Maintenance
|
||||
|
||||
```bash
|
||||
# Vacuum (reclaim space, update statistics)
|
||||
vacuumdb -U tablet_user -d tablet_management --analyze
|
||||
|
||||
# Reindex (rebuild indexes)
|
||||
reindexdb -U tablet_user -d tablet_management
|
||||
```
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
```bash
|
||||
# Daily backup
|
||||
pg_dump -U tablet_user -d tablet_management > /backups/tablet_management_$(date +%Y%m%d).sql
|
||||
|
||||
# Compressed backup
|
||||
pg_dump -U tablet_user -d tablet_management | gzip > /backups/tablet_management_$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Continuous archiving (WAL)
|
||||
# In postgresql.conf:
|
||||
wal_level = replica
|
||||
archive_mode = on
|
||||
archive_command = 'test ! -f /backups/wal/%f && cp %p /backups/wal/%f'
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Configuration Tuning
|
||||
|
||||
```conf
|
||||
# postgresql.conf recommendations
|
||||
shared_buffers = 4GB # 25% of total RAM
|
||||
work_mem = 16MB # For complex sorts
|
||||
maintenance_work_mem = 512MB # For VACUUM, index creation
|
||||
effective_cache_size = 12GB # 75% of total RAM
|
||||
random_page_cost = 1.1 # SSD: 1.1, HDD: 4.0
|
||||
max_worker_processes = 8 # Number of CPU cores
|
||||
max_parallel_workers_per_gather = 4 # Parallel query workers
|
||||
max_connections = 200 # Expected max connections
|
||||
```
|
||||
|
||||
### Index Optimization
|
||||
|
||||
```sql
|
||||
-- Add indexes for common queries
|
||||
CREATE INDEX idx_loans_user_status ON loans(user_id, status);
|
||||
CREATE INDEX idx_loans_date_range ON loans(loan_date, return_date);
|
||||
|
||||
-- Partial index for active loans
|
||||
CREATE INDEX idx_loans_active ON loans(tablet_id) WHERE status = 'active';
|
||||
|
||||
-- Composite index for user loans
|
||||
CREATE INDEX idx_loans_user_tablet ON loans(user_id, tablet_id);
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Connection refused:**
|
||||
```bash
|
||||
# Check if PostgreSQL is running
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# Check port
|
||||
netstat -tuln | grep 5432
|
||||
```
|
||||
|
||||
**Authentication failed:**
|
||||
```bash
|
||||
# Verify user and password
|
||||
psql -U tablet_user -d tablet_management -h localhost
|
||||
|
||||
# Check pg_hba.conf
|
||||
sudo nano /etc/postgresql/16/main/pg_hba.conf
|
||||
```
|
||||
|
||||
**Database does not exist:**
|
||||
```bash
|
||||
# Create database
|
||||
createdb -U postgres tablet_management
|
||||
```
|
||||
|
||||
**Permission denied:**
|
||||
```sql
|
||||
-- Grant permissions
|
||||
GRANT ALL PRIVILEGES ON DATABASE tablet_management TO tablet_user;
|
||||
GRANT ALL ON SCHEMA public TO tablet_user;
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# PostgreSQL logs
|
||||
sudo tail -f /var/log/postgresql/postgresql-16-main.log
|
||||
|
||||
# Application logs
|
||||
journalctl -u tablet_management -f
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Migrating from SQLite to PostgreSQL provides:
|
||||
- Better performance at scale
|
||||
- True concurrency
|
||||
- Enhanced reliability
|
||||
- Advanced features
|
||||
- Production-ready infrastructure
|
||||
|
||||
The repository pattern ensures a smooth transition with minimal code changes, and the migration script automates the data transfer process.
|
||||
520
docs/RESPONSIVE_CSS.md
Normal file
520
docs/RESPONSIVE_CSS.md
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
# Responsive CSS Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
Added responsive CSS to the Tablet Management System to improve mobile accessibility for internal technical staff. This addresses the issue where the interface was too wide for mobile devices.
|
||||
|
||||
## ⚠️ TOP PRIORITY: Spanish Translation Required
|
||||
|
||||
**Status:** Not started - Documentation only
|
||||
**Priority:** HIGH
|
||||
**Timeline:** To be determined
|
||||
|
||||
The entire user interface needs to be translated from English to Spanish. This includes:
|
||||
|
||||
### Scope of Translation
|
||||
- ✅ All template text (buttons, labels, headers, messages)
|
||||
- ✅ Navigation links
|
||||
- ✅ Form field labels and placeholders
|
||||
- ✅ Button text
|
||||
- ✅ Flash messages (success/error)
|
||||
- ✅ Table headers
|
||||
- ✅ Help text and descriptions
|
||||
- ✅ Page titles
|
||||
|
||||
### Files to Translate
|
||||
| File | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| `templates/base.html` | ⏳ Pending | Title, navigation, flash messages |
|
||||
| `templates/index.html` | ⏳ Pending | Section headers, table headers, messages |
|
||||
| `templates/add_tablet.html` | ⏳ Pending | Form labels, button |
|
||||
| `templates/add_user.html` | ⏳ Pending | Form labels, button |
|
||||
| `templates/loan_tablet.html` | ⏳ Pending | Form labels, button, search placeholders |
|
||||
| `templates/history.html` | ⏳ Pending | Section header, table headers, messages |
|
||||
| `templates/user_loans.html` | ⏳ Pending | All text content, search placeholder |
|
||||
| `templates/non_loanable_devices.html` | ⏳ Pending | Section header, table headers, messages, button |
|
||||
| `templates/edit_non_loanable_device.html` | ⏳ Pending | Form labels, buttons |
|
||||
| `templates/project_management.html` | ⏳ Pending | All text content, buttons |
|
||||
|
||||
### Approach Options
|
||||
|
||||
#### Option 1: Direct Template Translation (Recommended for simplicity)
|
||||
- Replace all English text with Spanish directly in templates
|
||||
- **Pros:** Simple, fast, no dependencies
|
||||
- **Cons:** Harder to maintain bilingual support
|
||||
|
||||
#### Option 2: Flask-Babel Integration (Recommended for future i18n)
|
||||
```python
|
||||
# Install: pip install flask-babel
|
||||
from flask_babel import Babel, gettext as _
|
||||
|
||||
app = Flask(__name__)
|
||||
babel = Babel(app)
|
||||
|
||||
# In templates:
|
||||
# Before: <h1>Tablet Management System</h1>
|
||||
# After: <h1>{{ _('Tablet Management System') }}</h1>
|
||||
```
|
||||
- **Pros:** Supports multiple languages, professional i18n
|
||||
- **Cons:** More complex setup, requires extracting strings
|
||||
|
||||
#### Option 3: Jinja2 Macros
|
||||
```html
|
||||
{# macros.html #}
|
||||
{% macro trans(text) %}{{ text|trans }}{% endmacro %}
|
||||
|
||||
{# In templates #}
|
||||
{% import 'macros.html' as m %}
|
||||
<h1>{{ m.trans('Tablet Management System') }}</h1>
|
||||
```
|
||||
- **Pros:** Reusable, clean templates
|
||||
- **Cons:** Requires macro setup
|
||||
|
||||
### Recommended Spanish Translations
|
||||
|
||||
| English | Spanish |
|
||||
|---------|---------|
|
||||
| Tablet Management System | Sistema de Gestión de Tablets |
|
||||
| Available Tablets | Tablets Disponibles |
|
||||
| Active Loans | Préstamos Activos |
|
||||
| Loan History | Historial de Préstamos |
|
||||
| User Loans | Préstamos por Usuario |
|
||||
| Non-Loanable Devices | Dispositivos No Prestables |
|
||||
| Project Management | Gestión de Proyectos |
|
||||
| Add Tablet | Añadir Tablet |
|
||||
| Add User | Añadir Usuario |
|
||||
| Loan Tablet | Prestar Tablet |
|
||||
| Return | Devolver |
|
||||
| Brand | Marca |
|
||||
| Model | Modelo |
|
||||
| Serial Number | Número de Serie |
|
||||
| Notes | Notas |
|
||||
| Name | Nombre |
|
||||
| Email | Correo Electrónico |
|
||||
| Phone | Teléfono |
|
||||
| Identification | Identificación |
|
||||
| Loan Date | Fecha de Préstamo |
|
||||
| Return Date | Fecha de Devolución |
|
||||
| Status | Estado |
|
||||
| Actions | Acciones |
|
||||
| Edit | Editar |
|
||||
| Delete | Eliminar |
|
||||
| Save | Guardar |
|
||||
| Search | Buscar |
|
||||
| No available tablets. | No hay tablets disponibles. |
|
||||
| No active loans. | No hay préstamos activos. |
|
||||
| No loan history available. | No hay historial de préstamos disponible. |
|
||||
|
||||
### Implementation Notes
|
||||
- **Do not change code yet** - This is documentation only for now
|
||||
- Consider using a translation dictionary or Flask-Babel for maintainability
|
||||
- Test all translated text fits within the responsive design
|
||||
- Verify character encoding supports Spanish (UTF-8 should be fine)
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### Date
|
||||
June 20, 2026
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Changes | Lines Changed |
|
||||
|------|---------|---------------|
|
||||
| `templates/base.html` | Added responsive CSS framework + fixed missing `</style>` tag | +173 +1 |
|
||||
| `templates/index.html` | Wrapped tables in `.table-container` | +84/-84 |
|
||||
| `templates/history.html` | Wrapped tables in `.table-container` | +46/-46 |
|
||||
| `templates/non_loanable_devices.html` | Wrapped tables in `.table-container` | +56/-56 |
|
||||
| `templates/user_loans.html` | Wrapped tables in `.table-container` | +80/-80 |
|
||||
| `templates/project_management.html` | Added mobile breakpoints for editor | +19/-3 |
|
||||
| `docs/RESPONSIVE_CSS.md` | **NEW** - Complete documentation | +208 |
|
||||
| **Total** | | **+538 / -129** |
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Approach
|
||||
- **Mobile-first design**: Styles start with mobile and scale up
|
||||
- **Progressive enhancement**: Works on all devices, enhances for larger screens
|
||||
- **No JavaScript changes**: Pure CSS solution
|
||||
- **No backend changes**: Only template modifications
|
||||
- **Backward compatible**: Existing functionality preserved
|
||||
|
||||
### Key Features
|
||||
|
||||
#### 1. Responsive Breakpoints
|
||||
```css
|
||||
/* Mobile-first base styles */
|
||||
/* Small devices (landscape phones, 576px and up) */
|
||||
@media (min-width: 576px) { ... }
|
||||
|
||||
/* Medium devices (tablets, 768px and up) */
|
||||
@media (min-width: 768px) { ... }
|
||||
|
||||
/* Large devices (desktops, 992px and up) */
|
||||
@media (min-width: 992px) { ... }
|
||||
|
||||
/* Extra large devices (large desktops, 1200px and up) */
|
||||
@media (min-width: 1200px) { ... }
|
||||
```
|
||||
|
||||
#### 2. Mobile Navigation
|
||||
- Navigation links **stack vertically** on mobile
|
||||
- Full-width buttons for easy tapping
|
||||
- Horizontal layout on tablet/desktop
|
||||
|
||||
#### 3. Responsive Tables
|
||||
- Tables wrapped in `.table-container` div
|
||||
- **Horizontal scrolling** on mobile when table is too wide
|
||||
- Full width on larger screens
|
||||
|
||||
#### 4. Form Elements
|
||||
- Full-width inputs on mobile
|
||||
- Proper spacing and padding
|
||||
- Touch-friendly sizes (minimum 48px tap targets)
|
||||
|
||||
#### 5. Buttons
|
||||
- Full-width on mobile
|
||||
- Inline on larger screens
|
||||
- Consistent styling
|
||||
|
||||
#### 6. Cards
|
||||
- Added `.tablet-card`, `.user-card`, `.loan-card` classes
|
||||
- Consistent styling for card-based layouts
|
||||
- Proper spacing on all devices
|
||||
|
||||
#### 7. Project Management Editor
|
||||
- Stacked layout on mobile (editor above preview)
|
||||
- Side-by-side on tablet/desktop
|
||||
- Responsive button controls
|
||||
|
||||
## Bug Fix
|
||||
|
||||
### Missing `</style>` Tag
|
||||
**Issue:** After adding responsive CSS to `base.html`, the closing `</style>` tag was accidentally omitted, causing the main page to render as blank.
|
||||
|
||||
**Fix:** Added `</style>` tag at line 298 in `templates/base.html` (commit `28232db`).
|
||||
|
||||
**Symptoms:**
|
||||
- Main page (index) displayed as blank
|
||||
- Other pages may have had styling issues
|
||||
- HTML structure was invalid
|
||||
|
||||
**Resolution:**
|
||||
- Added missing `</style>` tag
|
||||
- Verified all templates have proper structure
|
||||
- Tested that pages render correctly
|
||||
|
||||
### Navigation Overflow on Zoom
|
||||
**Issue:** Navigation buttons (Home, Add Tablet, Add User, etc.) overflow through the right margin when zooming in on the page.
|
||||
|
||||
**Fix:** Added overflow constraints to prevent horizontal scrolling (commit `26b77cf`).
|
||||
|
||||
**Changes:**
|
||||
- Added `overflow-x: hidden` to `body` element
|
||||
- Added `overflow: hidden` to `.container` element
|
||||
- Added `flex-wrap: wrap` to `.nav` in mobile-first styles
|
||||
- Added `flex: 1 1 auto` and `min-width: 120px` to `.nav a` for proper wrapping
|
||||
|
||||
**Result:**
|
||||
- Navigation buttons now wrap properly on all screen sizes
|
||||
- No horizontal overflow when zooming in
|
||||
- Buttons remain usable and visible at all zoom levels
|
||||
|
||||
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
1. **5 Internal Users**: No need for complex SPA frameworks
|
||||
2. **Technical Staff**: Users understand basic UI limitations
|
||||
3. **CRUD Operations**: Simple forms and lists don't need React/Vue
|
||||
4. **Minimal Changes**: Pure CSS, no JavaScript modifications
|
||||
5. **Fast Implementation**: Done in one session
|
||||
6. **Maintainable**: Simple to understand and modify
|
||||
|
||||
### Why Not HTMX or SPA?
|
||||
|
||||
While we explored [HTMX](docs/FRONTEND_OPTIONS.md#option-3-htmx) and [SPA options](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api), for 5 internal technical users:
|
||||
|
||||
- **HTMX**: Would add unnecessary complexity for minimal benefit
|
||||
- **SPA**: Significant overkill for the user base and use case
|
||||
- **Pure CSS**: Solves the problem with minimal changes
|
||||
|
||||
The responsive CSS approach provides **80% of the benefit with 20% of the effort**.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Cases
|
||||
|
||||
| Device | Screen Size | Expected Behavior |
|
||||
|--------|-------------|-------------------|
|
||||
| Mobile (Portrait) | 375px | Vertical nav, full-width inputs, scrollable tables |
|
||||
| Mobile (Landscape) | 667px | Vertical nav, full-width inputs, scrollable tables |
|
||||
| Small Tablet | 768px | Horizontal nav (wrapped), proper spacing |
|
||||
| Large Tablet | 1024px | Horizontal nav, side-by-side editor/preview |
|
||||
| Desktop | 1440px | Full desktop layout |
|
||||
|
||||
### Manual Testing
|
||||
1. Open on mobile device or use browser dev tools
|
||||
2. Resize browser window to test different breakpoints
|
||||
3. Verify all tables have horizontal scroll on mobile
|
||||
4. Verify navigation is usable on all devices
|
||||
5. Verify forms are easy to use on mobile
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- ✅ Chrome (all versions)
|
||||
- ✅ Firefox (all versions)
|
||||
- ✅ Safari (all versions)
|
||||
- ✅ Edge (all versions)
|
||||
- ✅ Mobile browsers (iOS Safari, Chrome for Android)
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Zero**: Pure CSS, no JavaScript overhead
|
||||
- **No additional requests**: All styles inlined in templates
|
||||
- **Fast rendering**: Browser-native CSS processing
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If user base grows or requirements change, consider:
|
||||
|
||||
1. **HTMX Enhancement** (1-2 days)
|
||||
- Add dynamic updates without page reloads
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 3](docs/FRONTEND_OPTIONS.md#option-3-flask--htmx-lightweight-dynamic-ui)
|
||||
|
||||
2. **SPA Migration** (1-2 weeks)
|
||||
- Full React/Vue frontend
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 1](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api)
|
||||
|
||||
3. **Mobile App** (2-4 weeks)
|
||||
- Native mobile experience
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 4](docs/FRONTEND_OPTIONS.md#option-4-mobile-app-native-or-cross-platform)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, simply revert the template changes:
|
||||
|
||||
```bash
|
||||
git checkout HEAD -- templates/
|
||||
```
|
||||
|
||||
All changes are isolated to template files, so rollback is trivial.
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html # Main responsive CSS + bug fix
|
||||
├── index.html # Table containers
|
||||
├── history.html # Table containers
|
||||
├── non_loanable_devices.html # Table containers
|
||||
├── user_loans.html # Table containers
|
||||
└── project_management.html # Editor responsiveness
|
||||
|
||||
docs/
|
||||
└── RESPONSIVE_CSS.md # This documentation
|
||||
```
|
||||
|
||||
## Commit Information
|
||||
|
||||
```
|
||||
Commit 1: 927c323a6e7de1f3068d54ffdee22b8a420a1190
|
||||
Author: ijuanes
|
||||
Date: June 20, 2026
|
||||
Message: feat(ui): add responsive CSS for mobile accessibility
|
||||
|
||||
Commit 2: 28232db0[...]
|
||||
Author: ijuanes
|
||||
Date: June 20, 2026
|
||||
Message: fix(ui): add missing </style> tag in base.html
|
||||
```
|
||||
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Approach
|
||||
- **Mobile-first design**: Styles start with mobile and scale up
|
||||
- **Progressive enhancement**: Works on all devices, enhances for larger screens
|
||||
- **No JavaScript changes**: Pure CSS solution
|
||||
- **No backend changes**: Only template modifications
|
||||
- **Backward compatible**: Existing functionality preserved
|
||||
|
||||
### Key Features
|
||||
|
||||
#### 1. Responsive Breakpoints
|
||||
```css
|
||||
/* Mobile-first base styles */
|
||||
/* Small devices (landscape phones, 576px and up) */
|
||||
@media (min-width: 576px) { ... }
|
||||
|
||||
/* Medium devices (tablets, 768px and up) */
|
||||
@media (min-width: 768px) { ... }
|
||||
|
||||
/* Large devices (desktops, 992px and up) */
|
||||
@media (min-width: 992px) { ... }
|
||||
|
||||
/* Extra large devices (large desktops, 1200px and up) */
|
||||
@media (min-width: 1200px) { ... }
|
||||
```
|
||||
|
||||
#### 2. Mobile Navigation
|
||||
- Navigation links **stack vertically** on mobile
|
||||
- Full-width buttons for easy tapping
|
||||
- Horizontal layout on tablet/desktop
|
||||
|
||||
#### 3. Responsive Tables
|
||||
- Tables wrapped in `.table-container` div
|
||||
- **Horizontal scrolling** on mobile when table is too wide
|
||||
- Full width on larger screens
|
||||
|
||||
#### 4. Form Elements
|
||||
- Full-width inputs on mobile
|
||||
- Proper spacing and padding
|
||||
- Touch-friendly sizes (minimum 48px tap targets)
|
||||
|
||||
#### 5. Buttons
|
||||
- Full-width on mobile
|
||||
- Inline on larger screens
|
||||
- Consistent styling
|
||||
|
||||
#### 6. Cards
|
||||
- Added `.tablet-card`, `.user-card`, `.loan-card` classes
|
||||
- Consistent styling for card-based layouts
|
||||
- Proper spacing on all devices
|
||||
|
||||
#### 7. Project Management Editor
|
||||
- Stacked layout on mobile (editor above preview)
|
||||
- Side-by-side on tablet/desktop
|
||||
- Responsive button controls
|
||||
|
||||
### CSS Structure
|
||||
|
||||
The responsive CSS is organized in `templates/base.html` with:
|
||||
|
||||
1. **Mobile-first base styles** (no media query)
|
||||
- Container: 100% width, 1rem padding
|
||||
- Navigation: vertical stack
|
||||
- Tables: horizontal scroll container
|
||||
- Forms: full-width inputs
|
||||
- Buttons: full-width, block display
|
||||
|
||||
2. **Breakpoint-specific styles**
|
||||
- 576px: Container max-width 540px, nav horizontal wrap
|
||||
- 768px: Container max-width 720px, proper body padding
|
||||
- 992px: Container max-width 960px, nav no wrap
|
||||
- 1200px: Container max-width 1140px
|
||||
|
||||
3. **Print styles**
|
||||
- Hide navigation and buttons
|
||||
- Clean layout for printing
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why This Approach?
|
||||
|
||||
1. **5 Internal Users**: No need for complex SPA frameworks
|
||||
2. **Technical Staff**: Users understand basic UI limitations
|
||||
3. **CRUD Operations**: Simple forms and lists don't need React/Vue
|
||||
4. **Minimal Changes**: Pure CSS, no JavaScript modifications
|
||||
5. **Fast Implementation**: Done in one session
|
||||
6. **Maintainable**: Simple to understand and modify
|
||||
|
||||
### Why Not HTMX or SPA?
|
||||
|
||||
While we explored [HTMX](docs/FRONTEND_OPTIONS.md#option-3-htmx) and [SPA options](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api), for 5 internal technical users:
|
||||
|
||||
- **HTMX**: Would add unnecessary complexity for minimal benefit
|
||||
- **SPA**: Significant overkill for the user base and use case
|
||||
- **Pure CSS**: Solves the problem with minimal changes
|
||||
|
||||
The responsive CSS approach provides **80% of the benefit with 20% of the effort**.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Cases
|
||||
|
||||
| Device | Screen Size | Expected Behavior |
|
||||
|--------|-------------|-------------------|
|
||||
| Mobile (Portrait) | 375px | Vertical nav, full-width inputs, scrollable tables |
|
||||
| Mobile (Landscape) | 667px | Vertical nav, full-width inputs, scrollable tables |
|
||||
| Small Tablet | 768px | Horizontal nav (wrapped), proper spacing |
|
||||
| Large Tablet | 1024px | Horizontal nav, side-by-side editor/preview |
|
||||
| Desktop | 1440px | Full desktop layout |
|
||||
|
||||
### Manual Testing
|
||||
1. Open on mobile device or use browser dev tools
|
||||
2. Resize browser window to test different breakpoints
|
||||
3. Verify all tables have horizontal scroll on mobile
|
||||
4. Verify navigation is usable on all devices
|
||||
5. Verify forms are easy to use on mobile
|
||||
|
||||
## Browser Compatibility
|
||||
|
||||
- ✅ Chrome (all versions)
|
||||
- ✅ Firefox (all versions)
|
||||
- ✅ Safari (all versions)
|
||||
- ✅ Edge (all versions)
|
||||
- ✅ Mobile browsers (iOS Safari, Chrome for Android)
|
||||
|
||||
## Performance Impact
|
||||
|
||||
- **Zero**: Pure CSS, no JavaScript overhead
|
||||
- **No additional requests**: All styles inlined in templates
|
||||
- **Fast rendering**: Browser-native CSS processing
|
||||
|
||||
## Future Considerations
|
||||
|
||||
If user base grows or requirements change, consider:
|
||||
|
||||
1. **HTMX Enhancement** (1-2 days)
|
||||
- Add dynamic updates without page reloads
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 3](docs/FRONTEND_OPTIONS.md#option-3-flask--htmx-lightweight-dynamic-ui)
|
||||
|
||||
2. **SPA Migration** (1-2 weeks)
|
||||
- Full React/Vue frontend
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 1](docs/FRONTEND_OPTIONS.md#option-1-single-page-application-spa-with-rest-api)
|
||||
|
||||
3. **Mobile App** (2-4 weeks)
|
||||
- Native mobile experience
|
||||
- See: [docs/FRONTEND_OPTIONS.md - Option 4](docs/FRONTEND_OPTIONS.md#option-4-mobile-app-native-or-cross-platform)
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, simply revert the template changes:
|
||||
|
||||
```bash
|
||||
git checkout HEAD -- templates/
|
||||
```
|
||||
|
||||
All changes are isolated to template files, so rollback is trivial.
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
```
|
||||
templates/
|
||||
├── base.html # Main responsive CSS
|
||||
├── index.html # Table containers
|
||||
├── history.html # Table containers
|
||||
├── non_loanable_devices.html # Table containers
|
||||
├── user_loans.html # Table containers
|
||||
└── project_management.html # Editor responsiveness
|
||||
```
|
||||
|
||||
## Commit Information
|
||||
|
||||
```
|
||||
Commit: [SHA will be added after commit]
|
||||
Author: ijuanes
|
||||
Date: June 20, 2026
|
||||
Message: feat(ui): add responsive CSS for mobile accessibility
|
||||
|
||||
- Add mobile-first responsive CSS to base.html
|
||||
- Wrap all tables in .table-container for horizontal scrolling
|
||||
- Add breakpoints for phones, tablets, and desktops
|
||||
- Improve mobile navigation and form layouts
|
||||
- Add print styles for clean printing
|
||||
- No backend or JavaScript changes
|
||||
```
|
||||
226
extract_translations.py
Normal file
226
extract_translations.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract strings for translation and create Spanish .po files
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
# Directories
|
||||
TEMPLATES_DIR = Path('templates')
|
||||
TRANSLATIONS_DIR = Path('translations')
|
||||
|
||||
# Find all template files
|
||||
def find_template_files():
|
||||
template_files = []
|
||||
for root, dirs, files in os.walk(TEMPLATES_DIR):
|
||||
for file in files:
|
||||
if file.endswith('.html'):
|
||||
template_files.append(Path(root) / file)
|
||||
return template_files
|
||||
|
||||
# Extract strings from templates
|
||||
def extract_strings_from_file(filepath):
|
||||
strings = set()
|
||||
with open(filepath, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all _('...') and gettext('...') calls
|
||||
pattern = r"[\"'](?:_|gettext)\([\"']([^\"']+)[\"']\)"
|
||||
matches = re.findall(r"\{\{\s*_\('([^']+)'\)\s*\}\}", content)
|
||||
matches += re.findall(r"\{\{\s*gettext\('([^']+)'\)\s*\}\}", content)
|
||||
|
||||
return matches
|
||||
|
||||
# Create .pot file
|
||||
def create_pot_file(strings):
|
||||
pot_content = """msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Tablet Management System\n"
|
||||
"POT-Creation-Date: 2026-06-20\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
"""
|
||||
|
||||
for string in sorted(strings):
|
||||
pot_content += f'msgid "{string}"\n'
|
||||
pot_content += 'msgstr ""\n\n'
|
||||
|
||||
return pot_content
|
||||
|
||||
# Create Spanish .po file
|
||||
def create_es_po_file(strings):
|
||||
# Spanish translations
|
||||
translations = {
|
||||
# Navigation
|
||||
'Tablet Management System': 'Sistema de Gestión de Tablets',
|
||||
'Home': 'Inicio',
|
||||
'Add Tablet': 'Añadir Tablet',
|
||||
'Add User': 'Añadir Usuario',
|
||||
'Loan Tablet': 'Prestar Tablet',
|
||||
'History': 'Historial',
|
||||
'User Loans': 'Préstamos por Usuario',
|
||||
'Non-Loanable Devices': 'Dispositivos No Prestables',
|
||||
'Project Management': 'Gestión de Proyectos',
|
||||
|
||||
# Language
|
||||
'Language': 'Idioma',
|
||||
'English': 'Inglés',
|
||||
'Español': 'Español',
|
||||
|
||||
# Common
|
||||
'Search': 'Buscar',
|
||||
'Filter': 'Filtrar',
|
||||
'Status': 'Estado',
|
||||
'Actions': 'Acciones',
|
||||
'Details': 'Detalles',
|
||||
'View': 'Ver',
|
||||
'All': 'Todos',
|
||||
'Active': 'Activo',
|
||||
'Inactive': 'Inactivo',
|
||||
'Available': 'Disponible',
|
||||
'Returned': 'Devuelto',
|
||||
'Never': 'Nunca',
|
||||
'N/A': 'N/D',
|
||||
'Yes': 'Sí',
|
||||
'No': 'No',
|
||||
'Showing': 'Mostrando',
|
||||
'to': 'a',
|
||||
'of': 'de',
|
||||
'Previous': 'Anterior',
|
||||
'Next': 'Siguiente',
|
||||
'Page': 'Página',
|
||||
|
||||
# User Loans
|
||||
'User Loans': 'Préstamos por Usuario',
|
||||
'Search Users': 'Buscar Usuarios',
|
||||
'Loan Status': 'Estado de Préstamo',
|
||||
'All Users': 'Todos los Usuarios',
|
||||
'With Active Loans': 'Con Préstamos Activos',
|
||||
'No Active Loans': 'Sin Préstamos Activos',
|
||||
'Search by name or identification...': 'Buscar por nombre o identificación...',
|
||||
'users': 'usuarios',
|
||||
'No users found': 'No se encontraron usuarios',
|
||||
'No users match your search for': 'Ningún usuario coincide con tu búsqueda de',
|
||||
'Clear Filters': 'Limpiar Filtros',
|
||||
|
||||
# Table Headers
|
||||
'User': 'Usuario',
|
||||
'Identification': 'Identificación',
|
||||
'Active Loans': 'Préstamos Activos',
|
||||
'Last Loan Date': 'Fecha del Último Préstamo',
|
||||
'Tablet': 'Tablet',
|
||||
'Model': 'Modelo',
|
||||
'Serial Number': 'Número de Serie',
|
||||
'Notes': 'Notas',
|
||||
'Borrower': 'Prestatario',
|
||||
'Loan Date': 'Fecha de Préstamo',
|
||||
'Return Date': 'Fecha de Devolución',
|
||||
'Not returned': 'No devuelto',
|
||||
|
||||
# Buttons
|
||||
'Loan': 'Prestar',
|
||||
'Return': 'Devolver',
|
||||
'Add': 'Añadir',
|
||||
'Save': 'Guardar',
|
||||
'Cancel': 'Cancelar',
|
||||
'Delete': 'Eliminar',
|
||||
'Edit': 'Editar',
|
||||
'Update': 'Actualizar',
|
||||
|
||||
# Messages
|
||||
'Tablet Management System - Internal Tool': 'Sistema de Gestión de Tablets - Herramienta Interna',
|
||||
'No available tablets': 'No hay tablets disponibles',
|
||||
'No active loans': 'No hay préstamos activos',
|
||||
'No loan history for this user': 'Este usuario no tiene historial de préstamos',
|
||||
|
||||
# Forms
|
||||
'Brand': 'Marca',
|
||||
'Model': 'Modelo',
|
||||
'Serial Number': 'Número de Serie',
|
||||
'Name': 'Nombre',
|
||||
'Email': 'Correo Electrónico',
|
||||
'Phone': 'Teléfono',
|
||||
'Identification': 'Identificación',
|
||||
'Date': 'Fecha',
|
||||
'Notes': 'Notas',
|
||||
'Required field': 'Campo obligatorio',
|
||||
|
||||
# Status
|
||||
'Active Loans': 'Préstamos Activos',
|
||||
'Returned': 'Devueltos',
|
||||
}
|
||||
|
||||
po_content = """msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Tablet Management System\n"
|
||||
"POT-Creation-Date: 2026-06-20\n"
|
||||
"PO-Revision-Date: 2026-06-20\n"
|
||||
"Last-Translator: Auto-generated\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
"""
|
||||
|
||||
for string in sorted(strings):
|
||||
msgid = string
|
||||
msgstr = translations.get(string, '')
|
||||
po_content += f'msgid "{msgid}"\n'
|
||||
po_content += f'msgstr "{msgstr}"\n\n'
|
||||
|
||||
return po_content
|
||||
|
||||
# Main
|
||||
def main():
|
||||
print("Extracting strings for translation...")
|
||||
|
||||
# Find all template files
|
||||
template_files = find_template_files()
|
||||
print(f"Found {len(template_files)} template files")
|
||||
|
||||
# Extract all strings
|
||||
all_strings = set()
|
||||
for filepath in template_files:
|
||||
strings = extract_strings_from_file(filepath)
|
||||
all_strings.update(strings)
|
||||
|
||||
print(f"Found {len(all_strings)} unique strings")
|
||||
|
||||
# Create .pot file
|
||||
pot_content = create_pot_file(all_strings)
|
||||
pot_path = TRANSLATIONS_DIR / 'messages.pot'
|
||||
with open(pot_path, 'w', encoding='utf-8') as f:
|
||||
f.write(pot_content)
|
||||
print(f"Created {pot_path}")
|
||||
|
||||
# Create Spanish .po file
|
||||
es_po_content = create_es_po_file(all_strings)
|
||||
es_dir = TRANSLATIONS_DIR / 'es' / 'LC_MESSAGES'
|
||||
es_dir.mkdir(parents=True, exist_ok=True)
|
||||
es_po_path = es_dir / 'messages.po'
|
||||
with open(es_po_path, 'w', encoding='utf-8') as f:
|
||||
f.write(es_po_content)
|
||||
print(f"Created {es_po_path}")
|
||||
|
||||
# Create English .po file
|
||||
en_dir = TRANSLATIONS_DIR / 'en' / 'LC_MESSAGES'
|
||||
en_dir.mkdir(parents=True, exist_ok=True)
|
||||
en_po_path = en_dir / 'messages.po'
|
||||
with open(en_po_path, 'w', encoding='utf-8') as f:
|
||||
f.write(create_pot_file(all_strings))
|
||||
print(f"Created {en_po_path}")
|
||||
|
||||
print("\n✅ Translation files created!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
1
gestiontablets.sh
Normal file
1
gestiontablets.sh
Normal file
|
|
@ -0,0 +1 @@
|
|||
vibe --resume fa1bb230
|
||||
47
index.html
Normal file
47
index.html
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tablet Management System</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 40px; }
|
||||
h1 { color: #4CAF50; }
|
||||
.info { background: #f0f0f0; padding: 20px; border-radius: 5px; }
|
||||
.command { background: #e0e0e0; padding: 10px; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tablet Management System</h1>
|
||||
|
||||
<div class="info">
|
||||
<h2>Welcome to the Tablet Management System!</h2>
|
||||
<p>This system is running with a SQLite backend.</p>
|
||||
|
||||
<h3>Available Commands:</h3>
|
||||
<ul>
|
||||
<li><strong>Test the system:</strong> <code class="command">python3 test_app.py</code></li>
|
||||
<li><strong>Run interactive mode:</strong> <code class="command">python3 minimal_app.py</code></li>
|
||||
<li><strong>Check database:</strong> <code class="command">sqlite3 tablets.db</code></li>
|
||||
</ul>
|
||||
|
||||
<h3>Current Status:</h3>
|
||||
<p>✓ Database: tablets.db</p>
|
||||
<p>✓ Web server: Running on port 8080</p>
|
||||
<p>✓ System: Ready for use</p>
|
||||
</div>
|
||||
|
||||
<h3>Quick Test Results:</h3>
|
||||
<pre id="test-results">Running tests...</pre>
|
||||
|
||||
<script>
|
||||
// Simple test to show the system is working
|
||||
fetch('/test-db')
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
document.getElementById('test-results').textContent = data;
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('test-results').textContent = 'Database test: Error - ' + error;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
6
main.py
Normal file
6
main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from gestiontablets!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
413
minimal_app.py
Normal file
413
minimal_app.py
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
DEPRECATED - Tablet Lending and Return Management System (CLI)
|
||||
|
||||
This CLI version is DEPRECATED. Please use the web interface instead.
|
||||
|
||||
The web interface (app.py) provides full functionality including:
|
||||
- Complete user management (name, email, phone, identification)
|
||||
- Full tablet management with notes field
|
||||
- User loans view with search
|
||||
- Edit functionality for non-loanable devices
|
||||
- Project management
|
||||
- Responsive mobile interface
|
||||
|
||||
To run the web interface:
|
||||
python3 app.py
|
||||
|
||||
Then open http://localhost:5000 in your browser.
|
||||
|
||||
This CLI will be removed in a future version.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
def init_db():
|
||||
"""Initialize database with required tables"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create tables
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available'
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
identification TEXT UNIQUE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create non_loanable_devices table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def add_tablet(brand, model, serial_number):
|
||||
"""Add a new tablet to inventory"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status)
|
||||
VALUES (?, ?, ?, 'available')
|
||||
''', (brand, model, serial_number))
|
||||
conn.commit()
|
||||
print(f"✓ Added tablet: {brand} {model} ({serial_number})")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"✗ Error: Serial number {serial_number} already exists")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def add_user(name, identification):
|
||||
"""Add a new user"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (name, identification)
|
||||
VALUES (?, ?)
|
||||
''', (name, identification))
|
||||
conn.commit()
|
||||
print(f"✓ Added user: {name} ({identification})")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"✗ Error: Identification {identification} already exists")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def loan_tablet(tablet_id, user_id):
|
||||
"""Loan a tablet to a user"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if tablet is available
|
||||
cursor.execute("SELECT status FROM tablets WHERE id = ?", (tablet_id,))
|
||||
result = cursor.fetchone()
|
||||
|
||||
if result and result[0] == 'available':
|
||||
# Update tablet status
|
||||
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
|
||||
|
||||
# Create loan record
|
||||
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO loans (tablet_id, user_id, loan_date, status)
|
||||
VALUES (?, ?, ?, 'active')
|
||||
''', (tablet_id, user_id, loan_date))
|
||||
|
||||
conn.commit()
|
||||
print(f"✓ Tablet {tablet_id} loaned to user {user_id}")
|
||||
else:
|
||||
print(f"✗ Error: Tablet {tablet_id} is not available for loan")
|
||||
|
||||
conn.close()
|
||||
|
||||
def return_tablet(loan_id):
|
||||
"""Return a loaned tablet"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get loan information
|
||||
cursor.execute("SELECT tablet_id FROM loans WHERE id = ? AND status = 'active'", (loan_id,))
|
||||
loan = cursor.fetchone()
|
||||
|
||||
if loan:
|
||||
tablet_id = loan[0]
|
||||
|
||||
# Update loan status and return date
|
||||
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
|
||||
''', (return_date, loan_id))
|
||||
|
||||
# Update tablet status
|
||||
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
|
||||
|
||||
conn.commit()
|
||||
print(f"✓ Tablet returned for loan {loan_id}")
|
||||
else:
|
||||
print(f"✗ Error: Loan {loan_id} not found or already returned")
|
||||
|
||||
conn.close()
|
||||
|
||||
def show_available_tablets():
|
||||
"""Show available tablets"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id, brand, model, serial_number FROM tablets WHERE status = 'available'")
|
||||
tablets = cursor.fetchall()
|
||||
|
||||
print("\n=== Available Tablets ===")
|
||||
if tablets:
|
||||
for tablet in tablets:
|
||||
print(f"ID: {tablet[0]}, {tablet[1]} {tablet[2]} ({tablet[3]})")
|
||||
else:
|
||||
print("No available tablets")
|
||||
|
||||
conn.close()
|
||||
|
||||
def show_active_loans():
|
||||
"""Show active loans"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
WHERE l.status = 'active'
|
||||
''')
|
||||
loans = cursor.fetchall()
|
||||
|
||||
print("\n=== Active Loans ===")
|
||||
if loans:
|
||||
for loan in loans:
|
||||
print(f"Loan ID: {loan[0]}, Tablet: {loan[1]} {loan[2]} ({loan[3]}), Borrower: {loan[4]}, Loan Date: {loan[5]}")
|
||||
else:
|
||||
print("No active loans")
|
||||
|
||||
conn.close()
|
||||
|
||||
def show_loan_history():
|
||||
"""Show complete loan history"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
|
||||
l.loan_date, l.return_date, l.status
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
ORDER BY l.loan_date DESC
|
||||
''')
|
||||
loans = cursor.fetchall()
|
||||
|
||||
print("\n=== Loan History ===")
|
||||
if loans:
|
||||
for loan in loans:
|
||||
return_date = loan[6] or 'Not returned'
|
||||
print(f"Loan ID: {loan[0]}, Tablet: {loan[1]} {loan[2]} ({loan[3]}), Borrower: {loan[4]}")
|
||||
print(f" Loan Date: {loan[5]}, Return Date: {return_date}, Status: {loan[7]}")
|
||||
else:
|
||||
print("No loan history")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def add_non_loanable_device(brand, model, serial_number, device_type, location='', notes='', purchase_date='', purchase_cost=None):
|
||||
"""Add a new non-loanable device to inventory"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO non_loanable_devices
|
||||
(brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost)
|
||||
VALUES (?, ?, ?, ?, ?, 'available', ?, ?, ?)
|
||||
''', (brand, model, serial_number, device_type, location, notes, purchase_date, purchase_cost))
|
||||
conn.commit()
|
||||
print(f"✓ Added non-loanable device: {brand} {model} ({serial_number}) - Type: {device_type}")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f"✗ Error: Serial number {serial_number} already exists")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def show_non_loanable_devices():
|
||||
"""Show all non-loanable devices"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id, brand, model, serial_number, device_type, location, status FROM non_loanable_devices")
|
||||
devices = cursor.fetchall()
|
||||
|
||||
print("\n=== Non-Loanable Devices ===")
|
||||
if devices:
|
||||
for device in devices:
|
||||
print(f"ID: {device[0]}, Type: {device[4]}, {device[1]} {device[2]} ({device[3]}), Location: {device[5] or 'N/A'}, Status: {device[6]}")
|
||||
else:
|
||||
print("No non-loanable devices")
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
def delete_non_loanable_device(device_id):
|
||||
"""Delete a non-loanable device"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("DELETE FROM non_loanable_devices WHERE id = ?", (device_id,))
|
||||
conn.commit()
|
||||
|
||||
if cursor.rowcount > 0:
|
||||
print(f"✓ Non-loanable device {device_id} deleted")
|
||||
else:
|
||||
print(f"✗ Error: Device {device_id} not found")
|
||||
|
||||
conn.close()
|
||||
|
||||
def main():
|
||||
"""Main menu"""
|
||||
print("=" * 70)
|
||||
print("DEPRECATION WARNING: This CLI is deprecated!")
|
||||
print("Please use the web interface instead: python3 app.py")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
init_db()
|
||||
|
||||
print("=== Tablet Lending and Return Management System (DEPRECATED) ===")
|
||||
print("Using SQLite database: tablets.db")
|
||||
print("NOTE: This CLI has limited functionality. Use web interface for full features.")
|
||||
|
||||
while True:
|
||||
print("\nMenu:")
|
||||
print("1. Add Tablet")
|
||||
print("2. Add User")
|
||||
print("3. Loan Tablet")
|
||||
print("4. Return Tablet")
|
||||
print("5. Show Available Tablets")
|
||||
print("6. Show Active Loans")
|
||||
print("7. Show Loan History")
|
||||
print("8. Add Non-Loanable Device")
|
||||
print("9. Show Non-Loanable Devices")
|
||||
print("10. Delete Non-Loanable Device")
|
||||
print("11. Exit")
|
||||
|
||||
choice = input("Enter your choice (1-11): ")
|
||||
|
||||
if choice == '1':
|
||||
print("\n=== Add Tablet ===")
|
||||
brand = input("Brand: ")
|
||||
model = input("Model: ")
|
||||
serial_number = input("Serial Number: ")
|
||||
add_tablet(brand, model, serial_number)
|
||||
|
||||
elif choice == '2':
|
||||
print("\n=== Add User ===")
|
||||
name = input("Name: ")
|
||||
identification = input("Identification: ")
|
||||
add_user(name, identification)
|
||||
|
||||
elif choice == '3':
|
||||
print("\n=== Loan Tablet ===")
|
||||
show_available_tablets()
|
||||
show_users()
|
||||
|
||||
tablet_id = input("Enter Tablet ID to loan: ")
|
||||
user_id = input("Enter User ID: ")
|
||||
|
||||
try:
|
||||
loan_tablet(int(tablet_id), int(user_id))
|
||||
except ValueError:
|
||||
print("✗ Error: Invalid ID format")
|
||||
|
||||
elif choice == '4':
|
||||
print("\n=== Return Tablet ===")
|
||||
show_active_loans()
|
||||
|
||||
loan_id = input("Enter Loan ID to return: ")
|
||||
|
||||
try:
|
||||
return_tablet(int(loan_id))
|
||||
except ValueError:
|
||||
print("✗ Error: Invalid Loan ID format")
|
||||
|
||||
elif choice == '5':
|
||||
show_available_tablets()
|
||||
|
||||
elif choice == '6':
|
||||
show_active_loans()
|
||||
|
||||
elif choice == '7':
|
||||
show_loan_history()
|
||||
|
||||
elif choice == '8':
|
||||
print("\n=== Add Non-Loanable Device ===")
|
||||
brand = input("Brand: ")
|
||||
model = input("Model: ")
|
||||
serial_number = input("Serial Number: ")
|
||||
device_type = input("Device Type (e.g., projector, monitor): ")
|
||||
location = input("Location (optional): ")
|
||||
notes = input("Notes (optional): ")
|
||||
add_non_loanable_device(brand, model, serial_number, device_type, location, notes)
|
||||
|
||||
elif choice == '9':
|
||||
show_non_loanable_devices()
|
||||
|
||||
elif choice == '10':
|
||||
print("\n=== Delete Non-Loanable Device ===")
|
||||
show_non_loanable_devices()
|
||||
device_id = input("Enter Device ID to delete: ")
|
||||
try:
|
||||
delete_non_loanable_device(int(device_id))
|
||||
except ValueError:
|
||||
print("✗ Error: Invalid ID format")
|
||||
|
||||
elif choice == '11':
|
||||
print("Goodbye!")
|
||||
break
|
||||
|
||||
else:
|
||||
print("✗ Invalid choice. Please try again.")
|
||||
|
||||
def show_users():
|
||||
"""Show available users"""
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT id, name, identification FROM users")
|
||||
users = cursor.fetchall()
|
||||
|
||||
print("\n=== Available Users ===")
|
||||
if users:
|
||||
for user in users:
|
||||
print(f"ID: {user[0]}, {user[1]} ({user[2]})")
|
||||
else:
|
||||
print("No users available")
|
||||
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
37
pyproject.toml
Normal file
37
pyproject.toml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
[project]
|
||||
name = "gestiontablets"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
python_files = "test_*.py"
|
||||
python_classes = "Test*"
|
||||
python_functions = "test_*"
|
||||
verbose = 1
|
||||
addopts = "-v"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["."]
|
||||
omit = [
|
||||
"*/tests/*",
|
||||
"*/.venv/*",
|
||||
"*/__pycache__/*",
|
||||
"*/.git/*",
|
||||
"*/templates/*",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"def __repr__",
|
||||
"raise NotImplementedError",
|
||||
"if __name__ == .__main__.:",
|
||||
"if TYPE_CHECKING:",
|
||||
]
|
||||
|
||||
[tool.coverage.html]
|
||||
directory = "htmlcov"
|
||||
20
requirements.txt
Normal file
20
requirements.txt
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Core dependencies
|
||||
Flask==2.3.3
|
||||
Flask-Babel==2.0.0
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# Database
|
||||
# sqlite3 is built-in with Python
|
||||
|
||||
# For CSV processing
|
||||
# No additional dependencies needed (csv is built-in)
|
||||
|
||||
# For development/testing
|
||||
pytest==7.4.0
|
||||
pytest-cov==4.1.0
|
||||
|
||||
# For running the application in production
|
||||
waitress==2.1.2 # Production WSGI server
|
||||
|
||||
# For production deployment (optional)
|
||||
# gunicorn==21.2.0 # Alternative WSGI server
|
||||
703
scripts/import_students.py
Normal file
703
scripts/import_students.py
Normal file
|
|
@ -0,0 +1,703 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
CSV Import Script for Student Data
|
||||
|
||||
This script imports student data from a CSV file (ISO-8859-1 encoded) into the SQLite database.
|
||||
It handles:
|
||||
- Encoding conversion from ISO-8859-1 to UTF-8
|
||||
- Splitting "Apellidos, Nombre" into separate fields
|
||||
- Date format conversion from DD/MM/YYYY to YYYY-MM-DD
|
||||
- Discarding the "Estudio" field as per requirements
|
||||
- Validation and error handling
|
||||
|
||||
Usage:
|
||||
python import_students.py <csv_file> [--database <db_file>] [--dry-run]
|
||||
|
||||
Examples:
|
||||
python import_students.py Datos_programa.csv
|
||||
python import_students.py Datos_programa.csv --database tablets.db
|
||||
python import_students.py Datos_programa.csv --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
# Default database file
|
||||
DEFAULT_DATABASE = 'tablets.db'
|
||||
|
||||
# CSV field mapping
|
||||
CSV_FIELDS = [
|
||||
'order_number',
|
||||
'full_name',
|
||||
'birth_date',
|
||||
'cial_code',
|
||||
'nif_nie_passport',
|
||||
'registration_number',
|
||||
'file_number',
|
||||
'gender',
|
||||
'study_group',
|
||||
'study' # This will be discarded
|
||||
]
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Import student data from CSV file to SQLite database'
|
||||
)
|
||||
parser.add_argument(
|
||||
'csv_file',
|
||||
help='Path to the CSV file to import (ISO-8859-1 encoded)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--database', '-d',
|
||||
default=DEFAULT_DATABASE,
|
||||
help=f'Path to SQLite database file (default: {DEFAULT_DATABASE})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Validate and preview data without importing'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--delimiter',
|
||||
default=',',
|
||||
help='CSV delimiter (default: comma)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--quotechar',
|
||||
default='"',
|
||||
help='CSV quote character (default: double quote)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--encoding',
|
||||
default='iso-8859-1',
|
||||
help='CSV file encoding (default: iso-8859-1)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip-errors',
|
||||
action='store_true',
|
||||
help='Skip rows with errors instead of stopping'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Show detailed processing information'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_csv_file(filepath: str, encoding: str, delimiter: str, quotechar: str) -> List[Dict[str, str]]:
|
||||
"""Read CSV file and return list of dictionaries."""
|
||||
"""
|
||||
Read a CSV file with specified encoding and return its contents as a list of dictionaries.
|
||||
|
||||
Args:
|
||||
filepath: Path to the CSV file
|
||||
encoding: File encoding (e.g., 'iso-8859-1')
|
||||
delimiter: CSV field delimiter
|
||||
quotechar: CSV quote character
|
||||
|
||||
Returns:
|
||||
List of dictionaries where keys are header names and values are field values
|
||||
"""
|
||||
if not os.path.exists(filepath):
|
||||
raise FileNotFoundError(f"CSV file not found: {filepath}")
|
||||
|
||||
if not os.path.isfile(filepath):
|
||||
raise ValueError(f"Path is not a file: {filepath}")
|
||||
|
||||
rows = []
|
||||
with open(filepath, 'r', encoding=encoding, newline='') as csvfile:
|
||||
reader = csv.DictReader(csvfile, delimiter=delimiter, quotechar=quotechar)
|
||||
|
||||
# Convert header names to lowercase and strip whitespace
|
||||
if reader.fieldnames:
|
||||
reader.fieldnames = [name.strip().lower() for name in reader.fieldnames]
|
||||
|
||||
for row in reader:
|
||||
rows.append(row)
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def split_full_name(full_name: str) -> Tuple[str, str]:
|
||||
"""
|
||||
Split a full name in format 'APELLIDOS, NOMBRE' into last_name and first_name.
|
||||
|
||||
Args:
|
||||
full_name: Full name string in format 'Last Name, First Name'
|
||||
|
||||
Returns:
|
||||
Tuple of (last_name, first_name)
|
||||
"""
|
||||
if not full_name or full_name.strip() == '':
|
||||
return '', ''
|
||||
|
||||
# Remove surrounding quotes if present
|
||||
full_name = full_name.strip().strip('"').strip("'")
|
||||
|
||||
# Split on comma followed by optional whitespace
|
||||
parts = re.split(r',\s*', full_name, maxsplit=1)
|
||||
|
||||
if len(parts) == 2:
|
||||
last_name = parts[0].strip()
|
||||
first_name = parts[1].strip()
|
||||
else:
|
||||
# If no comma, assume it's just a name
|
||||
last_name = ''
|
||||
first_name = full_name.strip()
|
||||
|
||||
return last_name, first_name
|
||||
|
||||
|
||||
def convert_date(date_str: str) -> Optional[str]:
|
||||
"""
|
||||
Convert date from DD/MM/YYYY to YYYY-MM-DD format.
|
||||
|
||||
Args:
|
||||
date_str: Date string in DD/MM/YYYY format
|
||||
|
||||
Returns:
|
||||
Date string in YYYY-MM-DD format, or None if invalid
|
||||
"""
|
||||
if not date_str or date_str.strip() == '':
|
||||
return None
|
||||
|
||||
date_str = date_str.strip()
|
||||
|
||||
try:
|
||||
# Parse DD/MM/YYYY
|
||||
day, month, year = re.split(r'[/-]', date_str)
|
||||
day = int(day)
|
||||
month = int(month)
|
||||
year = int(year)
|
||||
|
||||
# Validate date
|
||||
datetime(year, month, day)
|
||||
|
||||
# Format as YYYY-MM-DD
|
||||
return f"{year:04d}-{month:02d}-{day:02d}"
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def validate_gender(gender: str) -> Optional[str]:
|
||||
"""
|
||||
Validate and normalize gender field.
|
||||
|
||||
Args:
|
||||
gender: Gender string (M, F, etc.)
|
||||
|
||||
Returns:
|
||||
Normalized gender ('M', 'F', or 'O') or None if invalid
|
||||
"""
|
||||
if not gender or gender.strip() == '':
|
||||
return None
|
||||
|
||||
gender = gender.strip().upper()
|
||||
|
||||
if gender in ['M', 'H', 'MALE', 'HOMBRE']:
|
||||
return 'M'
|
||||
elif gender in ['F', 'MUJER', 'FEMALE']:
|
||||
return 'F'
|
||||
elif gender in ['O', 'OTHER', 'OTRO']:
|
||||
return 'O'
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def process_row(row: Dict[str, str], verbose: bool = False) -> Optional[Dict[str, Optional[str]]]:
|
||||
"""
|
||||
Process a single CSV row and convert it to database format.
|
||||
|
||||
Args:
|
||||
row: Dictionary with CSV field names as keys
|
||||
verbose: Whether to print processing details
|
||||
|
||||
Returns:
|
||||
Dictionary with processed data ready for database insertion, or None if invalid
|
||||
"""
|
||||
# Normalize the row keys to lowercase and remove special characters
|
||||
normalized_row = {}
|
||||
for key, value in row.items():
|
||||
# Remove special characters and normalize
|
||||
normalized_key = key.lower().replace('º', '').replace('ó', 'o').replace('á', 'a').replace('í', 'i').replace('ú', 'u').replace('ñ', 'n').replace('.', '').replace(',', '').strip()
|
||||
normalized_row[normalized_key] = value
|
||||
|
||||
# Map CSV fields to our expected field names
|
||||
mapped_row = {}
|
||||
field_mapping = {
|
||||
'n or': 'order_number',
|
||||
'apellidos nombre': 'full_name',
|
||||
'fecha nac': 'birth_date',
|
||||
'cial': 'cial_code',
|
||||
'nif/nie/pas': 'nif_nie_passport',
|
||||
'registro': 'registration_number',
|
||||
'expediente': 'file_number',
|
||||
'sexo': 'gender',
|
||||
'grupo': 'study_group',
|
||||
'estudio': 'study'
|
||||
}
|
||||
|
||||
for csv_key, our_key in field_mapping.items():
|
||||
if csv_key in normalized_row:
|
||||
mapped_row[our_key] = normalized_row[csv_key]
|
||||
else:
|
||||
mapped_row[our_key] = ''
|
||||
|
||||
# Extract and process fields
|
||||
try:
|
||||
# Order number
|
||||
order_number = mapped_row.get('order_number', '')
|
||||
if order_number.strip() == '':
|
||||
order_number = None
|
||||
else:
|
||||
try:
|
||||
order_number = int(order_number.strip())
|
||||
except ValueError:
|
||||
order_number = None
|
||||
|
||||
# Full name and split into last_name, first_name
|
||||
full_name = mapped_row.get('full_name', '')
|
||||
last_name, first_name = split_full_name(full_name)
|
||||
|
||||
# Birth date conversion
|
||||
birth_date_str = mapped_row.get('birth_date', '')
|
||||
birth_date = convert_date(birth_date_str)
|
||||
|
||||
# CIAL code
|
||||
cial_code = mapped_row.get('cial_code', '').strip()
|
||||
if cial_code == '':
|
||||
cial_code = None
|
||||
|
||||
# NIF/NIE/Passport
|
||||
nif_nie_passport = mapped_row.get('nif_nie_passport', '').strip()
|
||||
if nif_nie_passport == '':
|
||||
nif_nie_passport = None
|
||||
|
||||
# Registration number
|
||||
registration_number = mapped_row.get('registration_number', '')
|
||||
if registration_number.strip() == '':
|
||||
registration_number = None
|
||||
else:
|
||||
try:
|
||||
registration_number = int(registration_number.strip())
|
||||
except ValueError:
|
||||
registration_number = None
|
||||
|
||||
# File number
|
||||
file_number = mapped_row.get('file_number', '').strip()
|
||||
if file_number == '':
|
||||
file_number = None
|
||||
|
||||
# Gender
|
||||
gender = mapped_row.get('gender', '')
|
||||
gender = validate_gender(gender)
|
||||
|
||||
# Study group
|
||||
study_group = mapped_row.get('study_group', '').strip()
|
||||
if study_group == '':
|
||||
study_group = None
|
||||
|
||||
# Create result dictionary
|
||||
result = {
|
||||
'order_number': order_number,
|
||||
'cial_code': cial_code,
|
||||
'nif_nie_passport': nif_nie_passport,
|
||||
'registration_number': registration_number,
|
||||
'file_number': file_number,
|
||||
'first_name': first_name if first_name else None,
|
||||
'last_name': last_name if last_name else None,
|
||||
'full_name': full_name.strip() if full_name.strip() else None,
|
||||
'birth_date': birth_date,
|
||||
'gender': gender,
|
||||
'study_group': study_group,
|
||||
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'updated_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
}
|
||||
|
||||
if verbose:
|
||||
print(f" Processed: {result}")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing row: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def validate_row(data: Dict[str, Optional[str]]) -> Tuple[bool, List[str]]:
|
||||
"""
|
||||
Validate processed row data.
|
||||
|
||||
Args:
|
||||
data: Processed row data
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list_of_errors)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
# Check required fields
|
||||
if not data.get('cial_code'):
|
||||
errors.append("CIAL code is required")
|
||||
|
||||
if not data.get('first_name'):
|
||||
errors.append("First name is required")
|
||||
|
||||
if not data.get('last_name'):
|
||||
errors.append("Last name is required")
|
||||
|
||||
# Check date format
|
||||
birth_date = data.get('birth_date')
|
||||
if birth_date:
|
||||
try:
|
||||
datetime.strptime(birth_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
errors.append(f"Invalid birth date format: {birth_date}")
|
||||
|
||||
# Check gender
|
||||
gender = data.get('gender')
|
||||
if gender and gender not in ['M', 'F', 'O']:
|
||||
errors.append(f"Invalid gender: {gender}")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
def create_students_table(conn: sqlite3.Connection) -> bool:
|
||||
"""
|
||||
Create the students table if it doesn't exist.
|
||||
|
||||
Args:
|
||||
conn: SQLite database connection
|
||||
|
||||
Returns:
|
||||
True if table was created or already exists, False on error
|
||||
"""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_number INTEGER,
|
||||
cial_code TEXT UNIQUE NOT NULL,
|
||||
nif_nie_passport TEXT UNIQUE,
|
||||
registration_number INTEGER,
|
||||
file_number TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
birth_date TEXT,
|
||||
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
|
||||
study_group TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create indexes
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Error creating students table: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def insert_student(conn: sqlite3.Connection, data: Dict[str, Optional[str]], verbose: bool = False) -> Tuple[bool, Optional[int]]:
|
||||
"""
|
||||
Insert a student record into the database.
|
||||
|
||||
Args:
|
||||
conn: SQLite database connection
|
||||
data: Student data dictionary
|
||||
verbose: Whether to print details
|
||||
|
||||
Returns:
|
||||
Tuple of (success, student_id or None)
|
||||
"""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Prepare SQL and parameters
|
||||
sql = '''
|
||||
INSERT INTO students (
|
||||
order_number, cial_code, nif_nie_passport, registration_number,
|
||||
file_number, first_name, last_name, full_name, birth_date,
|
||||
gender, study_group, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
'''
|
||||
|
||||
params = (
|
||||
data['order_number'],
|
||||
data['cial_code'],
|
||||
data['nif_nie_passport'],
|
||||
data['registration_number'],
|
||||
data['file_number'],
|
||||
data['first_name'],
|
||||
data['last_name'],
|
||||
data['full_name'],
|
||||
data['birth_date'],
|
||||
data['gender'],
|
||||
data['study_group'],
|
||||
data['created_at'],
|
||||
data['updated_at']
|
||||
)
|
||||
|
||||
cursor.execute(sql, params)
|
||||
student_id = cursor.lastrowid
|
||||
conn.commit()
|
||||
|
||||
if verbose:
|
||||
print(f" Inserted student ID: {student_id}")
|
||||
|
||||
return True, student_id
|
||||
|
||||
except sqlite3.IntegrityError as e:
|
||||
if verbose:
|
||||
print(f" Duplicate entry (skipped): {e}")
|
||||
conn.rollback()
|
||||
return False, None
|
||||
except sqlite3.Error as e:
|
||||
print(f" Database error: {e}")
|
||||
conn.rollback()
|
||||
return False, None
|
||||
|
||||
|
||||
def check_duplicate(conn: sqlite3.Connection, cial_code: str, nif_nie_passport: Optional[str]) -> bool:
|
||||
"""
|
||||
Check if a student with the same CIAL code or NIF/NIE already exists.
|
||||
|
||||
Args:
|
||||
conn: SQLite database connection
|
||||
cial_code: CIAL code to check
|
||||
nif_nie_passport: NIF/NIE/Passport to check
|
||||
|
||||
Returns:
|
||||
True if duplicate exists, False otherwise
|
||||
"""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check by CIAL code
|
||||
cursor.execute("SELECT id FROM students WHERE cial_code = ?", (cial_code,))
|
||||
if cursor.fetchone():
|
||||
return True
|
||||
|
||||
# Check by NIF/NIE if provided
|
||||
if nif_nie_passport and nif_nie_passport.strip():
|
||||
cursor.execute("SELECT id FROM students WHERE nif_nie_passport = ?", (nif_nie_passport,))
|
||||
if cursor.fetchone():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Error checking for duplicates: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def import_csv(csv_file: str, database: str, dry_run: bool = False,
|
||||
skip_errors: bool = False, verbose: bool = False) -> Dict[str, int]:
|
||||
"""
|
||||
Main import function.
|
||||
|
||||
Args:
|
||||
csv_file: Path to CSV file
|
||||
database: Path to SQLite database
|
||||
dry_run: If True, validate without importing
|
||||
skip_errors: If True, skip invalid rows instead of stopping
|
||||
verbose: If True, print detailed information
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics
|
||||
"""
|
||||
stats = {
|
||||
'total_rows': 0,
|
||||
'processed': 0,
|
||||
'valid': 0,
|
||||
'invalid': 0,
|
||||
'imported': 0,
|
||||
'duplicates': 0,
|
||||
'errors': 0
|
||||
}
|
||||
|
||||
# Read CSV file
|
||||
try:
|
||||
if verbose:
|
||||
print(f"Reading CSV file: {csv_file}")
|
||||
|
||||
rows = read_csv_file(csv_file, encoding='iso-8859-1', delimiter=',', quotechar='"')
|
||||
stats['total_rows'] = len(rows)
|
||||
|
||||
if verbose:
|
||||
print(f"Found {len(rows)} rows in CSV file")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading CSV file: {e}")
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
# Connect to database
|
||||
try:
|
||||
conn = sqlite3.connect(database)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
if not dry_run:
|
||||
# Create students table if it doesn't exist
|
||||
if not create_students_table(conn):
|
||||
print("Failed to create students table")
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Error connecting to database: {e}")
|
||||
stats['errors'] += 1
|
||||
return stats
|
||||
|
||||
# Process each row
|
||||
for i, row in enumerate(rows, 1):
|
||||
if verbose:
|
||||
print(f"\nProcessing row {i}/{len(rows)}:")
|
||||
print(f" Raw: {row}")
|
||||
|
||||
# Process row
|
||||
processed = process_row(row, verbose)
|
||||
|
||||
if processed is None:
|
||||
stats['errors'] += 1
|
||||
if not skip_errors:
|
||||
print(f"Error processing row {i}, stopping.")
|
||||
break
|
||||
else:
|
||||
if verbose:
|
||||
print(f" Skipping row {i} due to processing error")
|
||||
continue
|
||||
|
||||
stats['processed'] += 1
|
||||
|
||||
# Validate row
|
||||
is_valid, errors = validate_row(processed)
|
||||
|
||||
if not is_valid:
|
||||
stats['invalid'] += 1
|
||||
if verbose:
|
||||
print(f" Invalid row {i}: {errors}")
|
||||
if not skip_errors:
|
||||
print(f"Error validating row {i}, stopping.")
|
||||
break
|
||||
else:
|
||||
continue
|
||||
|
||||
stats['valid'] += 1
|
||||
|
||||
if dry_run:
|
||||
# Just print what would be imported
|
||||
if verbose:
|
||||
print(f" [DRY RUN] Would import: {processed['full_name']} ({processed['cial_code']})")
|
||||
else:
|
||||
# Check for duplicates
|
||||
if check_duplicate(conn, processed['cial_code'], processed.get('nif_nie_passport')):
|
||||
stats['duplicates'] += 1
|
||||
if verbose:
|
||||
print(f" Duplicate found for CIAL: {processed['cial_code']}, skipping")
|
||||
continue
|
||||
|
||||
# Insert into database
|
||||
success, student_id = insert_student(conn, processed, verbose)
|
||||
|
||||
if success:
|
||||
stats['imported'] += 1
|
||||
else:
|
||||
stats['duplicates'] += 1
|
||||
|
||||
# Close database connection
|
||||
if not dry_run:
|
||||
conn.close()
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
def print_statistics(stats: Dict[str, int], dry_run: bool = False):
|
||||
"""Print import statistics."""
|
||||
print("\n" + "=" * 50)
|
||||
print("IMPORT STATISTICS")
|
||||
print("=" * 50)
|
||||
print(f"Total rows in CSV: {stats['total_rows']}")
|
||||
print(f"Rows processed: {stats['processed']}")
|
||||
print(f"Valid rows: {stats['valid']}")
|
||||
print(f"Invalid rows: {stats['invalid']}")
|
||||
if not dry_run:
|
||||
print(f"Rows imported: {stats['imported']}")
|
||||
print(f"Duplicates skipped: {stats['duplicates']}")
|
||||
print(f"Errors: {stats['errors']}")
|
||||
|
||||
if dry_run:
|
||||
print("\n[DRY RUN] No data was imported to the database.")
|
||||
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
args = parse_arguments()
|
||||
|
||||
print(f"Importing student data from: {args.csv_file}")
|
||||
print(f"Database: {args.database}")
|
||||
print(f"Dry run: {args.dry_run}")
|
||||
print(f"Skip errors: {args.skip_errors}")
|
||||
print()
|
||||
|
||||
# Perform import
|
||||
stats = import_csv(
|
||||
csv_file=args.csv_file,
|
||||
database=args.database,
|
||||
dry_run=args.dry_run,
|
||||
skip_errors=args.skip_errors,
|
||||
verbose=args.verbose
|
||||
)
|
||||
|
||||
# Print statistics
|
||||
print_statistics(stats, args.dry_run)
|
||||
|
||||
# Exit with appropriate code
|
||||
if stats['errors'] > 0:
|
||||
sys.exit(1)
|
||||
elif stats['invalid'] > 0 and not args.skip_errors:
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
445
scripts/migrate_database.py
Normal file
445
scripts/migrate_database.py
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database Migration Script
|
||||
|
||||
This script migrates the existing SQLite database to the new schema that includes
|
||||
the students table and relationships.
|
||||
|
||||
Usage:
|
||||
python migrate_database.py [--database <db_file>] [--dry-run]
|
||||
|
||||
Examples:
|
||||
python migrate_database.py
|
||||
python migrate_database.py --database tablets.db
|
||||
python migrate_database.py --dry-run
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
DEFAULT_DATABASE = 'tablets.db'
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Migrate database to new schema with students table'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--database', '-d',
|
||||
default=DEFAULT_DATABASE,
|
||||
help=f'Path to SQLite database file (default: {DEFAULT_DATABASE})'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--dry-run',
|
||||
action='store_true',
|
||||
help='Show migration steps without executing'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Show detailed migration information'
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def backup_database(database_path: str) -> str:
|
||||
"""
|
||||
Create a backup of the database before migration.
|
||||
|
||||
Args:
|
||||
database_path: Path to the database file
|
||||
|
||||
Returns:
|
||||
Path to the backup file
|
||||
"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = f"{database_path}.backup_{timestamp}"
|
||||
|
||||
if os.path.exists(database_path):
|
||||
import shutil
|
||||
shutil.copy2(database_path, backup_path)
|
||||
print(f"Created backup: {backup_path}")
|
||||
|
||||
return backup_path
|
||||
|
||||
|
||||
def check_table_exists(conn: sqlite3.Connection, table_name: str) -> bool:
|
||||
"""Check if a table exists in the database."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,)
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def check_column_exists(conn: sqlite3.Connection, table_name: str, column_name: str) -> bool:
|
||||
"""Check if a column exists in a table."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def create_students_table(conn: sqlite3.Connection) -> bool:
|
||||
"""Create the students table."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS students (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
order_number INTEGER,
|
||||
cial_code TEXT UNIQUE NOT NULL,
|
||||
nif_nie_passport TEXT UNIQUE,
|
||||
registration_number INTEGER,
|
||||
file_number TEXT,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
full_name TEXT NOT NULL,
|
||||
birth_date TEXT,
|
||||
gender TEXT CHECK(gender IN ('M', 'F', 'O')),
|
||||
study_group TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT unique_identification UNIQUE (cial_code, nif_nie_passport)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create indexes
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_cial ON students(cial_code)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_nif ON students(nif_nie_passport)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_name ON students(last_name, first_name)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_birth_date ON students(birth_date)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_gender ON students(gender)
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_students_group ON students(study_group)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
print("✓ Created students table")
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error creating students table: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def add_student_relationship_to_tablets(conn: sqlite3.Connection) -> bool:
|
||||
"""Add assigned_to_student column to tablets table."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if not check_column_exists(conn, 'tablets', 'assigned_to_student'):
|
||||
cursor.execute('''
|
||||
ALTER TABLE tablets ADD COLUMN assigned_to_student INTEGER
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_tablets_student ON tablets(assigned_to_student)
|
||||
''')
|
||||
conn.commit()
|
||||
print("✓ Added assigned_to_student column to tablets table")
|
||||
else:
|
||||
print("✓ assigned_to_student column already exists in tablets table")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error adding assigned_to_student to tablets: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def add_student_relationship_to_non_loanable_devices(conn: sqlite3.Connection) -> bool:
|
||||
"""Add assigned_to_student column to non_loanable_devices table."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if not check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
|
||||
cursor.execute('''
|
||||
ALTER TABLE non_loanable_devices ADD COLUMN assigned_to_student INTEGER
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_non_loanable_student ON non_loanable_devices(assigned_to_student)
|
||||
''')
|
||||
conn.commit()
|
||||
print("✓ Added assigned_to_student column to non_loanable_devices table")
|
||||
else:
|
||||
print("✓ assigned_to_student column already exists in non_loanable_devices table")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error adding assigned_to_student to non_loanable_devices: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def add_student_relationship_to_loans(conn: sqlite3.Connection) -> bool:
|
||||
"""Add student_id column to loans table."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
if not check_column_exists(conn, 'loans', 'student_id'):
|
||||
cursor.execute('''
|
||||
ALTER TABLE loans ADD COLUMN student_id INTEGER
|
||||
''')
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_loans_student ON loans(student_id)
|
||||
''')
|
||||
conn.commit()
|
||||
print("✓ Added student_id column to loans table")
|
||||
else:
|
||||
print("✓ student_id column already exists in loans table")
|
||||
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error adding student_id to loans: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def add_foreign_keys(conn: sqlite3.Connection) -> bool:
|
||||
"""Add foreign key constraints to tables."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# For tablets table
|
||||
if check_column_exists(conn, 'tablets', 'assigned_to_student'):
|
||||
# SQLite doesn't support adding foreign keys with ALTER TABLE easily
|
||||
# We'll rely on application-level enforcement
|
||||
print("✓ Foreign key for tablets.assigned_to_student will be enforced at application level")
|
||||
|
||||
# For non_loanable_devices table
|
||||
if check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
|
||||
print("✓ Foreign key for non_loanable_devices.assigned_to_student will be enforced at application level")
|
||||
|
||||
# For loans table
|
||||
if check_column_exists(conn, 'loans', 'student_id'):
|
||||
print("✓ Foreign key for loans.student_id will be enforced at application level")
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error adding foreign keys: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def add_timestamp_columns(conn: sqlite3.Connection) -> bool:
|
||||
"""Add created_at and updated_at columns to existing tables if they don't exist."""
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
|
||||
tables_to_update = ['tablets', 'users', 'loans', 'non_loanable_devices']
|
||||
|
||||
for table in tables_to_update:
|
||||
if not check_column_exists(conn, table, 'created_at'):
|
||||
cursor.execute(f'''
|
||||
ALTER TABLE {table} ADD COLUMN created_at TEXT
|
||||
''')
|
||||
# Update existing rows
|
||||
cursor.execute(f'''
|
||||
UPDATE {table} SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL
|
||||
''')
|
||||
print(f"✓ Added created_at column to {table} table")
|
||||
|
||||
if not check_column_exists(conn, table, 'updated_at'):
|
||||
cursor.execute(f'''
|
||||
ALTER TABLE {table} ADD COLUMN updated_at TEXT
|
||||
''')
|
||||
# Update existing rows
|
||||
cursor.execute(f'''
|
||||
UPDATE {table} SET updated_at = CURRENT_TIMESTAMP WHERE updated_at IS NULL
|
||||
''')
|
||||
print(f"✓ Added updated_at column to {table} table")
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"✗ Error adding timestamp columns: {e}")
|
||||
conn.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def migrate_database(database_path: str, dry_run: bool = False, verbose: bool = False) -> bool:
|
||||
"""
|
||||
Perform the database migration.
|
||||
|
||||
Args:
|
||||
database_path: Path to the database file
|
||||
dry_run: If True, show steps without executing
|
||||
verbose: If True, show detailed information
|
||||
|
||||
Returns:
|
||||
True if migration succeeded, False otherwise
|
||||
"""
|
||||
if dry_run:
|
||||
print("[DRY RUN] Migration steps:")
|
||||
else:
|
||||
print("Starting database migration...")
|
||||
# Create backup
|
||||
backup_path = backup_database(database_path)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(database_path)
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
if dry_run:
|
||||
print(" 1. Would create students table")
|
||||
print(" 2. Would add assigned_to_student to tablets table")
|
||||
print(" 3. Would add assigned_to_student to non_loanable_devices table")
|
||||
print(" 4. Would add student_id to loans table")
|
||||
print(" 5. Would add timestamp columns to existing tables")
|
||||
print(" 6. Would create indexes")
|
||||
else:
|
||||
print("\nRunning migration steps:")
|
||||
|
||||
# Step 1: Create students table
|
||||
if not create_students_table(conn):
|
||||
return False
|
||||
|
||||
# Step 2: Add student relationship to tablets
|
||||
if not add_student_relationship_to_tablets(conn):
|
||||
return False
|
||||
|
||||
# Step 3: Add student relationship to non_loanable_devices
|
||||
if not add_student_relationship_to_non_loanable_devices(conn):
|
||||
return False
|
||||
|
||||
# Step 4: Add student relationship to loans
|
||||
if not add_student_relationship_to_loans(conn):
|
||||
return False
|
||||
|
||||
# Step 5: Add timestamp columns
|
||||
if not add_timestamp_columns(conn):
|
||||
return False
|
||||
|
||||
# Step 6: Add foreign keys (enforced at application level)
|
||||
if not add_foreign_keys(conn):
|
||||
return False
|
||||
|
||||
conn.close()
|
||||
|
||||
if dry_run:
|
||||
print("\n[DRY RUN] No changes were made to the database.")
|
||||
else:
|
||||
print("\n✓ Database migration completed successfully!")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Migration failed: {e}")
|
||||
if not dry_run and 'conn' in locals():
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
|
||||
def verify_migration(database_path: str) -> bool:
|
||||
"""Verify that the migration was successful."""
|
||||
try:
|
||||
conn = sqlite3.connect(database_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
print("\nVerifying migration:")
|
||||
|
||||
# Check students table exists
|
||||
if check_table_exists(conn, 'students'):
|
||||
print("✓ students table exists")
|
||||
cursor.execute("SELECT COUNT(*) FROM students")
|
||||
count = cursor.fetchone()[0]
|
||||
print(f" - Contains {count} students")
|
||||
else:
|
||||
print("✗ students table missing")
|
||||
return False
|
||||
|
||||
# Check columns in tablets
|
||||
if check_column_exists(conn, 'tablets', 'assigned_to_student'):
|
||||
print("✓ tablets.assigned_to_student column exists")
|
||||
else:
|
||||
print("✗ tablets.assigned_to_student column missing")
|
||||
return False
|
||||
|
||||
# Check columns in non_loanable_devices
|
||||
if check_column_exists(conn, 'non_loanable_devices', 'assigned_to_student'):
|
||||
print("✓ non_loanable_devices.assigned_to_student column exists")
|
||||
else:
|
||||
print("✗ non_loanable_devices.assigned_to_student column missing")
|
||||
return False
|
||||
|
||||
# Check columns in loans
|
||||
if check_column_exists(conn, 'loans', 'student_id'):
|
||||
print("✓ loans.student_id column exists")
|
||||
else:
|
||||
print("✗ loans.student_id column missing")
|
||||
return False
|
||||
|
||||
# Check indexes
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index'")
|
||||
indexes = [row[0] for row in cursor.fetchall()]
|
||||
required_indexes = [
|
||||
'idx_students_cial', 'idx_students_nif', 'idx_students_name',
|
||||
'idx_students_birth_date', 'idx_students_gender', 'idx_students_group',
|
||||
'idx_tablets_student', 'idx_non_loanable_student', 'idx_loans_student'
|
||||
]
|
||||
for idx in required_indexes:
|
||||
if idx in indexes:
|
||||
print(f"✓ Index {idx} exists")
|
||||
else:
|
||||
print(f"✗ Index {idx} missing")
|
||||
return False
|
||||
|
||||
conn.close()
|
||||
print("\n✓ All migration verifications passed!")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Verification failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
args = parse_arguments()
|
||||
|
||||
print(f"Database: {args.database}")
|
||||
print(f"Dry run: {args.dry_run}")
|
||||
print()
|
||||
|
||||
# Perform migration
|
||||
success = migrate_database(
|
||||
database_path=args.database,
|
||||
dry_run=args.dry_run,
|
||||
verbose=args.verbose
|
||||
)
|
||||
|
||||
if success and not args.dry_run:
|
||||
# Verify migration
|
||||
verify_migration(args.database)
|
||||
|
||||
# Exit with appropriate code
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
60
setup.py
Normal file
60
setup.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Setup script for Tablet Management System
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
|
||||
def install_dependencies():
|
||||
"""Install required dependencies"""
|
||||
try:
|
||||
# Try to install Flask using pip
|
||||
subprocess.check_call([sys.executable, '-m', 'ensurepip', '--upgrade'])
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', '--upgrade', 'pip'])
|
||||
subprocess.check_call([sys.executable, '-m', 'pip', 'install', 'Flask==2.3.3'])
|
||||
print("✓ Dependencies installed successfully")
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"✗ Failed to install dependencies: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"✗ Error during installation: {e}")
|
||||
return False
|
||||
|
||||
def check_dependencies():
|
||||
"""Check if required dependencies are available"""
|
||||
try:
|
||||
import flask
|
||||
import sqlite3
|
||||
print("✓ All dependencies are available")
|
||||
return True
|
||||
except ImportError as e:
|
||||
print(f"✗ Missing dependency: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
print("Setting up Tablet Management System...")
|
||||
|
||||
# Check if dependencies are already available
|
||||
if not check_dependencies():
|
||||
print("Installing required dependencies...")
|
||||
if not install_dependencies():
|
||||
print("\nError: Could not install dependencies.")
|
||||
print("Please install Flask manually:")
|
||||
print(" pip install Flask==2.3.3")
|
||||
print("or")
|
||||
print(" python3 -m pip install Flask==2.3.3")
|
||||
return False
|
||||
|
||||
print("\nSetup complete!")
|
||||
print("\nTo run the application:")
|
||||
print(" python3 app.py")
|
||||
print("\nThe application will be available at: http://localhost:5000")
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == '__main__':
|
||||
success = main()
|
||||
sys.exit(0 if success else 1)
|
||||
164
setup.sh
Executable file
164
setup.sh
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/bin/bash
|
||||
# setup.sh - Script de configuración automática para GestionTablets
|
||||
# Este script instala las dependencias necesarias y configura el entorno
|
||||
|
||||
set -e # Salir en caso de error
|
||||
|
||||
# Colores para salida
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Función para imprimir mensajes
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[OK]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Verificar que estamos en el directorio correcto
|
||||
if [ ! -f "app.py" ]; then
|
||||
print_error "No se encuentra app.py. Ejecuta este script desde el directorio raíz de GestionTablets."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Verificar Python 3.13+
|
||||
print_status "Verificando versión de Python..."
|
||||
PYTHON_VERSION=$(python3 --version 2>&1 | cut -d' ' -f2 | cut -d'.' -f1-2)
|
||||
if [ "$PYTHON_VERSION" != "3.13" ]; then
|
||||
print_error "Se requiere Python 3.13. Encontrado: $PYTHON_VERSION"
|
||||
print_status "Instala Python 3.13 con: sudo apt install python3.13"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Python 3.13+ detectado"
|
||||
|
||||
# 2. Instalar uv si no está presente
|
||||
print_status "Verificando uv..."
|
||||
if ! command -v uv &> /dev/null; then
|
||||
print_status "Instalando uv (Astral)..."
|
||||
if ! curl -LsSf https://astral.sh/uv/install.sh | sh; then
|
||||
print_error "No se pudo instalar uv. Verifica tu conexión a internet y que curl está instalado."
|
||||
exit 1
|
||||
fi
|
||||
# Añadir uv al PATH para esta sesión
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
print_success "uv instalado correctamente"
|
||||
else
|
||||
print_success "uv ya está instalado"
|
||||
fi
|
||||
|
||||
# Verificar versión de uv
|
||||
UV_VERSION=$(uv --version 2>&1)
|
||||
print_status "Versión de uv: $UV_VERSION"
|
||||
|
||||
# 3. Crear entorno virtual
|
||||
print_status "Creando entorno virtual..."
|
||||
if [ -d ".venv" ]; then
|
||||
print_warning "El entorno virtual .venv ya existe. Usando el existente."
|
||||
else
|
||||
if ! uv venv .venv; then
|
||||
print_error "No se pudo crear el entorno virtual"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Entorno virtual creado"
|
||||
fi
|
||||
|
||||
# Activar el entorno virtual
|
||||
print_status "Activando entorno virtual..."
|
||||
source .venv/bin/activate
|
||||
|
||||
# 4. Instalar dependencias
|
||||
print_status "Instalando dependencias desde requirements.txt..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
if ! uv pip install -r requirements.txt; then
|
||||
print_error "No se pudieron instalar las dependencias"
|
||||
exit 1
|
||||
fi
|
||||
print_success "Dependencias instaladas"
|
||||
else
|
||||
print_error "No se encuentra requirements.txt"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 5. Inicializar base de datos
|
||||
print_status "Inicializando base de datos..."
|
||||
if [ ! -f "tablets.db" ]; then
|
||||
if python3 -c "from app import init_db; init_db(); print('Base de datos inicializada')" 2>&1; then
|
||||
print_success "Base de datos inicializada"
|
||||
else
|
||||
print_warning "No se pudo inicializar la base de datos automáticamente"
|
||||
print_status "Puedes inicializarla manualmente con: python3 -c \"from app import init_db; init_db()\""
|
||||
fi
|
||||
else
|
||||
print_warning "tablets.db ya existe. No se ha sobrescrito."
|
||||
fi
|
||||
|
||||
# 6. Configurar variables de entorno
|
||||
print_status "Configurando variables de entorno..."
|
||||
if [ ! -f ".env" ]; then
|
||||
# Generar clave secreta
|
||||
SECRET_KEY=$(openssl rand -hex 32 2>/dev/null || python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || echo "cambiar_esta_clave_manualmente")
|
||||
|
||||
cat > .env << EOF
|
||||
# Configuración de Flask
|
||||
FLASK_APP=app.py
|
||||
FLASK_ENV=development
|
||||
|
||||
# Clave secreta (¡CÁMBIALA EN PRODUCCIÓN!)
|
||||
SECRET_KEY=$SECRET_KEY
|
||||
|
||||
# Base de datos
|
||||
DATABASE=tablets.db
|
||||
EOF
|
||||
print_success "Archivo .env creado con configuración básica"
|
||||
print_warning "⚠️ REVISA el archivo .env y cambia la SECRET_KEY antes de usar en producción"
|
||||
else
|
||||
print_warning ".env ya existe. No se ha sobrescrito."
|
||||
fi
|
||||
|
||||
# 7. Crear .env.example si no existe
|
||||
if [ ! -f ".env.example" ]; then
|
||||
cp .env .env.example 2>/dev/null || true
|
||||
print_status "Creado .env.example"
|
||||
fi
|
||||
|
||||
# 8. Verificar instalación
|
||||
print_status "Verificando instalación..."
|
||||
print_status "Comprobando importación de Flask..."
|
||||
python3 -c "import flask; print(f'Flask versión: {flask.__version__}')" 2>&1
|
||||
|
||||
print_status "Comprobando importación de la aplicación..."
|
||||
python3 -c "import app; print('Aplicación importada correctamente')" 2>&1
|
||||
|
||||
# 9. Instrucciones finales
|
||||
echo ""
|
||||
print_success "=========================================="
|
||||
print_success "¡Configuración completada con éxito!"
|
||||
print_success "=========================================="
|
||||
echo ""
|
||||
print_status "Para ejecutar la aplicación en desarrollo:"
|
||||
echo " 1. Activa el entorno virtual: source .venv/bin/activate"
|
||||
echo " 2. Ejecuta la aplicación: python3 app.py"
|
||||
echo ""
|
||||
print_status "La aplicación estará disponible en: http://localhost:5000"
|
||||
echo ""
|
||||
print_status "Para producción, usa Waitress:"
|
||||
echo " waitress-serve --port=8080 app:app"
|
||||
echo ""
|
||||
print_warning "⚠️ NO OLVIDES:"
|
||||
print_warning " - Revisar y cambiar SECRET_KEY en .env"
|
||||
print_warning " - Configurar FLASK_ENV=production para producción"
|
||||
print_warning " - Hacer backup de tablets.db regularmente"
|
||||
echo ""
|
||||
571
simple_app.py
Normal file
571
simple_app.py
Normal file
|
|
@ -0,0 +1,571 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple Tablet Lending and Return Management System
|
||||
Using only built-in Python modules (no Flask required)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import json
|
||||
|
||||
# Database setup
|
||||
DATABASE = 'tablets.db'
|
||||
|
||||
def get_db():
|
||||
"""Get database connection"""
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
"""Initialize database with required tables"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create tablets table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# Create users table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
identification TEXT UNIQUE
|
||||
)
|
||||
''')
|
||||
|
||||
# Create loans table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create non_loanable_devices table
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
device_type TEXT NOT NULL,
|
||||
location TEXT,
|
||||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
class SimpleHTTPRequestHandler(BaseHTTPRequestHandler):
|
||||
def _set_headers(self, content_type="text/html"):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', content_type)
|
||||
self.end_headers()
|
||||
|
||||
def _send_html(self, content):
|
||||
self._set_headers()
|
||||
self.wfile.write(content.encode('utf-8'))
|
||||
|
||||
def _send_json(self, data):
|
||||
self._set_headers(content_type="application/json")
|
||||
self.wfile.write(json.dumps(data).encode('utf-8'))
|
||||
|
||||
def _parse_form_data(self):
|
||||
content_length = int(self.headers['Content-Length'])
|
||||
post_data = self.rfile.read(content_length)
|
||||
return parse_qs(post_data.decode('utf-8'))
|
||||
|
||||
def do_GET(self):
|
||||
parsed_path = urlparse(self.path)
|
||||
|
||||
if parsed_path.path == '/':
|
||||
self._handle_index()
|
||||
elif parsed_path.path == '/add_tablet':
|
||||
self._handle_add_tablet_form()
|
||||
elif parsed_path.path == '/add_user':
|
||||
self._handle_add_user_form()
|
||||
elif parsed_path.path == '/loan_tablet':
|
||||
self._handle_loan_tablet_form()
|
||||
elif parsed_path.path == '/history':
|
||||
self._handle_history()
|
||||
elif parsed_path.path.startswith('/return_tablet/'):
|
||||
self._handle_return_tablet(parsed_path.path)
|
||||
else:
|
||||
self._send_html("<h1>404 Not Found</h1>")
|
||||
|
||||
def do_POST(self):
|
||||
parsed_path = urlparse(self.path)
|
||||
|
||||
if parsed_path.path == '/add_tablet':
|
||||
self._handle_add_tablet()
|
||||
elif parsed_path.path == '/add_user':
|
||||
self._handle_add_user()
|
||||
elif parsed_path.path == '/loan_tablet':
|
||||
self._handle_loan_tablet()
|
||||
else:
|
||||
self._send_html("<h1>404 Not Found</h1>")
|
||||
|
||||
def _handle_index(self):
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get available tablets
|
||||
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
|
||||
available_tablets = cursor.fetchall()
|
||||
|
||||
# Get active loans
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
WHERE l.status = 'active'
|
||||
''')
|
||||
active_loans = cursor.fetchall()
|
||||
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tablet Management System</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; }}
|
||||
h1 {{ color: #333; }}
|
||||
.nav {{ margin-bottom: 20px; }}
|
||||
.nav a {{ margin-right: 15px; text-decoration: none; color: #4CAF50; }}
|
||||
table {{ width: 100%; border-collapse: collapse; margin-bottom: 20px; }}
|
||||
th, td {{ padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }}
|
||||
th {{ background-color: #4CAF50; color: white; }}
|
||||
.btn {{ padding: 5px 10px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 3px; }}
|
||||
.btn-danger {{ background-color: #f44336; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Tablet Management System</h1>
|
||||
<div class="nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/add_tablet">Add Tablet</a>
|
||||
<a href="/add_user">Add User</a>
|
||||
<a href="/loan_tablet">Loan Tablet</a>
|
||||
<a href="/history">Loan History</a>
|
||||
</div>
|
||||
|
||||
<h2>Available Tablets</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Brand</th>
|
||||
<th>Model</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
if available_tablets:
|
||||
for tablet in available_tablets:
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{tablet['brand']}</td>
|
||||
<td>{tablet['model']}</td>
|
||||
<td>{tablet['serial_number']}</td>
|
||||
<td>{tablet['notes'] or '-'}</td>
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
html += "<tr><td colspan='4'>No available tablets.</td></tr>"
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>Active Loans</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tablet</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Borrower</th>
|
||||
<th>Loan Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
if active_loans:
|
||||
for loan in active_loans:
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{loan['brand']} {loan['model']}</td>
|
||||
<td>{loan['serial_number']}</td>
|
||||
<td>{loan['name']}</td>
|
||||
<td>{loan['loan_date']}</td>
|
||||
<td><a href='/return_tablet/{loan['id']}' class='btn btn-danger'>Return</a></td>
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
html += "<tr><td colspan='5'>No active loans.</td></tr>"
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
self._send_html(html)
|
||||
|
||||
def _handle_add_tablet_form(self):
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Add Tablet</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; }
|
||||
input, textarea { width: 100%; padding: 8px; }
|
||||
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Add New Tablet</h1>
|
||||
<form method="POST" action="/add_tablet">
|
||||
<div class="form-group">
|
||||
<label for="brand">Brand:</label>
|
||||
<input type="text" id="brand" name="brand" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="model">Model:</label>
|
||||
<input type="text" id="model" name="model" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="serial_number">Serial Number:</label>
|
||||
<input type="text" id="serial_number" name="serial_number" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes:</label>
|
||||
<textarea id="notes" name="notes"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn">Add Tablet</button>
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self._send_html(html)
|
||||
|
||||
def _handle_add_tablet(self):
|
||||
form_data = self._parse_form_data()
|
||||
brand = form_data['brand'][0]
|
||||
model = form_data['model'][0]
|
||||
serial_number = form_data['serial_number'][0]
|
||||
notes = form_data.get('notes', [''])[0]
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status, notes)
|
||||
VALUES (?, ?, ?, 'available', ?)
|
||||
''', (brand, model, serial_number, notes))
|
||||
conn.commit()
|
||||
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
except sqlite3.IntegrityError:
|
||||
self._send_html("<h1>Error: Serial number already exists!</h1><p><a href='/add_tablet'>Try again</a></p>")
|
||||
|
||||
def _handle_add_user_form(self):
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Add User</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; }
|
||||
input { width: 100%; padding: 8px; }
|
||||
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Add New User</h1>
|
||||
<form method="POST" action="/add_user">
|
||||
<div class="form-group">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone:</label>
|
||||
<input type="text" id="phone" name="phone">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="identification">Identification:</label>
|
||||
<input type="text" id="identification" name="identification" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn">Add User</button>
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self._send_html(html)
|
||||
|
||||
def _handle_add_user(self):
|
||||
form_data = self._parse_form_data()
|
||||
name = form_data['name'][0]
|
||||
email = form_data.get('email', [''])[0]
|
||||
phone = form_data.get('phone', [''])[0]
|
||||
identification = form_data['identification'][0]
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO users (name, email, phone, identification)
|
||||
VALUES (?, ?, ?, ?)
|
||||
''', (name, email, phone, identification))
|
||||
conn.commit()
|
||||
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
except sqlite3.IntegrityError:
|
||||
self._send_html("<h1>Error: Identification already exists!</h1><p><a href='/add_user'>Try again</a></p>")
|
||||
|
||||
def _handle_loan_tablet_form(self):
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
|
||||
available_tablets = cursor.fetchall()
|
||||
cursor.execute("SELECT * FROM users")
|
||||
users = cursor.fetchall()
|
||||
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Loan Tablet</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
.form-group { margin-bottom: 15px; }
|
||||
label { display: block; margin-bottom: 5px; }
|
||||
select { width: 100%; padding: 8px; }
|
||||
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loan Tablet</h1>
|
||||
<form method="POST" action="/loan_tablet">
|
||||
<div class="form-group">
|
||||
<label for="tablet_id">Tablet:</label>
|
||||
<select id="tablet_id" name="tablet_id" required>
|
||||
<option value="">Select a tablet</option>
|
||||
"""
|
||||
|
||||
for tablet in available_tablets:
|
||||
html += f"<option value='{tablet['id']}'>{tablet['brand']} {tablet['model']} ({tablet['serial_number']})</option>"
|
||||
|
||||
html += """
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="user_id">User:</label>
|
||||
<select id="user_id" name="user_id" required>
|
||||
<option value="">Select a user</option>
|
||||
"""
|
||||
|
||||
for user in users:
|
||||
html += f"<option value='{user['id']}'>{user['name']} ({user['identification']})</option>"
|
||||
|
||||
html += """
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn">Loan Tablet</button>
|
||||
</div>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
self._send_html(html)
|
||||
|
||||
def _handle_loan_tablet(self):
|
||||
form_data = self._parse_form_data()
|
||||
tablet_id = form_data['tablet_id'][0]
|
||||
user_id = form_data['user_id'][0]
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Update tablet status
|
||||
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
|
||||
|
||||
# Create loan record
|
||||
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO loans (tablet_id, user_id, loan_date, status)
|
||||
VALUES (?, ?, ?, 'active')
|
||||
''', (tablet_id, user_id, loan_date))
|
||||
|
||||
conn.commit()
|
||||
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
|
||||
def _handle_return_tablet(self, path):
|
||||
loan_id = path.split('/')[-1]
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get loan information
|
||||
cursor.execute("SELECT tablet_id FROM loans WHERE id = ?", (loan_id,))
|
||||
loan = cursor.fetchone()
|
||||
|
||||
if loan:
|
||||
tablet_id = loan['tablet_id']
|
||||
|
||||
# Update loan status and return date
|
||||
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
|
||||
''', (return_date, loan_id))
|
||||
|
||||
# Update tablet status
|
||||
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
|
||||
|
||||
conn.commit()
|
||||
|
||||
self.send_response(303)
|
||||
self.send_header('Location', '/')
|
||||
self.end_headers()
|
||||
|
||||
def _handle_history(self):
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
|
||||
l.loan_date, l.return_date, l.status
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
ORDER BY l.loan_date DESC
|
||||
''')
|
||||
loans = cursor.fetchall()
|
||||
|
||||
html = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Loan History</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; }
|
||||
.nav { margin-bottom: 20px; }
|
||||
.nav a { margin-right: 15px; text-decoration: none; color: #4CAF50; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { background-color: #4CAF50; color: white; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Loan History</h1>
|
||||
<div class="nav">
|
||||
<a href="/">Home</a>
|
||||
<a href="/add_tablet">Add Tablet</a>
|
||||
<a href="/add_user">Add User</a>
|
||||
<a href="/loan_tablet">Loan Tablet</a>
|
||||
<a href="/history">Loan History</a>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tablet</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Borrower</th>
|
||||
<th>Loan Date</th>
|
||||
<th>Return Date</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
"""
|
||||
|
||||
if loans:
|
||||
for loan in loans:
|
||||
html += f"""
|
||||
<tr>
|
||||
<td>{loan['brand']} {loan['model']}</td>
|
||||
<td>{loan['serial_number']}</td>
|
||||
<td>{loan['name']}</td>
|
||||
<td>{loan['loan_date']}</td>
|
||||
<td>{loan['return_date'] or '-'}</td>
|
||||
<td>{loan['status']}</td>
|
||||
</tr>
|
||||
"""
|
||||
else:
|
||||
html += "<tr><td colspan='6'>No loan history available.</td></tr>"
|
||||
|
||||
html += """
|
||||
</tbody>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
self._send_html(html)
|
||||
|
||||
def run_server():
|
||||
"""Run the HTTP server"""
|
||||
server_address = ('', 8000)
|
||||
httpd = HTTPServer(server_address, SimpleHTTPRequestHandler)
|
||||
print(f"Server running on http://localhost:8000")
|
||||
print("Press Ctrl+C to stop the server")
|
||||
httpd.serve_forever()
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Initialize database
|
||||
init_db()
|
||||
|
||||
# Run the server
|
||||
run_server()
|
||||
BIN
static/Schamann.png
Normal file
BIN
static/Schamann.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
tablets.db.backup_20260623_190526
Normal file
BIN
tablets.db.backup_20260623_190526
Normal file
Binary file not shown.
BIN
tablets.db.backup_20260623_190602
Normal file
BIN
tablets.db.backup_20260623_190602
Normal file
Binary file not shown.
106
templates/add_non_loanable_device.html
Normal file
106
templates/add_non_loanable_device.html
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add Non-Loanable Device') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('Add a device that will be tracked in inventory but cannot be loaned to users (e.g., projectors, monitors, etc.)') }}</p>
|
||||
</div>
|
||||
|
||||
{# Device Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ _('Device Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/add_non_loanable_device" class="space-y-6">
|
||||
<div>
|
||||
<label for="device_type" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Device Type') }} *</label>
|
||||
<select id="device_type" name="device_type" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<option value="">{{ _('Select device type...') }}</option>
|
||||
<option value="projector">{{ _('Projector') }}</option>
|
||||
<option value="monitor">{{ _('Monitor') }}</option>
|
||||
<option value="laptop">{{ _('Laptop (non-loanable)') }}</option>
|
||||
<option value="printer">{{ _('Printer') }}</option>
|
||||
<option value="camera">{{ _('Camera') }}</option>
|
||||
<option value="audio">{{ _('Audio Equipment') }}</option>
|
||||
<option value="network">{{ _('Network Equipment') }}</option>
|
||||
<option value="other">{{ _('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
|
||||
<input type="text" id="brand" name="brand" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., Epson, Samsung, Dell') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
|
||||
<input type="text" id="model" name="model" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., PowerLite 2250U, UltraSharp U2723QE') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
|
||||
<input type="text" id="serial_number" name="serial_number" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Unique serial number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Each device must have a unique serial number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Location') }}</label>
|
||||
<input type="text" id="location" name="location"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., Room 101, Lab A, Storage') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Optional location where the device is stored') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Date') }}</label>
|
||||
<input type="date" id="purchase_date" name="purchase_date"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purchase_cost" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Cost') }}</label>
|
||||
<input type="number" id="purchase_cost" name="purchase_cost" step="0.01"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('0.00') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
|
||||
<textarea id="notes" name="notes" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Optional notes about the device (condition, accessories, etc.)') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="/non_loanable_devices"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ _('Add Device') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
143
templates/add_student.html
Normal file
143
templates/add_student.html
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Add Student') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Add Student') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Register a new student in the system') }}</p>
|
||||
</div>
|
||||
|
||||
{# Student Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ url_for('add_student') }}" class="space-y-6">
|
||||
{# Identification Section #}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Identification') }}</h3>
|
||||
|
||||
<div>
|
||||
<label for="cial_code" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CIAL Code') }} *</label>
|
||||
<input type="text" id="cial_code" name="cial_code" required autofocus
|
||||
value="{{ request.form.cial_code if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Unique internal identification code') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Unique internal identification code') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nif_nie_passport" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('NIF/NIE/Passport') }}</label>
|
||||
<input type="text" id="nif_nie_passport" name="nif_nie_passport"
|
||||
value="{{ request.form.nif_nie_passport if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('National ID, Foreign ID, or Passport number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('National ID, Foreign ID, or Passport number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="registration_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Registration Number') }}</label>
|
||||
<input type="number" id="registration_number" name="registration_number"
|
||||
value="{{ request.form.registration_number if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional registration number') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="file_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('File Number') }}</label>
|
||||
<input type="text" id="file_number" name="file_number"
|
||||
value="{{ request.form.file_number if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional file number') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Order Number') }}</label>
|
||||
<input type="number" id="order_number" name="order_number"
|
||||
value="{{ request.form.order_number if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional order number') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Personal Information Section #}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Personal Information') }}</h3>
|
||||
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('First Name') }} *</label>
|
||||
<input type="text" id="first_name" name="first_name" required
|
||||
value="{{ request.form.first_name if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Student first name') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Last Name') }} *</label>
|
||||
<input type="text" id="last_name" name="last_name" required
|
||||
value="{{ request.form.last_name if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Student last name') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Full Name') }} *</label>
|
||||
<input type="text" id="full_name" name="full_name" required
|
||||
value="{{ request.form.full_name if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Complete name') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Full name for display purposes') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="birth_date" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Birth Date') }}</label>
|
||||
<input type="date" id="birth_date" name="birth_date"
|
||||
value="{{ request.form.birth_date if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
|
||||
<select id="gender" name="gender"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<option value="">{{ gettext('Select gender') }}</option>
|
||||
<option value="M" {% if request.form.gender == 'M' %}selected{% endif %}>M</option>
|
||||
<option value="F" {% if request.form.gender == 'F' %}selected{% endif %}>F</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="study_group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
|
||||
<input type="text" id="study_group" name="study_group"
|
||||
value="{{ request.form.study_group if request.method == 'POST' else '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('e.g., 2º ESO A, 3º ESO B') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Class or study group') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="{{ url_for('list_students') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ gettext('Add Student') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
67
templates/add_tablet.html
Normal file
67
templates/add_tablet.html
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add New Tablet') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('Register a new tablet in the system') }}</p>
|
||||
</div>
|
||||
|
||||
{# Tablet Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ _('Tablet Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/add_tablet" class="space-y-6">
|
||||
<div>
|
||||
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
|
||||
<input type="text" id="brand" name="brand" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., Apple, Samsung, Lenovo') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
|
||||
<input type="text" id="model" name="model" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., iPad 10th Gen, Galaxy Tab A8') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
|
||||
<input type="text" id="serial_number" name="serial_number" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Unique serial number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Each tablet must have a unique serial number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
|
||||
<textarea id="notes" name="notes" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Optional notes about the tablet (condition, accessories, etc.)') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ _('Add Tablet') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
69
templates/add_user.html
Normal file
69
templates/add_user.html
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Add New User') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('Register a new staff user in the system') }}</p>
|
||||
</div>
|
||||
|
||||
{# User Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ _('User Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/add_user" class="space-y-6">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Name') }} *</label>
|
||||
<input type="text" id="name" name="name" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Full name') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Email') }}</label>
|
||||
<input type="email" id="email" name="email"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('user@example.com') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Optional email address') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Phone') }}</label>
|
||||
<input type="text" id="phone" name="phone"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Phone number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Optional phone number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="identification" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Identification') }} *</label>
|
||||
<input type="text" id="identification" name="identification" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Employee ID, badge number, etc.') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Unique identification for the staff member') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
{{ _('Add User') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
651
templates/base.html
Normal file
651
templates/base.html
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="{{ language or 'en' }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ _('Tablet Management System') }}</title>
|
||||
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-1y8c6Y8XkZ7Q1Y+v5vYpX+g5Z4XhY+6eZ6X9p6Z1cM5Xf+Z7x2J6FQ0w9Xc3YfXMgZ6e5Z7+6f5Q==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
<!-- Custom styles -->
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
}
|
||||
|
||||
/* HTMX loading spinner */
|
||||
.htmx-indicator {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid rgba(0,0,0,.3);
|
||||
border-radius: 50%;
|
||||
border-top-color: #FC6A03;
|
||||
animation: spin 1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Hide HTMX indicator by default */
|
||||
.htmx-indicator {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
.htmx-request .htmx-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
body.dark-mode {
|
||||
background-color: #1a1a2e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .bg-gray-50 {
|
||||
background-color: #1a1a2e;
|
||||
}
|
||||
body.dark-mode .bg-white {
|
||||
background-color: #16213e;
|
||||
}
|
||||
body.dark-mode .text-gray-900,
|
||||
body.dark-mode .text-gray-800,
|
||||
body.dark-mode .text-gray-700 {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .text-gray-600 {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
body.dark-mode .text-gray-500 {
|
||||
color: #9ca3af;
|
||||
}
|
||||
body.dark-mode th.text-gray-500 {
|
||||
color: #d1d5db;
|
||||
}
|
||||
body.dark-mode .border-gray-200 {
|
||||
border-color: #374151;
|
||||
}
|
||||
body.dark-mode .border-gray-300 {
|
||||
border-color: #4b5563;
|
||||
}
|
||||
body.dark-mode input,
|
||||
body.dark-mode select,
|
||||
body.dark-mode textarea {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode input:focus,
|
||||
body.dark-mode select:focus,
|
||||
body.dark-mode textarea:focus {
|
||||
border-color: #4d7aff;
|
||||
ring-color: #4d7aff;
|
||||
}
|
||||
body.dark-mode .bg-green-100 {
|
||||
background-color: #064e3b;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
body.dark-mode .bg-red-100 {
|
||||
background-color: #7f1d1d;
|
||||
color: #fecaca;
|
||||
}
|
||||
body.dark-mode .bg-yellow-100 {
|
||||
background-color: #78350f;
|
||||
color: #fde68a;
|
||||
}
|
||||
body.dark-mode .bg-blue-100 {
|
||||
background-color: #1e3a8a;
|
||||
color: #bfdbfe;
|
||||
}
|
||||
body.dark-mode .bg-cobalt-600 {
|
||||
background-color: #0b1f72;
|
||||
}
|
||||
body.dark-mode .bg-cobalt-700 {
|
||||
background-color: #081552;
|
||||
}
|
||||
body.dark-mode table {
|
||||
background-color: #16213e;
|
||||
}
|
||||
body.dark-mode th {
|
||||
background-color: #1f2937;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode td {
|
||||
border-color: #374151;
|
||||
}
|
||||
body.dark-mode tr:hover {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
body.dark-mode .shadow-md,
|
||||
body.dark-mode .shadow-lg {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
body.dark-mode code,
|
||||
body.dark-mode .bg-gray-100 {
|
||||
background-color: #374151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
/* Dark mode for responsive tables */
|
||||
body.dark-mode .table-responsive {
|
||||
background-color: #16213e;
|
||||
}
|
||||
body.dark-mode .table-responsive thead {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
body.dark-mode .table-responsive th {
|
||||
background-color: #1f2937;
|
||||
color: #d1d5db;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
body.dark-mode .table-responsive td {
|
||||
color: #e0e0e0;
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
body.dark-mode .table-responsive tbody tr:hover {
|
||||
background-color: #1f2937;
|
||||
}
|
||||
|
||||
/* Dark mode for mobile cards */
|
||||
body.dark-mode .mobile-card {
|
||||
background-color: #16213e;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
body.dark-mode .mobile-card .card-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
body.dark-mode .mobile-card .card-value {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .mobile-card .card-row {
|
||||
border-bottom-color: #374151;
|
||||
}
|
||||
|
||||
/* Dark mode for form elements */
|
||||
body.dark-mode input[type="text"],
|
||||
body.dark-mode input[type="email"],
|
||||
body.dark-mode input[type="number"],
|
||||
body.dark-mode input[type="date"],
|
||||
body.dark-mode input[type="file"],
|
||||
body.dark-mode select,
|
||||
body.dark-mode textarea {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode input::placeholder,
|
||||
body.dark-mode textarea::placeholder {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Dark mode for buttons */
|
||||
body.dark-mode .bg-gray-200 {
|
||||
background-color: #374151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .bg-gray-200:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
body.dark-mode .hover\:bg-gray-300:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
/* Dark mode for footer */
|
||||
body.dark-mode footer {
|
||||
border-color: #374151;
|
||||
}
|
||||
body.dark-mode footer p {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
/* Dark mode for breadcrumb */
|
||||
body.dark-mode .text-cobalt-600 {
|
||||
color: #80a0ff;
|
||||
}
|
||||
body.dark-mode .hover\:text-cobalt-800:hover {
|
||||
color: #b3c6ff;
|
||||
}
|
||||
|
||||
/* Dark mode for prose (markdown preview) */
|
||||
body.dark-mode .prose {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .prose h1,
|
||||
body.dark-mode .prose h2,
|
||||
body.dark-mode .prose h3 {
|
||||
color: #ffffff;
|
||||
}
|
||||
body.dark-mode .prose a {
|
||||
color: #80a0ff;
|
||||
}
|
||||
body.dark-mode .prose code {
|
||||
background-color: #374151;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
body.dark-mode .prose pre {
|
||||
background-color: #1f2937;
|
||||
border-color: #374151;
|
||||
}
|
||||
body.dark-mode .prose blockquote {
|
||||
border-color: #374151;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
/* Responsive Table Styles */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.table-responsive thead {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.table-responsive th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.table-responsive td {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-responsive tbody tr:hover {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
/* Mobile Card Layout */
|
||||
@media (max-width: 640px) {
|
||||
.table-card-mobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mobile-card {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mobile-card .card-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.mobile-card .card-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mobile-card .card-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mobile-card .card-value {
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Tailwind config for custom corporate colors -->
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#ecfdf5',
|
||||
100: '#d1fae5',
|
||||
200: '#a7f3d0',
|
||||
300: '#6ee7b7',
|
||||
400: '#34d399',
|
||||
500: '#10b981',
|
||||
600: '#059669',
|
||||
700: '#047857',
|
||||
800: '#065f46',
|
||||
900: '#064e3b',
|
||||
},
|
||||
cobalt: {
|
||||
50: '#f0f5ff',
|
||||
100: '#e0eaff',
|
||||
200: '#b3c6ff',
|
||||
300: '#80a0ff',
|
||||
400: '#4d7aff',
|
||||
500: '#1338BE',
|
||||
600: '#0f2a9c',
|
||||
700: '#0b1f72',
|
||||
800: '#081552',
|
||||
900: '#060f38',
|
||||
},
|
||||
tiger: {
|
||||
50: '#fff8f0',
|
||||
100: '#ffe8d6',
|
||||
200: '#ffccab',
|
||||
300: '#ffae80',
|
||||
400: '#ff8c55',
|
||||
500: '#FC6A03',
|
||||
600: '#e05a00',
|
||||
700: '#b84500',
|
||||
800: '#8c3300',
|
||||
900: '#662500',
|
||||
},
|
||||
emerald: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#028A0F',
|
||||
600: '#006b0c',
|
||||
700: '#005209',
|
||||
800: '#003d07',
|
||||
900: '#002904',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
<!-- Skip to main content -->
|
||||
<a href="#main-content" class="sr-only focus:not-sr-only">Skip to main content</a>
|
||||
|
||||
<!-- Container -->
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-cobalt-600 text-white shadow-lg">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo / Title -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img src="/static/Schamann.png" alt="Corporate Logo" class="h-10 w-auto object-contain" style="max-height: 40px; height: 40px !important;">
|
||||
<h1 class="text-xl font-bold">{{ _('Tablet Management System') }}</h1>
|
||||
</div>
|
||||
|
||||
<!-- Desktop Controls: Language + Theme Toggle -->
|
||||
<div class="hidden sm:flex items-center gap-2">
|
||||
<span class="text-sm opacity-80">{{ _('Language') }}:</span>
|
||||
{% for lang_code, lang_name in config['LANGUAGES'].items() %}
|
||||
<a
|
||||
href="{{ url_for('set_language', lang=lang_code) }}"
|
||||
class="px-2 py-1 text-sm rounded hover:bg-white/20 transition-colors
|
||||
{% if language == lang_code %}bg-white/30 font-medium{% endif %}"
|
||||
>
|
||||
{{ lang_name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Theme Toggle -->
|
||||
<a href="#" id="theme-toggle" class="text-white hover:text-cobalt-200 ml-2" title="{{ _('Toggle dark mode') }}">
|
||||
<span class="icon-moon">🌙</span>
|
||||
<span class="icon-sun" style="display:none;">☀️</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Menu Button -->
|
||||
<button id="mobile-menu-btn" class="sm:hidden text-white hover:text-cobalt-200 focus:outline-none"
|
||||
aria-label="Toggle navigation menu" aria-expanded="false">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path id="menu-icon-hamburger" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
<path id="menu-icon-close" class="hidden" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu (hidden by default) -->
|
||||
<div id="mobile-menu" class="sm:hidden hidden">
|
||||
<div class="px-4 py-3 space-y-2 bg-cobalt-700">
|
||||
<!-- Mobile Language Switcher -->
|
||||
<div class="flex items-center gap-2 pb-2 border-b border-cobalt-600 mb-2">
|
||||
<span class="text-sm opacity-80">{{ _('Language') }}:</span>
|
||||
{% for lang_code, lang_name in config['LANGUAGES'].items() %}
|
||||
<a
|
||||
href="{{ url_for('set_language', lang=lang_code) }}"
|
||||
class="px-2 py-1 text-sm rounded hover:bg-white/20 transition-colors
|
||||
{% if language == lang_code %}bg-white/30 font-medium{% endif %}"
|
||||
>
|
||||
{{ lang_name }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Mobile Theme Toggle -->
|
||||
<a href="#" id="theme-toggle-mobile" class="text-white hover:text-cobalt-200 ml-2" title="{{ _('Toggle dark mode') }}">
|
||||
<span class="icon-moon">🌙</span>
|
||||
<span class="icon-sun" style="display:none;">☀️</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Links -->
|
||||
<a href="/" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Home') }}
|
||||
</a>
|
||||
<a href="/add_tablet" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Add Tablet') }}
|
||||
</a>
|
||||
<a href="/add_user" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Add User') }}
|
||||
</a>
|
||||
<a href="/loan_tablet" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Loan Tablet') }}
|
||||
</a>
|
||||
<a href="/history" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('History') }}
|
||||
</a>
|
||||
<a href="/user_loans" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('User Loans') }}
|
||||
</a>
|
||||
<a href="/students" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Students') }}
|
||||
</a>
|
||||
<a href="/non_loanable_devices" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Non-Loanable Devices') }}
|
||||
</a>
|
||||
<a href="/project_management" class="block px-3 py-2 text-white rounded hover:bg-cobalt-600 transition-colors">
|
||||
{{ _('Project Management') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main id="main-content" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-3">
|
||||
{% for category, message in messages %}
|
||||
<div class="p-4 rounded-lg
|
||||
{% if category == 'success' %}bg-green-100 text-green-800
|
||||
{% elif category == 'error' %}bg-red-100 text-red-800
|
||||
{% elif category == 'warning' %}bg-yellow-100 text-yellow-800
|
||||
{% else %}bg-blue-100 text-blue-800{% endif %}">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if category == 'success' %}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{% elif category == 'error' %}
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
<span>{{ _(message) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- Navigation (Desktop only) -->
|
||||
<nav class="hidden sm:block bg-cobalt-600 rounded-lg shadow-md mb-6" aria-label="Main navigation">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-wrap justify-center sm:justify-start gap-1 sm:gap-2 py-3">
|
||||
<a href="/"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Home') }}
|
||||
</a>
|
||||
<a href="/add_tablet"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Add Tablet') }}
|
||||
</a>
|
||||
<a href="/add_user"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Add User') }}
|
||||
</a>
|
||||
<a href="/loan_tablet"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Loan Tablet') }}
|
||||
</a>
|
||||
<a href="/history"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('History') }}
|
||||
</a>
|
||||
<a href="/user_loans"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('User Loans') }}
|
||||
</a>
|
||||
<a href="/students"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Students') }}
|
||||
</a>
|
||||
<a href="/non_loanable_devices"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Non-Loanable Devices') }}
|
||||
</a>
|
||||
<a href="/project_management"
|
||||
class="px-3 py-2 text-sm sm:text-base text-white rounded hover:bg-cobalt-700 transition-colors whitespace-nowrap">
|
||||
{{ _('Project Management') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Page Content -->
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-12 py-6 border-t border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500">
|
||||
<p>{{ _('Tablet Management System - Internal Tool') }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Theme Toggle Script -->
|
||||
<script>
|
||||
// Mobile menu toggle
|
||||
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
const menuIconHamburger = document.getElementById('menu-icon-hamburger');
|
||||
const menuIconClose = document.getElementById('menu-icon-close');
|
||||
|
||||
if (mobileMenuBtn && mobileMenu) {
|
||||
mobileMenuBtn.addEventListener('click', function() {
|
||||
const isOpen = mobileMenu.classList.toggle('hidden');
|
||||
mobileMenuBtn.setAttribute('aria-expanded', !isOpen);
|
||||
|
||||
// Toggle icons
|
||||
menuIconHamburger.classList.toggle('hidden');
|
||||
menuIconClose.classList.toggle('hidden');
|
||||
});
|
||||
|
||||
// Close menu when clicking a link
|
||||
mobileMenu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', function() {
|
||||
mobileMenu.classList.add('hidden');
|
||||
mobileMenuBtn.setAttribute('aria-expanded', false);
|
||||
menuIconHamburger.classList.remove('hidden');
|
||||
menuIconClose.classList.add('hidden');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check for saved theme preference or default to light mode
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
updateToggleIcon(true);
|
||||
} else if (savedTheme !== 'light') {
|
||||
// Invalid value - clear and default to light
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
|
||||
// Theme toggle for desktop
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
updateToggleIcon(isDark);
|
||||
});
|
||||
}
|
||||
|
||||
// Theme toggle for mobile
|
||||
const themeToggleMobile = document.getElementById('theme-toggle-mobile');
|
||||
if (themeToggleMobile) {
|
||||
themeToggleMobile.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const isDark = document.body.classList.toggle('dark-mode');
|
||||
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||
updateToggleIcon(isDark);
|
||||
});
|
||||
}
|
||||
|
||||
function updateToggleIcon(isDark) {
|
||||
// Update all theme toggle icons (desktop and mobile)
|
||||
document.querySelectorAll('.icon-moon').forEach(icon => {
|
||||
icon.style.display = isDark ? 'none' : 'inline';
|
||||
});
|
||||
document.querySelectorAll('.icon-sun').forEach(icon => {
|
||||
icon.style.display = isDark ? 'inline' : 'none';
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
206
templates/components/user_loans_results.html
Normal file
206
templates/components/user_loans_results.html
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
{# templates/components/user_loans_results.html #}
|
||||
|
||||
{%- if users %}
|
||||
{# Results Table Card #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-green-600 text-white">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{{ _('User') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{{ _('Identification') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{{ _('Active Loans') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider">
|
||||
{{ _('Last Loan Date') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider">
|
||||
{{ _('Actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{%- for user in users %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ user.name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||
{{ user.identification or _('N/A') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if user.loan_count and user.loan_count > 0 %}bg-green-100 text-green-800
|
||||
{% else %}bg-gray-100 text-gray-800
|
||||
{% endif %}">
|
||||
{{ user.loan_count or 0 }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ user.last_loan_date or _('Never') }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||
<a href="/user_loans/{{ user.id }}"
|
||||
class="text-green-600 hover:text-green-800 font-medium">
|
||||
{{ _('View Details') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Pagination #}
|
||||
{%- if total_pages > 1 %}
|
||||
<nav class="flex items-center justify-between pt-4" aria-label="Table navigation">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm text-gray-500">
|
||||
{{ _('Showing') }} <span class="font-medium text-gray-900">{{ (page - 1) * per_page + 1 }}</span>
|
||||
{{ _('to') }} <span class="font-medium text-gray-900">{{ min(page * per_page, total_users) }}</span>
|
||||
{{ _('of') }} <span class="font-medium text-gray-900">{{ total_users }}</span> {{ _('users') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
{# Previous Button #}
|
||||
{%- if page > 1 %}
|
||||
<a
|
||||
href="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ page - 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 ml-0 leading-tight text-gray-500 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ _('Previous') }}
|
||||
</a>
|
||||
{%- else %}
|
||||
<span class="px-3 py-2 ml-0 leading-tight text-gray-300 bg-white border border-gray-300 rounded-l-lg cursor-not-allowed">
|
||||
{{ _('Previous') }}
|
||||
</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Page Numbers #}
|
||||
{%- if total_pages <= 7 %}
|
||||
{%- for p in range(1, total_pages + 1) %}
|
||||
{%- if p == page %}
|
||||
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
|
||||
{{ p }}
|
||||
</span>
|
||||
{%- else %}
|
||||
<a
|
||||
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ p }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
{%- else %}
|
||||
{# Show first page #}
|
||||
<a
|
||||
href="/user_loans?page=1&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page=1&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
1
|
||||
</a>
|
||||
{%- if page > 3 %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Show pages around current #}
|
||||
{%- for p in range(max(2, page - 2), min(total_pages - 1, page + 2) + 1) %}
|
||||
{%- if p == page %}
|
||||
<span class="px-3 py-2 leading-tight text-white bg-green-600 border border-green-600 rounded">
|
||||
{{ p }}
|
||||
</span>
|
||||
{%- else %}
|
||||
<a
|
||||
href="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ p }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ p }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
|
||||
{%- if page < total_pages - 2 %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300">...</span>
|
||||
{%- endif %}
|
||||
|
||||
{# Show last page #}
|
||||
<a
|
||||
href="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ total_pages }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ total_pages }}
|
||||
</a>
|
||||
{%- endif %}
|
||||
|
||||
{# Next Button #}
|
||||
{%- if page < total_pages %}
|
||||
<a
|
||||
href="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-get="/user_loans?page={{ page + 1 }}&search={{ search }}&status={{ status }}"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status']"
|
||||
class="px-3 py-2 leading-tight text-gray-500 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-100 hover:text-gray-700"
|
||||
>
|
||||
{{ _('Next') }}
|
||||
</a>
|
||||
{%- else %}
|
||||
<span class="px-3 py-2 leading-tight text-gray-300 bg-white border border-gray-300 rounded-r-lg cursor-not-allowed">
|
||||
{{ _('Next') }}
|
||||
</span>
|
||||
{%- endif %}
|
||||
</div>
|
||||
</nav>
|
||||
{%- endif %}
|
||||
|
||||
{%- else %}
|
||||
{# No Results #}
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">{{ _('No users found') }}</h3>
|
||||
<p class="text-gray-500 mb-4">
|
||||
{% if search %}
|
||||
{{ _('No users match your search for') }} "{{ search }}"
|
||||
{% elif status == 'with_loans' %}
|
||||
{{ _('No users have active loans') }}
|
||||
{% elif status == 'no_loans' %}
|
||||
{{ _('All users have at least one active loan') }}
|
||||
{% else %}
|
||||
{{ _('There are no users in the system yet') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
<a href="/user_loans"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 transition-colors">
|
||||
{{ _('Clear Filters') }}
|
||||
</a>
|
||||
</div>
|
||||
{%- endif %}
|
||||
116
templates/edit_non_loanable_device.html
Normal file
116
templates/edit_non_loanable_device.html
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Edit Non-Loanable Device') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('Update device information') }}</p>
|
||||
</div>
|
||||
|
||||
{# Device Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ _('Device Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/edit_non_loanable_device/{{ device.id }}" class="space-y-6">
|
||||
<div>
|
||||
<label for="device_type" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Device Type') }} *</label>
|
||||
<select id="device_type" name="device_type" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<option value="projector" {% if device.device_type == 'projector' %}selected{% endif %}>{{ _('Projector') }}</option>
|
||||
<option value="monitor" {% if device.device_type == 'monitor' %}selected{% endif %}>{{ _('Monitor') }}</option>
|
||||
<option value="laptop" {% if device.device_type == 'laptop' %}selected{% endif %}>{{ _('Laptop (non-loanable)') }}</option>
|
||||
<option value="printer" {% if device.device_type == 'printer' %}selected{% endif %}>{{ _('Printer') }}</option>
|
||||
<option value="camera" {% if device.device_type == 'camera' %}selected{% endif %}>{{ _('Camera') }}</option>
|
||||
<option value="audio" {% if device.device_type == 'audio' %}selected{% endif %}>{{ _('Audio Equipment') }}</option>
|
||||
<option value="network" {% if device.device_type == 'network' %}selected{% endif %}>{{ _('Network Equipment') }}</option>
|
||||
<option value="other" {% if device.device_type == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="brand" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Brand') }} *</label>
|
||||
<input type="text" id="brand" name="brand" value="{{ device.brand }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., Epson, Samsung, Dell') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Model') }} *</label>
|
||||
<input type="text" id="model" name="model" value="{{ device.model }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., PowerLite 2250U, UltraSharp U2723QE') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="serial_number" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Serial Number') }} *</label>
|
||||
<input type="text" id="serial_number" name="serial_number" value="{{ device.serial_number }}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Unique serial number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Each device must have a unique serial number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="location" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Location') }}</label>
|
||||
<input type="text" id="location" name="location" value="{{ device.location or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('e.g., Room 101, Lab A, Storage') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ _('Optional location where the device is stored') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Status') }} *</label>
|
||||
<select id="status" name="status" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<option value="available" {% if device.status == 'available' %}selected{% endif %}>{{ _('Available') }}</option>
|
||||
<option value="in_use" {% if device.status == 'in_use' %}selected{% endif %}>{{ _('In Use') }}</option>
|
||||
<option value="maintenance" {% if device.status == 'maintenance' %}selected{% endif %}>{{ _('Maintenance') }}</option>
|
||||
<option value="retired" {% if device.status == 'retired' %}selected{% endif %}>{{ _('Retired') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="purchase_date" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Date') }}</label>
|
||||
<input type="date" id="purchase_date" name="purchase_date" value="{{ device.purchase_date or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="purchase_cost" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Purchase Cost') }}</label>
|
||||
<input type="number" id="purchase_cost" name="purchase_cost" step="0.01" value="{{ device.purchase_cost or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('0.00') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 mb-1">{{ _('Notes') }}</label>
|
||||
<textarea id="notes" name="notes" rows="3"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ _('Optional notes about the device (condition, accessories, etc.)') }}">{{ device.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="/non_loanable_devices"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{{ _('Update Device') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
168
templates/edit_student.html
Normal file
168
templates/edit_student.html
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Edit Student') }} - {{ student.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Breadcrumb #}
|
||||
<nav class="flex" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2">
|
||||
<li>
|
||||
<a href="{{ url_for('list_students') }}" class="text-cobalt-600 hover:text-cobalt-800">{{ gettext('Students') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800">{{ student.full_name }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-500">{{ gettext('Edit') }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Edit Student') }}: {{ student.full_name }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Update student information') }}</p>
|
||||
</div>
|
||||
|
||||
{# Student Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ url_for('edit_student', student_id=student.id) }}" class="space-y-6">
|
||||
{# Identification Section #}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Identification') }}</h3>
|
||||
|
||||
<div>
|
||||
<label for="cial_code" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CIAL Code') }} *</label>
|
||||
<input type="text" id="cial_code" name="cial_code" required autofocus
|
||||
value="{{ request.form.cial_code if request.method == 'POST' else student.cial_code }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Unique internal identification code') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Unique internal identification code') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nif_nie_passport" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('NIF/NIE/Passport') }}</label>
|
||||
<input type="text" id="nif_nie_passport" name="nif_nie_passport"
|
||||
value="{{ request.form.nif_nie_passport if request.method == 'POST' else (student.nif_nie_passport or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('National ID, Foreign ID, or Passport number') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('National ID, Foreign ID, or Passport number') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="registration_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Registration Number') }}</label>
|
||||
<input type="number" id="registration_number" name="registration_number"
|
||||
value="{{ request.form.registration_number if request.method == 'POST' else (student.registration_number or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional registration number') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="file_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('File Number') }}</label>
|
||||
<input type="text" id="file_number" name="file_number"
|
||||
value="{{ request.form.file_number if request.method == 'POST' else (student.file_number or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional file number') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="order_number" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Order Number') }}</label>
|
||||
<input type="number" id="order_number" name="order_number"
|
||||
value="{{ request.form.order_number if request.method == 'POST' else (student.order_number or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Optional order number') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Personal Information Section #}
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800 border-b border-gray-200 pb-2">{{ gettext('Personal Information') }}</h3>
|
||||
|
||||
<div>
|
||||
<label for="first_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('First Name') }} *</label>
|
||||
<input type="text" id="first_name" name="first_name" required
|
||||
value="{{ request.form.first_name if request.method == 'POST' else student.first_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Student first name') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="last_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Last Name') }} *</label>
|
||||
<input type="text" id="last_name" name="last_name" required
|
||||
value="{{ request.form.last_name if request.method == 'POST' else student.last_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Student last name') }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="full_name" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Full Name') }} *</label>
|
||||
<input type="text" id="full_name" name="full_name" required
|
||||
value="{{ request.form.full_name if request.method == 'POST' else student.full_name }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('Complete name') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Full name for display purposes') }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="birth_date" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Birth Date') }}</label>
|
||||
<input type="date" id="birth_date" name="birth_date"
|
||||
value="{{ request.form.birth_date if request.method == 'POST' else (student.birth_date or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
|
||||
<select id="gender" name="gender"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<option value="">{{ gettext('Select gender') }}</option>
|
||||
<option value="M" {% if request.form.gender == 'M' or student.gender == 'M' %}selected{% endif %}>M</option>
|
||||
<option value="F" {% if request.form.gender == 'F' or student.gender == 'F' %}selected{% endif %}>F</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="study_group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
|
||||
<input type="text" id="study_group" name="study_group"
|
||||
value="{{ request.form.study_group if request.method == 'POST' else (student.study_group or '') }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
placeholder="{{ gettext('e.g., 2º ESO A, 3º ESO B') }}">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Class or study group') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{{ gettext('Save Changes') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
139
templates/history.html
Normal file
139
templates/history.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{# Page Header #}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Loan History') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('View all past and current tablet loans') }}</p>
|
||||
</div>
|
||||
<a href="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
|
||||
</svg>
|
||||
{{ _('Back to Dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if loans %}
|
||||
{# Desktop Table (hidden on mobile) #}
|
||||
<div class="table-container table-card-mobile">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Tablet') }}</th>
|
||||
<th>{{ _('Serial Number') }}</th>
|
||||
<th>{{ _('Borrower') }}</th>
|
||||
<th>{{ _('Loan Date') }}</th>
|
||||
<th>{{ _('Return Date') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loan in loans %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium text-gray-900">{{ loan.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.identification }}</div>
|
||||
</td>
|
||||
<td class="text-sm text-gray-500">
|
||||
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
|
||||
<br>
|
||||
<span class="text-xs">{{ loan.loan_date[11:16] if loan.loan_date else '' }}</span>
|
||||
</td>
|
||||
<td class="text-sm text-gray-500">
|
||||
{% if loan.return_date %}
|
||||
{{ loan.return_date[:10] }}
|
||||
<br>
|
||||
<span class="text-xs">{{ loan.return_date[11:16] }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if loan.status == 'active' %}bg-green-100 text-green-800
|
||||
{% elif loan.status == 'returned' %}bg-blue-100 text-blue-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ loan.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Mobile Cards (hidden on desktop) #}
|
||||
<div class="mobile-cards hidden">
|
||||
{% for loan in loans %}
|
||||
<div class="mobile-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.name }}</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if loan.status == 'active' %}bg-green-100 text-green-800
|
||||
{% elif loan.status == 'returned' %}bg-blue-100 text-blue-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ loan.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Serial Number') }}</span>
|
||||
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Borrower') }}</span>
|
||||
<div class="card-value text-right">
|
||||
<div>{{ loan.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ loan.identification }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Loan Date') }}</span>
|
||||
<span class="card-value">{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}</span>
|
||||
</div>
|
||||
{% if loan.return_date %}
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Return Date') }}</span>
|
||||
<span class="card-value">{{ loan.return_date[:10] }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500">{{ _('No loan history available.') }}</p>
|
||||
<a href="/loan_tablet"
|
||||
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
|
||||
{{ _('Create a Loan') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show mobile cards on small screens
|
||||
if (window.innerWidth <= 640) {
|
||||
document.querySelector('.table-card-mobile').style.display = 'none';
|
||||
document.querySelector('.mobile-cards').style.display = 'flex';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
157
templates/import_students.html
Normal file
157
templates/import_students.html
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Import Students from CSV') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Import Students from CSV') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Bulk import student data from a CSV file') }}</p>
|
||||
</div>
|
||||
|
||||
{# Instructions Card #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Instructions') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-2 text-gray-700">
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('Upload a CSV file with student data') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('The file should be encoded in ISO-8859-1 (Latin-1)') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('Required columns: Apellidos, Nombre (will be split), C.I.A.L., Fecha Nac.') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('Optional columns: NIF/NIE/Pas., Registro, Expediente, Sexo, Grupo') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('The "Estudio" column will be discarded') }}
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<svg class="w-5 h-5 text-cobalt-600 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('Duplicate students (by CIAL code or NIF/NIE) will be skipped') }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ gettext('Example CSV format:') }}<br>
|
||||
<code class="text-xs bg-gray-200 px-2 py-1 rounded">Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Upload Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-green-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Upload CSV File') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="{{ url_for('import_students') }}" enctype="multipart/form-data" class="space-y-4">
|
||||
<div>
|
||||
<label for="csv_file" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('CSV File') }} *</label>
|
||||
<input type="file" id="csv_file" name="csv_file" accept=".csv" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500">
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select a CSV file to upload') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<a href="{{ url_for('list_students') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
{{ gettext('Import Students') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Sample Data Preview #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-gray-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Sample Data Format') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div class="table-container">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ gettext('Nº Or.') }}</th>
|
||||
<th>{{ gettext('Apellidos, Nombre') }}</th>
|
||||
<th>{{ gettext('Fecha Nac.') }}</th>
|
||||
<th>{{ gettext('C.I.A.L.') }}</th>
|
||||
<th>{{ gettext('NIF/NIE/Pas.') }}</th>
|
||||
<th>{{ gettext('Registro') }}</th>
|
||||
<th>{{ gettext('Expediente') }}</th>
|
||||
<th>{{ gettext('Sexo') }}</th>
|
||||
<th>{{ gettext('Grupo') }}</th>
|
||||
<th class="text-gray-400 line-through">{{ gettext('Estudio') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>García López, María</td>
|
||||
<td>15/03/2005</td>
|
||||
<td>12345678A</td>
|
||||
<td>12345678A</td>
|
||||
<td>REG001</td>
|
||||
<td>EXP001</td>
|
||||
<td>F</td>
|
||||
<td>2º ESO A</td>
|
||||
<td class="text-gray-400 line-through">ESO</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td>Martínez Ruiz, Carlos</td>
|
||||
<td>22/07/2004</td>
|
||||
<td>87654321B</td>
|
||||
<td>87654321B</td>
|
||||
<td>REG002</td>
|
||||
<td>EXP002</td>
|
||||
<td>M</td>
|
||||
<td>3º ESO B</td>
|
||||
<td class="text-gray-400 line-through">ESO</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-gray-500">
|
||||
<span class="text-gray-400 line-through">{{ gettext('Estudio') }}</span>
|
||||
{{ gettext('column will be discarded during import') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
233
templates/index.html
Normal file
233
templates/index.html
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
{# Available Tablets Section #}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
|
||||
</svg>
|
||||
{{ _('Available Tablets') }}
|
||||
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
{{ available_tablets|length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if available_tablets %}
|
||||
{# Desktop Table (hidden on mobile) #}
|
||||
<div class="table-container table-card-mobile">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Brand') }}</th>
|
||||
<th>{{ _('Model') }}</th>
|
||||
<th>{{ _('Serial Number') }}</th>
|
||||
<th>{{ _('Notes') }}</th>
|
||||
<th class="text-right">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for tablet in available_tablets %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium text-gray-900">{{ tablet.brand }}</div>
|
||||
</td>
|
||||
<td class="text-gray-500">
|
||||
{{ tablet.model }}
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ tablet.serial_number }}</code>
|
||||
</td>
|
||||
<td class="text-gray-500">
|
||||
{{ tablet.notes or '-' }}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="/loan_tablet?tablet_id={{ tablet.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ _('Loan') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Mobile Cards (hidden on desktop) #}
|
||||
<div class="mobile-cards hidden p-6">
|
||||
{% for tablet in available_tablets %}
|
||||
<div class="mobile-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ tablet.brand }}</div>
|
||||
<div class="text-sm text-gray-500">{{ tablet.model }}</div>
|
||||
</div>
|
||||
<a href="/loan_tablet?tablet_id={{ tablet.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-700 rounded-lg text-sm hover:bg-green-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ _('Loan') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Serial Number') }}</span>
|
||||
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ tablet.serial_number }}</code>
|
||||
</div>
|
||||
{% if tablet.notes %}
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Notes') }}</span>
|
||||
<span class="card-value text-sm">{{ tablet.notes }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
||||
</svg>
|
||||
<p>{{ _('No available tablets') }}</p>
|
||||
<a href="/add_tablet"
|
||||
class="mt-4 inline-block px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700">
|
||||
{{ _('Add Tablet') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Active Loans Section #}
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-xl font-semibold text-gray-800 flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
{{ _('Active Loans') }}
|
||||
<span class="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs font-medium rounded-full">
|
||||
{{ active_loans|length }}
|
||||
</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{% if active_loans %}
|
||||
{# Desktop Table (hidden on mobile) #}
|
||||
<div class="table-container table-card-mobile">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Tablet') }}</th>
|
||||
<th>{{ _('Serial Number') }}</th>
|
||||
<th>{{ _('Borrower') }}</th>
|
||||
<th>{{ _('Loan Date') }}</th>
|
||||
<th class="text-right">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for loan in active_loans %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-medium text-gray-900">{{ loan.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.identification }}</div>
|
||||
</td>
|
||||
<td class="text-sm text-gray-500">
|
||||
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
|
||||
<br>
|
||||
<span class="text-xs">{{ loan.loan_date[11:16] if loan.loan_date else '' }}</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="/return_tablet/{{ loan.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
{{ _('Return') }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Mobile Cards (hidden on desktop) #}
|
||||
<div class="mobile-cards hidden p-6">
|
||||
{% for loan in active_loans %}
|
||||
<div class="mobile-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.name }}</div>
|
||||
</div>
|
||||
<a href="/return_tablet/{{ loan.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
{{ _('Return') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Serial Number') }}</span>
|
||||
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ loan.serial_number }}</code>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Borrower') }}</span>
|
||||
<div class="card-value text-right">
|
||||
<div>{{ loan.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ loan.identification }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Loan Date') }}</span>
|
||||
<span class="card-value">{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p>{{ _('No active loans') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show mobile cards on small screens
|
||||
if (window.innerWidth <= 640) {
|
||||
document.querySelectorAll('.table-card-mobile').forEach(el => el.style.display = 'none');
|
||||
document.querySelectorAll('.mobile-cards').forEach(el => el.style.display = 'flex');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
templates/loan_tablet.html
Normal file
122
templates/loan_tablet.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Loan Tablet') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Loan Tablet') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Loan a tablet to a user or student') }}</p>
|
||||
</div>
|
||||
|
||||
{# Loan Form #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Loan Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<form method="POST" action="/loan_tablet" class="space-y-6">
|
||||
{# Tablet Selection #}
|
||||
<div>
|
||||
<label for="tablet_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Tablet') }} *</label>
|
||||
<input type="text" id="tablet_search"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
|
||||
placeholder="{{ gettext('Search tablets (brand, model, or serial)...') }}"
|
||||
onkeyup="filterDropdown('tablet_search', 'tablet_id')">
|
||||
<select id="tablet_id" name="tablet_id" required size="5"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
style="max-height: 200px; overflow-y: auto;">
|
||||
<option value="">{{ gettext('Select a tablet') }}</option>
|
||||
{% for tablet in available_tablets %}
|
||||
<option value="{{ tablet.id }}"
|
||||
data-search="{{ tablet.brand|lower }} {{ tablet.model|lower }} {{ tablet.serial_number|lower }}">
|
||||
{{ tablet.brand }} {{ tablet.model }} ({{ tablet.serial_number }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select which tablet to loan') }}</p>
|
||||
</div>
|
||||
|
||||
{# User Selection (Staff) #}
|
||||
<div>
|
||||
<label for="user_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Staff User') }} *</label>
|
||||
<input type="text" id="user_search"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
|
||||
placeholder="{{ gettext('Search users (name or ID)...') }}"
|
||||
onkeyup="filterDropdown('user_search', 'user_id')">
|
||||
<select id="user_id" name="user_id" required size="5"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
style="max-height: 200px; overflow-y: auto;">
|
||||
<option value="">{{ gettext('Select a user') }}</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}"
|
||||
data-search="{{ user.name|lower }} {{ user.identification|lower }}">
|
||||
{{ user.name }} ({{ user.identification }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Select the staff member processing the loan') }}</p>
|
||||
</div>
|
||||
|
||||
{# Student Selection (Optional) #}
|
||||
<div>
|
||||
<label for="student_id" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Student (Optional)') }}</label>
|
||||
<input type="text" id="student_search"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 mb-2"
|
||||
placeholder="{{ gettext('Search students (name, CIAL, or NIF)...') }}"
|
||||
onkeyup="filterDropdown('student_search', 'student_id')">
|
||||
<select id="student_id" name="student_id" size="5"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
style="max-height: 200px; overflow-y: auto;">
|
||||
<option value="">{{ gettext('Select a student (optional)') }}</option>
|
||||
{% for student in students %}
|
||||
<option value="{{ student.id }}"
|
||||
data-search="{{ student.last_name|lower }} {{ student.first_name|lower }} {{ student.cial_code|lower }} {{ student.nif_nie_passport|lower }}">
|
||||
{{ student.last_name }}, {{ student.first_name }} ({{ student.cial_code }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-sm text-gray-500">{{ gettext('Optionally associate this loan with a student') }}</p>
|
||||
</div>
|
||||
|
||||
{# Form Actions #}
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-200">
|
||||
<a href="/"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{{ gettext('Loan Tablet') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function filterDropdown(searchId, selectId) {
|
||||
const search = document.getElementById(searchId).value.toLowerCase();
|
||||
const select = document.getElementById(selectId);
|
||||
const options = select.options;
|
||||
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const searchText = options[i].getAttribute('data-search') || '';
|
||||
options[i].style.display = searchText.includes(search) ? '' : 'none';
|
||||
}
|
||||
|
||||
// Show first visible option if search is empty, otherwise keep selection
|
||||
if (search === '') {
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
149
templates/non_loanable_devices.html
Normal file
149
templates/non_loanable_devices.html
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{# Page Header #}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Non-Loanable Devices Inventory') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('These devices are tracked in inventory but cannot be loaned to users.') }}</p>
|
||||
</div>
|
||||
<a href="/add_non_loanable_device"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ _('Add Non-Loanable Device') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if devices %}
|
||||
{# Desktop Table (hidden on mobile) #}
|
||||
<div class="table-container table-card-mobile">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ _('Type') }}</th>
|
||||
<th>{{ _('Brand') }}</th>
|
||||
<th>{{ _('Model') }}</th>
|
||||
<th>{{ _('Serial Number') }}</th>
|
||||
<th>{{ _('Location') }}</th>
|
||||
<th>{{ _('Status') }}</th>
|
||||
<th class="text-right">{{ _('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for device in devices %}
|
||||
<tr>
|
||||
<td>{{ device.device_type }}</td>
|
||||
<td>{{ device.brand }}</td>
|
||||
<td>{{ device.model }}</td>
|
||||
<td>
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ device.serial_number }}</code>
|
||||
</td>
|
||||
<td>{{ device.location or '-' }}</td>
|
||||
<td>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if device.status == 'available' %}bg-green-100 text-green-800
|
||||
{% elif device.status == 'maintenance' %}bg-yellow-100 text-yellow-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ device.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href="/edit_non_loanable_device/{{ device.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
{{ _('Edit') }}
|
||||
</a>
|
||||
<a href="/delete_non_loanable_device/{{ device.id }}"
|
||||
onclick="return confirm('{{ _("%") }}')"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
{{ _('Delete') }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Mobile Cards (hidden on desktop) #}
|
||||
<div class="mobile-cards hidden">
|
||||
{% for device in devices %}
|
||||
<div class="mobile-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ device.brand }} {{ device.model }}</div>
|
||||
<div class="text-sm text-gray-500">{{ device.device_type }}</div>
|
||||
</div>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||
{% if device.status == 'available' %}bg-green-100 text-green-800
|
||||
{% elif device.status == 'maintenance' %}bg-yellow-100 text-yellow-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ device.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Serial Number') }}</span>
|
||||
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ device.serial_number }}</code>
|
||||
</div>
|
||||
{% if device.location %}
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ _('Location') }}</span>
|
||||
<span class="card-value">{{ device.location }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||
<a href="/edit_non_loanable_device/{{ device.id }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
{{ _('Edit') }}
|
||||
</a>
|
||||
<a href="/delete_non_loanable_device/{{ device.id }}"
|
||||
onclick="return confirm('{{ _("%") }}')"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-red-100 text-red-700 rounded-lg text-sm hover:bg-red-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
{{ _('Delete') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500">{{ _('No non-loanable devices registered.') }}</p>
|
||||
<a href="/add_non_loanable_device"
|
||||
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
|
||||
{{ _('Add Device') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show mobile cards on small screens
|
||||
if (window.innerWidth <= 640) {
|
||||
document.querySelector('.table-card-mobile').style.display = 'none';
|
||||
document.querySelector('.mobile-cards').style.display = 'flex';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
templates/project_management.html
Normal file
122
templates/project_management.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
{# Page Header #}
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ _('Project Management - Development Notes') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ _('Internal development notes and project documentation') }}</p>
|
||||
</div>
|
||||
|
||||
{# Editor Section #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4 flex justify-between items-center">
|
||||
<h2 class="text-lg font-semibold text-white">{{ _('Editor') }}</h2>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="saveNotes()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
{{ _('Save Notes') }}
|
||||
</button>
|
||||
<button onclick="loadNotes()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
{{ _('Reload') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div id="status" class="text-sm text-gray-500 italic mb-4"></div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-3">{{ _('Editor') }}</h3>
|
||||
<textarea id="markdown-editor" rows="20"
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500 font-mono text-sm"
|
||||
placeholder="{{ _('Write your markdown notes here...') }}"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-3">{{ _('Preview') }}</h3>
|
||||
<div id="markdown-preview"
|
||||
class="w-full min-h-[400px] p-4 border border-gray-300 rounded-md bg-gray-50 overflow-y-auto prose prose-sm max-w-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<script>
|
||||
const NOTES_FILE = 'notes_development/project_notes.md';
|
||||
|
||||
// Initialize: load and render notes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadNotes();
|
||||
|
||||
// Set up live preview
|
||||
const editor = document.getElementById('markdown-editor');
|
||||
const preview = document.getElementById('markdown-preview');
|
||||
|
||||
editor.addEventListener('input', function() {
|
||||
preview.innerHTML = marked.parse(editor.value);
|
||||
});
|
||||
});
|
||||
|
||||
function loadNotes() {
|
||||
fetch('/get_notes/' + NOTES_FILE)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('File not found, using default');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
document.getElementById('markdown-editor').value = data.content;
|
||||
document.getElementById('markdown-preview').innerHTML = marked.parse(data.content);
|
||||
showStatus('{{ _("Notes loaded successfully") }}');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading notes:', error);
|
||||
showStatus('{{ _("Error loading notes") }}', true);
|
||||
});
|
||||
}
|
||||
|
||||
function saveNotes() {
|
||||
const content = document.getElementById('markdown-editor').value;
|
||||
fetch('/save_notes/' + NOTES_FILE, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content: content })
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
showStatus('{{ _("Notes saved successfully") }}');
|
||||
} else {
|
||||
showStatus('{{ _("Error saving notes") }}: ' + data.error, true);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving notes:', error);
|
||||
showStatus('{{ _("Error saving notes") }}', true);
|
||||
});
|
||||
}
|
||||
|
||||
function showStatus(message, isError = false) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = 'text-sm italic ' + (isError ? 'text-red-600' : 'text-green-600');
|
||||
|
||||
// Clear status after 3 seconds
|
||||
setTimeout(() => {
|
||||
status.textContent = '';
|
||||
status.className = 'text-sm text-gray-500 italic';
|
||||
}, 3000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
179
templates/student_detail.html
Normal file
179
templates/student_detail.html
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Student Details') }} - {{ student.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{# Breadcrumb #}
|
||||
<nav class="flex" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center space-x-2">
|
||||
<li>
|
||||
<a href="{{ url_for('list_students') }}" class="text-cobalt-600 hover:text-cobalt-800">{{ gettext('Students') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</li>
|
||||
<li>
|
||||
<span class="text-gray-500">{{ student.full_name }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{# Student Header #}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ student.full_name }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Student Details') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('edit_student', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-yellow-500 text-white rounded-lg hover:bg-yellow-600 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
{{ gettext('Edit') }}
|
||||
</a>
|
||||
<a href="{{ url_for('delete_student', student_id=student.id) }}"
|
||||
onclick="return confirm('{{ gettext('Are you sure you want to delete this student?') }}')"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
{{ gettext('Delete') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Student Information and Assigned Devices #}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{# Student Information Card #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-cobalt-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Student Information') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<dl class="space-y-4">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('ID') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.id }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('CIAL Code') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">
|
||||
<code class="bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('NIF/NIE/Passport') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.nif_nie_passport or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Registration Number') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.registration_number or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('File Number') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.file_number or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Order Number') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.order_number or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Full Name') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.full_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('First Name') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.first_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Last Name') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.last_name }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Birth Date') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.birth_date or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Gender') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.gender or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Study Group') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.study_group or '-' }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Created') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.created_at }}</dd>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center">
|
||||
<dt class="text-sm font-medium text-gray-500 sm:w-1/3">{{ gettext('Updated') }}:</dt>
|
||||
<dd class="text-sm text-gray-900 sm:w-2/3">{{ student.updated_at }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Assigned Devices Card #}
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="bg-green-600 px-6 py-4">
|
||||
<h2 class="text-lg font-semibold text-white">{{ gettext('Assigned Devices') }}</h2>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
{# Assigned Tablets #}
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">{{ gettext('Tablets') }}</h3>
|
||||
{% if assigned_tablets %}
|
||||
<div class="space-y-3 mb-6">
|
||||
{% for tablet in assigned_tablets %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ tablet.brand }} {{ tablet.model }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ gettext('Serial:') }} {{ tablet.serial_number }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ gettext('Status:') }}
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
|
||||
{% if tablet.status == 'available' %}bg-green-100 text-green-800
|
||||
{% elif tablet.status == 'loaned' %}bg-yellow-100 text-yellow-800
|
||||
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||
{{ tablet.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm mb-6">{{ gettext('No tablets assigned.') }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# Assigned Non-Loanable Devices #}
|
||||
<h3 class="text-sm font-semibold text-gray-700 uppercase tracking-wider mb-3">{{ gettext('Non-Loanable Devices') }}</h3>
|
||||
{% if assigned_devices %}
|
||||
<div class="space-y-3">
|
||||
{% for device in assigned_devices %}
|
||||
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div class="font-medium text-gray-900">{{ device.brand }} {{ device.model }}</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ gettext('Serial:') }} {{ device.serial_number }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
{{ gettext('Type:') }} {{ device.device_type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 text-sm">{{ gettext('No non-loanable devices assigned.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
201
templates/students.html
Normal file
201
templates/students.html
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Students') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
{# Page Header #}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ gettext('Students') }}</h1>
|
||||
<p class="text-gray-600 mt-1">{{ gettext('Total students:') }} {{ total_students }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('add_student') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
{{ gettext('Add Student') }}
|
||||
</a>
|
||||
<a href="{{ url_for('import_students') }}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
{{ gettext('Import CSV') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Search and Filter Form #}
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">{{ gettext('Search & Filter') }}</h2>
|
||||
<form method="get" action="{{ url_for('list_students') }}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Search') }}</label>
|
||||
<input type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500"
|
||||
id="search" name="search" value="{{ search }}" placeholder="{{ gettext('Name, CIAL, NIF...') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="gender" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Gender') }}</label>
|
||||
<select class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500" id="gender" name="gender">
|
||||
<option value="">{{ gettext('All') }}</option>
|
||||
{% for g in genders %}
|
||||
<option value="{{ g }}" {% if gender == g %}selected{% endif %}>{{ g }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="group" class="block text-sm font-medium text-gray-700 mb-1">{{ gettext('Study Group') }}</label>
|
||||
<select class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-cobalt-500 focus:border-cobalt-500" id="group" name="group">
|
||||
<option value="">{{ gettext('All') }}</option>
|
||||
{% for g in groups %}
|
||||
<option value="{{ g }}" {% if group == g %}selected{% endif %}>{{ g }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<button type="submit" class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
{{ gettext('Search') }}
|
||||
</button>
|
||||
<a href="{{ url_for('list_students') }}" class="inline-flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
{{ gettext('Clear') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if students %}
|
||||
{# Desktop Table (hidden on mobile) #}
|
||||
<div class="table-container table-card-mobile">
|
||||
<table class="table-responsive">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ gettext('ID') }}</th>
|
||||
<th>{{ gettext('Name') }}</th>
|
||||
<th>{{ gettext('CIAL Code') }}</th>
|
||||
<th>{{ gettext('NIF/NIE') }}</th>
|
||||
<th>{{ gettext('Gender') }}</th>
|
||||
<th>{{ gettext('Study Group') }}</th>
|
||||
<th>{{ gettext('Birth Date') }}</th>
|
||||
<th class="text-right">{{ gettext('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for student in students %}
|
||||
<tr>
|
||||
<td>{{ student.id }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800 font-medium">
|
||||
{{ student.last_name }}, {{ student.first_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
|
||||
</td>
|
||||
<td>{{ student.nif_nie_passport }}</td>
|
||||
<td>{{ student.gender }}</td>
|
||||
<td>{{ student.study_group }}</td>
|
||||
<td class="text-sm text-gray-500">{{ student.birth_date }}</td>
|
||||
<td class="text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
{{ gettext('View') }}
|
||||
</a>
|
||||
<a href="{{ url_for('edit_student', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-lg text-sm hover:bg-yellow-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
{{ gettext('Edit') }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{# Mobile Cards (hidden on desktop) #}
|
||||
<div class="mobile-cards hidden">
|
||||
{% for student in students %}
|
||||
<div class="mobile-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="text-cobalt-600 hover:text-cobalt-800 font-medium">
|
||||
{{ student.last_name }}, {{ student.first_name }}
|
||||
</a>
|
||||
<div class="text-sm text-gray-500">{{ student.study_group }} | {{ student.gender }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ gettext('CIAL Code') }}</span>
|
||||
<code class="card-value text-sm bg-gray-100 px-2 py-1 rounded">{{ student.cial_code }}</code>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ gettext('NIF/NIE') }}</span>
|
||||
<span class="card-value">{{ student.nif_nie_passport }}</span>
|
||||
</div>
|
||||
<div class="card-row">
|
||||
<span class="card-label">{{ gettext('Birth Date') }}</span>
|
||||
<span class="card-value">{{ student.birth_date }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 mt-4 pt-3 border-t border-gray-100">
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||
</svg>
|
||||
{{ gettext('View') }}
|
||||
</a>
|
||||
<a href="{{ url_for('edit_student', student_id=student.id) }}"
|
||||
class="inline-flex items-center gap-1 px-3 py-1 bg-yellow-100 text-yellow-700 rounded-lg text-sm hover:bg-yellow-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
{{ gettext('Edit') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zM12 7a4 4 0 11-8 0 4 4 0 018 0z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500">{{ gettext('No students found.') }}</p>
|
||||
<a href="{{ url_for('add_student') }}"
|
||||
class="mt-4 inline-block px-4 py-2 bg-cobalt-600 text-white rounded-lg hover:bg-cobalt-700">
|
||||
{{ gettext('Add Student') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show mobile cards on small screens
|
||||
if (window.innerWidth <= 640) {
|
||||
document.querySelector('.table-card-mobile').style.display = 'none';
|
||||
document.querySelector('.mobile-cards').style.display = 'flex';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
122
templates/user_loans.html
Normal file
122
templates/user_loans.html
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-800">{{ _('User Loans') }}</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="result-count" hx-swap-oob="true">{{ total_users }} {{ _('users') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filters Card -->
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<form id="search-form"
|
||||
hx-get="/user_loans"
|
||||
hx-target="#results-container"
|
||||
hx-swap="innerHTML"
|
||||
hx-include="[name='search'], [name='status'], [name='page']"
|
||||
class="space-y-4">
|
||||
|
||||
<!-- Search Row -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Search Input -->
|
||||
<div class="md:col-span-2">
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ _('Search Users') }}
|
||||
</label>
|
||||
<div class="relative">
|
||||
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
name="search"
|
||||
value="{{ search or '' }}"
|
||||
placeholder="{{ _('Search by name or identification...') }}"
|
||||
class="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div>
|
||||
<label for="status" class="block text-sm font-medium text-gray-700 mb-1">
|
||||
{{ _('Loan Status') }}
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
class="w-full p-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-green-500 focus:border-green-500"
|
||||
>
|
||||
<option value="" {% if not status %}selected{% endif %}>{{ _('All Users') }}</option>
|
||||
<option value="with_loans" {% if status == 'with_loans' %}selected{% endif %}>{{ _('With Active Loans') }}</option>
|
||||
<option value="no_loans" {% if status == 'no_loans' %}selected{% endif %}>{{ _('No Active Loans') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit"
|
||||
class="w-full sm:w-auto px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
{{ _('Search') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Loading Indicator -->
|
||||
<div id="loading" class="htmx-indicator hidden"></div>
|
||||
|
||||
<!-- Results Container -->
|
||||
<div id="results-container">
|
||||
{% include 'components/user_loans_results.html' %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HTMX Script to handle URL updates -->
|
||||
<script>
|
||||
document.body.addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.requestConfig.method === 'get' && evt.detail.successful) {
|
||||
// Update URL without reload
|
||||
const url = new URL(window.location);
|
||||
const form = document.getElementById('search-form');
|
||||
if (form) {
|
||||
const formData = new FormData(form);
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (value) {
|
||||
url.searchParams.set(key, value);
|
||||
} else {
|
||||
url.searchParams.delete(key);
|
||||
}
|
||||
}
|
||||
window.history.pushState({}, '', url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const form = document.getElementById('search-form');
|
||||
if (form) {
|
||||
for (let [key, value] of urlParams.entries()) {
|
||||
const input = form.querySelector(`[name="${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox' || input.type === 'radio') {
|
||||
input.checked = input.value === value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
126
templates/user_loans_detail.html
Normal file
126
templates/user_loans_detail.html
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
<!-- Back Navigation -->
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/user_loans"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
{{ _('Back to All Users') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- User Header -->
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span class="text-2xl font-bold text-green-600">{{ user.name[:1].upper() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold text-gray-900">{{ user.name }}</h1>
|
||||
<p class="text-gray-500">
|
||||
{{ _('ID') }}: {{ user.id }} | {{ _('Identification') }}: {{ user.identification or _('N/A') }}
|
||||
</p>
|
||||
<div class="flex gap-4 mt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
<span class="text-sm text-gray-600">{{ active_count }} {{ _('Active Loans') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-3 h-3 bg-gray-400 rounded-full"></span>
|
||||
<span class="text-sm text-gray-600">{{ returned_count }} {{ _('Returned') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if user.email %}
|
||||
<a href="mailto:{{ user.email }}"
|
||||
class="px-3 py-1 bg-blue-100 text-blue-700 rounded-lg text-sm hover:bg-blue-200">
|
||||
{{ _('Email') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.phone %}
|
||||
<a href="tel:{{ user.phone }}"
|
||||
class="px-3 py-1 bg-purple-100 text-purple-700 rounded-lg text-sm hover:bg-purple-200">
|
||||
{{ _('Call') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loan History -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="p-6 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-800">{{ _('Loan History') }}</h2>
|
||||
</div>
|
||||
|
||||
{% if loans %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ _('Tablet') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ _('Loan Date') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ _('Return Date') }}
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ _('Status') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for loan in loans %}
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="font-medium text-gray-900">{{ loan.brand }} {{ loan.model }}</div>
|
||||
<div class="text-sm text-gray-500">{{ loan.serial_number }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ loan.loan_date[:10] if loan.loan_date else _('N/A') }}
|
||||
{{ loan.loan_date[11:16] if loan.loan_date else '' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if loan.return_date %}
|
||||
{{ loan.return_date[:10] }} {{ loan.return_date[11:16] }}
|
||||
{% else %}
|
||||
<span class="text-gray-400 italic">{{ _('Not returned') }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full
|
||||
{% if loan.status == 'active' %}bg-green-100 text-green-800
|
||||
{% elif loan.status == 'returned' %}bg-gray-100 text-gray-800
|
||||
{% else %}bg-yellow-100 text-yellow-800
|
||||
{% endif %}">
|
||||
{{ loan.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="p-8 text-center text-gray-500">
|
||||
<svg class="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<p>{{ _('No loan history for this user') }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
210
test_app.py
Normal file
210
test_app.py
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for the Tablet Management System
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
def test_tablet_management():
|
||||
"""Test the tablet management system functionality"""
|
||||
|
||||
# Initialize database
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create tables
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS tablets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
brand TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
serial_number TEXT UNIQUE NOT NULL,
|
||||
status TEXT DEFAULT 'available'
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
identification TEXT UNIQUE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS loans (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tablet_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
loan_date TEXT NOT NULL,
|
||||
return_date TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
||||
FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
print("=== Testing Tablet Management System ===")
|
||||
|
||||
# Test 1: Add tablets
|
||||
print("\n1. Adding tablets...")
|
||||
tablets = [
|
||||
("Samsung", "Galaxy Tab S7", "SN001"),
|
||||
("Apple", "iPad Pro", "SN002"),
|
||||
("Lenovo", "Tab P11", "SN003")
|
||||
]
|
||||
|
||||
for brand, model, serial in tablets:
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO tablets (brand, model, serial_number, status)
|
||||
VALUES (?, ?, ?, 'available')
|
||||
''', (brand, model, serial))
|
||||
print(f" ✓ Added: {brand} {model} ({serial})")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f" ✗ Duplicate: {serial}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Test 2: Add users
|
||||
print("\n2. Adding users...")
|
||||
users = [
|
||||
("John Doe", "ID001"),
|
||||
("Jane Smith", "ID002"),
|
||||
("Bob Johnson", "ID003")
|
||||
]
|
||||
|
||||
for name, identification in users:
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT INTO users (name, identification)
|
||||
VALUES (?, ?)
|
||||
''', (name, identification))
|
||||
print(f" ✓ Added: {name} ({identification})")
|
||||
except sqlite3.IntegrityError:
|
||||
print(f" ✗ Duplicate: {identification}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Test 3: Show available tablets
|
||||
print("\n3. Available tablets:")
|
||||
cursor.execute("SELECT id, brand, model, serial_number FROM tablets WHERE status = 'available'")
|
||||
available_tablets = cursor.fetchall()
|
||||
|
||||
for tablet in available_tablets:
|
||||
print(f" ID {tablet[0]}: {tablet[1]} {tablet[2]} ({tablet[3]})")
|
||||
|
||||
# Test 4: Loan tablets
|
||||
print("\n4. Loaning tablets...")
|
||||
|
||||
# Loan tablet 1 to user 1
|
||||
tablet_id = 1
|
||||
user_id = 1
|
||||
|
||||
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
|
||||
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO loans (tablet_id, user_id, loan_date, status)
|
||||
VALUES (?, ?, ?, 'active')
|
||||
''', (tablet_id, user_id, loan_date))
|
||||
print(f" ✓ Loaned tablet {tablet_id} to user {user_id}")
|
||||
|
||||
# Loan tablet 2 to user 2
|
||||
tablet_id = 2
|
||||
user_id = 2
|
||||
|
||||
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
|
||||
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
INSERT INTO loans (tablet_id, user_id, loan_date, status)
|
||||
VALUES (?, ?, ?, 'active')
|
||||
''', (tablet_id, user_id, loan_date))
|
||||
print(f" ✓ Loaned tablet {tablet_id} to user {user_id}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Test 5: Show active loans
|
||||
print("\n5. Active loans:")
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
WHERE l.status = 'active'
|
||||
''')
|
||||
active_loans = cursor.fetchall()
|
||||
|
||||
for loan in active_loans:
|
||||
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]}) to {loan[4]} on {loan[5]}")
|
||||
|
||||
# Test 6: Return a tablet
|
||||
print("\n6. Returning tablet...")
|
||||
loan_id = 1
|
||||
|
||||
cursor.execute("SELECT tablet_id FROM loans WHERE id = ?", (loan_id,))
|
||||
loan = cursor.fetchone()
|
||||
|
||||
if loan:
|
||||
tablet_id = loan[0]
|
||||
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
cursor.execute('''
|
||||
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
|
||||
''', (return_date, loan_id))
|
||||
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
|
||||
print(f" ✓ Returned tablet for loan {loan_id}")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Test 7: Show loan history
|
||||
print("\n7. Complete loan history:")
|
||||
cursor.execute('''
|
||||
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
|
||||
l.loan_date, l.return_date, l.status
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
ORDER BY l.loan_date DESC
|
||||
''')
|
||||
loans = cursor.fetchall()
|
||||
|
||||
for loan in loans:
|
||||
return_date = loan[6] or 'Not returned'
|
||||
print(f" Loan {loan[0]}: {loan[1]} {loan[2]} ({loan[3]}) to {loan[4]}")
|
||||
print(f" Loan: {loan[5]}, Return: {return_date}, Status: {loan[7]}")
|
||||
|
||||
# Test 8: Show final status
|
||||
print("\n8. Final status:")
|
||||
|
||||
# Available tablets
|
||||
cursor.execute("SELECT COUNT(*) FROM tablets WHERE status = 'available'")
|
||||
available_count = cursor.fetchone()[0]
|
||||
print(f" Available tablets: {available_count}")
|
||||
|
||||
# Loaned tablets
|
||||
cursor.execute("SELECT COUNT(*) FROM tablets WHERE status = 'loaned'")
|
||||
loaned_count = cursor.fetchone()[0]
|
||||
print(f" Loaned tablets: {loaned_count}")
|
||||
|
||||
# Active loans
|
||||
cursor.execute("SELECT COUNT(*) FROM loans WHERE status = 'active'")
|
||||
active_loans_count = cursor.fetchone()[0]
|
||||
print(f" Active loans: {active_loans_count}")
|
||||
|
||||
# Returned loans
|
||||
cursor.execute("SELECT COUNT(*) FROM loans WHERE status = 'returned'")
|
||||
returned_loans_count = cursor.fetchone()[0]
|
||||
print(f" Returned loans: {returned_loans_count}")
|
||||
|
||||
conn.close()
|
||||
|
||||
print("\n=== Test completed successfully! ===")
|
||||
print("\nYou can now run the interactive application:")
|
||||
print(" python3 minimal_app.py")
|
||||
print("\nOr use the database directly with SQLite:")
|
||||
print(" sqlite3 tablets.db")
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_tablet_management()
|
||||
3
tests/__init__.py
Normal file
3
tests/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""
|
||||
Unit tests for Tablet Management System
|
||||
"""
|
||||
181
tests/conftest.py
Normal file
181
tests/conftest.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""
|
||||
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
|
||||
2
tests/requirements.txt
Normal file
2
tests/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pytest==8.3.2
|
||||
pytest-cov==5.0.0
|
||||
481
tests/test_core.py
Normal file
481
tests/test_core.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
"""
|
||||
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'
|
||||
513
tests/test_edge_cases.py
Normal file
513
tests/test_edge_cases.py
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
"""
|
||||
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
|
||||
365
tests/test_minimal_app.py
Normal file
365
tests/test_minimal_app.py
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
"""
|
||||
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
|
||||
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
191
translations/en/LC_MESSAGES/messages.po
Normal file
191
translations/en/LC_MESSAGES/messages.po
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Tablet Management System\n"
|
||||
"POT-Creation-Date: 2026-06-20\n"
|
||||
"PO-Revision-Date: 2026-06-20\n"
|
||||
"Last-Translator: Auto-generated\n"
|
||||
"Language-Team: \n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Active Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add User"
|
||||
msgstr ""
|
||||
|
||||
msgid "All Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "All users have at least one active loan"
|
||||
msgstr ""
|
||||
|
||||
msgid "Available Tablets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back to All Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Borrower"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brand"
|
||||
msgstr ""
|
||||
|
||||
msgid "Call"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Filters"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
msgid "Español"
|
||||
msgstr ""
|
||||
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Identification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Loan Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan History"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Status"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Model"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
msgid "No Active Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No active loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No available tablets"
|
||||
msgstr ""
|
||||
|
||||
msgid "No loan history for this user"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users found"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users have active loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users match your search for"
|
||||
msgstr ""
|
||||
|
||||
msgid "Non-Loanable Devices"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
|
||||
msgid "N/A"
|
||||
msgstr ""
|
||||
|
||||
msgid "of"
|
||||
msgstr ""
|
||||
|
||||
msgid "Previous"
|
||||
msgstr ""
|
||||
|
||||
msgid "Project Management"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search by name or identification..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Search Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Serial Number"
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet Management System"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet Management System - Internal Tool"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no users in the system yet"
|
||||
msgstr ""
|
||||
|
||||
msgid "to"
|
||||
msgstr ""
|
||||
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
msgid "View"
|
||||
msgstr ""
|
||||
|
||||
msgid "View Details"
|
||||
msgstr ""
|
||||
|
||||
msgid "With Active Loans"
|
||||
msgstr ""
|
||||
BIN
translations/es/LC_MESSAGES/messages.mo
Normal file
BIN
translations/es/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
192
translations/es/LC_MESSAGES/messages.po
Normal file
192
translations/es/LC_MESSAGES/messages.po
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Tablet Management System\n"
|
||||
"POT-Creation-Date: 2026-06-20\n"
|
||||
"PO-Revision-Date: 2026-06-20\n"
|
||||
"Last-Translator: Auto-generated\n"
|
||||
"Language-Team: \n"
|
||||
"Language: es\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr "Acciones"
|
||||
|
||||
msgid "Active Loans"
|
||||
msgstr "Préstamos Activos"
|
||||
|
||||
msgid "Add Tablet"
|
||||
msgstr "Añadir Tablet"
|
||||
|
||||
msgid "Add User"
|
||||
msgstr "Añadir Usuario"
|
||||
|
||||
msgid "All Users"
|
||||
msgstr "Todos los Usuarios"
|
||||
|
||||
msgid "All users have at least one active loan"
|
||||
msgstr "Todos los usuarios tienen al menos un préstamo activo"
|
||||
|
||||
msgid "Available Tablets"
|
||||
msgstr "Tablets Disponibles"
|
||||
|
||||
msgid "Back to All Users"
|
||||
msgstr "Volver a Todos los Usuarios"
|
||||
|
||||
msgid "Borrower"
|
||||
msgstr "Prestatario"
|
||||
|
||||
msgid "Brand"
|
||||
msgstr "Marca"
|
||||
|
||||
msgid "Call"
|
||||
msgstr "Llamar"
|
||||
|
||||
msgid "Clear Filters"
|
||||
msgstr "Limpiar Filtros"
|
||||
|
||||
msgid "Email"
|
||||
msgstr "Correo Electrónico"
|
||||
|
||||
msgid "English"
|
||||
msgstr "Inglés"
|
||||
|
||||
msgid "Español"
|
||||
msgstr "Español"
|
||||
|
||||
msgid "History"
|
||||
msgstr "Historial"
|
||||
|
||||
msgid "Home"
|
||||
msgstr "Inicio"
|
||||
|
||||
msgid "ID"
|
||||
msgstr "ID"
|
||||
|
||||
msgid "Identification"
|
||||
msgstr "Identificación"
|
||||
|
||||
msgid "Language"
|
||||
msgstr "Idioma"
|
||||
|
||||
msgid "Last Loan Date"
|
||||
msgstr "Fecha del Último Préstamo"
|
||||
|
||||
msgid "Loan"
|
||||
msgstr "Prestar"
|
||||
|
||||
msgid "Loan Date"
|
||||
msgstr "Fecha de Préstamo"
|
||||
|
||||
msgid "Loan History"
|
||||
msgstr "Historial de Préstamos"
|
||||
|
||||
msgid "Loan Status"
|
||||
msgstr "Estado de Préstamo"
|
||||
|
||||
msgid "Loan Tablet"
|
||||
msgstr "Prestar Tablet"
|
||||
|
||||
msgid "Model"
|
||||
msgstr "Modelo"
|
||||
|
||||
msgid "Next"
|
||||
msgstr "Siguiente"
|
||||
|
||||
msgid "No Active Loans"
|
||||
msgstr "Sin Préstamos Activos"
|
||||
|
||||
msgid "No active loans"
|
||||
msgstr "No hay préstamos activos"
|
||||
|
||||
msgid "No available tablets"
|
||||
msgstr "No hay tablets disponibles"
|
||||
|
||||
msgid "No loan history for this user"
|
||||
msgstr "Este usuario no tiene historial de préstamos"
|
||||
|
||||
msgid "No users found"
|
||||
msgstr "No se encontraron usuarios"
|
||||
|
||||
msgid "No users have active loans"
|
||||
msgstr "No hay usuarios con préstamos activos"
|
||||
|
||||
msgid "No users match your search for"
|
||||
msgstr "Ningún usuario coincide con tu búsqueda de"
|
||||
|
||||
msgid "Non-Loanable Devices"
|
||||
msgstr "Dispositivos No Prestables"
|
||||
|
||||
msgid "Notes"
|
||||
msgstr "Notas"
|
||||
|
||||
msgid "N/A"
|
||||
msgstr "N/D"
|
||||
|
||||
msgid "of"
|
||||
msgstr "de"
|
||||
|
||||
msgid "Previous"
|
||||
msgstr "Anterior"
|
||||
|
||||
msgid "Project Management"
|
||||
msgstr "Gestión de Proyectos"
|
||||
|
||||
msgid "Return"
|
||||
msgstr "Devolver"
|
||||
|
||||
msgid "Return Date"
|
||||
msgstr "Fecha de Devolución"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
msgid "Search by name or identification..."
|
||||
msgstr "Buscar por nombre o identificación..."
|
||||
|
||||
msgid "Search Users"
|
||||
msgstr "Buscar Usuarios"
|
||||
|
||||
msgid "Serial Number"
|
||||
msgstr "Número de Serie"
|
||||
|
||||
msgid "Showing"
|
||||
msgstr "Mostrando"
|
||||
|
||||
msgid "Status"
|
||||
msgstr "Estado"
|
||||
|
||||
msgid "Tablet"
|
||||
msgstr "Tablet"
|
||||
|
||||
msgid "Tablet Management System"
|
||||
msgstr "Sistema de Gestión de Tablets"
|
||||
|
||||
msgid "Tablet Management System - Internal Tool"
|
||||
msgstr "Sistema de Gestión de Tablets - Herramienta Interna"
|
||||
|
||||
msgid "There are no users in the system yet"
|
||||
msgstr "Aún no hay usuarios en el sistema"
|
||||
|
||||
msgid "to"
|
||||
msgstr "a"
|
||||
|
||||
msgid "User"
|
||||
msgstr "Usuario"
|
||||
|
||||
msgid "User Loans"
|
||||
msgstr "Préstamos por Usuario"
|
||||
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
msgid "View"
|
||||
msgstr "Ver"
|
||||
|
||||
msgid "View Details"
|
||||
msgstr "Ver Detalles"
|
||||
|
||||
msgid "With Active Loans"
|
||||
msgstr "Con Préstamos Activos"
|
||||
195
translations/messages.pot
Normal file
195
translations/messages.pot
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Tablet Management System
|
||||
"
|
||||
"POT-Creation-Date: 2026-06-20
|
||||
"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE
|
||||
"
|
||||
"Last-Translator:
|
||||
"
|
||||
"Language-Team:
|
||||
"
|
||||
"Language: en
|
||||
"
|
||||
"MIME-Version: 1.0
|
||||
"
|
||||
"Content-Type: text/plain; charset=UTF-8
|
||||
"
|
||||
"Content-Transfer-Encoding: 8bit
|
||||
"
|
||||
|
||||
msgid "Actions"
|
||||
msgstr ""
|
||||
|
||||
msgid "Active Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add User"
|
||||
msgstr ""
|
||||
|
||||
msgid "All Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "All users have at least one active loan"
|
||||
msgstr ""
|
||||
|
||||
msgid "Available Tablets"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back to All Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Borrower"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brand"
|
||||
msgstr ""
|
||||
|
||||
msgid "Call"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Filters"
|
||||
msgstr ""
|
||||
|
||||
msgid "Email"
|
||||
msgstr ""
|
||||
|
||||
msgid "History"
|
||||
msgstr ""
|
||||
|
||||
msgid "Home"
|
||||
msgstr ""
|
||||
|
||||
msgid "ID"
|
||||
msgstr ""
|
||||
|
||||
msgid "Identification"
|
||||
msgstr ""
|
||||
|
||||
msgid "Language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Last Loan Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan History"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Status"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loan Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Model"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next"
|
||||
msgstr ""
|
||||
|
||||
msgid "No Active Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No active loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No available tablets"
|
||||
msgstr ""
|
||||
|
||||
msgid "No loan history for this user"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users found"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users have active loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "No users match your search for"
|
||||
msgstr ""
|
||||
|
||||
msgid "Non-Loanable Devices"
|
||||
msgstr ""
|
||||
|
||||
msgid "Not returned"
|
||||
msgstr ""
|
||||
|
||||
msgid "Notes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Previous"
|
||||
msgstr ""
|
||||
|
||||
msgid "Project Management"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return Date"
|
||||
msgstr ""
|
||||
|
||||
msgid "Returned"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search Users"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search by name or identification..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Serial Number"
|
||||
msgstr ""
|
||||
|
||||
msgid "Showing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Status"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet Management System"
|
||||
msgstr ""
|
||||
|
||||
msgid "Tablet Management System - Internal Tool"
|
||||
msgstr ""
|
||||
|
||||
msgid "There are no users in the system yet"
|
||||
msgstr ""
|
||||
|
||||
msgid "User"
|
||||
msgstr ""
|
||||
|
||||
msgid "User Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "View Details"
|
||||
msgstr ""
|
||||
|
||||
msgid "With Active Loans"
|
||||
msgstr ""
|
||||
|
||||
msgid "of"
|
||||
msgstr ""
|
||||
|
||||
msgid "to"
|
||||
msgstr ""
|
||||
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
8
uv.lock
generated
Normal file
8
uv.lock
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "gestiontablets"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
182
working_server.py
Normal file
182
working_server.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Working web server for tablet management system
|
||||
"""
|
||||
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
import sqlite3
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
import json
|
||||
|
||||
class TabletHandler(BaseHTTPRequestHandler):
|
||||
def _set_headers(self, status=200, content_type='text/html'):
|
||||
self.send_response(status)
|
||||
self.send_header('Content-type', content_type)
|
||||
self.end_headers()
|
||||
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
|
||||
if parsed.path == '/':
|
||||
self._serve_main_page()
|
||||
elif parsed.path == '/api/tablets':
|
||||
self._serve_tablets()
|
||||
elif parsed.path == '/api/users':
|
||||
self._serve_users()
|
||||
elif parsed.path == '/api/loans':
|
||||
self._serve_loans()
|
||||
else:
|
||||
self._set_headers(404)
|
||||
self.wfile.write(b'404 Not Found')
|
||||
|
||||
def _serve_main_page(self):
|
||||
html = '''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tablet Management System</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #4CAF50; }
|
||||
.container { max-width: 800px; margin: 0 auto; }
|
||||
.section { margin-bottom: 20px; padding: 15px; background: #f5f5f5; border-radius: 5px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }
|
||||
th { background-color: #4CAF50; color: white; }
|
||||
.btn { padding: 8px 16px; background-color: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }
|
||||
.btn-danger { background-color: #f44336; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Tablet Management System</h1>
|
||||
<p>SQLite backend - Port 8000</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>Available Tablets</h2>
|
||||
<div id="tablets"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Active Loans</h2>
|
||||
<div id="loans"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>System Info</h2>
|
||||
<p>✓ Database: tablets.db</p>
|
||||
<p>✓ Status: Operational</p>
|
||||
<p>Port: 8000</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load data from API
|
||||
fetch('/api/tablets')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
let html = '<table><thead><tr><th>ID</th><th>Brand</th><th>Model</th><th>Serial</th><th>Status</th></tr></thead><tbody>';
|
||||
data.forEach(tablet => {
|
||||
html += `<tr><td>${tablet.id}</td><td>${tablet.brand}</td><td>${tablet.model}</td><td>${tablet.serial_number}</td><td>${tablet.status}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('tablets').innerHTML = html;
|
||||
});
|
||||
|
||||
fetch('/api/loans')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
let html = '<table><thead><tr><th>ID</th><th>Tablet</th><th>User</th><th>Loan Date</th><th>Status</th></tr></thead><tbody>';
|
||||
data.forEach(loan => {
|
||||
html += `<tr><td>${loan.id}</td><td>${loan.tablet}</td><td>${loan.user}</td><td>${loan.loan_date}</td><td>${loan.status}</td></tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
document.getElementById('loans').innerHTML = html;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
self._set_headers()
|
||||
self.wfile.write(html.encode('utf-8'))
|
||||
|
||||
def _serve_tablets(self):
|
||||
try:
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM tablets')
|
||||
tablets = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
self._set_headers(content_type='application/json')
|
||||
self.wfile.write(json.dumps(tablets).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self._set_headers(500)
|
||||
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
|
||||
|
||||
def _serve_users(self):
|
||||
try:
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
conn.row_factory = sqlite3.Row
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM users')
|
||||
users = [dict(row) for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
self._set_headers(content_type='application/json')
|
||||
self.wfile.write(json.dumps(users).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self._set_headers(500)
|
||||
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
|
||||
|
||||
def _serve_loans(self):
|
||||
try:
|
||||
conn = sqlite3.connect('tablets.db')
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT l.id, l.tablet_id, l.user_id, l.loan_date, l.return_date, l.status,
|
||||
t.brand || ' ' || t.model as tablet,
|
||||
u.name as user
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
JOIN users u ON l.user_id = u.id
|
||||
''')
|
||||
|
||||
loans = []
|
||||
for row in cursor.fetchall():
|
||||
loans.append({
|
||||
'id': row[0],
|
||||
'tablet_id': row[1],
|
||||
'user_id': row[2],
|
||||
'loan_date': row[3],
|
||||
'return_date': row[4],
|
||||
'status': row[5],
|
||||
'tablet': row[6],
|
||||
'user': row[7]
|
||||
})
|
||||
conn.close()
|
||||
|
||||
self._set_headers(content_type='application/json')
|
||||
self.wfile.write(json.dumps(loans).encode('utf-8'))
|
||||
except Exception as e:
|
||||
self._set_headers(500)
|
||||
self.wfile.write(json.dumps({'error': str(e)}).encode('utf-8'))
|
||||
|
||||
def run_server():
|
||||
server_address = ('', 8000)
|
||||
httpd = HTTPServer(server_address, TabletHandler)
|
||||
|
||||
print("Tablet Management System - Web Interface")
|
||||
print("========================================")
|
||||
print("Server running on: http://localhost:8000")
|
||||
print("Database: tablets.db (SQLite)")
|
||||
print("Press Ctrl+C to stop the server")
|
||||
print()
|
||||
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped")
|
||||
|
||||
if __name__ == '__main__':
|
||||
run_server()
|
||||
Loading…
Add table
Add a link
Reference in a new issue