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.')