321 lines
17 KiB
Python
321 lines
17 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)
|
|
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'),
|
|
# 'customer' is intentionally excluded — customer accounts are managed via /customers
|
|
], 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()])
|
|
|
|
|
|
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()])
|
|
|
|
|
|
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)
|
|
handler_type = SelectField('Handled By', choices=[
|
|
('', '— Select —'),
|
|
('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)])
|
|
|
|
# ── 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):
|
|
"""Simplified form for creating a customer account via email invitation.
|
|
|
|
Admin enters Full Name and Email only. A username is auto-generated
|
|
from the email address. The customer sets their own username and
|
|
password via the emailed link.
|
|
"""
|
|
full_name = StringField('Full Name', validators=[DataRequired(), Length(max=150)])
|
|
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=255)])
|
|
|
|
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.') |