Student data import implementation: added tables, migration, import script, tests, and documentation.
This commit is contained in:
parent
9662f98169
commit
2959cd5299
19 changed files with 3186 additions and 79 deletions
BIN
.coverage
Normal file
BIN
.coverage
Normal file
Binary file not shown.
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.
|
||||
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)
|
||||
|
392
IMPLEMENTATION_SUMMARY.md
Normal file
392
IMPLEMENTATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
# 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
|
||||
413
app.py
413
app.py
|
|
@ -124,6 +124,47 @@ def init_db():
|
|||
)
|
||||
''')
|
||||
|
||||
# Create students table
|
||||
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 for students
|
||||
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)
|
||||
''')
|
||||
|
||||
# Create non_loanable_devices table for devices that cannot be loaned
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
||||
|
|
@ -136,10 +177,19 @@ def init_db():
|
|||
status TEXT DEFAULT 'available',
|
||||
notes TEXT,
|
||||
purchase_date TEXT,
|
||||
purchase_cost REAL
|
||||
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)
|
||||
)
|
||||
''')
|
||||
|
||||
# Create index for non_loanable_devices student relationship
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_non_loanable_student ON non_loanable_devices(assigned_to_student)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
|
||||
@app.route('/')
|
||||
|
|
@ -222,6 +272,7 @@ def loan_tablet():
|
|||
if request.method == 'POST':
|
||||
tablet_id = request.form['tablet_id']
|
||||
user_id = request.form['user_id']
|
||||
student_id = request.form.get('student_id') # Optional - can be loaned to student directly
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
|
@ -232,9 +283,9 @@ def loan_tablet():
|
|||
# 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))
|
||||
INSERT INTO loans (tablet_id, user_id, student_id, loan_date, status)
|
||||
VALUES (?, ?, ?, ?, 'active')
|
||||
''', (tablet_id, user_id, student_id, loan_date))
|
||||
|
||||
conn.commit()
|
||||
flash('Tablet loaned successfully!', 'success')
|
||||
|
|
@ -248,10 +299,362 @@ def loan_tablet():
|
|||
available_tablets = cursor.fetchall()
|
||||
cursor.execute("SELECT * FROM users")
|
||||
users = cursor.fetchall()
|
||||
cursor.execute("SELECT * FROM students ORDER BY last_name, first_name")
|
||||
students = cursor.fetchall()
|
||||
|
||||
return render_template('loan_tablet.html',
|
||||
available_tablets=available_tablets,
|
||||
users=users)
|
||||
users=users, students=students)
|
||||
|
||||
@app.route('/students')
|
||||
def list_students():
|
||||
"""List all students with pagination and search"""
|
||||
from flask import request
|
||||
|
||||
# Get query parameters
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search', '').strip()
|
||||
gender_filter = request.args.get('gender', '')
|
||||
group_filter = request.args.get('group', '')
|
||||
|
||||
# Pagination settings
|
||||
per_page = 50
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Build query
|
||||
query = "SELECT * FROM students"
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
# Add search filter
|
||||
if search:
|
||||
conditions.append("(last_name LIKE ? OR first_name LIKE ? OR full_name LIKE ? OR cial_code LIKE ? OR nif_nie_passport LIKE ?)")
|
||||
search_param = f"%{search}%"
|
||||
params.extend([search_param] * 5)
|
||||
|
||||
# Add gender filter
|
||||
if gender_filter and gender_filter != 'all':
|
||||
conditions.append("gender = ?")
|
||||
params.append(gender_filter)
|
||||
|
||||
# Add group filter
|
||||
if group_filter and group_filter != 'all':
|
||||
conditions.append("study_group LIKE ?")
|
||||
params.append(f"%{group_filter}%")
|
||||
|
||||
# Combine conditions
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDER BY last_name, first_name COLLATE NOCASE"
|
||||
|
||||
# Get total count for pagination
|
||||
count_query = f"SELECT COUNT(*) FROM students"
|
||||
if conditions:
|
||||
count_query += " WHERE " + " AND ".join(conditions)
|
||||
cursor.execute(count_query, params)
|
||||
total_students = cursor.fetchone()[0]
|
||||
total_pages = (total_students + per_page - 1) // per_page
|
||||
|
||||
# Add pagination to query
|
||||
query += f" LIMIT {per_page} OFFSET {offset}"
|
||||
|
||||
# Execute main query
|
||||
cursor.execute(query, params)
|
||||
students = cursor.fetchall()
|
||||
|
||||
# Get distinct values for filters
|
||||
cursor.execute("SELECT DISTINCT gender FROM students WHERE gender IS NOT NULL ORDER BY gender")
|
||||
genders = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
cursor.execute("SELECT DISTINCT study_group FROM students WHERE study_group IS NOT NULL ORDER BY study_group")
|
||||
groups = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
return render_template('students.html',
|
||||
students=students,
|
||||
page=page,
|
||||
total_pages=total_pages,
|
||||
total_students=total_students,
|
||||
search=search,
|
||||
gender=gender_filter,
|
||||
group=group_filter,
|
||||
genders=genders,
|
||||
groups=groups,
|
||||
per_page=per_page)
|
||||
|
||||
|
||||
@app.route('/add_student', methods=['GET', 'POST'])
|
||||
def add_student():
|
||||
"""Add a new student"""
|
||||
if request.method == 'POST':
|
||||
order_number = request.form.get('order_number')
|
||||
cial_code = request.form.get('cial_code', '').strip()
|
||||
nif_nie_passport = request.form.get('nif_nie_passport', '').strip()
|
||||
registration_number = request.form.get('registration_number')
|
||||
file_number = request.form.get('file_number', '').strip()
|
||||
first_name = request.form.get('first_name', '').strip()
|
||||
last_name = request.form.get('last_name', '').strip()
|
||||
full_name = f"{last_name}, {first_name}" if last_name and first_name else request.form.get('full_name', '').strip()
|
||||
birth_date = request.form.get('birth_date', '').strip()
|
||||
gender = request.form.get('gender', '').strip()
|
||||
study_group = request.form.get('study_group', '').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not cial_code:
|
||||
flash('Error: CIAL code is required!', 'error')
|
||||
return render_template('add_student.html')
|
||||
|
||||
if not first_name:
|
||||
flash('Error: First name is required!', 'error')
|
||||
return render_template('add_student.html')
|
||||
|
||||
if not last_name:
|
||||
flash('Error: Last name is required!', 'error')
|
||||
return render_template('add_student.html')
|
||||
|
||||
# Validate gender
|
||||
if gender and gender.upper() not in ['M', 'F', 'O']:
|
||||
flash('Error: Invalid gender! Must be M, F, or O.', 'error')
|
||||
return render_template('add_student.html')
|
||||
|
||||
# Validate date format
|
||||
if birth_date:
|
||||
try:
|
||||
datetime.strptime(birth_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Error: Invalid date format! Use YYYY-MM-DD.', 'error')
|
||||
return render_template('add_student.html')
|
||||
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
''', (order_number, cial_code, nif_nie_passport or None, registration_number,
|
||||
file_number or None, first_name, last_name, full_name,
|
||||
birth_date or None, gender or None, study_group or None))
|
||||
conn.commit()
|
||||
flash('Student added successfully!', 'success')
|
||||
return redirect(url_for('list_students'))
|
||||
except sqlite3.IntegrityError as e:
|
||||
if 'UNIQUE constraint failed: students.cial_code' in str(e):
|
||||
flash('Error: CIAL code already exists!', 'error')
|
||||
elif 'UNIQUE constraint failed: students.nif_nie_passport' in str(e):
|
||||
flash('Error: NIF/NIE/Passport already exists!', 'error')
|
||||
else:
|
||||
flash(f'Error: {str(e)}', 'error')
|
||||
|
||||
return render_template('add_student.html')
|
||||
|
||||
|
||||
@app.route('/student/<int:student_id>')
|
||||
def student_detail(student_id):
|
||||
"""Show details for a specific student"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get student info
|
||||
cursor.execute("SELECT * FROM students WHERE id = ?", (student_id,))
|
||||
student = cursor.fetchone()
|
||||
|
||||
if not student:
|
||||
flash('Error: Student not found!', 'error')
|
||||
return redirect(url_for('list_students'))
|
||||
|
||||
# Get tablets assigned to this student
|
||||
cursor.execute('''
|
||||
SELECT t.* FROM tablets t
|
||||
WHERE t.assigned_to_student = ?
|
||||
''', (student_id,))
|
||||
assigned_tablets = cursor.fetchall()
|
||||
|
||||
# Get non-loanable devices assigned to this student
|
||||
cursor.execute('''
|
||||
SELECT * FROM non_loanable_devices
|
||||
WHERE assigned_to_student = ?
|
||||
''', (student_id,))
|
||||
assigned_devices = cursor.fetchall()
|
||||
|
||||
# Get loan history for this student
|
||||
cursor.execute('''
|
||||
SELECT l.*, t.brand, t.model, t.serial_number
|
||||
FROM loans l
|
||||
JOIN tablets t ON l.tablet_id = t.id
|
||||
WHERE l.student_id = ?
|
||||
ORDER BY l.loan_date DESC
|
||||
''', (student_id,))
|
||||
loans = cursor.fetchall()
|
||||
|
||||
return render_template('student_detail.html',
|
||||
student=student,
|
||||
assigned_tablets=assigned_tablets,
|
||||
assigned_devices=assigned_devices,
|
||||
loans=loans)
|
||||
|
||||
|
||||
@app.route('/edit_student/<int:student_id>', methods=['GET', 'POST'])
|
||||
def edit_student(student_id):
|
||||
"""Edit a student"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM students WHERE id = ?", (student_id,))
|
||||
student = cursor.fetchone()
|
||||
|
||||
if not student:
|
||||
flash('Error: Student not found!', 'error')
|
||||
return redirect(url_for('list_students'))
|
||||
|
||||
if request.method == 'POST':
|
||||
order_number = request.form.get('order_number')
|
||||
cial_code = request.form.get('cial_code', '').strip()
|
||||
nif_nie_passport = request.form.get('nif_nie_passport', '').strip()
|
||||
registration_number = request.form.get('registration_number')
|
||||
file_number = request.form.get('file_number', '').strip()
|
||||
first_name = request.form.get('first_name', '').strip()
|
||||
last_name = request.form.get('last_name', '').strip()
|
||||
full_name = f"{last_name}, {first_name}" if last_name and first_name else request.form.get('full_name', '').strip()
|
||||
birth_date = request.form.get('birth_date', '').strip()
|
||||
gender = request.form.get('gender', '').strip()
|
||||
study_group = request.form.get('study_group', '').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not cial_code:
|
||||
flash('Error: CIAL code is required!', 'error')
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
if not first_name:
|
||||
flash('Error: First name is required!', 'error')
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
if not last_name:
|
||||
flash('Error: Last name is required!', 'error')
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
# Validate gender
|
||||
if gender and gender.upper() not in ['M', 'F', 'O']:
|
||||
flash('Error: Invalid gender! Must be M, F, or O.', 'error')
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
# Validate date format
|
||||
if birth_date:
|
||||
try:
|
||||
datetime.strptime(birth_date, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Error: Invalid date format! Use YYYY-MM-DD.', 'error')
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
UPDATE students SET
|
||||
order_number = ?,
|
||||
cial_code = ?,
|
||||
nif_nie_passport = ?,
|
||||
registration_number = ?,
|
||||
file_number = ?,
|
||||
first_name = ?,
|
||||
last_name = ?,
|
||||
full_name = ?,
|
||||
birth_date = ?,
|
||||
gender = ?,
|
||||
study_group = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
''', (order_number, cial_code, nif_nie_passport or None, registration_number,
|
||||
file_number or None, first_name, last_name, full_name,
|
||||
birth_date or None, gender or None, study_group or None, student_id))
|
||||
conn.commit()
|
||||
flash('Student updated successfully!', 'success')
|
||||
return redirect(url_for('student_detail', student_id=student_id))
|
||||
except sqlite3.IntegrityError as e:
|
||||
if 'UNIQUE constraint failed: students.cial_code' in str(e):
|
||||
flash('Error: CIAL code already exists!', 'error')
|
||||
elif 'UNIQUE constraint failed: students.nif_nie_passport' in str(e):
|
||||
flash('Error: NIF/NIE/Passport already exists!', 'error')
|
||||
else:
|
||||
flash(f'Error: {str(e)}', 'error')
|
||||
|
||||
return render_template('edit_student.html', student=student)
|
||||
|
||||
|
||||
@app.route('/delete_student/<int:student_id>')
|
||||
def delete_student(student_id):
|
||||
"""Delete a student"""
|
||||
with get_db() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if student has active loans or assigned devices
|
||||
cursor.execute("SELECT COUNT(*) FROM loans WHERE student_id = ? AND status = 'active'", (student_id,))
|
||||
active_loans = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM tablets WHERE assigned_to_student = ?", (student_id,))
|
||||
assigned_tablets = cursor.fetchone()[0]
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM non_loanable_devices WHERE assigned_to_student = ?", (student_id,))
|
||||
assigned_devices = cursor.fetchone()[0]
|
||||
|
||||
if active_loans > 0 or assigned_tablets > 0 or assigned_devices > 0:
|
||||
flash('Error: Cannot delete student with active loans or assigned devices!', 'error')
|
||||
return redirect(url_for('student_detail', student_id=student_id))
|
||||
|
||||
cursor.execute("DELETE FROM students WHERE id = ?", (student_id,))
|
||||
conn.commit()
|
||||
flash('Student deleted successfully!', 'success')
|
||||
|
||||
return redirect(url_for('list_students'))
|
||||
|
||||
|
||||
@app.route('/import_students', methods=['GET', 'POST'])
|
||||
def import_students():
|
||||
"""Import students from CSV file"""
|
||||
if request.method == 'POST':
|
||||
# Check if file was uploaded
|
||||
if 'csv_file' not in request.files:
|
||||
flash('Error: No file uploaded!', 'error')
|
||||
return redirect(url_for('import_students'))
|
||||
|
||||
file = request.files['csv_file']
|
||||
|
||||
if file.filename == '':
|
||||
flash('Error: No file selected!', 'error')
|
||||
return redirect(url_for('import_students'))
|
||||
|
||||
if not file.filename.lower().endswith('.csv'):
|
||||
flash('Error: Please upload a CSV file!', 'error')
|
||||
return redirect(url_for('import_students'))
|
||||
|
||||
# Save uploaded file temporarily
|
||||
temp_filename = file.filename if file.filename else 'import.csv'
|
||||
temp_path = os.path.join('/tmp', temp_filename)
|
||||
file.save(temp_path)
|
||||
|
||||
# Run import script
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
['python3', 'scripts/import_students.py', temp_path, '--database', DATABASE],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=os.path.dirname(os.path.abspath(__file__))
|
||||
)
|
||||
|
||||
# Clean up temp file
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
if result.returncode == 0:
|
||||
flash('Students imported successfully!', 'success')
|
||||
else:
|
||||
flash(f'Error importing students: {result.stderr}', 'error')
|
||||
|
||||
return redirect(url_for('list_students'))
|
||||
|
||||
return render_template('import_students.html')
|
||||
|
||||
|
||||
@app.route('/return_tablet/<int:loan_id>')
|
||||
def return_tablet(loan_id):
|
||||
|
|
|
|||
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
|
||||
|
|
@ -1 +1,16 @@
|
|||
# Core dependencies
|
||||
Flask==2.3.3
|
||||
Flask-Babel==2.0.0
|
||||
|
||||
# Database
|
||||
sqlite3 # Built-in, but listed for clarity
|
||||
|
||||
# For CSV processing
|
||||
python-dotenv==1.0.0
|
||||
|
||||
# For development/testing
|
||||
pytest==7.4.0
|
||||
pytest-cov==4.1.0
|
||||
|
||||
# For running the application
|
||||
waitress==2.1.2 # Production 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()
|
||||
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.
139
templates/add_student.html
Normal file
139
templates/add_student.html
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Add Student') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
<h1 class="page-title">{{ gettext('Add Student') }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Student Information') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('add_student') }}">
|
||||
|
||||
<!-- Identification Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Identification') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="cial_code" class="col-sm-3 col-form-label">{{ gettext('CIAL Code') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="cial_code" name="cial_code"
|
||||
value="{{ request.form.cial_code if request.method == 'POST' else '' }}"
|
||||
required autofocus>
|
||||
<div class="form-text">{{ gettext('Unique internal identification code') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="nif_nie_passport" class="col-sm-3 col-form-label">{{ gettext('NIF/NIE/Passport') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="nif_nie_passport" name="nif_nie_passport"
|
||||
value="{{ request.form.nif_nie_passport if request.method == 'POST' else '' }}">
|
||||
<div class="form-text">{{ gettext('National ID, Foreign ID, or Passport number') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="registration_number" class="col-sm-3 col-form-label">{{ gettext('Registration Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="number" class="form-control" id="registration_number" name="registration_number"
|
||||
value="{{ request.form.registration_number if request.method == 'POST' else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="file_number" class="col-sm-3 col-form-label">{{ gettext('File Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="file_number" name="file_number"
|
||||
value="{{ request.form.file_number if request.method == 'POST' else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="order_number" class="col-sm-3 col-form-label">{{ gettext('Order Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="number" class="form-control" id="order_number" name="order_number"
|
||||
value="{{ request.form.order_number if request.method == 'POST' else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Personal Information Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Personal Information') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="last_name" class="col-sm-3 col-form-label">{{ gettext('Last Name') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="last_name" name="last_name"
|
||||
value="{{ request.form.last_name if request.method == 'POST' else '' }}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="first_name" class="col-sm-3 col-form-label">{{ gettext('First Name') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="first_name" name="first_name"
|
||||
value="{{ request.form.first_name if request.method == 'POST' else '' }}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="birth_date" class="col-sm-3 col-form-label">{{ gettext('Birth Date') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="date" class="form-control" id="birth_date" name="birth_date"
|
||||
value="{{ request.form.birth_date if request.method == 'POST' else '' }}">
|
||||
<div class="form-text">{{ gettext('Format: YYYY-MM-DD') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="gender" class="col-sm-3 col-form-label">{{ gettext('Gender') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" id="gender" name="gender">
|
||||
<option value="">{{ gettext('Select...') }}</option>
|
||||
<option value="M" {% if request.method == 'POST' and request.form.gender == 'M' %}selected{% endif %}>{{ gettext('Male') }}</option>
|
||||
<option value="F" {% if request.method == 'POST' and request.form.gender == 'F' %}selected{% endif %}>{{ gettext('Female') }}</option>
|
||||
<option value="O" {% if request.method == 'POST' and request.form.gender == 'O' %}selected{% endif %}>{{ gettext('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Study Information Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Study Information') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="study_group" class="col-sm-3 col-form-label">{{ gettext('Study Group') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="study_group" name="study_group"
|
||||
value="{{ request.form.study_group if request.method == 'POST' else '' }}">
|
||||
<div class="form-text">{{ gettext('E.g., 1º FPB BÁSICA') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{{ url_for('list_students') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> {{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> {{ gettext('Save Student') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -194,6 +194,10 @@
|
|||
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') }}
|
||||
|
|
|
|||
147
templates/edit_student.html
Normal file
147
templates/edit_student.html
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Edit Student') }} - {{ student.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('list_students') }}">{{ gettext('Students') }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('student_detail', student_id=student.id) }}">{{ student.full_name }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ gettext('Edit') }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="page-title">{{ gettext('Edit Student') }}: {{ student.full_name }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Student Information') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('edit_student', student_id=student.id) }}">
|
||||
|
||||
<!-- Identification Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Identification') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="cial_code" class="col-sm-3 col-form-label">{{ gettext('CIAL Code') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="cial_code" name="cial_code"
|
||||
value="{{ request.form.cial_code if request.method == 'POST' else student.cial_code }}"
|
||||
required autofocus>
|
||||
<div class="form-text">{{ gettext('Unique internal identification code') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="nif_nie_passport" class="col-sm-3 col-form-label">{{ gettext('NIF/NIE/Passport') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="nif_nie_passport" name="nif_nie_passport"
|
||||
value="{{ request.form.nif_nie_passport if request.method == 'POST' else (student.nif_nie_passport or '') }}">
|
||||
<div class="form-text">{{ gettext('National ID, Foreign ID, or Passport number') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="registration_number" class="col-sm-3 col-form-label">{{ gettext('Registration Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="number" class="form-control" id="registration_number" name="registration_number"
|
||||
value="{{ request.form.registration_number if request.method == 'POST' else (student.registration_number or '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="file_number" class="col-sm-3 col-form-label">{{ gettext('File Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="file_number" name="file_number"
|
||||
value="{{ request.form.file_number if request.method == 'POST' else (student.file_number or '') }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="order_number" class="col-sm-3 col-form-label">{{ gettext('Order Number') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="number" class="form-control" id="order_number" name="order_number"
|
||||
value="{{ request.form.order_number if request.method == 'POST' else (student.order_number or '') }}">
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Personal Information Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Personal Information') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="last_name" class="col-sm-3 col-form-label">{{ gettext('Last Name') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="last_name" name="last_name"
|
||||
value="{{ request.form.last_name if request.method == 'POST' else student.last_name }}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="first_name" class="col-sm-3 col-form-label">{{ gettext('First Name') }} *</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="first_name" name="first_name"
|
||||
value="{{ request.form.first_name if request.method == 'POST' else student.first_name }}"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="birth_date" class="col-sm-3 col-form-label">{{ gettext('Birth Date') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="date" class="form-control" id="birth_date" name="birth_date"
|
||||
value="{{ request.form.birth_date if request.method == 'POST' else (student.birth_date or '') }}">
|
||||
<div class="form-text">{{ gettext('Format: YYYY-MM-DD') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="gender" class="col-sm-3 col-form-label">{{ gettext('Gender') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<select class="form-select" id="gender" name="gender">
|
||||
<option value="">{{ gettext('Select...') }}</option>
|
||||
<option value="M" {% if request.method == 'POST' and request.form.gender == 'M' %}selected{% elif student.gender == 'M' %}selected{% endif %}>{{ gettext('Male') }}</option>
|
||||
<option value="F" {% if request.method == 'POST' and request.form.gender == 'F' %}selected{% elif student.gender == 'F' %}selected{% endif %}>{{ gettext('Female') }}</option>
|
||||
<option value="O" {% if request.method == 'POST' and request.form.gender == 'O' %}selected{% elif student.gender == 'O' %}selected{% endif %}>{{ gettext('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Study Information Section -->
|
||||
<fieldset class="mb-4">
|
||||
<legend class="text-primary">{{ gettext('Study Information') }}</legend>
|
||||
|
||||
<div class="row mb-3">
|
||||
<label for="study_group" class="col-sm-3 col-form-label">{{ gettext('Study Group') }}</label>
|
||||
<div class="col-sm-9">
|
||||
<input type="text" class="form-control" id="study_group" name="study_group"
|
||||
value="{{ request.form.study_group if request.method == 'POST' else (student.study_group or '') }}">
|
||||
<div class="form-text">{{ gettext('E.g., 1º FPB BÁSICA') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> {{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> {{ gettext('Update Student') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
102
templates/import_students.html
Normal file
102
templates/import_students.html
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Import Students from CSV') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
<h1 class="page-title">{{ gettext('Import Students from CSV') }}</h1>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Instructions') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul>
|
||||
<li>{{ gettext('Upload a CSV file with student data') }}</li>
|
||||
<li>{{ gettext('The file should be encoded in ISO-8859-1 (Latin-1)') }}</li>
|
||||
<li>{{ gettext('Required columns: Apellidos, Nombre (will be split), C.I.A.L., Fecha Nac.') }}</li>
|
||||
<li>{{ gettext('Optional columns: NIF/NIE/Pas., Registro, Expediente, Sexo, Grupo') }}</li>
|
||||
<li>{{ gettext('The "Estudio" column will be discarded') }}</li>
|
||||
<li>{{ gettext('Duplicate students (by CIAL code or NIF/NIE) will be skipped') }}</li>
|
||||
</ul>
|
||||
<p class="text-muted">
|
||||
{{ gettext('Example CSV format:') }}<br>
|
||||
<code>Nº Or.,"Apellidos, Nombre",Fecha Nac.,C.I.A.L.,NIF/NIE/Pas.,Registro,Expediente,Sexo,Grupo,Estudio</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Upload CSV File') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('import_students') }}" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="csv_file" class="form-label">{{ gettext('CSV File') }} *</label>
|
||||
<input class="form-control" type="file" id="csv_file" name="csv_file"
|
||||
accept=".csv" required>
|
||||
<div class="form-text">{{ gettext('Select a CSV file to upload') }}</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="{{ url_for('list_students') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> {{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-upload"></i> {{ gettext('Import Students') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sample Data Preview -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Sample Data Format') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<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>{{ gettext('Estudio') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td>PERIQUITA DE LOS PALOTES, JUANITA</td>
|
||||
<td>24/08/2009</td>
|
||||
<td>B19L64119K</td>
|
||||
<td>24587243H</td>
|
||||
<td>4847</td>
|
||||
<td></td>
|
||||
<td>M</td>
|
||||
<td>1º FPB BÁSICA</td>
|
||||
<td>1º CFGB Servicios Socioculturales...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 text-muted small">
|
||||
{{ gettext('Note: The "Estudio" field will be discarded during import.') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -1,78 +1,113 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Loan Tablet') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2>{{ _('Loan Tablet') }}</h2>
|
||||
<form method="POST" action="/loan_tablet">
|
||||
<!-- Tablet Selection with Search -->
|
||||
<div class="form-group">
|
||||
<label for="tablet_id">{{ _('Tablet') }}:</label>
|
||||
<input type="text" id="tablet_search" class="search-input"
|
||||
placeholder="{{ _('Search tablets (brand, model, or serial)...') }}"
|
||||
onkeyup="filterDropdown('tablet_search', 'tablet_id')">
|
||||
<select id="tablet_id" name="tablet_id" required size="5">
|
||||
<option value="">{{ _('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>
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12 col-lg-8 offset-lg-2">
|
||||
<h1 class="page-title">{{ gettext('Loan Tablet') }}</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Loan Information') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/loan_tablet">
|
||||
|
||||
<!-- Tablet Selection -->
|
||||
<div class="mb-3">
|
||||
<label for="tablet_id" class="form-label">{{ gettext('Tablet') }} *</label>
|
||||
<input type="text" id="tablet_search" class="form-control mb-2"
|
||||
placeholder="{{ gettext('Search tablets (brand, model, or serial)...') }}"
|
||||
onkeyup="filterDropdown('tablet_search', 'tablet_id')">
|
||||
<select id="tablet_id" name="tablet_id" class="form-select" required size="5">
|
||||
<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>
|
||||
<div class="form-text">{{ gettext('Select which tablet to loan') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- User Selection (Staff) -->
|
||||
<div class="mb-3">
|
||||
<label for="user_id" class="form-label">{{ gettext('Staff User') }} *</label>
|
||||
<input type="text" id="user_search" class="form-control mb-2"
|
||||
placeholder="{{ gettext('Search users (name or ID)...') }}"
|
||||
onkeyup="filterDropdown('user_search', 'user_id')">
|
||||
<select id="user_id" name="user_id" class="form-select" required size="5">
|
||||
<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>
|
||||
<div class="form-text">{{ gettext('Select the staff member processing the loan') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Student Selection (Optional) -->
|
||||
<div class="mb-3">
|
||||
<label for="student_id" class="form-label">{{ gettext('Student (Optional)') }}</label>
|
||||
<input type="text" id="student_search" class="form-control mb-2"
|
||||
placeholder="{{ gettext('Search students (name, CIAL, or NIF)...') }}"
|
||||
onkeyup="filterDropdown('student_search', 'student_id')">
|
||||
<select id="student_id" name="student_id" class="form-select" size="5">
|
||||
<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>
|
||||
<div class="form-text">{{ gettext('Optionally associate this loan with a student') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Actions -->
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<a href="/" class="btn btn-secondary">
|
||||
<i class="bi bi-x-circle"></i> {{ gettext('Cancel') }}
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-circle"></i> {{ gettext('Loan Tablet') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Selection with Search -->
|
||||
<div class="form-group">
|
||||
<label for="user_id">{{ _('User') }}:</label>
|
||||
<input type="text" id="user_search" class="search-input"
|
||||
placeholder="{{ _('Search users (name or ID)...') }}"
|
||||
onkeyup="filterDropdown('user_search', 'user_id')">
|
||||
<select id="user_id" name="user_id" required size="5">
|
||||
<option value="">{{ _('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>
|
||||
</div>
|
||||
<script>
|
||||
function filterDropdown(searchId, selectId) {
|
||||
const search = document.getElementById(searchId).value.toLowerCase();
|
||||
const select = document.getElementById(selectId);
|
||||
const options = select.options;
|
||||
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn">{{ _('Loan Tablet') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<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;
|
||||
}
|
||||
for (let i = 1; i < options.length; i++) {
|
||||
const searchText = options[i].getAttribute('data-search') || '';
|
||||
options[i].style.display = searchText.includes(search) ? '' : 'none';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 5px;
|
||||
// Show first visible option if search is empty, otherwise keep selection
|
||||
if (search === '') {
|
||||
select.selectedIndex = 0;
|
||||
}
|
||||
select[size] {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
select[size] {
|
||||
width: 100%;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
|
|
|||
183
templates/student_detail.html
Normal file
183
templates/student_detail.html
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Student Details') }} - {{ student.full_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="{{ url_for('list_students') }}">{{ gettext('Students') }}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ student.full_name }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1 class="page-title mb-0">{{ student.full_name }}</h1>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('edit_student', student_id=student.id) }}" class="btn btn-warning">
|
||||
<i class="bi bi-pencil"></i> {{ gettext('Edit') }}
|
||||
</a>
|
||||
<a href="{{ url_for('delete_student', student_id=student.id) }}" class="btn btn-danger"
|
||||
onclick="return confirm('{{ gettext('Are you sure you want to delete this student?') }}')">
|
||||
<i class="bi bi-trash"></i> {{ gettext('Delete') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Student Information Card -->
|
||||
<div class="col-12 col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">{{ gettext('Student Information') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">{{ gettext('ID') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.id }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('CIAL Code') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.cial_code }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('NIF/NIE/Passport') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.nif_nie_passport or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Registration Number') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.registration_number or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('File Number') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.file_number or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Order Number') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.order_number or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Full Name') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.full_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('First Name') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.first_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Last Name') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.last_name }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Birth Date') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.birth_date or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Gender') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.gender or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Study Group') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.study_group or '-' }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Created') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.created_at }}</dd>
|
||||
|
||||
<dt class="col-sm-4">{{ gettext('Updated') }}:</dt>
|
||||
<dd class="col-sm-8">{{ student.updated_at }}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Devices Card -->
|
||||
<div class="col-12 col-lg-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">{{ gettext('Assigned Devices') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Assigned Tablets -->
|
||||
<h6>{{ gettext('Tablets') }}</h6>
|
||||
{% if assigned_tablets %}
|
||||
<ul class="list-group list-group-flush mb-3">
|
||||
{% for tablet in assigned_tablets %}
|
||||
<li class="list-group-item">
|
||||
<strong>{{ tablet.brand }} {{ tablet.model }}</strong><br>
|
||||
<small class="text-muted">{{ gettext('Serial:') }} {{ tablet.serial_number }}</small><br>
|
||||
<small class="text-muted">{{ gettext('Status:') }} {{ tablet.status }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">{{ gettext('No tablets assigned') }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Assigned Non-Loanable Devices -->
|
||||
<h6>{{ gettext('Other Devices') }}</h6>
|
||||
{% if assigned_devices %}
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for device in assigned_devices %}
|
||||
<li class="list-group-item">
|
||||
<strong>{{ device.brand }} {{ device.model }}</strong><br>
|
||||
<small class="text-muted">{{ gettext('Type:') }} {{ device.device_type }}</small><br>
|
||||
<small class="text-muted">{{ gettext('Serial:') }} {{ device.serial_number }}</small>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-muted">{{ gettext('No other devices assigned') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loan History Card -->
|
||||
<div class="col-12 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-success text-white">
|
||||
<h5 class="mb-0">{{ gettext('Loan History') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{{ gettext('ID') }}</th>
|
||||
<th>{{ gettext('Tablet') }}</th>
|
||||
<th>{{ gettext('Loan Date') }}</th>
|
||||
<th>{{ gettext('Return Date') }}</th>
|
||||
<th>{{ gettext('Status') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if loans %}
|
||||
{% for loan in loans %}
|
||||
<tr>
|
||||
<td>{{ loan.id }}</td>
|
||||
<td>{{ loan.brand }} {{ loan.model }} ({{ loan.serial_number }})</td>
|
||||
<td>{{ loan.loan_date }}</td>
|
||||
<td>{{ loan.return_date or '-' }}</td>
|
||||
<td>
|
||||
<span class="badge bg-{{ 'success' if loan.status == 'returned' else 'primary' }}">
|
||||
{{ loan.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">
|
||||
{{ gettext('No loan history') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<a href="{{ url_for('list_students') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-arrow-left"></i> {{ gettext('Back to Students') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
182
templates/students.html
Normal file
182
templates/students.html
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ gettext('Students') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<h1 class="page-title">{{ gettext('Students') }}</h1>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<p class="text-muted">{{ gettext('Total students:') }} {{ total_students }}</p>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('add_student') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle"></i> {{ gettext('Add Student') }}
|
||||
</a>
|
||||
<a href="{{ url_for('import_students') }}" class="btn btn-secondary">
|
||||
<i class="bi bi-upload"></i> {{ gettext('Import CSV') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search and Filter Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Search & Filter') }}</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('list_students') }}" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="search" class="form-label">{{ gettext('Search') }}</label>
|
||||
<input type="text" class="form-control" id="search" name="search"
|
||||
value="{{ search }}" placeholder="{{ gettext('Name, CIAL, NIF...') }}">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="gender" class="form-label">{{ gettext('Gender') }}</label>
|
||||
<select class="form-select" 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 class="col-md-3">
|
||||
<label for="group" class="form-label">{{ gettext('Study Group') }}</label>
|
||||
<select class="form-select" 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="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-search"></i> {{ gettext('Search') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<a href="{{ url_for('list_students') }}" class="btn btn-secondary w-100">
|
||||
<i class="bi bi-x-circle"></i> {{ gettext('Clear') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Students Table -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">{{ gettext('Student List') }}</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<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-end">{{ gettext('Actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if students %}
|
||||
{% for student in students %}
|
||||
<tr>
|
||||
<td>{{ student.id }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}">
|
||||
{{ student.last_name }}, {{ student.first_name }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ student.cial_code }}</td>
|
||||
<td>{{ student.nif_nie_passport or '-' }}</td>
|
||||
<td>{{ student.gender or '-' }}</td>
|
||||
<td>{{ student.study_group or '-' }}</td>
|
||||
<td>{{ student.birth_date or '-' }}</td>
|
||||
<td class="text-end">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="{{ url_for('student_detail', student_id=student.id) }}"
|
||||
class="btn btn-info btn-sm" title="{{ gettext('View') }}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('edit_student', student_id=student.id) }}"
|
||||
class="btn btn-warning btn-sm" title="{{ gettext('Edit') }}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<a href="{{ url_for('delete_student', student_id=student.id) }}"
|
||||
class="btn btn-danger btn-sm"
|
||||
onclick="return confirm('{{ gettext('Are you sure you want to delete this student?') }}')"
|
||||
title="{{ gettext('Delete') }}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">
|
||||
{{ gettext('No students found') }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if total_pages > 1 %}
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
{% if page > 1 %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page - 1 }}&search={{ search }}&gender={{ gender }}&group={{ group }}">
|
||||
{{ gettext('Previous') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for p in range(1, total_pages + 1) %}
|
||||
{% if p == page %}
|
||||
<li class="page-item active"><span class="page-link">{{ p }}</span></li>
|
||||
{% elif p <= 3 or p > total_pages - 3 or (p >= page - 2 and p <= page + 2) %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ p }}&search={{ search }}&gender={{ gender }}&group={{ group }}">{{ p }}</a>
|
||||
</li>
|
||||
{% elif p == 4 or p == total_pages - 3 %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if page < total_pages %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="?page={{ page + 1 }}&search={{ search }}&gender={{ gender }}&group={{ group }}">
|
||||
{{ gettext('Next') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-muted text-center mt-2">
|
||||
{{ gettext('Showing') }} {{ (page - 1) * per_page + 1 }}-
|
||||
{{ min(page * per_page, total_students) }} {{ gettext('of') }} {{ total_students }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
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 = "." }
|
||||
Loading…
Add table
Add a link
Reference in a new issue