Mar 02 2026: implement user's profile page

This commit is contained in:
2026-03-02 16:59:23 -05:00
parent 7e12d519c1
commit 90ea135917
4 changed files with 308 additions and 2 deletions
+62 -2
View File
@@ -3,8 +3,11 @@ from flask_login import login_user, logout_user, login_required, current_user
from urllib.parse import urlparse from urllib.parse import urlparse
from app import db from app import db
from app.models.user import User from app.models.user import User
from app.utils.forms import LoginForm, UserForm from app.utils.forms import LoginForm, UserForm, ProfileForm
from app.utils.decorators import admin_required from app.utils.decorators import admin_required
import logging
logger = logging.getLogger(__name__)
bp = Blueprint('auth', __name__, url_prefix='/auth') bp = Blueprint('auth', __name__, url_prefix='/auth')
@@ -55,6 +58,57 @@ def logout():
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
@bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
"""User profile page — view stats and update email/password."""
from app.models.inspection import Inspection
from app.models.issue import Issue
form = ProfileForm(user=current_user, obj=current_user)
if form.validate_on_submit():
current_user.email = form.email.data
if form.new_password.data:
current_user.set_password(form.new_password.data)
logger.info('AUTH | profile_password_change | user_id=%s username=%s',
current_user.id, current_user.username)
db.session.commit()
logger.info('AUTH | profile_update | user_id=%s username=%s email=%s',
current_user.id, current_user.username, current_user.email)
flash('Profile updated successfully.', 'success')
return redirect(url_for('auth.profile'))
# ── Activity stats ────────────────────────────────────────────────────
total_inspections = Inspection.query.filter_by(inspector_id=current_user.id).count()
completed_inspections = Inspection.query.filter_by(
inspector_id=current_user.id, status='completed'
).count()
recent_inspections = (
Inspection.query
.filter_by(inspector_id=current_user.id)
.order_by(Inspection.inspection_date.desc())
.limit(5)
.all()
)
open_issues = Issue.query.filter_by(
assigned_to=current_user.id, status='open'
).count() if hasattr(Issue, 'assigned_to') else 0
return render_template(
'auth/profile.html',
form=form,
total_inspections=total_inspections,
completed_inspections=completed_inspections,
recent_inspections=recent_inspections,
open_issues=open_issues,
)
@bp.route('/users') @bp.route('/users')
@login_required @login_required
@admin_required @admin_required
@@ -78,6 +132,8 @@ def create_user():
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
logger.info('AUTH | user_create | admin_id=%s admin=%s new_user=%s role=%s',
current_user.id, current_user.username, user.username, user.role)
flash(f'User {user.username} created successfully.', 'success') flash(f'User {user.username} created successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
@@ -100,6 +156,8 @@ def edit_user(user_id):
user.set_password(form.password.data) user.set_password(form.password.data)
db.session.commit() db.session.commit()
logger.info('AUTH | user_edit | admin_id=%s admin=%s target_user_id=%s target_user=%s',
current_user.id, current_user.username, user.id, user.username)
flash(f'User {user.username} updated successfully.', 'success') flash(f'User {user.username} updated successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
@@ -130,5 +188,7 @@ def delete_user(user_id):
username = user.username username = user.username
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
logger.info('AUTH | user_delete | admin_id=%s admin=%s deleted_user=%s',
current_user.id, current_user.username, username)
flash(f'User {username} deleted successfully.', 'success') flash(f'User {username} deleted successfully.', 'success')
return redirect(url_for('auth.list_users')) return redirect(url_for('auth.list_users'))
+212
View File
@@ -0,0 +1,212 @@
{% extends "base.html" %}
{% block title %}My Profile{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2><i class="bi bi-person-circle"></i> My Profile</h2>
<p class="text-muted mb-0">Manage your account information and review your activity.</p>
</div>
</div>
<div class="row g-4">
<!-- ── Left column: account card + stats ─────────────────────────── -->
<div class="col-lg-4">
<!-- Account Info Card -->
<div class="card shadow-sm mb-4">
<div class="card-body text-center py-4">
<div class="mb-3">
<span class="display-1 text-primary">
<i class="bi bi-person-circle"></i>
</span>
</div>
<h4 class="mb-1 fw-bold">{{ current_user.username }}</h4>
<p class="text-muted mb-2">{{ current_user.email }}</p>
<span class="badge fs-6 bg-{% if current_user.role == 'admin' %}danger{% elif current_user.role == 'supervisor' %}warning{% else %}info{% endif %}">
<i class="bi bi-{% if current_user.role == 'admin' %}shield-fill{% elif current_user.role == 'supervisor' %}star-fill{% else %}person-badge-fill{% endif %} me-1"></i>
{{ current_user.role | title }}
</span>
<hr>
<small class="text-muted">
<i class="bi bi-calendar3 me-1"></i>
Member since {{ current_user.created_at.strftime('%B %d, %Y') }}
</small>
</div>
</div>
<!-- Activity Stats Card -->
<div class="card shadow-sm">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-bar-chart-fill me-1"></i>Activity Summary</h6>
</div>
<div class="card-body">
<div class="row text-center g-3">
<div class="col-6">
<div class="p-2 rounded bg-primary bg-opacity-10">
<div class="fs-3 fw-bold text-primary">{{ total_inspections }}</div>
<small class="text-muted">Total Inspections</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-success bg-opacity-10">
<div class="fs-3 fw-bold text-success">{{ completed_inspections }}</div>
<small class="text-muted">Completed</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-warning bg-opacity-10">
<div class="fs-3 fw-bold text-warning">{{ open_issues }}</div>
<small class="text-muted">Open Issues</small>
</div>
</div>
<div class="col-6">
<div class="p-2 rounded bg-info bg-opacity-10">
{% if total_inspections > 0 %}
<div class="fs-3 fw-bold text-info">
{{ ((completed_inspections / total_inspections) * 100) | int }}%
</div>
{% else %}
<div class="fs-3 fw-bold text-info"></div>
{% endif %}
<small class="text-muted">Completion Rate</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Right column: edit form + recent inspections ──────────────── -->
<div class="col-lg-8">
<!-- Edit Profile Form -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<h6 class="mb-0 fw-semibold"><i class="bi bi-pencil-square me-1"></i>Edit Profile</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('auth.profile') }}">
{{ form.hidden_tag() }}
<!-- Email -->
<div class="mb-3">
{{ form.email.label(class="form-label fw-semibold") }}
{{ form.email(class="form-control" + (" is-invalid" if form.email.errors else ""),
placeholder="your@email.com") }}
{% for error in form.email.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<hr class="my-3">
<p class="text-muted small mb-3">
<i class="bi bi-lock me-1"></i>Leave the password fields blank to keep your current password.
</p>
<!-- Current Password -->
<div class="mb-3">
{{ form.current_password.label(class="form-label fw-semibold") }}
{{ form.current_password(class="form-control" + (" is-invalid" if form.current_password.errors else ""),
autocomplete="current-password") }}
{% for error in form.current_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<!-- New Password -->
<div class="mb-3">
{{ form.new_password.label(class="form-label fw-semibold") }}
{{ form.new_password(class="form-control" + (" is-invalid" if form.new_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.new_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
<div class="form-text">Minimum 6 characters.</div>
</div>
<!-- Confirm New Password -->
<div class="mb-4">
{{ form.confirm_password.label(class="form-label fw-semibold") }}
{{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else ""),
autocomplete="new-password") }}
{% for error in form.confirm_password.errors %}
<div class="invalid-feedback">{{ error }}</div>
{% endfor %}
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>Save Changes
</button>
</form>
</div>
</div>
<!-- Recent Inspections -->
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold"><i class="bi bi-clipboard-check me-1"></i>Recent Inspections</h6>
<a href="{{ url_for('inspections.index') }}" class="btn btn-sm btn-outline-primary">
View All
</a>
</div>
<div class="card-body p-0">
{% if recent_inspections %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Date</th>
<th>Facility</th>
<th>Template</th>
<th>Score</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{% for insp in recent_inspections %}
<tr>
<td>{{ insp.inspection_date.strftime('%Y-%m-%d') }}</td>
<td>{{ insp.facility.name if insp.facility else '—' }}</td>
<td>{{ insp.template.name if insp.template else '—' }}</td>
<td>
{% if insp.overall_score is not none %}
<span class="badge bg-{% if insp.overall_score >= 80 %}success{% elif insp.overall_score >= 60 %}warning{% else %}danger{% endif %}">
{{ "%.1f"|format(insp.overall_score) }}%
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<span class="badge bg-{% if insp.status == 'completed' %}success{% elif insp.status == 'in_progress' %}primary{% else %}warning{% endif %}">
{{ insp.status | replace('_', ' ') | title }}
</span>
</td>
<td>
<a href="{{ url_for('inspections.view', inspection_id=insp.id) }}"
class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4 text-muted">
<i class="bi bi-clipboard-x fs-3 d-block mb-2"></i>
No inspections recorded yet.
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+7
View File
@@ -139,6 +139,13 @@
<i class="bi bi-person-circle"></i> {{ current_user.username }} <i class="bi bi-person-circle"></i> {{ current_user.username }}
</a> </a>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
href="{{ url_for('auth.profile') }}">
<i class="bi bi-person-circle me-1"></i>My Profile
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li> <li>
<a class="dropdown-item" <a class="dropdown-item"
href="{{ url_for('notifications.preferences') }}"> href="{{ url_for('notifications.preferences') }}">
+27
View File
@@ -16,6 +16,33 @@ class LoginForm(FlaskForm):
remember_me = BooleanField('Keep me logged in') remember_me = BooleanField('Keep me logged in')
class ProfileForm(FlaskForm):
"""Self-service profile update form — available to all authenticated users."""
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
current_password = PasswordField('Current Password', validators=[Optional()])
new_password = PasswordField('New Password', validators=[Optional(), Length(min=6, max=100)])
confirm_password = PasswordField('Confirm New Password', validators=[EqualTo('new_password', message='Passwords must match.')])
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
def validate_email(self, field):
q = User.query.filter_by(email=field.data).first()
if self.user and field.data != self.user.email and q:
raise ValidationError('Email already registered.')
elif not self.user and q:
raise ValidationError('Email already registered.')
def validate_current_password(self, field):
"""Require current password only when the user wants to set a new one."""
if self.new_password.data:
if not field.data:
raise ValidationError('Please enter your current password to set a new one.')
if self.user and not self.user.check_password(field.data):
raise ValidationError('Current password is incorrect.')
class UserForm(FlaskForm): class UserForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)]) username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)]) email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])