class Pollbook(ModelBase): """Pollbook model, stores one election census.""" __versioned__ = {} __tablename__ = 'pollbook_meta' id = db.Column(UuidType, primary_key=True, default=uuid.uuid4) name = db.Column(NestedMutableJson) weight = db.Column(db.Integer, nullable=False, default=1) priority = db.Column(db.Integer, nullable=False, default=0) election_id = db.Column(UuidType, db.ForeignKey('election.id'), nullable=False) election = db.relationship('Election', back_populates='pollbooks', lazy='joined') voters = db.relationship('Voter', cascade='all, delete-orphan') voter_objects = db.relationship('Voter', lazy='dynamic') census_file_imports = db.relationship('CensusFileImport') @property def has_votes(self): """True if there are already cast votes in this pollbook.""" return bool( self.voter_objects.filter(Voter.votes.__ne__(None)).count()) @property def self_added_voters(self): """List of all selv added voters.""" return [x for x in self.voters if x.self_added] @property def valid_voters(self): """List of all valid voters.""" return [x for x in self.voters if x.is_valid_voter()] @property def voters_admin_added(self): """List of all voters added by the admins.""" return [x for x in self.voters if not x.self_added] @property def valid_voters_with_vote(self): """List of all valid voters with a vote.""" voters = [ x for x in self.voters if x.is_valid_voter() and len(x.votes) > 0 ] return voters @property def valid_voters_without_vote(self): """List of all valid voters without a vote.""" voters = [x for x in self.voters if x.is_valid_voter() and not x.votes] return voters
class ElectionGroupCount(ModelBase): __versioned__ = {} id = db.Column(evalg.database.types.UuidType, default=uuid.uuid4, primary_key=True) group_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('election_group.id')) election_group = db.relationship('ElectionGroup', back_populates='election_group_counts', lazy='joined') election_results = db.relationship('ElectionResult') initiated_at = db.Column(evalg.database.types.UtcDateTime) finished_at = db.Column(evalg.database.types.UtcDateTime) audit = db.Column(evalg.database.types.MutableJson) @hybrid_property def status(self): if self.finished_at: return 'finished' return 'ongoing' @status.expression def status(cls): return case([(cls.finished_at.isnot(None), 'finished')], else_='ongoing')
class PersonExternalId(ModelBase): """ Person external ID. """ __versioned__ = {} __tablename__ = 'person_external_id' person_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('person.id'), nullable=False) id_type = db.Column(db.UnicodeText, primary_key=True) id_value = db.Column(db.UnicodeText, primary_key=True) person = db.relationship('Person', back_populates='identifiers') @validates('id_type') def validate_id_type(self, key, id_type): return PersonIdType(id_type).value @classmethod def find_ids(cls, *where): def ensure_iterable(obj): if iterable_but_not_str(obj): return obj return (obj, ) or_clauses = or_( and_( cls.id_type == id_type, cls.id_value.in_(ensure_iterable(values)), ) for id_type, values in where) return cls.query.filter(or_clauses)
class ElectionList(ModelBase): """ List of electable candidates in an election. """ __versioned__ = {} id = db.Column( UuidType, primary_key=True, default=uuid.uuid4) name = db.Column(NestedMutableJson) description = db.Column(NestedMutableJson) information_url = db.Column(UrlType) election_id = db.Column( UuidType, db.ForeignKey('election.id'), nullable=False) election = db.relationship( 'Election', back_populates='lists', lazy='joined') candidates = db.relationship('Candidate', cascade='all, delete-orphan') @property def election_status(self): return self.election.status
class GroupExternalIDType(ModelBase): """ Group external ID type. """ __versioned__ = {} code = db.Column(db.UnicodeText, primary_key=True) description = db.Column(db.UnicodeText)
class Person(ModelBase): """ Person. """ __versioned__ = {} # Prioritized order of external ids to use when eg creating a voter object PREFERRED_IDS = ('feide_id', 'nin') id = db.Column(evalg.database.types.UuidType, primary_key=True, default=uuid.uuid4) email = db.Column(db.UnicodeText, index=True) display_name = db.Column(db.UnicodeText) last_update = db.Column(evalg.database.types.UtcDateTime, default=utcnow) last_update_from_feide = db.Column(evalg.database.types.UtcDateTime, default=utcnow) principal = db.relationship('PersonPrincipal', uselist=False) identifiers = db.relationship( 'PersonExternalId', back_populates='person', cascade='all, delete-orphan', ) def get_preferred_id(self, *preference): """ Get the first available *preferred* identifier :param preference: ``PersonExternalId.id_type``s to consider. The first id_type will be the *most* preferred. :rtype: PersonExternalId :return: Returns the most preferred ``PersonExternalId`` object, or ``None`` if the person does not have any of the given id types. """ preferred_ids = preference or self.PREFERRED_IDS for obj in sorted( (obj for obj in self.identifiers if obj.id_type in preferred_ids), key=lambda o: preferred_ids.index(o.id_type)): return obj return None
class AbstractElection(ModelBase): """Base model for elections and election groups.""" __abstract__ = True id = db.Column(evalg.database.types.UuidType, default=uuid.uuid4, primary_key=True) # Translated name name = db.Column(evalg.database.types.MutableJson) # Translated text description = db.Column(evalg.database.types.MutableJson) # Template metadata meta = db.Column(evalg.database.types.NestedMutableJson)
class GroupMembership(ModelBase): """ Group memberships. """ __versioned__ = {} id = db.Column(evalg.database.types.UuidType, primary_key=True, default=uuid.uuid4) group_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('group.id'), nullable=False) person_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('person.id'), nullable=False) __table_args__ = (schema.UniqueConstraint('group_id', 'person_id'), )
class Group(ModelBase): """ Group of persons. """ __versioned__ = {} id = db.Column(evalg.database.types.UuidType, primary_key=True, default=uuid.uuid4) dp_group_id = db.Column(db.UnicodeText, index=True) name = db.Column(db.UnicodeText, unique=True, nullable=False) last_update = db.Column(evalg.database.types.UtcDateTime, default=utcnow) principals = db.relationship('GroupPrincipal') external_ids = db.relationship('GroupExternalID', back_populates='group')
class GroupExternalID(ModelBase): """ Group external ID. """ __versioned__ = {} __tablename__ = 'group_external_id' group_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('group.id'), nullable=False) external_id = db.Column(db.UnicodeText, primary_key=True) type_code = db.Column(db.UnicodeText, db.ForeignKey('group_external_id_type.code'), primary_key=True) group = db.relationship('Group', back_populates='external_ids') id_type = db.relationship('GroupExternalIDType') # no b.ref needed
class Candidate(ModelBase): __versioned__ = {} id = db.Column( UuidType, primary_key=True, default=uuid.uuid4) list_id = db.Column( UuidType, db.ForeignKey('election_list.id'), nullable=False) list = db.relationship( 'ElectionList', back_populates='candidates', lazy='joined') name = db.Column( db.UnicodeText, nullable=False) meta = db.Column(NestedMutableJson) information_url = db.Column(UrlType) priority = db.Column( db.Integer, default=0) pre_cumulated = db.Column( db.Boolean, default=False) user_cumulated = db.Column( db.Boolean, default=False) def __str__(self): return self.name @property def election_status(self): return self.list.election.status
class ElectionResult(ModelBase): """The ElectionResult class""" __versioned__ = {} id = db.Column(evalg.database.types.UuidType, default=uuid.uuid4, primary_key=True) election_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('election.id')) election = db.relationship('Election', back_populates='election_results', lazy='joined') election_group_count_id = db.Column( evalg.database.types.UuidType, db.ForeignKey('election_group_count.id')) election_group_count = db.relationship('ElectionGroupCount', back_populates='election_results', lazy='joined') """ election group count that the result belongs to """ election_protocol = deferred(db.Column(evalg.database.types.MutableJson)) ballots = deferred(db.Column(evalg.database.types.NestedMutableJson)) """ These are deferred to avoid loading too much data """ result = db.Column(evalg.database.types.MutableJson) pollbook_stats = db.Column(evalg.database.types.MutableJson) @property def election_protocol_text(self): """election_protocol_text-property""" try: protcol_cls = count.PROTOCOL_MAPPINGS[self.election.type_str] return protcol_cls.from_dict(self.election_protocol).render() except KeyError: raise Exception('Unsupported counting method for protocol') return self.value
class ElectionGroup(AbstractElection): __versioned__ = {} ou_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('organizational_unit.id'), nullable=True) ou = db.relationship('OrganizationalUnit') # Organizational unit elections = db.relationship('Election') election_group_counts = db.relationship('ElectionGroupCount') # Public election key public_key = db.Column(db.Text) # Announced if set announced_at = db.Column(evalg.database.types.UtcDateTime) # Published if set published_at = db.Column(evalg.database.types.UtcDateTime) # Cancelled if set cancelled_at = db.Column(evalg.database.types.UtcDateTime) # Deleted if set deleted_at = db.Column(evalg.database.types.UtcDateTime) # Name of the template used to create the election group template_name = db.Column(db.UnicodeText) # Internal use type = db.Column(db.UnicodeText) def announce(self): """Mark as announced.""" self.announced_at = utcnow() def unannounce(self): """Mark as unannounced.""" self.announced_at = None @hybrid_property def announced(self): return self.announced_at is not None @announced.expression def announced(cls): return cls.announced_at.isnot(None) def publish(self): """Mark as published.""" self.published_at = utcnow() def unpublish(self): """Mark as unpublished.""" self.published_at = None @hybrid_property def published(self): return self.published_at is not None @published.expression def published(cls): return cls.published_at.isnot(None) def cancel(self): """ Mark as cancelled. """ self.cancelled_at = utcnow() @hybrid_property def cancelled(self): return self.cancelled_at is not None def delete(self): """ Mark as deleted. """ self.deleted_at = utcnow() @hybrid_property def deleted(self): return self.deleted_at is not None @deleted.expression def deleted(cls): return cls.deleted_at.isnot(None) @hybrid_property def status(self): statuses = set(list(map(lambda x: x.status, self.elections))) if not statuses: return 'draft' if len(statuses) == 1: return statuses.pop() if len(statuses) == 2 and 'inactive' in statuses: statuses.discard('inactive') return statuses.pop() return 'multipleStatuses' @property def announcement_blockers(self): """Check whether the election group can be announced.""" blockers = [] if self.announced: blockers.append('already-announced') for election in self.elections: if election.active: if election.missing_start_or_end: blockers.append('missing-start-or-end') if election.start_after_end: blockers.append('start-must-be-before-end') return blockers @property def publication_blockers(self): """Check whether the election group can be published.""" blockers = [] if self.published: blockers.append('already-published') if not self.public_key: blockers.append('missing-key') no_active_elections = True for election in self.elections: if election.active: if election.missing_start_or_end: blockers.append('missing-start-or-end') if election.start_after_end: blockers.append('start-must-be-before-end') no_active_elections = False if no_active_elections: blockers.append('no-active-election') return blockers
class Election(AbstractElection): """Election.""" __versioned__ = {} # Some ID for the UI sequence = db.Column(db.Text) start = db.Column(evalg.database.types.UtcDateTime) end = db.Column(evalg.database.types.UtcDateTime) information_url = db.Column(evalg.database.types.UrlType) contact = db.Column(db.Text) mandate_period_start = db.Column(db.Date) mandate_period_end = db.Column(db.Date) group_id = db.Column(evalg.database.types.UuidType, db.ForeignKey('election_group.id')) election_group = db.relationship('ElectionGroup', back_populates='elections', lazy='joined') election_results = db.relationship('ElectionResult', cascade='all, delete-orphan') lists = db.relationship('ElectionList', cascade='all, delete-orphan') pollbooks = db.relationship('Pollbook', cascade='all, delete-orphan') # Whether election is active. # We usually create more elections than needed to make templates consistent # But not all elections should be used. This can improve voter UI, by # telling voter that their group does not have an active election. active = db.Column(db.Boolean, default=False) @hybrid_property def announced_at(self): return self.election_group.announced_at @announced_at.expression def announced_at(cls): return select([ElectionGroup.announced_at ]).where(cls.group_id == ElectionGroup.id).as_scalar() @hybrid_property def published_at(self): return self.election_group.published_at @published_at.expression def published_at(cls): return select([ElectionGroup.published_at ]).where(cls.group_id == ElectionGroup.id).as_scalar() @hybrid_property def cancelled_at(self): return self.election_group.cancelled_at @cancelled_at.expression def cancelled_at(cls): return select([ElectionGroup.cancelled_at ]).where(cls.group_id == ElectionGroup.id).as_scalar() @hybrid_property def status(self): """ inactive → draft → announced → published → ongoing/closed/cancelled """ if not self.active: return 'inactive' if self.election_group.cancelled_at: return 'cancelled' if self.election_group.published_at: if self.end <= utcnow(): return 'closed' if self.start <= utcnow(): return 'ongoing' return 'published' if self.election_group.announced_at: return 'announced' return 'draft' @status.expression def status(cls): return case([(cls.active is False, 'inactive'), (cls.cancelled_at.isnot(None), 'cancelled'), (and_(cls.published_at.isnot(None), cls.end <= func.now()), 'closed'), (and_(cls.published_at.isnot(None), cls.start <= func.now()), 'ongoing'), (cls.published_at.isnot(None), 'published'), (cls.announced_at.isnot(None), 'announced')], else_='draft') @hybrid_property def has_started(self): """Check if an election is past its start time.""" return bool(self.start and self.start <= utcnow()) @has_started.expression def has_started(cls): return case( [(and_(cls.start.isnot(None), cls.start <= func.now()), True)], else_=False) @property def missing_start_or_end(self): return not self.start or not self.end @property def start_after_end(self): if self.missing_start_or_end: return False return self.start > self.end @property def has_votes(self): """True if there are already cast votes in this election""" for pollbook in self.pollbooks: if pollbook.has_votes: return True return False @property def is_locked(self): """ A wrapper property for several existing checks True if self.has_votes OR self.is_ongoing """ return self.is_ongoing or self.has_votes @hybrid_property def is_ongoing(self): """Check if an election is currently ongoing.""" return bool(self.election_group.published_at and self.start <= utcnow() and self.end >= utcnow()) @is_ongoing.expression def is_ongoing(cls): return case([(and_(cls.published_at.isnot(None), cls.start.isnot(None), cls.start <= func.now(), cls.end.isnot(None), cls.end >= func.now()), True)], else_=False) @property def ou_id(self): return self.election_group.ou_id @property def ou(self): return self.election_group.ou @property def list_ids(self): return [l.id for l in self.lists if not l.deleted] @property def num_choosable(self): return self.meta['candidate_rules']['seats'] @property def num_substitutes(self): return self.meta['candidate_rules'].get('substitutes', 0) @property def candidates(self): if len(self.lists) > 1: raise Exception('Not intended for use on election with ' 'with multiple candidate lists') return self.lists[0].candidates @property def quotas(self): quotas = [] if self.election_group.meta['candidate_rules'].get('candidate_gender'): quota_names = self.meta['counting_rules']['affirmative_action'] for quota_name in quota_names: if quota_name == 'gender_40': # the only one supported so far males = [] females = [] min_value = 0 min_value_substitutes = 0 # for uiostv .. etc for candidate in self.candidates: if candidate.meta['gender'] == 'male': males.append(candidate) elif candidate.meta['gender'] == 'female': females.append(candidate) if self.type_str == 'uio_stv': # no other elections implemented yet... if self.num_choosable <= 1: min_value = 0 elif self.num_choosable <= 3: min_value = 1 elif self.num_choosable: min_value = math.ceil(0.4 * self.num_choosable) if self.num_substitutes <= 1: min_value_substitutes = 0 elif self.num_substitutes <= 3: min_value_substitutes = 1 elif self.num_substitutes: min_value_substitutes = math.ceil( 0.4 * self.num_substitutes) # handle universal cases when members < min_value min_value_males = min([min_value, len(males)]) min_value_females = min([min_value, len(females)]) quotas.append( QuotaGroup({ 'en': 'Males', 'nn': 'Menn', 'nb': 'Menn' }, males, min_value_males, min([ min_value_substitutes, len(males) - min_value_males ]))) quotas.append( QuotaGroup( { 'en': 'Females', 'nn': 'Kvinner', 'nb': 'Kvinner' }, females, min_value_females, min([ min_value_substitutes, len(females) - min_value_females ]))) return quotas @property def type_str(self): """type_str-property""" return self.meta['counting_rules']['method']
class OrganizationalUnit(ModelBase): """ Organizational unit. """ __versioned__ = {} id = db.Column( UuidType, default=uuid.uuid4, primary_key=True) name = db.Column( JsonType, nullable=False) external_id = db.Column( db.Text, nullable=False, unique=True) deleted = db.Column( db.Boolean, default=False) tag = db.Column(db.String) parent = db.relationship( 'OrganizationalUnit', backref='children', remote_side=id) parent_id = db.Column( UuidType(), db.ForeignKey('organizational_unit.id')) def isunder(self, other, acceptsame=True): """ Checks if self is a sub ou of other. """ if isinstance(other, OrganizationalUnit): other = other.id if self.id == other: return True if self.parent_id is None: return False return self.parent.isunder(other) def __lt__(self, other): """ Checks if self is a sub ou of other. """ return self.isunder(other, acceptsame=False) def __le__(self, other): """ Checks if self is a sub ou of other. """ return self.isunder(other, acceptsame=True) def isover(self, other, acceptsame=True): """ Checks if other is a sub ou of self. """ if isinstance(other, OrganizationalUnit): return other.isunder(self.id) return other in self.subous(acceptsame) def __gt__(self, other): return self.isover(other, acceptsame=False) def __ge__(self, other): return self.isover(other, acceptsame=True) def __eq__(self, other): """ Checks for equality between ous. """ return isinstance(other, OrganizationalUnit) and self.id == other.id def subous(self, includeself=True): """ Generates all children, grandchildren and so on of self. """ queue = [self] if includeself else self.children[:] while queue: front = queue.pop() yield front queue.extend(front.children)
class Voter(ModelBase): """ Voter / census member model.""" __versioned__ = {} __tablename__ = 'pollbook_voters' id = sqlalchemy.schema.Column(UuidType, primary_key=True, default=uuid.uuid4) id_type = sqlalchemy.schema.Column( sqlalchemy.types.UnicodeText, doc='person identifier type', nullable=False, ) id_value = sqlalchemy.schema.Column( sqlalchemy.types.UnicodeText, doc='person identifier value', nullable=False, ) pollbook_id = sqlalchemy.schema.Column(UuidType, db.ForeignKey('pollbook_meta.id'), nullable=False) pollbook = db.relationship('Pollbook', back_populates='voters') self_added = db.Column(sqlalchemy.types.Boolean, doc='voter was added to the poll book by himself', nullable=False) reviewed = db.Column(sqlalchemy.types.Boolean, doc='voter has been reviewed by admin', nullable=False) verified = sqlalchemy.schema.Column( sqlalchemy.types.Boolean, doc='voter is verified, and any vote should be counted', nullable=False) votes = db.relationship('Vote', cascade='all, delete-orphan') reason = sqlalchemy.schema.Column( sqlalchemy.types.UnicodeText, doc='reason why this voter should be included in the pollbook', nullable=True) def is_valid_voter(self): """ Checks if the voter is a valid one. Valid voters have the status ADMIN_ADDED_AUTO_VERIFIED or SELF_ADDED_VERIFIED """ if (self.verified_status == VerifiedStatus.ADMIN_ADDED_AUTO_VERIFIED or self.verified_status == VerifiedStatus.SELF_ADDED_VERIFIED): return True return False def ensure_rereview(self): """Ensure that the admin need to make a new review of the voter.""" if self.verified_status is VerifiedStatus.SELF_ADDED_REJECTED: self.reviewed = False def undo_review(self): if self.verified_status in (VerifiedStatus.SELF_ADDED_VERIFIED, VerifiedStatus.SELF_ADDED_REJECTED): self.reviewed = False self.verified = False elif self.verified_status is VerifiedStatus.ADMIN_ADDED_REJECTED: self.reviewed = False self.verified = True @hybrid_property def has_voted(self): """Has the voter voted.""" return len(self.votes) > 0 @has_voted.expression def has_voted(cls): """has_voted sqlalchemy expression.""" return (select([ case( [(exists().where(and_(Vote.voter_id == cls.id, )).correlate(cls), True)], else_=False, ).label("has_votes") ]).label("number_has_votes")) @hybrid_property def verified_status(self): return VERIFIED_STATUS_MAP[(self.self_added, self.reviewed, self.verified)] verified_status_check_constraint = not_( or_( and_(self_added.is_(True), reviewed.is_(False), verified.is_(True)), and_(self_added.is_(False), reviewed.is_(True), verified.is_(True)), and_(self_added.is_(False), reviewed.is_(False), verified.is_(False)))) # # TODO: Get ID_TYPE_CHOICES from PersonExternalId, or implement a separate # set of id types? We may not want to support dp_user_id or uid here? # @validates('id_type') def validate_id_type(self, key, id_type): return PersonIdType(id_type).value __table_args__ = (UniqueConstraint('pollbook_id', 'id_type', 'id_value', name='_pollbook_voter_uc'), CheckConstraint( verified_status_check_constraint, name='_pollbook_voter_cc_verified_status'))