Source code for purchasing.opportunities.forms

# -*- coding: utf-8 -*-

import os
import json
import pytz

from collections import defaultdict

from werkzeug import secure_filename

from flask import current_app, request
from flask_wtf import FlaskForm
from flask_wtf.file import FileField, FileAllowed
from wtforms import widgets, fields, Form as NoCSRFForm
from wtforms.ext.dateutil.fields import DateTimeField
from wtforms.validators import (
    DataRequired, Email, ValidationError, Optional, Length
)
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField

from purchasing.opportunities.models import Category, RequiredBidDocument

from purchasing.utils import RequiredIf
from purchasing.users.models import Department
from purchasing.data.contracts import ContractType
from purchasing.opportunities.util import parse_contact, select_multi_checkbox
from purchasing.opportunities.validators import (
    email_present, city_domain_email, max_words,
    after_now, validate_phone_number
)

from purchasing.utils import connect_to_s3, _get_aggressive_cache_headers

[docs]class MultiCheckboxField(fields.SelectMultipleField): '''Custom multiple select field that displays a list of checkboxes We have a custom ``pre_validate`` to handle cases where a user has choices from multiple categories. the validation to pass. Attributes: widget: wtforms `ListWidget <http://wtforms.readthedocs.org/en/latest/widgets.html#wtforms.widgets.ListWidget>`_ option_widget: wtforms `CheckboxInput <http://wtforms.readthedocs.org/en/latest/widgets.html#wtforms.widgets.CheckboxInput>`_ ''' widget = widgets.ListWidget(prefix_label=False) option_widget = widgets.CheckboxInput()
[docs] def pre_validate(self, form): '''Automatically passes We override pre-validate to allow the form to use dynamically created CHOICES. See Also: :py:class:`~purchasing.opportunities.models.Category`, :py:class:`~purchasing.opportunities.forms.CategoryForm` ''' pass
[docs]class DynamicSelectField(fields.SelectField): '''Custom dynamic select field '''
[docs] def pre_validate(self, form): '''Ensure we have at least one Category and they all correctly typed See Also: * :py:class:`~purchasing.opportunities.models.Category` ''' if len(self.data) == 0: raise ValidationError('You must select at least one!') return False for category in self.data: if isinstance(category, Category): self.choices.append([category, category]) continue else: raise ValidationError('Invalid category!') return False return True
[docs]class CategoryForm(FlaskForm): '''Base form for anything involving Beacon categories "Categories" and "Subcategories" are originally derived from NIGP codes. NIGP codes can be somewhat hard to parse and aren't updated incredibly regularly, especially when it comes to things like IT services and software. Therefore, we took time to devise our own descriptions of things that map back to NIGP codes. The category form is the UI representation of that mapping. Each detailed "subcategory" has a parent "category" to which it belongs. Users select the "parent" category they are interested in, and a series of checkboxes are presented to them. This raises some interesting challenges for validation, which are handled in the ``process`` method. See Also: * :py:func:`~purchasing.opportunities.util.select_multi_checkbox` widget used to generate the UI components through jinja templates. * :py:class:`~purchasing.opportunities.models.Category` base object that is used to build the CategoryForm. Attributes: subcategories: A :py:class:`~purchasing.opportunities.forms.MultiCheckboxField` categories: A :py:class:`~purchasing.opportunities.forms.DynamicSelectField` ''' subcategories = MultiCheckboxField(coerce=int, choices=[]) categories = DynamicSelectField(choices=[])
[docs] def get_categories(self): '''Getter for the form's parent categories ''' return self._categories
[docs] def get_subcategories(self): '''Getter for the form's subcategories ''' return self._subcategories
[docs] def pop_categories(self, categories=True, subcategories=True): '''Pop categories and/or subcategories off of the Form's ``data`` attribute In order to prevent wtforms from throwing ValidationErrors improperly, we need to modify some of the internal form data. This method allows us to pop off the categories or subcategories of the form data as necessary. Arguments: categories: Pop categories from form's data if True subcategories: Pop subcategories from form's data if True Returns: Modified form data with categories and/or subcategories removed as necessary ''' cleaned_data = self.data cleaned_data.pop('categories') if categories else None cleaned_data.pop('subcategories') if subcategories else None return cleaned_data
[docs] def build_categories(self, all_categories): '''Build form's category and subcategory choices For our :py:func:`~purchasing.opportunities.util.select_multi_checkbox`, we need to give both top-level choices for the select field and individual level subcategories. This method creates those and modifies the form in-place to build the appropriate choices Arguments: all_categories: A list of :py:class:`~purchasing.opportunities.models.Category` objects Returns: A dictionary with top-level parent category names as keys and list of that parent's subcategories as values. ''' categories, subcategories = set(), defaultdict(list) for category in all_categories: categories.add(category.category) subcategories['Select All'].append((category.id, u'{} - {}'.format(category.category_friendly_name, category.category))) subcategories[category.category].append((category.id, category.category_friendly_name)) self.categories.choices = list(sorted(zip(categories, categories))) + [('Select All', 'Select All')] self.categories.choices.insert(0, ('', '-- Choose One --')) self.subcategories.choices = [] return subcategories
[docs] def display_cleanup(self, all_categories=None): '''Clean up form's data for display purposes: 1. Constructs and modifies the form's ``categories`` and ``subcategories`` 2. Creates the template-used subcatgories and display categories 3. Removed the ``Select All`` choice from the available categories Arguments: all_categories: A list of :py:class:`~purchasing.opportunities.models.Category` objects, or None. If None, defaults to all Categories. ''' all_categories = all_categories if all_categories else Category.query.all() subcategories = self.build_categories(all_categories) self._subcategories = json.dumps(subcategories) display_categories = subcategories.keys() if 'Select All' in display_categories: display_categories.remove('Select All') self._categories = json.dumps(sorted(display_categories))
[docs] def process(self, formdata=None, obj=None, data=None, **kwargs): '''Process the form and append data to the ``categories`` Manually iterates through the flask Request.form, appending valid Categories to the form's ``categories`` data See Also: For more information about parameters, see the `Wtforms base form <http://wtforms.readthedocs.org/en/latest/forms.html#wtforms.form.BaseForm.process>`_ ''' super(CategoryForm, self).process(formdata, obj, data, **kwargs) self.categories.data = obj.categories if obj and hasattr(obj, 'categories') else set() subcats = set() for k, v in request.form.iteritems(): if not k.startswith('subcategories-'): continue else: subcat_id = int(k.split('-')[1]) # make sure the field is checked (or 'on') and we don't have it already if v == 'on' and subcat_id not in subcats: subcats.add(subcat_id) subcat = Category.query.get(subcat_id) # make sure it's a valid category_friendly_name if subcat is None: self.errors['subcategories'] = ['{} is not a valid choice!'.format(subcat)] break self.categories.data.add(subcat)
[docs]class VendorSignupForm(CategoryForm): '''Signup form vendors use to sign up for Beacon updates The goal here is to lower the barrier to signing up by as much as possible, making it as easy as possible to sign up. This means that very little of this information is required. This form is an implementation of the CategoryForm, which means that it processes categories and subcategories in addition to the below fields. Attributes: business_name: Name of business, required email: Email address of vendor signing up, required, must be unique first_name: First name of vendor, optional last_name: Last name of vendor, optional phone_number: Phone number of vendor, optional fax_number: Fax number of vendor, optional woman_owned: Whether the business is woman owned, optional minority_owned: Whether the business is minority owned, optional veteran_owned: Whether the business is veteran owned, optional disadvantaged_owned: Whether the business is disadvantaged owned, optional subscribed_to_newsletter: Boolean flag for whether a business is signed up to the receive the newsletter ''' business_name = fields.TextField(validators=[DataRequired()]) email = fields.TextField(validators=[DataRequired(), Email()]) first_name = fields.TextField() last_name = fields.TextField() phone_number = fields.TextField(validators=[validate_phone_number, Length(max=20)]) fax_number = fields.TextField(validators=[validate_phone_number, Length(max=20)]) woman_owned = fields.BooleanField('Woman-owned business') minority_owned = fields.BooleanField('Minority-owned business') veteran_owned = fields.BooleanField('Veteran-owned business') disadvantaged_owned = fields.BooleanField('Disadvantaged business enterprise') subscribed_to_newsletter = fields.BooleanField( label='Biweekly update on all opportunities posted to Beacon', validators=[Optional()], default="checked" )
[docs]class OpportunitySignupForm(FlaskForm): '''Signup form vendors can use for individual opportunities Attributes: business_name: Name of business, required email: Email address of vendor signing up, required, must be unique also_categories: Flag for whether or not a business should be signed up to receive updates about opportunities with the same categories as this one ''' business_name = fields.TextField(validators=[DataRequired()]) email = fields.TextField(validators=[DataRequired(), Email()]) also_categories = fields.BooleanField()
[docs]class UnsubscribeForm(FlaskForm): '''Subscription management form, where Vendors can unsubscribe from all different emails Attributes: email: Email address of vendor signing up, required categories: A multicheckbox of all categories the Vendor is signed up to receive emails about opportunities: A multicheckbox of all opportunities the Vendor is signed up to receive emails about subscribed_to_newsletter: A flag for whether or not the Vendor should receive the biweekly update newsletter ''' email = fields.TextField(validators=[DataRequired(), Email(), email_present]) categories = MultiCheckboxField(coerce=int) opportunities = MultiCheckboxField(coerce=int) subscribed_to_newsletter = fields.BooleanField( label='Biweekly update on all opportunities posted to Beacon', validators=[Optional()], default='checked' )
[docs]class OpportunityDocumentForm(NoCSRFForm): '''Document subform for the main :py:class:`~purchasing.opportunities.forms.OpportunityForm` Attributes: title: Name of document to be uploaded document: Actual document file that should be uploaded ''' title = fields.TextField(label='Document Name', validators=[RequiredIf('document')]) document = FileField('Document', validators=[FileAllowed( ['pdf', 'doc', 'docx', 'xls', 'xlsx'], '.pdf, Word (.doc/.docx), and Excel (.xls/.xlsx) documents only!'), RequiredIf('title')] )
[docs] def upload_document(self, _id): '''Take the document and filename and either upload it to S3 or the local uploads folder Arguments: _id: The id of the :py:class:`~purchasing.opportunities.models.Opportunity` the document will be attached to Returns: A two-tuple of (the document name, the document filepath/url) ''' if self.document.data is None or self.document.data == '': return None, None filename = secure_filename(self.document.data.filename) if filename == '': return None, None _filename = 'opportunity-{}-{}'.format(_id, filename) if current_app.config.get('UPLOAD_S3') is True: # upload file to s3 conn, bucket = connect_to_s3( current_app.config['AWS_ACCESS_KEY_ID'], current_app.config['AWS_SECRET_ACCESS_KEY'], current_app.config['UPLOAD_DESTINATION'] ) _document = bucket.new_key(_filename) aggressive_headers = _get_aggressive_cache_headers(_document) _document.set_contents_from_file(self.document.data, headers=aggressive_headers, replace=True) _document.set_acl('public-read') return _document.name, _document.generate_url(expires_in=0, query_auth=False) else: try: os.mkdir(current_app.config['UPLOAD_DESTINATION']) except: pass filepath = os.path.join(current_app.config['UPLOAD_DESTINATION'], _filename) self.document.data.save(filepath) return _filename, filepath
[docs]class OpportunityForm(CategoryForm): '''Form to create and edit individual opportunities This form is an implementation of the CategoryForm, which means that it processes categories and subcategories in addition to the below fields. Attributes: department: link to :py:class:`~purchasing.users.models.Department` that is primarily responsible for administering the RFP, required opportunity_type: link to :py:class:`~purchasing.data.contracts.ContractType` objects that have the ``allow_opportunities`` field set to True contact_email: Email address of the opportunity's point of contact for questions title: Title of the opportunity, required description: 500 or less word description of the opportunity, required planned_publish: Date when the opportunity should be made public on Beacon planned_submission_start: Date when the opportunity opens to accept responses planned_submission_end: Date when the opportunity closes and no longer accepts submissions vendor_documents_needed: A multicheckbox for all documents that a vendor might need to respond to this opportunity. documents: A list of :py:class:`~purchasing.opportunities.forms.OpportunityDocumentForm` fields. See Also: * :py:class:`~purchasing.data.contracts.ContractType` The ContractType model informs the construction of the "How to Bid" section in the template * :py:class:`~purchasing.opportunities.models.Opportunity` The base model that powers the form. ''' department = QuerySelectField( query_factory=Department.query_factory, get_pk=lambda i: i.id, get_label=lambda i: i.name, allow_blank=True, blank_text='-----', validators=[DataRequired()] ) opportunity_type = QuerySelectField( query_factory=ContractType.opportunity_type_query, get_pk=lambda i: i.id, get_label=lambda i: i.name, allow_blank=True, blank_text='-----', validators=[DataRequired()] ) contact_email = fields.TextField(validators=[Email(), city_domain_email, DataRequired()]) title = fields.TextField(validators=[DataRequired()]) description = fields.TextAreaField(validators=[max_words(), DataRequired()]) planned_publish = fields.DateField(validators=[DataRequired()]) planned_submission_start = fields.DateField(validators=[DataRequired()]) planned_submission_end = DateTimeField(validators=[after_now, DataRequired()]) vendor_documents_needed = QuerySelectMultipleField( widget=select_multi_checkbox, query_factory=RequiredBidDocument.generate_choices, get_pk=lambda i: i[0], get_label=lambda i: i[1] ) documents = fields.FieldList(fields.FormField(OpportunityDocumentForm), min_entries=1)
[docs] def display_cleanup(self, opportunity=None): '''Cleans up data for display in the form 1. Builds the choices for the ``vendor_documents_needed`` 2. Formats the contact email for the form 3. Localizes the ``planned_submission_end`` time See Also: :py:meth:`CategoryForm.display_cleanup` Arguments: opportunity: A :py:class:`purchasing.opportunities.model.Opportunity` object or None. ''' self.vendor_documents_needed.choices = RequiredBidDocument.generate_choices() if opportunity and not self.contact_email.data: self.contact_email.data = opportunity.contact.email if self.planned_submission_end.data: self.planned_submission_end.data = pytz.UTC.localize( self.planned_submission_end.data ).astimezone(current_app.config['DISPLAY_TIMEZONE']) super(OpportunityForm, self).display_cleanup()
[docs] def data_cleanup(self): '''Cleans up form data for processing and storage 1. Pops off categories 2. Pops off documents (they are handled separately) 3. Sets the foreign keys Opportunity model relationships 4. Removes csrf token Returns: An ``opportunity_data`` dictionary, which can be used to instantiate or modify an :py:class:`~purchasing.opportunities.model.Opportunity` instance ''' opportunity_data = self.pop_categories(categories=False) opportunity_data.pop('documents') opportunity_data.pop('csrf_token', None) opportunity_data['department_id'] = self.department.data.id opportunity_data['contact_id'] = parse_contact(opportunity_data.pop('contact_email'), self.department.data) opportunity_data['vendor_documents_needed'] = [int(i[0]) for i in opportunity_data['vendor_documents_needed']] return opportunity_data
[docs] def process(self, formdata=None, obj=None, data=None, **kwargs): '''Processes category data and localizes ``planned_submission_end`` times See Also: :py:meth:`CategoryForm.process` ''' super(OpportunityForm, self).process(formdata, obj, data, **kwargs) if self.planned_submission_end.data and formdata: self.planned_submission_end.data = current_app.config['DISPLAY_TIMEZONE'].localize( self.planned_submission_end.data ).astimezone(pytz.UTC).replace(tzinfo=None)