Mar 02 2026: implement user's profile page
This commit is contained in:
+61
-1
@@ -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'))
|
||||||
@@ -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 %}
|
||||||
@@ -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') }}">
|
||||||
|
|||||||
@@ -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)])
|
||||||
|
|||||||
Reference in New Issue
Block a user