Source code for purchasing.conductor.forms
# -*- coding: utf-8 -*-
import datetime
from flask import current_app
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed, FileRequired
from flask_security import current_user
from wtforms import Form as NoCSRFForm
from wtforms.fields import (
TextField, IntegerField, DateField, TextAreaField, HiddenField,
FieldList, FormField, SelectField, BooleanField
)
from wtforms.ext.dateutil.fields import DateTimeField
from wtforms.ext.sqlalchemy.fields import QuerySelectField
from wtforms.validators import DataRequired, URL, Optional, Email, Length, Regexp, ValidationError
from purchasing.database import db
from purchasing.filters import better_title
from purchasing.notifications import Notification
from purchasing.users.models import Department, User
from purchasing.data.flows import Flow
from purchasing.data.companies import Company
from purchasing.opportunities.forms import OpportunityForm, city_domain_email
from purchasing.opportunities.models import Opportunity
from purchasing.utils import RequiredIf, RequiredOne, RequiredNotBoth
from purchasing.conductor.validators import (
validate_date, validate_integer, not_all_hidden, get_default,
validate_multiple_emails, STATE_ABBREV, US_PHONE_REGEX, validate_different,
validate_unique_name
)
[docs]class DynamicStageSelectField(SelectField):
'''Select field to allow dynamic choice construction
'''
[docs] def pre_validate(self, form):
'''Raise an error if there is nothing in the data, otherwise return true
'''
if len(self.data) == 0:
raise ValidationError('You must select at least one!')
return False
return True
[docs]class FlowForm(FlaskForm):
'''Form for editing existing form metadata
Attributes:
id: Hidden field to hold the form's ID
flow_name: Name of the flow
is_archived: Whether or not the flow is archived
'''
id = HiddenField()
flow_name = TextField(validators=[DataRequired(), validate_unique_name])
is_archived = BooleanField()
[docs]class NewFlowForm(FlaskForm):
'''Form for creating new flows
Attributes:
flow_name: Name of the flow
stage_order: A list of
:py:class:`~purchasing.conductor.forms.DynamicStageSelectField`
fields that represent different stages
Arguments:
stages: A list of (id, name) values used to build choices
*args: List of arguments passed to the form's superclass
**kwargs: List of keyword arguments passed to the form's
superclass
'''
flow_name = TextField(validators=[DataRequired(), validate_unique_name])
stage_order = FieldList(
DynamicStageSelectField(), min_entries=1,
validators=[validate_different]
)
def __init__(self, stages=[], *args, **kwargs):
super(NewFlowForm, self).__init__(*args, **kwargs)
self.stages = stages
for i in self.stage_order.entries:
i.choices = self.stages
[docs]class CompleteForm(FlaskForm):
'''Form to hold the completion times
Attributes:
complete: Field to hold the date/time for when to complete
a certain :py:class:`~purchasing.data.contract_stages.ContractStage`
maximum: (instance variable) The current utc time
Arguments:
started: Datetime of what the minimum time should be,
such as when the
:py:class:`~purchasing.data.contract_stages.ContractStage`
started. Because the ``complete`` form attribute uses the
:py:func:`~purchasing.conductor.validators.validate_date`
validator, passing a minumim is important to ensure the validator
works properly (if you want to make sure that
:py:class:`~purchasing.data.contract_stages.ContractStage`
objects don't end before they start. Optional to allow the first
stage in a give :py:class:`~purchasing.data.flows.Flow` to start
in the past.
'''
complete = DateTimeField(
validators=[validate_date]
)
def __init__(self, started=None, *args, **kwargs):
super(CompleteForm, self).__init__(*args, **kwargs)
self.started = started.replace(second=0, microsecond=0) if started else None
self.maximum = datetime.datetime.utcnow()
[docs]class NewContractForm(FlaskForm):
'''Form for starting new work on a contract through conductor
Attributes:
description: The contract's description
flow: The :py:class:`~purchasing.data.flows.Flow` the
contract should follow
assigned: The :py:class:`~purchasing.users.models.User`
the contract should be assigned to
department: The :py:class:`~purchasing.users.models.Department`
the contract should be assigned to
start: The start time for the first
:py:class:`~purchasing.data.contract_stages.ContractStage`
for the contract
'''
description = TextField(validators=[DataRequired()])
flow = QuerySelectField(
query_factory=Flow.nonarchived_query_factory,
get_pk=lambda i: i.id,
get_label=lambda i: i.flow_name,
allow_blank=True, blank_text='-----'
)
assigned = QuerySelectField(
query_factory=User.conductor_users_query,
get_pk=lambda i: i.id,
get_label=lambda i: i.email,
allow_blank=True, blank_text='-----'
)
department = QuerySelectField(
query_factory=Department.query_factory,
get_pk=lambda i: i.id,
get_label=lambda i: i.name,
allow_blank=True, blank_text='-----'
)
start = DateTimeField(default=get_default)
[docs]class ContractMetadataForm(FlaskForm):
'''Edit a contract's metadata during the renewal process
Attributes:
financial_id: The
:py:class:`~purchasing.data.contracts.ContractBase`
financial_id
spec_number: The spec number for a contrat. See
:py:meth:`~purchasing.data.contracts.ContractBase.get_spec_number`
for more information about spec numbers
department: The :py:class:`~purchasing.users.models.Department` to set
for the contract
all_blank: Placeholder to indicate if all other fields are blank
'''
financial_id = IntegerField(validators=[Optional()])
spec_number = TextField(validators=[Optional()], filters=[lambda x: x or None])
department = QuerySelectField(
query_factory=Department.query_factory,
get_pk=lambda i: i.id,
get_label=lambda i: i.name,
allow_blank=True, blank_text='-----'
)
all_blank = HiddenField(validators=[not_all_hidden])
[docs] def post_validate_action(self, action, contract, current_stage):
'''Update the contract's metadata
Arguments:
action: A
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
that needs to be updated with details for the action
log
contract: A :py:class:`~purchasing.data.contracts.ContractBase` object
current_stage: The current
:py:class:`~purchasing.data.contract_stages.ContractStage`
Returns:
The modified
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
with the action detail updated to include the form's data
'''
current_app.logger.info(
'CONDUCTOR UPDATE METADATA | Contract update metadata on stage "{}" from contract "{}" (ID: {})'.format(
current_stage.name, contract.description, contract.id
)
)
# remove the blank hidden field -- we don't need it
data = self.data
del data['all_blank']
contract.update_with_spec_number(data)
# this process pops off the spec number, so get it back
data['spec_number'] = self.data.get('spec_number')
# get department
if self.data.get('department', None):
data['department'] = self.data.get('department').name
action.action_detail = data
return action
[docs]class AttachmentForm(NoCSRFForm):
'''Form to hold individual attachments for email updates
Wrapped in a list by :py:class:`~purchasing.conductor.forms.SendUpdateForm`
Attributes:
upload: File field which holds an uploaded file. Must be a Word,
Excel, or .pdf document
'''
upload = FileField('datafile', validators=[
FileAllowed(['doc', 'docx', 'xls', 'xlsx', 'pdf'], message='Invalid file type')
])
[docs]class SendUpdateForm(FlaskForm):
'''Form to send an email update
Attributes:
send_to: Email or semicolon-delimited list of email addresses
to send the update email to
send_to_cc: Email or semicolon-delimited list of email
addresses to cc on the update
subject: The subject the update should have
body: The body of the message the subject should have. This
can be configured as a property of a
:py:class:`~purchasing.data.stages.Stage`
attachments: A list of files. Wraps
:py:class:`~purchasing.conductor.forms.AttachmentForm`
'''
send_to = TextField(validators=[DataRequired(), validate_multiple_emails])
send_to_cc = TextField(validators=[Optional(), validate_multiple_emails])
subject = TextField(validators=[DataRequired()])
body = TextAreaField(validators=[DataRequired()])
attachments = FieldList(FormField(AttachmentForm), min_entries=1)
[docs] def get_attachment_filenames(self):
'''Return the names of all of the attached files or None
'''
filenames = []
for attachment in self.attachments.entries:
try:
return filenames.append(attachment.upload.data.filename)
except AttributeError:
continue
return filenames if len(filenames) > 0 else None
[docs] def post_validate_action(self, action, contract, current_stage):
'''Send the email updates
Arguments:
action: A
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
that needs to be updated with details for the action
log
contract: A :py:class:`~purchasing.data.contracts.ContractBase` object
current_stage: The current
:py:class:`~purchasing.data.contract_stages.ContractStage`
Returns:
The modified
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
with the action detail updated to include the form's data
'''
current_app.logger.info(
'CONDUCTOR EMAIL UPDATE | New update on stage "{}" from contract "{}" (ID: {})'.format(
current_stage.name, contract.description, contract.id
)
)
action.action_detail = {
'sent_to': self.data.get('send_to', ''),
'body': self.data.get('body'),
'subject': self.data.get('subject'),
'stage_name': current_stage.name,
'attachments': self.get_attachment_filenames()
}
Notification(
to_email=[i.strip() for i in self.data.get('send_to').split(';') if i != ''],
from_email=current_app.config['CONDUCTOR_SENDER'],
reply_to=current_user.email,
cc_email=[i.strip() for i in self.data.get('send_to_cc').split(';') if i != ''],
subject=self.data.get('subject'),
html_template='conductor/emails/email_update.html',
body=self.data.get('body'),
attachments=[i.upload.data for i in self.attachments.entries]
).send(multi=False)
return action
[docs]class PostOpportunityForm(OpportunityForm):
'''Form to post opportunities to Beacon
Attributes:
contact_email: Override the base form to make the
contact email optional for a opportunity posted
from conductor
See Also:
:py:class:`~purchasing.opportunities.forms.OpportunityForm`
'''
contact_email = TextField(validators=[Email(), city_domain_email, Optional()])
[docs] def post_validate_action(self, action, contract, current_stage):
'''Post the opportunity to Beacon
Arguments:
action: A
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
that needs to be updated with details for the action
log
contract: A :py:class:`~purchasing.data.contracts.ContractBase` object
current_stage: The current
:py:class:`~purchasing.data.contract_stages.ContractStage`
Returns:
The modified
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
with the action detail updated to include the form's data
'''
current_app.logger.info(
'CONDUCTOR BEACON POST | Beacon posting on stage "{}" from contract "{}" (ID: {})'.format(
current_stage.name, contract.description, contract.id
)
)
opportunity_data = self.data_cleanup()
opportunity_data['created_from_id'] = contract.id
if contract.opportunity:
label = 'updated'
contract.opportunity.update(
opportunity_data, current_user,
self.documents, True
)
opportunity = contract.opportunity
else:
label = 'created'
opportunity = Opportunity.create(
opportunity_data, current_user,
self.documents, True
)
db.session.add(opportunity)
db.session.commit()
action.action_detail = {
'opportunity_id': opportunity.id, 'title': opportunity.title,
'label': label
}
return action
[docs]class NoteForm(FlaskForm):
'''Form to take notes
Attributes:
note: Text of the note to be taken
'''
note = TextAreaField(validators=[DataRequired(message='Note cannot be blank.')])
[docs] def post_validate_action(self, action, contract, current_stage):
'''Post the note
Arguments:
action: A
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
that needs to be updated with details for the action
log
contract: A :py:class:`~purchasing.data.contracts.ContractBase` object
current_stage: The current
:py:class:`~purchasing.data.contract_stages.ContractStage`
Returns:
The modified
:py:class:`~purchasing.data.contract_stages.ContractStageActionItem`
with the action detail updated to include the form's data
'''
current_app.logger.info(
'CONDUCTOR NOTE | New note on stage "{}" from contract "{}" (ID: {})'.format(
current_stage.name, contract.description, contract.id
)
)
action.action_detail = {
'note': self.data.get('note', ''),
'stage_name': current_stage.name
}
return action
[docs]class FileUploadForm(FlaskForm):
'''Form to take new costars data for upload
Attributes:
upload: csv file to be uploaded
'''
upload = FileField('datafile', validators=[
FileAllowed(['csv'], message='.csv files only')
])
[docs]class ContractUploadForm(FlaskForm):
'''Form to upload a pdf for a costars contract
Attributes:
contract_id: ID of the contract to be uploaded (hidden field)
upload: The file to be uploaded
'''
contract_id = HiddenField('id', validators=[DataRequired()])
upload = FileField('datafile', validators=[
FileRequired(),
FileAllowed(['pdf'], message='.pdf files only')
])
[docs]class EditContractForm(FlaskForm):
'''Form to control details needed to finalize a new/renewed contract
Attributes:
description: The final description of the contract (required)
expiration_date: The new expiration date for the new/renewed contract (required)
spec_number: The county spec number (see
:py:meth:`~purchasing.data.contracts.ContractBase.get_spec_number`
, required)
contract_href: A link to the actual contract document
'''
description = TextField(validators=[DataRequired()])
expiration_date = DateField(validators=[DataRequired()])
spec_number = TextField(validators=[DataRequired()])
contract_href = TextField(validators=[Optional(), URL(message="That URL doesn't work!")])
[docs]class CompanyContactForm(NoCSRFForm):
'''Form to capture contact information for a company
Attributes:
first_name: First name of the contact
last_name: Last name of the contact
addr1: First line of the contact's address
addr2: Second line of the contract's address
city: Contact address city
state: Contact address state
zip_code: Contact address zip code
phone_number: Contact phone number
fax_number: Contact fax number
email: Contact email
See Also:
:py:class:`~purchasing.data.models.companies.CompanyContact`
'''
first_name = TextField(validators=[Optional()])
last_name = TextField(validators=[Optional()])
addr1 = TextField(validators=[Optional()])
addr2 = TextField(validators=[Optional()])
city = TextField(validators=[Optional()])
state = SelectField(validators=[Optional(), RequiredIf('city')], choices=[
('', '---')] +
[(state, state) for state in STATE_ABBREV]
)
zip_code = TextField(
validators=[
validate_integer, Optional(),
RequiredIf('city'), Length(min=5, max=5, message='Field must be 5 characters long.')
]
)
phone_number = TextField(validators=[Optional(), Regexp(
US_PHONE_REGEX, message='Please enter numbers in XXX-XXX-XXXX format'
)])
fax_number = TextField(validators=[Optional(), Regexp(
US_PHONE_REGEX, message='Please enter numbers in XXX-XXX-XXXX format'
)])
email = TextField(validators=[Email(), Optional()])
[docs]class CompanyForm(NoCSRFForm):
'''A form to assign a company to a contract
This is an individual new/existing company that is part of the
:py:class:`~purchasing.conductor.forms.CompanyListForm` entries.
For an individual entry, both the ``new_company_name`` and the
``new_company_controller_number`` *or* the ``company_name``
and the ``controller_number`` must be filled out. Both cannot be
filled out and they cannot be partially filled out.
Attributes:
new_company_controller_number: Controller number for a new company
new_company_name: Name for a new company
controller_number: Controller number for an existing company
company_name: Name of an existing copany, uses a query select field
'''
new_company_controller_number = TextField('New Company Controller Number', validators=[
RequiredOne('controller_number'),
RequiredNotBoth('controller_number'), RequiredIf('new_company_name'),
validate_integer
])
new_company_name = TextField('New Company Name', validators=[
RequiredOne('company_name'),
RequiredNotBoth('company_name'), RequiredIf('new_company_controller_number'),
])
controller_number = TextField('Existing Company Controller Number', validators=[
RequiredOne('new_company_controller_number'),
RequiredNotBoth('new_company_controller_number'),
RequiredIf('company_name'), validate_integer
])
company_name = QuerySelectField(
'Existing Company Name', query_factory=Company.all_companies_query_factory, get_pk=lambda i: i.id,
get_label=lambda i: better_title(i.company_name),
allow_blank=True, blank_text='-----',
validators=[
RequiredOne('new_company_name'),
RequiredNotBoth('new_company_name'),
RequiredIf('controller_number')
]
)
[docs]class CompanyListForm(FlaskForm):
'''Form that holds lists of new/existing companies
Attributes:
companies: List of :py:class:`~purchasing.conductor.forms.CompanyForm`
form fields
'''
companies = FieldList(FormField(CompanyForm), min_entries=1)
[docs]class CompanyContactList(NoCSRFForm):
'''Form to hold lists of company contacts
This form is the inner level of a twice-nested list. On the outer
level (see :py:class:`~purchasing.conductor.forms.CompanyContactListForm`
), we have a list of companies. Each of those companies could have
multiple contacts, though, so we need an inner nested list to handle
that. This is that inner portion.
Attributes:
contacts: List of :py:class:`~purchasing.conductor.forms.CompanyContactForm`
form fields
'''
contacts = FieldList(FormField(CompanyContactForm), min_entries=1)
[docs]class CompanyContactListForm(FlaskForm):
'''Outer form to collect all contacts for all companies for the contract
Attributes:
companies: A outer list around the nested
:py:class:`~purchasing.conductor.forms.CompanyContactList`
'''
companies = FieldList(FormField(CompanyContactList), min_entries=0)