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'), # '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 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()]) 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): """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(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()]) notes = TextAreaField('Notes', 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 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)