2026-06-03 10:43:17 +01:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""
|
|
|
|
|
Simple Tablet Lending and Return Management Web Application
|
|
|
|
|
with SQLite backend
|
|
|
|
|
"""
|
|
|
|
|
|
2026-06-20 13:14:41 +01:00
|
|
|
from flask import Flask, render_template, request, redirect, url_for, flash, make_response, session
|
2026-06-03 10:43:17 +01:00
|
|
|
import sqlite3
|
|
|
|
|
import os
|
|
|
|
|
from datetime import datetime
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
from flask_babel import Babel, gettext, lazy_gettext
|
2026-06-03 10:43:17 +01:00
|
|
|
|
|
|
|
|
app = Flask(__name__)
|
|
|
|
|
app.secret_key = 'your_secret_key_here'
|
|
|
|
|
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
# Configure supported languages
|
|
|
|
|
app.config['BABEL_DEFAULT_LOCALE'] = 'es'
|
|
|
|
|
app.config['LANGUAGES'] = {
|
|
|
|
|
'en': 'English',
|
|
|
|
|
'es': 'Español'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Configure translation directory
|
|
|
|
|
app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'translations'
|
|
|
|
|
|
2026-06-20 10:04:53 +01:00
|
|
|
# Locale selector function
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
def get_locale():
|
2026-06-20 13:14:41 +01:00
|
|
|
# Try to get language from cookie
|
|
|
|
|
lang = request.cookies.get('language')
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
if lang and lang in app.config['LANGUAGES']:
|
|
|
|
|
return lang
|
|
|
|
|
# Try to get from session
|
|
|
|
|
if hasattr(request, 'session') and 'language' in request.session:
|
|
|
|
|
return request.session['language']
|
2026-06-20 13:14:41 +01:00
|
|
|
# Default to Spanish (es) for internal app
|
|
|
|
|
return 'es'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Before request handler to set language from URL param and persist to cookie/session
|
|
|
|
|
@app.before_request
|
|
|
|
|
def handle_language():
|
|
|
|
|
lang = request.args.get('lang')
|
|
|
|
|
if lang and lang in app.config['LANGUAGES']:
|
|
|
|
|
# Store in session
|
|
|
|
|
session['language'] = lang
|
|
|
|
|
# Set cookie for longer persistence (1 year)
|
|
|
|
|
# Note: We need to use response.set_cookie, but before_request can't modify response
|
|
|
|
|
# So we'll handle this in a separate route
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Language switcher endpoint
|
|
|
|
|
@app.route('/set_language')
|
|
|
|
|
def set_language():
|
|
|
|
|
lang = request.args.get('lang')
|
|
|
|
|
if lang and lang in app.config['LANGUAGES']:
|
|
|
|
|
session['language'] = lang
|
|
|
|
|
# Set cookie
|
|
|
|
|
resp = make_response(redirect(request.referrer or '/'))
|
|
|
|
|
resp.set_cookie('language', lang, max_age=31536000) # 1 year
|
|
|
|
|
return resp
|
|
|
|
|
return redirect(request.referrer or '/')
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
|
2026-06-20 10:04:53 +01:00
|
|
|
# Initialize Babel with locale selector
|
|
|
|
|
babel = Babel(app, locale_selector=get_locale)
|
|
|
|
|
|
feat(i18n): add Spanish localization support with Flask-Babel
- Add Flask-Babel for internationalization
- Configure app for Spanish (default) and English
- Add language switcher in navigation
- Wrap all UI text in templates with gettext()
- Create translation directory structure
- Add complete Spanish translations (58 strings)
- Add English translations (template)
- Compile .po to .mo files
- Add extract_translations.py script for future updates
- Add compile_translations.py script
Spanish is now the default language, with English available via ?lang=en
Translated strings include:
- Navigation (Home, Add Tablet, Add User, etc.)
- User Loans interface (Search, Filter, Status, etc.)
- Table headers (User, Tablet, Serial Number, etc.)
- Buttons (Loan, Return, Search, etc.)
- Messages (No users found, No active loans, etc.)
- All UI text across all templates
2026-06-20 09:58:13 +01:00
|
|
|
# Context processor to make language and config available in templates
|
|
|
|
|
@app.context_processor
|
|
|
|
|
def inject_global_variables():
|
|
|
|
|
lang = get_locale()
|
|
|
|
|
return {
|
|
|
|
|
'language': lang,
|
|
|
|
|
'config': app.config
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
# Database setup
|
|
|
|
|
DATABASE = 'tablets.db'
|
|
|
|
|
|
|
|
|
|
def get_db():
|
|
|
|
|
"""Get database connection"""
|
|
|
|
|
conn = sqlite3.connect(DATABASE)
|
|
|
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
|
return conn
|
|
|
|
|
|
|
|
|
|
def init_db():
|
|
|
|
|
"""Initialize database with required tables"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
# Create tablets table
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
CREATE TABLE IF NOT EXISTS tablets (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
brand TEXT NOT NULL,
|
|
|
|
|
model TEXT NOT NULL,
|
|
|
|
|
serial_number TEXT UNIQUE NOT NULL,
|
|
|
|
|
status TEXT DEFAULT 'available',
|
|
|
|
|
notes TEXT
|
|
|
|
|
)
|
|
|
|
|
''')
|
|
|
|
|
|
|
|
|
|
# Create users table
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
name TEXT NOT NULL,
|
|
|
|
|
email TEXT,
|
|
|
|
|
phone TEXT,
|
|
|
|
|
identification TEXT UNIQUE
|
|
|
|
|
)
|
|
|
|
|
''')
|
|
|
|
|
|
|
|
|
|
# Create loans table
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
CREATE TABLE IF NOT EXISTS loans (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
tablet_id INTEGER NOT NULL,
|
|
|
|
|
user_id INTEGER NOT NULL,
|
|
|
|
|
loan_date TEXT NOT NULL,
|
|
|
|
|
return_date TEXT,
|
|
|
|
|
status TEXT DEFAULT 'active',
|
|
|
|
|
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
|
|
|
|
|
FOREIGN KEY (user_id) REFERENCES users (id)
|
|
|
|
|
)
|
|
|
|
|
''')
|
|
|
|
|
|
2026-06-23 19:57:49 +01:00
|
|
|
# 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)
|
|
|
|
|
''')
|
|
|
|
|
|
2026-06-09 23:54:38 +01:00
|
|
|
# Create non_loanable_devices table for devices that cannot be loaned
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
CREATE TABLE IF NOT EXISTS non_loanable_devices (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
brand TEXT NOT NULL,
|
|
|
|
|
model TEXT NOT NULL,
|
|
|
|
|
serial_number TEXT UNIQUE NOT NULL,
|
|
|
|
|
device_type TEXT NOT NULL,
|
|
|
|
|
location TEXT,
|
|
|
|
|
status TEXT DEFAULT 'available',
|
|
|
|
|
notes TEXT,
|
|
|
|
|
purchase_date TEXT,
|
2026-06-23 19:57:49 +01:00
|
|
|
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)
|
2026-06-09 23:54:38 +01:00
|
|
|
)
|
|
|
|
|
''')
|
|
|
|
|
|
2026-06-23 19:57:49 +01:00
|
|
|
# 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)
|
|
|
|
|
''')
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
@app.route('/')
|
|
|
|
|
def index():
|
|
|
|
|
"""Main page showing available tablets and active loans"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
# Get available tablets
|
|
|
|
|
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
|
|
|
|
|
available_tablets = cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
# Get active loans
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
SELECT l.id, t.brand, t.model, t.serial_number, u.name, l.loan_date
|
|
|
|
|
FROM loans l
|
|
|
|
|
JOIN tablets t ON l.tablet_id = t.id
|
|
|
|
|
JOIN users u ON l.user_id = u.id
|
|
|
|
|
WHERE l.status = 'active'
|
|
|
|
|
''')
|
|
|
|
|
active_loans = cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
return render_template('index.html',
|
|
|
|
|
available_tablets=available_tablets,
|
|
|
|
|
active_loans=active_loans)
|
|
|
|
|
|
|
|
|
|
@app.route('/add_tablet', methods=['GET', 'POST'])
|
|
|
|
|
def add_tablet():
|
|
|
|
|
"""Add a new tablet to inventory"""
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
brand = request.form['brand']
|
|
|
|
|
model = request.form['model']
|
|
|
|
|
serial_number = request.form['serial_number']
|
|
|
|
|
notes = request.form['notes']
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
INSERT INTO tablets (brand, model, serial_number, status, notes)
|
|
|
|
|
VALUES (?, ?, ?, 'available', ?)
|
|
|
|
|
''', (brand, model, serial_number, notes))
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('Tablet added successfully!', 'success')
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
flash('Error: Serial number already exists!', 'error')
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('index'))
|
|
|
|
|
|
|
|
|
|
return render_template('add_tablet.html')
|
|
|
|
|
|
|
|
|
|
@app.route('/add_user', methods=['GET', 'POST'])
|
|
|
|
|
def add_user():
|
|
|
|
|
"""Add a new user"""
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
name = request.form['name']
|
|
|
|
|
email = request.form['email']
|
|
|
|
|
phone = request.form['phone']
|
|
|
|
|
identification = request.form['identification']
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
INSERT INTO users (name, email, phone, identification)
|
|
|
|
|
VALUES (?, ?, ?, ?)
|
|
|
|
|
''', (name, email, phone, identification))
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('User added successfully!', 'success')
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
flash('Error: Identification already exists!', 'error')
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('index'))
|
|
|
|
|
|
|
|
|
|
return render_template('add_user.html')
|
|
|
|
|
|
|
|
|
|
@app.route('/loan_tablet', methods=['GET', 'POST'])
|
|
|
|
|
def loan_tablet():
|
|
|
|
|
"""Loan a tablet to a user"""
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
tablet_id = request.form['tablet_id']
|
|
|
|
|
user_id = request.form['user_id']
|
2026-06-23 19:57:49 +01:00
|
|
|
student_id = request.form.get('student_id') # Optional - can be loaned to student directly
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
2026-06-23 19:57:49 +01:00
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
# Update tablet status
|
|
|
|
|
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
|
2026-06-23 19:57:49 +01:00
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
# Create loan record
|
|
|
|
|
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
cursor.execute('''
|
2026-06-23 19:57:49 +01:00
|
|
|
INSERT INTO loans (tablet_id, user_id, student_id, loan_date, status)
|
|
|
|
|
VALUES (?, ?, ?, ?, 'active')
|
|
|
|
|
''', (tablet_id, user_id, student_id, loan_date))
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
conn.commit()
|
|
|
|
|
flash('Tablet loaned successfully!', 'success')
|
2026-06-23 19:57:49 +01:00
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
return redirect(url_for('index'))
|
|
|
|
|
|
|
|
|
|
# Get data for form
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute("SELECT * FROM tablets WHERE status = 'available'")
|
|
|
|
|
available_tablets = cursor.fetchall()
|
|
|
|
|
cursor.execute("SELECT * FROM users")
|
|
|
|
|
users = cursor.fetchall()
|
2026-06-23 19:57:49 +01:00
|
|
|
cursor.execute("SELECT * FROM students ORDER BY last_name, first_name")
|
|
|
|
|
students = cursor.fetchall()
|
2026-06-03 10:43:17 +01:00
|
|
|
|
|
|
|
|
return render_template('loan_tablet.html',
|
|
|
|
|
available_tablets=available_tablets,
|
2026-06-23 19:57:49 +01:00
|
|
|
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')
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
|
|
|
|
|
@app.route('/return_tablet/<int:loan_id>')
|
|
|
|
|
def return_tablet(loan_id):
|
|
|
|
|
"""Return a loaned tablet"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
# Get loan information
|
|
|
|
|
cursor.execute("SELECT tablet_id FROM loans WHERE id = ?", (loan_id,))
|
|
|
|
|
loan = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
if loan:
|
|
|
|
|
tablet_id = loan['tablet_id']
|
|
|
|
|
|
|
|
|
|
# Update loan status and return date
|
|
|
|
|
return_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
UPDATE loans SET status = 'returned', return_date = ? WHERE id = ?
|
|
|
|
|
''', (return_date, loan_id))
|
|
|
|
|
|
|
|
|
|
# Update tablet status
|
|
|
|
|
cursor.execute("UPDATE tablets SET status = 'available' WHERE id = ?", (tablet_id,))
|
|
|
|
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('Tablet returned successfully!', 'success')
|
|
|
|
|
else:
|
|
|
|
|
flash('Error: Loan not found!', 'error')
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('index'))
|
|
|
|
|
|
|
|
|
|
@app.route('/history')
|
|
|
|
|
def history():
|
|
|
|
|
"""Show loan history"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
SELECT l.id, t.brand, t.model, t.serial_number, u.name,
|
|
|
|
|
l.loan_date, l.return_date, l.status
|
|
|
|
|
FROM loans l
|
|
|
|
|
JOIN tablets t ON l.tablet_id = t.id
|
|
|
|
|
JOIN users u ON l.user_id = u.id
|
|
|
|
|
ORDER BY l.loan_date DESC
|
|
|
|
|
''')
|
|
|
|
|
loans = cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
return render_template('history.html', loans=loans)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/project_management')
|
|
|
|
|
def project_management():
|
|
|
|
|
"""Project management page with markdown editor for development notes"""
|
|
|
|
|
return render_template('project_management.html')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/get_notes/<path:filename>')
|
|
|
|
|
def get_notes(filename):
|
|
|
|
|
"""Serve notes file content"""
|
|
|
|
|
try:
|
|
|
|
|
with open(filename, 'r', encoding='utf-8') as f:
|
|
|
|
|
content = f.read()
|
|
|
|
|
return content, 200, {
|
|
|
|
|
'Content-Type': 'text/markdown; charset=utf-8',
|
|
|
|
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
|
|
|
'Pragma': 'no-cache',
|
|
|
|
|
'Expires': '0'
|
|
|
|
|
}
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return '', 404
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return str(e), 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/save_notes', methods=['POST'])
|
|
|
|
|
def save_notes():
|
|
|
|
|
"""Save markdown notes to file"""
|
|
|
|
|
data = request.get_json()
|
|
|
|
|
content = data.get('content', '')
|
|
|
|
|
filename = data.get('filename', 'notes_development/project_notes.md')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
# Ensure directory exists
|
|
|
|
|
os.makedirs(os.path.dirname(filename), exist_ok=True)
|
|
|
|
|
|
|
|
|
|
# Write content to file
|
|
|
|
|
with open(filename, 'w', encoding='utf-8') as f:
|
|
|
|
|
f.write(content)
|
|
|
|
|
|
|
|
|
|
return {'status': 'success', 'message': 'Notes saved successfully'}
|
|
|
|
|
except Exception as e:
|
|
|
|
|
return {'status': 'error', 'message': str(e)}, 500
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/user_loans')
|
|
|
|
|
def user_loans():
|
|
|
|
|
"""
|
2026-06-20 08:38:42 +01:00
|
|
|
Show all users with their loan information.
|
|
|
|
|
Supports search, filtering, and pagination via HTMX.
|
2026-06-03 10:43:17 +01:00
|
|
|
"""
|
2026-06-20 08:38:42 +01:00
|
|
|
from flask import request
|
|
|
|
|
|
|
|
|
|
# Get query parameters
|
|
|
|
|
page = request.args.get('page', 1, type=int)
|
|
|
|
|
search = request.args.get('search', '').strip()
|
|
|
|
|
status_filter = request.args.get('status', '')
|
|
|
|
|
|
|
|
|
|
# Pagination settings
|
|
|
|
|
per_page = 20
|
|
|
|
|
offset = (page - 1) * per_page
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
# Build base query for users with loan counts
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
u.id, u.name, u.identification, u.email, u.phone,
|
|
|
|
|
COUNT(l.id) as loan_count,
|
|
|
|
|
MAX(l.loan_date) as last_loan_date
|
|
|
|
|
FROM users u
|
|
|
|
|
LEFT JOIN loans l ON u.id = l.user_id AND l.status = 'active'
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
conditions = []
|
|
|
|
|
params = []
|
2026-06-20 08:53:55 +01:00
|
|
|
having_conditions = []
|
2026-06-20 08:38:42 +01:00
|
|
|
|
|
|
|
|
# Add search filter
|
|
|
|
|
if search:
|
|
|
|
|
conditions.append("(u.name LIKE ? OR u.identification LIKE ? OR u.email LIKE ?)")
|
|
|
|
|
search_param = f"%{search}%"
|
|
|
|
|
params.extend([search_param, search_param, search_param])
|
|
|
|
|
|
2026-06-20 08:53:55 +01:00
|
|
|
# Add status filter (use HAVING for aggregate functions)
|
2026-06-20 08:38:42 +01:00
|
|
|
if status_filter == 'with_loans':
|
2026-06-20 08:53:55 +01:00
|
|
|
having_conditions.append("COUNT(l.id) > 0")
|
2026-06-20 08:38:42 +01:00
|
|
|
elif status_filter == 'no_loans':
|
2026-06-20 08:53:55 +01:00
|
|
|
having_conditions.append("COUNT(l.id) = 0")
|
2026-06-20 08:38:42 +01:00
|
|
|
|
2026-06-20 08:53:55 +01:00
|
|
|
# Combine conditions for WHERE clause
|
|
|
|
|
where_clause = ""
|
2026-06-20 08:38:42 +01:00
|
|
|
if conditions:
|
2026-06-20 08:53:55 +01:00
|
|
|
where_clause = " WHERE " + " AND ".join(conditions)
|
|
|
|
|
|
|
|
|
|
# Combine HAVING conditions
|
|
|
|
|
having_clause = ""
|
|
|
|
|
if having_conditions:
|
|
|
|
|
having_clause = " HAVING " + " AND ".join(having_conditions)
|
2026-06-20 08:38:42 +01:00
|
|
|
|
|
|
|
|
# Group by and order
|
2026-06-20 08:53:55 +01:00
|
|
|
group_by = " GROUP BY u.id, u.name, u.identification, u.email, u.phone"
|
|
|
|
|
order_by = " ORDER BY u.name COLLATE NOCASE"
|
2026-06-20 08:38:42 +01:00
|
|
|
|
|
|
|
|
# Get total count for pagination
|
2026-06-20 08:53:55 +01:00
|
|
|
# We need to count distinct users matching the criteria
|
|
|
|
|
count_query = f"SELECT COUNT(DISTINCT u.id) FROM users u LEFT JOIN loans l ON u.id = l.user_id AND l.status = 'active'{where_clause}{having_clause}"
|
2026-06-20 08:38:42 +01:00
|
|
|
cursor.execute(count_query, params)
|
2026-06-20 08:55:18 +01:00
|
|
|
result = cursor.fetchone()
|
|
|
|
|
total_users = result[0] if result else 0
|
2026-06-20 08:38:42 +01:00
|
|
|
total_pages = (total_users + per_page - 1) // per_page
|
|
|
|
|
|
2026-06-20 08:53:55 +01:00
|
|
|
# Build final query with pagination
|
|
|
|
|
final_query = query + where_clause + group_by + having_clause + order_by + f" LIMIT {per_page} OFFSET {offset}"
|
2026-06-20 08:38:42 +01:00
|
|
|
|
|
|
|
|
# Execute main query
|
2026-06-20 08:53:55 +01:00
|
|
|
cursor.execute(final_query, params)
|
2026-06-03 10:43:17 +01:00
|
|
|
users = cursor.fetchall()
|
|
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
# Convert to list of dicts for template
|
|
|
|
|
users_list = []
|
|
|
|
|
for user in users:
|
|
|
|
|
users_list.append({
|
|
|
|
|
'id': user[0],
|
|
|
|
|
'name': user[1],
|
|
|
|
|
'identification': user[2],
|
|
|
|
|
'email': user[3],
|
|
|
|
|
'phone': user[4],
|
|
|
|
|
'loan_count': user[5],
|
|
|
|
|
'last_loan_date': user[6]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
# Check if this is an HTMX request
|
|
|
|
|
is_htmx = request.headers.get('HX-Request') == 'true'
|
|
|
|
|
|
|
|
|
|
if is_htmx:
|
|
|
|
|
# Return just the results partial for HTMX swap
|
|
|
|
|
return render_template('components/user_loans_results.html',
|
|
|
|
|
users=users_list,
|
|
|
|
|
page=page,
|
|
|
|
|
total_pages=total_pages,
|
|
|
|
|
total_users=total_users,
|
|
|
|
|
search=search,
|
|
|
|
|
status=status_filter,
|
|
|
|
|
per_page=per_page)
|
|
|
|
|
else:
|
|
|
|
|
# Full page render
|
|
|
|
|
return render_template('user_loans.html',
|
|
|
|
|
users=users_list,
|
|
|
|
|
page=page,
|
|
|
|
|
total_pages=total_pages,
|
|
|
|
|
total_users=total_users,
|
|
|
|
|
search=search,
|
|
|
|
|
status=status_filter,
|
|
|
|
|
per_page=per_page)
|
|
|
|
|
|
|
|
|
|
|
2026-06-20 08:53:55 +01:00
|
|
|
|
|
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
@app.route('/user_loans/<int:user_id>')
|
|
|
|
|
def user_loans_detail(user_id):
|
|
|
|
|
"""
|
|
|
|
|
Show detailed loan history for a specific user.
|
|
|
|
|
"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
|
|
|
|
|
# Get user info
|
|
|
|
|
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
|
|
|
|
|
user = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
if not user:
|
|
|
|
|
flash('User not found', 'error')
|
|
|
|
|
return redirect(url_for('user_loans'))
|
|
|
|
|
|
|
|
|
|
# Get all loans for this user
|
2026-06-03 10:43:17 +01:00
|
|
|
cursor.execute('''
|
2026-06-20 08:38:42 +01:00
|
|
|
SELECT l.id, l.tablet_id, l.loan_date, l.return_date, l.status,
|
|
|
|
|
t.brand, t.model, t.serial_number, t.status as tablet_status
|
2026-06-03 10:43:17 +01:00
|
|
|
FROM loans l
|
|
|
|
|
JOIN tablets t ON l.tablet_id = t.id
|
2026-06-20 08:38:42 +01:00
|
|
|
WHERE l.user_id = ?
|
|
|
|
|
ORDER BY l.loan_date DESC
|
|
|
|
|
''', (user_id,))
|
2026-06-03 10:43:17 +01:00
|
|
|
loans = cursor.fetchall()
|
|
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
# Get active loans count
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
SELECT COUNT(*) FROM loans
|
|
|
|
|
WHERE user_id = ? AND status = 'active'
|
|
|
|
|
''', (user_id,))
|
|
|
|
|
active_count = cursor.fetchone()[0]
|
2026-06-03 10:43:17 +01:00
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
# Get returned loans count
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
SELECT COUNT(*) FROM loans
|
|
|
|
|
WHERE user_id = ? AND status = 'returned'
|
|
|
|
|
''', (user_id,))
|
|
|
|
|
returned_count = cursor.fetchone()[0]
|
2026-06-03 10:43:17 +01:00
|
|
|
|
2026-06-20 08:38:42 +01:00
|
|
|
return render_template('user_loans_detail.html',
|
|
|
|
|
user=user,
|
|
|
|
|
loans=loans,
|
|
|
|
|
active_count=active_count,
|
|
|
|
|
returned_count=returned_count)
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
|
|
|
|
|
|
2026-06-09 23:54:38 +01:00
|
|
|
@app.route('/non_loanable_devices')
|
|
|
|
|
def non_loanable_devices():
|
|
|
|
|
"""Show all non-loanable devices"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute("SELECT * FROM non_loanable_devices ORDER BY device_type, brand, model")
|
|
|
|
|
devices = cursor.fetchall()
|
|
|
|
|
|
|
|
|
|
return render_template('non_loanable_devices.html', devices=devices)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/add_non_loanable_device', methods=['GET', 'POST'])
|
|
|
|
|
def add_non_loanable_device():
|
|
|
|
|
"""Add a new non-loanable device to inventory"""
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
brand = request.form['brand']
|
|
|
|
|
model = request.form['model']
|
|
|
|
|
serial_number = request.form['serial_number']
|
|
|
|
|
device_type = request.form['device_type']
|
|
|
|
|
location = request.form['location']
|
|
|
|
|
notes = request.form['notes']
|
|
|
|
|
purchase_date = request.form.get('purchase_date', '')
|
|
|
|
|
purchase_cost = request.form.get('purchase_cost', '')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
INSERT INTO non_loanable_devices
|
|
|
|
|
(brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost)
|
|
|
|
|
VALUES (?, ?, ?, ?, ?, 'available', ?, ?, ?)
|
|
|
|
|
''', (brand, model, serial_number, device_type, location, notes, purchase_date, purchase_cost))
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('Non-loanable device added successfully!', 'success')
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
flash('Error: Serial number already exists!', 'error')
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('non_loanable_devices'))
|
|
|
|
|
|
|
|
|
|
return render_template('add_non_loanable_device.html')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/edit_non_loanable_device/<int:device_id>', methods=['GET', 'POST'])
|
|
|
|
|
def edit_non_loanable_device(device_id):
|
|
|
|
|
"""Edit a non-loanable device"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute("SELECT * FROM non_loanable_devices WHERE id = ?", (device_id,))
|
|
|
|
|
device = cursor.fetchone()
|
|
|
|
|
|
|
|
|
|
if not device:
|
|
|
|
|
flash('Error: Device not found!', 'error')
|
|
|
|
|
return redirect(url_for('non_loanable_devices'))
|
|
|
|
|
|
|
|
|
|
if request.method == 'POST':
|
|
|
|
|
brand = request.form['brand']
|
|
|
|
|
model = request.form['model']
|
|
|
|
|
serial_number = request.form['serial_number']
|
|
|
|
|
device_type = request.form['device_type']
|
|
|
|
|
location = request.form['location']
|
|
|
|
|
status = request.form['status']
|
|
|
|
|
notes = request.form['notes']
|
|
|
|
|
purchase_date = request.form.get('purchase_date', '')
|
|
|
|
|
purchase_cost = request.form.get('purchase_cost', '')
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
cursor.execute('''
|
|
|
|
|
UPDATE non_loanable_devices SET
|
|
|
|
|
brand = ?, model = ?, serial_number = ?, device_type = ?,
|
|
|
|
|
location = ?, status = ?, notes = ?, purchase_date = ?, purchase_cost = ?
|
|
|
|
|
WHERE id = ?
|
|
|
|
|
''', (brand, model, serial_number, device_type, location, status, notes, purchase_date, purchase_cost, device_id))
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('Device updated successfully!', 'success')
|
|
|
|
|
return redirect(url_for('non_loanable_devices'))
|
|
|
|
|
except sqlite3.IntegrityError:
|
|
|
|
|
flash('Error: Serial number already exists!', 'error')
|
|
|
|
|
|
|
|
|
|
return render_template('edit_non_loanable_device.html', device=device)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@app.route('/delete_non_loanable_device/<int:device_id>')
|
|
|
|
|
def delete_non_loanable_device(device_id):
|
|
|
|
|
"""Delete a non-loanable device"""
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
cursor = conn.cursor()
|
|
|
|
|
cursor.execute("DELETE FROM non_loanable_devices WHERE id = ?", (device_id,))
|
|
|
|
|
conn.commit()
|
|
|
|
|
flash('Device deleted successfully!', 'success')
|
|
|
|
|
|
|
|
|
|
return redirect(url_for('non_loanable_devices'))
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 10:43:17 +01:00
|
|
|
if __name__ == '__main__':
|
|
|
|
|
# Initialize database
|
|
|
|
|
init_db()
|
|
|
|
|
|
|
|
|
|
# Create templates directory if it doesn't exist
|
|
|
|
|
if not os.path.exists('templates'):
|
|
|
|
|
os.makedirs('templates')
|
|
|
|
|
|
|
|
|
|
app.run(debug=True, host='0.0.0.0', port=5000)
|