435 lines
24 KiB
Python
435 lines
24 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, DateField, SelectMultipleField)
|
||
from wtforms.validators import (DataRequired, Email, Length, EqualTo,
|
||
Optional, NumberRange, ValidationError)
|
||
from app.models.user import User
|
||
|
||
|
||
# ── 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(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):
|
||
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(min=6, max=100)])
|
||
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
|
||
|
||
def validate_password(self, field):
|
||
"""Enforce minimum length only when a new password is actually provided."""
|
||
if field.data and len(field.data) < 6:
|
||
raise ValidationError('Password must be at least 6 characters.')
|
||
|
||
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 (phase35) — set at creation by staff
|
||
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.')
|
||
])
|
||
# Who handles the issue (phase35)
|
||
handler_type = SelectField('Handled By', choices=[
|
||
('internal', 'Janitorial Staff'),
|
||
('facility', 'Facility Staff'),
|
||
('vendor', 'External Vendor'),
|
||
], validators=[Optional()])
|
||
# Facility-staff handler (free text) — used when handler_type == 'facility'
|
||
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)])
|
||
# External contractor / vendor fields (phase26) — used when handler_type == 'vendor'
|
||
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)])
|
||
|
||
# ── 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(min=8)])
|
||
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(min=8, max=100)])
|
||
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(min=8, max=100)])
|
||
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 facility QR — occupant "Report a problem" ─────────────────────────
|
||
|
||
class PublicIssueReportForm(FlaskForm):
|
||
"""Login-free issue report submitted from a facility's public QR page.
|
||
|
||
`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)])
|
||
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
|
||
|
||
|
||
# ── Scheduled Inspections (phase36) ──────────────────────────────────────────
|
||
|
||
class ScheduledInspectionForm(FlaskForm):
|
||
facility_id = SelectField('Facility', coerce=int, validators=[DataRequired()])
|
||
template_id = SelectField('Template', coerce=int, validators=[DataRequired()])
|
||
inspector_id = SelectField('Assign Inspector', coerce=int, validators=[DataRequired()])
|
||
frequency = SelectField('Frequency', choices=[
|
||
('once', 'One-time'), ('daily', 'Daily'),
|
||
('weekly', 'Weekly'), ('monthly', 'Monthly'),
|
||
], validators=[DataRequired()])
|
||
next_due_date = DateField('Start / Due Date', validators=[DataRequired()])
|
||
# Label is overridden per context in routes/scheduled_inspections.py:
|
||
# "Start Date" when creating, "Next Due Date" when editing. The default
|
||
# above is only a fallback.
|
||
end_date = DateField('End Date', validators=[Optional()])
|
||
# UI label only. The field name, the ScheduledInspection.notes attribute and
|
||
# the scheduled_inspections.notes column all stay `notes` — renaming any of
|
||
# them would break the API payload key the iPad decodes.
|
||
notes = TextAreaField('Instructions', validators=[Optional(), Length(max=1000)])
|
||
active = BooleanField('Active', default=True)
|
||
|
||
# ── Recurrence detail (phase43) ──────────────────────────────────────────
|
||
# Only the block matching `frequency` is required; the rest is ignored and
|
||
# cleared on save. Shown/hidden client-side, enforced in validate() below.
|
||
weekdays = SelectMultipleField(
|
||
'Days of the Week', coerce=int, validators=[Optional()],
|
||
choices=[(i, n) for i, n in enumerate(
|
||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||
'Friday', 'Saturday', 'Sunday'])],
|
||
)
|
||
month_mode = SelectField('Monthly Rule', validators=[Optional()], choices=[
|
||
('day_of_month', 'On a day of the month'),
|
||
('nth_weekday', 'On a weekday of the month'),
|
||
], default='day_of_month')
|
||
day_of_month = IntegerField(
|
||
'Day of Month', validators=[Optional(), NumberRange(min=1, max=31)])
|
||
nth_week = SelectField('Week', coerce=int, validators=[Optional()], choices=[
|
||
(1, '1st'), (2, '2nd'), (3, '3rd'), (4, '4th'), (5, '5th'), (-1, 'Last'),
|
||
], default=1)
|
||
nth_weekday = SelectField(
|
||
'Weekday', coerce=int, validators=[Optional()],
|
||
choices=[(i, n) for i, n in enumerate(
|
||
['Monday', 'Tuesday', 'Wednesday', 'Thursday',
|
||
'Friday', 'Saturday', 'Sunday'])],
|
||
default=0,
|
||
)
|
||
|
||
def validate(self, extra_validators=None):
|
||
"""Conditionally require the recurrence block for the chosen frequency."""
|
||
if not super().validate(extra_validators):
|
||
return False
|
||
ok = True
|
||
if self.frequency.data == 'weekly' and not self.weekdays.data:
|
||
self.weekdays.errors.append('Pick at least one day of the week.')
|
||
ok = False
|
||
elif self.frequency.data == 'monthly':
|
||
if self.month_mode.data == 'nth_weekday':
|
||
if not self.nth_week.data or self.nth_weekday.data is None:
|
||
self.nth_week.errors.append('Choose which weekday of the month.')
|
||
ok = False
|
||
elif not self.day_of_month.data:
|
||
self.day_of_month.errors.append('Enter a day of the month (1–31).')
|
||
ok = False
|
||
|
||
# End date (phase44). Only meaningful for recurring schedules — a
|
||
# one-time schedule ends by deactivating when it is completed. Rejecting
|
||
# an end date before the due date here is what makes the "already past
|
||
# its boundary on save" case unreachable in the routes.
|
||
if self.end_date.data:
|
||
if self.frequency.data == 'once':
|
||
self.end_date.errors.append(
|
||
'A one-time schedule has no end date — it closes when completed.')
|
||
ok = False
|
||
elif self.next_due_date.data and self.end_date.data < self.next_due_date.data:
|
||
self.end_date.errors.append(
|
||
'End date must be on or after the due date.')
|
||
ok = False
|
||
return ok
|
||
|
||
|
||
# ── Support Knowledge Base (phase38) ─────────────────────────────────────────
|
||
|
||
class SupportKnowledgeForm(FlaskForm):
|
||
title = StringField('Topic / Question', validators=[DataRequired(), Length(max=200)])
|
||
content = TextAreaField('Answer / Knowledge', validators=[DataRequired(), Length(max=4000)])
|
||
sort_order = IntegerField('Sort Order', validators=[Optional(), NumberRange(min=0, max=9999)], default=0)
|
||
active = BooleanField('Active (included in the chatbot)', default=True)
|