class User(UserMixin, SurrogatePK, Model): __tablename__ = 'users' email = Column(db.String(80), unique=True, nullable=False, index=True) created_at = Column(db.DateTime, nullable=False, default=dt.datetime.utcnow) first_name = Column(db.String(30), nullable=True) last_name = Column(db.String(30), nullable=True) department = Column(db.String(255), nullable=False) active = Column(db.Boolean(), default=True) role_id = ReferenceCol('roles', ondelete='SET NULL', nullable=True) @property def full_name(self): return "{0} {1}".format(self.first_name, self.last_name) def __repr__(self): return '<User({email!r})>'.format(email=self.email) def __unicode__(self): return self.email def get_starred(self): return [i.id for i in self.contracts_starred] def get_following(self): return [i.id for i in self.contracts_following]
class Vendor(Model): __tablename__ = 'vendor' id = Column(db.Integer, primary_key=True, index=True) business_name = Column(db.String(255), nullable=False) email = Column(db.String(80), unique=True, nullable=False) created_at = Column(db.DateTime, nullable=False, default=datetime.datetime.utcnow) first_name = Column(db.String(30), nullable=True) last_name = Column(db.String(30), nullable=True) phone_number = Column(db.String(20)) fax_number = Column(db.String(20)) minority_owned = Column(db.Boolean()) veteran_owned = Column(db.Boolean()) woman_owned = Column(db.Boolean()) disadvantaged_owned = Column(db.Boolean()) categories = db.relationship('Category', secondary=category_vendor_association_table, backref='vendors') def __unicode__(self): return self.email
class Vendor(Model): '''Base Vendor model for businesses interested in Beacon The primary driving thought behind Beacon is that it should be as easy as possible to sign up to receive updates about new opportunities. Therefore, there are no Vendor accounts or anything like that, just email addresses and business names. Attributes: id: Primary key unique ID business_name: Name of the business, required email: Email address for the vendor, required first_name: First name of the vendor last_name: Last name of the vendor phone_number: Phone number for the vendor fax_number: Fax number for the vendor minority_owned: Whether the vendor is minority owned veteran_owned: Whether the vendor is veteran owned woman_owned: Whether the vendor is woman owned disadvantaged_owned: Whether the vendor is any class of Disadvantaged Business Enterprise (DBE) categories: Many-to-many relationship with :py:class:`~purchasing.opportunities.models.Category`; describes what the vendor is subscribed to opportunities: Many-to-many relationship with :py:class:`~purchasing.opportunities.models.Opportunity`; describes what opportunities the vendor is subscribed to subscribed_to_newsletter: Whether the vendor is subscribed to receive the biweekly newsletter of all opportunities ''' __tablename__ = 'vendor' id = Column(db.Integer, primary_key=True, index=True) business_name = Column(db.String(255), nullable=False) email = Column(db.String(80), unique=True, nullable=False) first_name = Column(db.String(30), nullable=True) last_name = Column(db.String(30), nullable=True) phone_number = Column(db.String(20)) fax_number = Column(db.String(20)) minority_owned = Column(db.Boolean()) veteran_owned = Column(db.Boolean()) woman_owned = Column(db.Boolean()) disadvantaged_owned = Column(db.Boolean()) categories = db.relationship('Category', secondary=category_vendor_association_table, backref='vendors', collection_class=set) opportunities = db.relationship( 'Opportunity', secondary=opportunity_vendor_association_table, backref='vendors', collection_class=set) subscribed_to_newsletter = Column(db.Boolean(), default=False, nullable=False) @classmethod def newsletter_subscribers(cls): '''Query to return all vendors signed up to the newsletter ''' return cls.query.filter(cls.subscribed_to_newsletter == True).all() def build_downloadable_row(self): '''Take a Vendor object and build a list for a .tsv download Returns: List of all vendor fields in order for a bulk vendor download ''' return [ self.first_name, self.last_name, self.business_name, self.email, self.phone_number, self.minority_owned, self.woman_owned, self.veteran_owned, self.disadvantaged_owned, build_downloadable_groups('category_friendly_name', self.categories), build_downloadable_groups('title', self.opportunities) ] def __unicode__(self): return self.email
class Opportunity(Model): '''Base Opportunity Model -- the central point for Beacon The Beacon model is centered around three dates: :py:attr:`~purchasing.opportunities.models.Opportunity.planned_publish`, :py:attr:`~purchasing.opportunities.models.Opportunity.planned_submission_start`, and :py:attr:`~purchasing.opportunities.models.Opportunity.planned_submission_end`. The publish date is when opportunities that are approved appear on Beacon. The publication date also is when vendors are notified via email. Attributes: id: Primary key unique ID title: Title of the Opportunity description: Short (maximum 500-word) description of the opportunity planned_publish: Date when the opportunity should show up on Beacon planned_submission_start: Date when vendors can begin submitting responses to the opportunity planned_submission_end: Deadline for submitted responses to the Opportunity vendor_documents_needed: Array of integers that relate to :py:class:`~purchasing.opportunities.models.RequiredBidDocument` ids is_public: True if opportunity is approved (publicly visible), False otherwise is_archived: True if opportunity is archived (not visible), False otherwise published_at: Date when an alert email was sent out to relevant vendors publish_notification_sent: True is notification sent, False otherwise department_id: ID of primary :py:class:`~purchasing.users.models.Department` for this opportunity department: Sqlalchemy relationship to primary :py:class:`~purchasing.users.models.Department` for this opportunity contact_id: ID of the :py:class:`~purchasing.users.models.User` for this opportunity contact: Sqlalchemy relationship to :py:class:`~purchasing.users.models.User` for this opportunity categories: Many-to-many relationship of the :py:class:`~purchasing.opportunities.models.Category` objects for this opportunity created_from_id: ID of the :py:class:`~purchasing.data.models.ContractBase` this opportunity was created from through Conductor opportunity_type_id: ID of the :py:class:`~purchasing.data.models.ContractType` opportunity_type: Sqlalchemy relationship to the :py:class:`~purchasing.data.models.ContractType` See Also: For more on the Conductor <--> Beacon relationship, look at the :py:func:`~purchasing.conductor.handle_form()` Conductor utility method and the :py:class:`~purchasing.conductor.forms.PostOpportunityForm` Conductor Form ''' __tablename__ = 'opportunity' id = Column(db.Integer, primary_key=True) title = Column(db.String(255)) description = Column(db.Text) planned_publish = Column(db.DateTime, nullable=False) planned_submission_start = Column(db.DateTime, nullable=False) planned_submission_end = Column(db.DateTime, nullable=False) vendor_documents_needed = Column(ARRAY(db.Integer())) is_public = Column(db.Boolean(), default=False) is_archived = Column(db.Boolean(), default=False, nullable=False) published_at = Column(db.DateTime, nullable=True) publish_notification_sent = Column(db.Boolean, default=False, nullable=False) department_id = ReferenceCol('department', ondelete='SET NULL', nullable=True) department = db.relationship('Department', backref=backref('opportunities', lazy='dynamic')) contact_id = ReferenceCol('users', ondelete='SET NULL') contact = db.relationship('User', backref=backref('opportunities', lazy='dynamic'), foreign_keys='Opportunity.contact_id') categories = db.relationship( 'Category', secondary=category_opportunity_association_table, backref='opportunities', collection_class=set) created_from_id = ReferenceCol('contract', ondelete='cascade', nullable=True) opportunity_type_id = ReferenceCol('contract_type', ondelete='SET NULL', nullable=True) opportunity_type = db.relationship( 'ContractType', backref=backref('opportunities', lazy='dynamic'), ) @classmethod def create(cls, data, user, documents, publish=False): '''Create a new opportunity Arguments: data: dictionary of fields needed to populate new opportunity object user: :py:class:`~purchasing.users.models.User` object creating the new opportunity documents: The documents FieldList from the :py:class:`~purchasing.opportunities.forms.OpportunityForm` Keyword Arguments: publish: Boolean as to whether to publish this document. If True, it will set ``is_public`` to True. See Also: The :py:class:`~purchasing.opportunities.forms.OpportunityForm` and :py:class:`~purchasing.opportunities.forms.OpportunityDocumentForm` have more information about the documents. ''' opportunity = Opportunity(**data) current_app.logger.info( u'''BEACON NEW - New Opportunity Created: Department: {} | Title: {} | Publish Date: {} | Submission Start Date: {} | Submission End Date: {} '''.format( opportunity.id, opportunity.department.name if opportunity.department else '', opportunity.title, str(opportunity.planned_publish), str(opportunity.planned_submission_start), str(opportunity.planned_submission_end))) if not (user.is_conductor() or publish): # only send 'your post has been sent/a new post needs review' # emails when 1. the submitter isn't from OMB and 2. they are # saving a draft as opposed to publishing the opportunity opportunity.notify_approvals(user) opportunity._handle_uploads(documents) opportunity._publish(publish) return opportunity def raw_update(self, **kwargs): '''Performs a basic update based on the passed kwargs. Arguments: **kwargs: Keyword arguments of fields to be updated in the existing Opportunity model ''' super(Opportunity, self).update(**kwargs) def update(self, data, user, documents, publish=False): '''Performs an update, uploads new documents, and publishes Arguments: data: dictionary of fields needed to populate new opportunity object user: :py:class:`~purchasing.users.models.User` object updating the opportunity documents: The documents FieldList from the :py:class:`~purchasing.opportunities.forms.OpportunityForm` Keyword Arguments: publish: Boolean as to whether to publish this document. If True, it will set ``is_public`` to True. ''' data.pop('publish_notification_sent', None) for attr, value in data.iteritems(): setattr(self, attr, value) current_app.logger.info( u'''BEACON Update - Opportunity Updated: ID: {} | Title: {} | Publish Date: {} | Submission Start Date: {} | Submission End Date: {} '''.format(self.id, self.title, str(self.planned_publish), str(self.planned_submission_start), str(self.planned_submission_end))) self._handle_uploads(documents) self._publish(publish) @property def is_published(self): '''Determine if an opportunity can be displayed Returns: True if the planned publish date is before or on today, and the opportunity is approved, False otherwise ''' return self.coerce_to_date( self.planned_publish) <= localize_today() and self.is_public @property def is_upcoming(self): '''Determine if an opportunity is upcoming Returns: True if the planned publish date is before or on today, is approved, is not accepting submissions, and is not closed; False otherwise ''' return self.coerce_to_date(self.planned_publish) <= localize_today() and \ not self.is_submission_start and not self.is_submission_end and self.is_public @property def is_submission_start(self): '''Determine if the oppportunity is accepting submissions Returns: True if the submission start date and planned publish date are before or on today, is approved, and the opportunity is not closed; False otherwise ''' return self.coerce_to_date(self.planned_submission_start) <= localize_today() and \ self.coerce_to_date(self.planned_publish) <= localize_today() and \ not self.is_submission_end and self.is_public @property def is_submission_end(self): '''Determine if an opportunity is closed to new submissions Returns: True if the submission end date is on or before today, and it is approved ''' return pytz.UTC.localize(self.planned_submission_end).astimezone( current_app.config['DISPLAY_TIMEZONE'] ) <= localize_now() and \ self.is_public @property def has_docs(self): '''True if the opportunity has at least one document, False otherwise ''' return self.opportunity_documents.count() > 0 def estimate_submission_start(self): '''Returns the month/year based on submission start date ''' return self.planned_submission_start.strftime('%B %d, %Y') def estimate_submission_end(self): '''Returns the localized date and time based on submission end date ''' return pytz.UTC.localize(self.planned_submission_end).astimezone( current_app.config['DISPLAY_TIMEZONE']).strftime( '%B %d, %Y at %I:%M%p %Z') def can_view(self, user): '''Check if a user can see opportunity detail Arguments: user: A :py:class:`~purchasing.users.models.User` object Returns: Boolean indiciating if the user can view this opportunity ''' return False if user.is_anonymous and not self.is_published else True def can_edit(self, user): '''Check if a user can edit the contract Arguments: user: A :py:class:`~purchasing.users.models.User` object Returns: Boolean indiciating if the user can edit this opportunity. Conductors, the opportunity creator, and the primary opportunity contact can all edit the opportunity before it is published. After it is published, only conductors can edit it. ''' if self.is_public and user.is_conductor(): return True elif not self.is_public and \ (user.is_conductor() or user.id in (self.created_by_id, self.contact_id)): return True return False def coerce_to_date(self, field): '''Coerces the input field to a datetime.date object Arguments: field: A datetime.datetime or datetime.date object Returns: A datetime.date object ''' if isinstance(field, datetime.datetime): return field.date() if isinstance(field, datetime.date): return field return field def get_vendor_emails(self): '''Return list of all signed up vendors ''' return [i.email for i in self.vendors] def has_vendor_documents(self): '''Returns a Boolean for whether there are required bid documents See Also: :py:class:`~purchasing.opportunities.models.RequiredBidDocument` ''' return self.vendor_documents_needed and len( self.vendor_documents_needed) > 0 def get_vendor_documents(self): '''Returns a list of documents the the vendor will need to provide See Also: :py:class:`~purchasing.opportunities.models.RequiredBidDocument` ''' if self.has_vendor_documents(): return RequiredBidDocument.query.filter( RequiredBidDocument.id.in_( self.vendor_documents_needed)).all() return [] def get_events(self): '''Returns the opportunity dates out as a nice ordered list for rendering ''' return [{ 'event': 'bid_submission_start', 'classes': 'event event-submission_start', 'date': self.estimate_submission_start(), 'description': 'Opportunity opens for submissions.' }, { 'event': 'bid_submission_end', 'classes': 'event event-submission_end', 'date': self.estimate_submission_end(), 'description': 'Deadline to submit proposals.' }] def _handle_uploads(self, documents): opp_documents = self.opportunity_documents.all() for document in documents.entries: if document.title.data == '': continue _id = self.id if self.id else random_id(6) _file = document.document.data if _file.filename in [i.name for i in opp_documents]: continue filename, filepath = document.upload_document(_id) if filepath: self.opportunity_documents.append( OpportunityDocument(name=document.title.data, href=filepath)) def _publish(self, publish): if not self.is_public: if publish: self.is_public = True def notify_approvals(self, user): '''Send the approval notifications to everyone with approval rights Arguments: user: A :py:class:`~purchasing.users.models.User` object ''' Notification( to_email=[user.email], subject='Your post has been sent to OMB for approval', html_template='opportunities/emails/staff_postsubmitted.html', txt_template='opportunities/emails/staff_postsubmitted.txt', opportunity=self).send(multi=True) Notification( to_email=db.session.query(User.email).filter( User.roles.any( Role.name.in_(['conductor', 'admin', 'superadmin']))).all(), subject='A new Beacon post needs review', html_template='opportunities/emails/admin_postforapproval.html', txt_template='opportunities/emails/admin_postforapproval.txt', opportunity=self).send(multi=True) def send_publish_email(self): '''Sends the "new opportunity available" email to subscribed vendors If a new Opportunity is created and it has a publish date before or on today's date, it will trigger an immediate publish email send. This operates in a very similar way to the nightly :py:class:`~purchasing.jobs.beacon_nightly.BeaconNewOppotunityOpenJob`. It will build a list of all vendors signed up to the Opportunity or to any of the categories that describe the Opportunity. ''' if self.is_published and not self.publish_notification_sent: opp_categories = [i.id for i in self.categories] vendors = Vendor.query.filter( Vendor.categories.any(Category.id.in_(opp_categories))).all() Notification( to_email=[i.email for i in vendors], subject='A new City of Pittsburgh opportunity from Beacon!', html_template='opportunities/emails/newopp.html', txt_template='opportunities/emails/newopp.txt', opportunity=self).send(multi=True) self.publish_notification_sent = True self.published_at = datetime.datetime.utcnow() current_app.logger.info( u'''BEACON PUBLISHED: ID: {} | Title: {} | Publish Date: {} | Submission Start Date: {} | Submission End Date: {} '''.format(self.id, self.title, str(self.planned_publish), str(self.planned_submission_start), str(self.planned_submission_end))) return True return False
class User(UserMixin, SurrogatePK, Model): '''User model Attributes: id: primary key email: user email address first_name: first name of user last_name: last name of user active: whether user is currently active or not role_id: foreign key of user's role role: relationship of user to role table department_id: foreign key of user's department department: relationship of user to department table ''' __tablename__ = 'users' email = Column(db.String(80), unique=True, nullable=False, index=True) first_name = Column(db.String(30), nullable=True) last_name = Column(db.String(30), nullable=True) active = Column(db.Boolean(), default=True) role_id = ReferenceCol('roles', ondelete='SET NULL', nullable=True) role = db.relationship('Role', backref=backref('users', lazy='dynamic'), foreign_keys=role_id, primaryjoin='User.role_id==Role.id') department_id = ReferenceCol('department', ondelete='SET NULL', nullable=True) department = db.relationship( 'Department', backref=backref('users', lazy='dynamic'), foreign_keys=department_id, primaryjoin='User.department_id==Department.id') def __repr__(self): return '<User({email!r})>'.format(email=self.email) def __unicode__(self): return self.email @property def full_name(self): '''Build full name of user Returns: concatenated string of first_name and last_name values ''' return "{0} {1}".format(self.first_name, self.last_name) @classmethod def department_user_factory(cls, department_id): return cls.query.filter( cls.department_id == department_id, db.func.lower(Department.name) != 'equal opportunity review commission') @classmethod def county_purchaser_factory(cls): return cls.query.join( Role, User.role_id == Role.id).filter(Role.name == 'county') @classmethod def eorc_user_factory(cls): return cls.query.join(Department, User.department_id == Department.id).filter( db.func.lower(Department.name) == 'equal opportunity review commission') @classmethod def get_subscriber_groups(cls, department_id): return [ cls.department_user_factory(department_id).all(), cls.county_purchaser_factory().all(), cls.eorc_user_factory().all() ] def get_following(self): '''Generate user contract subscriptions Returns: list of ids for contracts followed by user ''' return [i.id for i in self.contracts_following] def is_conductor(self): '''Check if user can access conductor application Returns: True if user's role is either conductor, admin, or superadmin, False otherwise ''' return self.role.name in ('conductor', 'admin', 'superadmin') def print_pretty_name(self): '''Generate long version text representation of user Returns: full_name if first_name and last_name exist, email otherwise ''' if self.first_name and self.last_name: return self.full_name else: return self.email def print_pretty_first_name(self): '''Generate abbreviated text representation of user Returns: first_name if first_name exists, `localpart <https://en.wikipedia.org/wiki/Email_address#Local_part>`_ otherwise ''' if self.first_name: return self.first_name else: return self.email.split('@')[0] @classmethod def conductor_users_query(cls): '''Query users with access to conductor Returns: list of users with ``is_conductor`` value of True ''' return [i for i in cls.query.all() if i.is_conductor()]