Files
JQC_multi_tenant/app/utils/forms.py
T

390 lines
21 KiB
Python

from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, MultipleFileField
from wtforms import (StringField, PasswordField, SelectField, TextAreaField,
DecimalField, BooleanField, IntegerField, HiddenField,
RadioField, SelectMultipleField)
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
Optional, NumberRange, ValidationError)
import re as _re
from app.models.user import User
# ── Password strength ──────────────────────────────────────────────────────────
# A small blocklist of trivially weak passwords, matched case-insensitively.
_COMMON_PASSWORDS = {
'password', 'password1', 'password123', '12345678', '123456789',
'qwerty123', 'qwertyui', '11111111', 'letmein1', 'welcome1', 'welcome123',
'iloveyou', 'admin123', 'changeme1', 'passw0rd', 'abc12345', 'football1',
}
def strong_password(min_length=8):
"""WTForms validator enforcing a baseline password strength.
Policy (NIST-aligned — length first, light complexity, blocklist):
* at least ``min_length`` characters,
* at least one letter AND one digit,
* not a well-known weak password.
Skips empty values, so it can sit after ``Optional()`` on edit forms where a
blank password means "leave the existing one unchanged".
"""
def _validator(form, field):
pw = field.data or ''
if not pw:
return
if len(pw) < min_length:
raise ValidationError(
f'Password must be at least {min_length} characters long.')
if not (_re.search(r'[A-Za-z]', pw) and _re.search(r'\d', pw)):
raise ValidationError(
'Password must include at least one letter and one number.')
if pw.lower() in _COMMON_PASSWORDS:
raise ValidationError(
'That password is too common — please choose a less predictable one.')
return _validator
# ── Auth ─────────────────────────────────────────────────────────────────────
class LoginForm(FlaskForm):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Keep me logged in')
class ProfileForm(FlaskForm):
"""Self-service profile update form — available to all authenticated users."""
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
current_password = PasswordField('Current Password', validators=[Optional()])
new_password = PasswordField('New Password', validators=[Optional(), Length(max=100), strong_password()])
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):
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField('Password', validators=[Optional(), Length(max=100), strong_password()])
confirm_password = PasswordField('Confirm Password', validators=[Optional(), EqualTo('password')])
role = SelectField('Role', choices=[
('admin', 'Administrator'),
('director', 'Director'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
('auditor', 'Auditor'),
# Both customer-side roles are intentionally excluded — 'customer'
# (Customer Director) and 'external_inspector' (Customer Inspector) are
# created, edited and switched exclusively in Customer Management
# (/customers). phase51 removed 'external_inspector' from here; see
# User.CUSTOMER_ROLES.
], validators=[Optional()])
# NOTE: Optional() here because directors submit no role value (the field is
# hidden in user_form.html for them). Role enforcement is handled in the
# route: directors always keep/default to 'inspector'; only admins may set
# an arbitrary role. DataRequired() would cause validate_on_submit() to
# fail silently for directors, preventing any save at all.
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
# Password strength is enforced by the strong_password() field validator.
def validate_confirm_password(self, field):
"""Require confirmation to match only when a new password is provided."""
if self.password.data and field.data != self.password.data:
raise ValidationError('Passwords must match.')
def validate_username(self, field):
q = User.query.filter_by(username=field.data).first()
if self.user:
if field.data != self.user.username and q:
raise ValidationError('Username already exists.')
elif q:
raise ValidationError('Username already exists.')
def validate_email(self, field):
q = User.query.filter_by(email=field.data).first()
if self.user:
if field.data != self.user.email and q:
raise ValidationError('Email already registered.')
elif q:
raise ValidationError('Email already registered.')
# ── Facility / Area ──────────────────────────────────────────────────────────
class FacilityForm(FlaskForm):
name = StringField('Facility Name', validators=[DataRequired(), Length(max=255)])
address = TextAreaField('Address', validators=[Optional()])
contact_person = StringField('Contact Person', validators=[Optional(), Length(max=100)])
contact_phone = StringField('Contact Phone', validators=[Optional(), Length(max=20)])
project_id = SelectField('Contract', coerce=int, validators=[Optional()])
active = BooleanField('Active', default=True)
class AreaForm(FlaskForm):
name = StringField('Area Name', validators=[DataRequired(), Length(max=255)])
area_type = SelectField('Area Type', choices=[
('restroom','Restroom'), ('lobby','Lobby'), ('hallway','Hallway'),
('office','Office'), ('kitchen','Kitchen'), ('storage','Storage'),
('floor','Floor'),
('outdoor','Outdoor'), ('other','Other'),
], validators=[Optional()])
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
# ── Templates ────────────────────────────────────────────────────────────────
class InspectionTemplateForm(FlaskForm):
name = StringField('Template Name', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('Description', validators=[Optional()])
frequency = SelectField('Inspection Frequency', choices=[
('daily','Daily'), ('weekly','Weekly'),
('monthly','Monthly'), ('quarterly','Quarterly'),
], validators=[DataRequired()])
# phase52 — which contracts may use this form. Choices are populated in the
# route. Selecting NONE leaves the form shared with every contract, which
# is the default and what every pre-phase52 template does.
contract_ids = SelectMultipleField('Available on contracts', coerce=int,
validators=[Optional()])
class ChecklistItemForm(FlaskForm):
category = StringField('Category', validators=[DataRequired(), Length(max=100)])
item_description = TextAreaField('Item Description', validators=[DataRequired()])
scoring_type = SelectField('Scoring Type', choices=[
('pass_fail','Pass/Fail'), ('rating_5','5-Point Rating'), ('rating_10','10-Point Rating'),
], validators=[DataRequired()])
weight = DecimalField('Weight', validators=[Optional(), NumberRange(min=0.1, max=10.0)], default=1.00)
requires_photo = BooleanField('Requires Photo Evidence', default=False)
display_order = IntegerField('Display Order', validators=[Optional()], default=0)
# ── Inspections ──────────────────────────────────────────────────────────────
class StartInspectionForm(FlaskForm):
template_id = SelectField('Template', coerce=int, validators=[DataRequired()])
project_id = SelectField('Contract', coerce=int, validators=[DataRequired()])
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
area_id = SelectField('Area', coerce=int, validators=[Optional()])
class ChecklistResultForm(FlaskForm):
"""Dynamically rendered per checklist item — base validators only."""
score = DecimalField('Score', validators=[Optional(), NumberRange(min=0, max=10)])
passed = HiddenField('Passed') # 'true' / 'false' / ''
comments = TextAreaField('Comments', validators=[Optional(), Length(max=1000)])
photo = FileField('Photo', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
# ── Issues ───────────────────────────────────────────────────────────────────
class IssueForm(FlaskForm):
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
severity = SelectField('Severity', choices=[
('low','Low'), ('medium','Medium'), ('high','High'), ('critical','Critical'),
], validators=[DataRequired()])
description = TextAreaField('Description', validators=[DataRequired(), Length(max=2000)])
photo = FileField('Photo Evidence', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
# Who handles the issue (phase44) — set at creation by staff. MT previously
# exposed these only on the update form, so a handler chosen at creation had
# to be re-entered afterwards.
handler_type = SelectField('Handled By', choices=[
('internal', 'Janitorial Staff'),
('facility', 'Facility Staff'),
('vendor', 'External Vendor'),
], validators=[Optional()])
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
facility_handler_contact = StringField('Facility Contact', validators=[Optional(), Length(max=200)])
facility_handler_notes = TextAreaField('Facility Handling Notes', validators=[Optional(), Length(max=1000)])
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal'
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
class IssueUpdateForm(FlaskForm):
status = SelectField('Status', choices=[
('open','Open'), ('in_progress','In Progress'),
('pending_verification','Pending Verification'), ('resolved','Resolved'),
], validators=[DataRequired()])
assigned_to = SelectField('Assign To', coerce=int, validators=[Optional()])
update_notes = TextAreaField('Update Notes', validators=[Optional(), Length(max=1000)])
result_notes = TextAreaField('Result Notes', validators=[Optional(), Length(max=2000)])
result_photos = MultipleFileField('Result Photos', validators=[
Optional(),
FileAllowed(['jpg','jpeg','png','gif'], 'Images only.')
])
# External contractor / vendor fields (phase26)
vendor_name = StringField('Contractor Name', validators=[Optional(), Length(max=100)])
vendor_contact = StringField('Contractor Contact', validators=[Optional(), Length(max=200)])
vendor_notes = TextAreaField('Contractor Notes', validators=[Optional(), Length(max=1000)])
# Handler type (phase39; empty option removed phase44 — handler_type is now
# NOT NULL DEFAULT 'internal', so "unset" is not a representable state)
handler_type = SelectField('Handled By', choices=[
('internal', 'Janitorial Staff'),
('facility', 'Facility Staff'),
('vendor', 'External Vendor'),
], validators=[Optional()])
facility_handler_name = StringField('Facility Contact Name', validators=[Optional(), Length(max=100)])
facility_handler_contact = StringField('Facility Contact Phone/Email', validators=[Optional(), Length(max=200)])
facility_handler_notes = TextAreaField('Facility Handler Notes', validators=[Optional(), Length(max=1000)])
# Janitorial staff member's name + contact — used when handler_type == 'internal' (phase44)
internal_handler_name = StringField('Staff Name', validators=[Optional(), Length(max=100)])
internal_handler_contact = StringField('Staff Contact', validators=[Optional(), Length(max=200)])
# ── Projects ─────────────────────────────────────────────────────────────────
class ProjectForm(FlaskForm):
name = StringField('Contract Name', validators=[DataRequired(), Length(max=255)])
description = TextAreaField('Description', validators=[Optional()])
project_manager_id = SelectField('Project Manager', coerce=int, validators=[Optional()])
active = BooleanField('Active', default=True)
class CustomerAssignmentForm(FlaskForm):
user_id = SelectField('Customer User', coerce=int, validators=[DataRequired()])
facility_id = SelectField('Facility Scope', coerce=int, validators=[Optional()])
class CustomerUserForm(FlaskForm):
"""Create / edit a customer-role user account.
Used exclusively in the Customer Management UI.
Password is required on create; optional on edit.
"""
username = StringField('Username', validators=[DataRequired(), Length(min=3, max=100)])
full_name = StringField('Full Name', validators=[Optional(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
password = PasswordField('Password', validators=[Optional(), Length(max=128), strong_password()])
confirm_password = PasswordField('Confirm Password',
validators=[Optional(), EqualTo('password',
message='Passwords must match.')])
def __init__(self, user=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self._user = user # existing user instance (edit mode) or None (create mode)
def validate_username(self, field):
from app.models.user import User
existing = User.query.filter_by(username=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Username already in use.')
def validate_email(self, field):
from app.models.user import User
existing = User.query.filter_by(email=field.data).first()
if existing and (self._user is None or existing.id != self._user.id):
raise ValidationError('Email address already in use.')
def validate_password(self, field):
"""Password is required when creating a new account."""
if self._user is None and not field.data:
raise ValidationError('Password is required for new accounts.')
class CustomerInviteForm(FlaskForm):
"""Create a customer-side account via email invitation.
Admin enters Full Name, Email and which of the two customer roles the
person holds. A username is auto-generated from the email address; the
invitee sets their own username and password via the emailed link.
Both roles use the SAME invitation flow — neither is an account we set a
password for. phase51 folded the Customer Inspector (stored as
'external_inspector') in here from User Management.
"""
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
role = SelectField('Role', choices=[
('customer', 'Customer Director — portal access for their facilities'),
('external_inspector', 'Customer Inspector — performs inspections on their contracts'),
], default='customer', validators=[DataRequired()])
def validate_email(self, field):
if User.query.filter_by(email=field.data.strip().lower()).first():
raise ValidationError('An account with this email address already exists.')
class ForgotPasswordForm(FlaskForm):
email = StringField('Email Address', validators=[DataRequired(), Email(), Length(max=255)])
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(max=100), strong_password()])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')])
class SetPasswordForm(FlaskForm):
"""Public form for customer to choose their username and password via emailed link."""
username = StringField('Choose a Username', validators=[DataRequired(), Length(min=3, max=100)])
password = PasswordField('Password', validators=[DataRequired(), Length(max=100), strong_password()])
confirm_password = PasswordField('Confirm Password',
validators=[DataRequired(),
EqualTo('password', message='Passwords must match.')])
def validate_username(self, field):
existing = User.query.filter_by(username=field.data.strip()).first()
if existing:
raise ValidationError('This username is already taken. Please choose another.')
# ── Public QR issue report (phase42) ─────────────────────────────────────────
class PublicIssueReportForm(FlaskForm):
"""Login-free issue report submitted from a facility's or area's public QR page.
Ported from the single-tenant tree, with MT's occupant-chosen `severity`
field retained (the ST original always filed at 'medium').
`website` is a honeypot: real users never see it (hidden via CSS); bots
that fill every field trip it and the submission is silently rejected.
"""
area_label = StringField('Where in the building?',
validators=[Optional(), Length(max=120)])
description = TextAreaField('Describe the problem',
validators=[DataRequired(), Length(min=5, max=2000)])
severity = SelectField('How urgent is it?', choices=[
('low', 'Minor — can wait'),
('medium', 'Normal'),
('high', 'Urgent — needs attention today'),
], default='medium', validators=[Optional()])
reporter_name = StringField('Your name (optional)',
validators=[Optional(), Length(max=100)])
reporter_contact = StringField('Email or phone (optional)',
validators=[Optional(), Length(max=120)])
photos = MultipleFileField('Add photos (optional, up to 5)',
validators=[Optional(),
FileAllowed(['jpg', 'jpeg', 'png', 'gif'],
'Images only (jpg, png, gif).')])
website = StringField('Website') # honeypot — must stay empty