GestionTablets/app.py
ijuanes c9f3382c7c feat(ui): implement HTMX + Tailwind frontend revamp
- Add Tailwind CSS via CDN for modern styling
- Add HTMX for AJAX functionality without full page reloads
- Revamp user_loans interface with:
  - Real-time search (debounced 500ms)
  - Status filtering (all users, with loans, no loans)
  - Pagination (20 users per page)
  - Responsive table design
  - Clean, modern UI with Tailwind classes
- Create user_loans_detail.html for individual user loan history
- Update index.html with Tailwind styling
- Update base.html with Tailwind + HTMX setup
- Add components/user_loans_results.html for HTMX partial updates
- Update app.py user_loans route to support search, filter, pagination
- Add user_loans_detail route for detailed user view

This addresses the issues:
- Navigation overflow (fixed with responsive Tailwind classes)
- Basic look (modern, professional UI)
- User loans UX (fast search, filtering, pagination for 500+ users)
2026-06-20 08:38:42 +01:00

541 lines
No EOL
18 KiB
Python

#!/usr/bin/env python3
"""
Simple Tablet Lending and Return Management Web Application
with SQLite backend
"""
from flask import Flask, render_template, request, redirect, url_for, flash
import sqlite3
import os
from datetime import datetime
app = Flask(__name__)
app.secret_key = 'your_secret_key_here'
# Database setup
DATABASE = 'tablets.db'
def get_db():
"""Get database connection"""
conn = sqlite3.connect(DATABASE)
conn.row_factory = sqlite3.Row
return conn
def init_db():
"""Initialize database with required tables"""
with get_db() as conn:
cursor = conn.cursor()
# Create tablets table
cursor.execute('''
CREATE TABLE IF NOT EXISTS tablets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
brand TEXT NOT NULL,
model TEXT NOT NULL,
serial_number TEXT UNIQUE NOT NULL,
status TEXT DEFAULT 'available',
notes TEXT
)
''')
# Create users table
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT,
phone TEXT,
identification TEXT UNIQUE
)
''')
# Create loans table
cursor.execute('''
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tablet_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
loan_date TEXT NOT NULL,
return_date TEXT,
status TEXT DEFAULT 'active',
FOREIGN KEY (tablet_id) REFERENCES tablets (id),
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
# Create non_loanable_devices table 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,
purchase_cost REAL
)
''')
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']
with get_db() as conn:
cursor = conn.cursor()
# Update tablet status
cursor.execute("UPDATE tablets SET status = 'loaned' WHERE id = ?", (tablet_id,))
# Create loan record
loan_date = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
cursor.execute('''
INSERT INTO loans (tablet_id, user_id, loan_date, status)
VALUES (?, ?, ?, 'active')
''', (tablet_id, user_id, loan_date))
conn.commit()
flash('Tablet loaned successfully!', 'success')
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()
return render_template('loan_tablet.html',
available_tablets=available_tablets,
users=users)
@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():
"""
Show all users with their loan information.
Supports search, filtering, and pagination via HTMX.
"""
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
with get_db() as conn:
cursor = conn.cursor()
# 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 = []
# 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])
# Add status filter
if status_filter == 'with_loans':
conditions.append("COUNT(l.id) > 0")
elif status_filter == 'no_loans':
conditions.append("COUNT(l.id) = 0")
# Combine conditions
if conditions:
query += " WHERE " + " AND ".join(conditions)
# Group by and order
query += " GROUP BY u.id, u.name, u.identification, u.email, u.phone"
query += " ORDER BY u.name COLLATE NOCASE"
# Get total count for pagination
count_query = f"SELECT COUNT(*) FROM ({query})"
cursor.execute(count_query, params)
total_users = cursor.fetchone()[0]
total_pages = (total_users + per_page - 1) // per_page
# Add pagination to query
query += f" LIMIT {per_page} OFFSET {offset}"
# Execute main query
cursor.execute(query, params)
users = cursor.fetchall()
# 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)
@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
cursor.execute('''
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
FROM loans l
JOIN tablets t ON l.tablet_id = t.id
WHERE l.user_id = ?
ORDER BY l.loan_date DESC
''', (user_id,))
loans = cursor.fetchall()
# Get active loans count
cursor.execute('''
SELECT COUNT(*) FROM loans
WHERE user_id = ? AND status = 'active'
''', (user_id,))
active_count = cursor.fetchone()[0]
# Get returned loans count
cursor.execute('''
SELECT COUNT(*) FROM loans
WHERE user_id = ? AND status = 'returned'
''', (user_id,))
returned_count = cursor.fetchone()[0]
return render_template('user_loans_detail.html',
user=user,
loans=loans,
active_count=active_count,
returned_count=returned_count)
@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'))
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)