civics-cdf-validator 1.67.dev2__tar.gz → 1.67.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {civics_cdf_validator-1.67.dev2/civics_cdf_validator.egg-info → civics_cdf_validator-1.67.dev3}/PKG-INFO +1 -1
  2. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3/civics_cdf_validator.egg-info}/PKG-INFO +1 -1
  3. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/civics_cdf_validator.egg-info/SOURCES.txt +1 -0
  4. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/rules.py +2 -460
  5. civics_cdf_validator-1.67.dev3/rulesets/officeholder_rules.py +484 -0
  6. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/version.py +1 -1
  7. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/CONTRIBUTING.md +0 -0
  8. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/LICENSE-2.0.txt +0 -0
  9. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/MANIFEST.in +0 -0
  10. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/README.md +0 -0
  11. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/__init__.py +0 -0
  12. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/base.py +0 -0
  13. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/civics_cdf_validator.egg-info/dependency_links.txt +0 -0
  14. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/civics_cdf_validator.egg-info/entry_points.txt +0 -0
  15. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/civics_cdf_validator.egg-info/requires.txt +0 -0
  16. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/civics_cdf_validator.egg-info/top_level.txt +0 -0
  17. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/gpunit_rules.py +0 -0
  18. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/loggers.py +0 -0
  19. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/office_utils.py +0 -0
  20. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/rulesets/__init__.py +0 -0
  21. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/rulesets/common_rules.py +0 -0
  22. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/rulesets/rule_util.py +0 -0
  23. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/setup.cfg +0 -0
  24. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/setup.py +0 -0
  25. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/stats.py +0 -0
  26. {civics_cdf_validator-1.67.dev2 → civics_cdf_validator-1.67.dev3}/validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: civics_cdf_validator
3
- Version: 1.67.dev2
3
+ Version: 1.67.dev3
4
4
  Summary: Checks if an election feed follows best practices
5
5
  Home-page: https://github.com/google/civics_cdf_validator
6
6
  Author: Google Civics
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: civics_cdf_validator
3
- Version: 1.67.dev2
3
+ Version: 1.67.dev3
4
4
  Summary: Checks if an election feed follows best practices
5
5
  Home-page: https://github.com/google/civics_cdf_validator
6
6
  Author: Google Civics
@@ -17,6 +17,7 @@ setup.py
17
17
  ./version.py
18
18
  ./rulesets/__init__.py
19
19
  ./rulesets/common_rules.py
20
+ ./rulesets/officeholder_rules.py
20
21
  ./rulesets/rule_util.py
21
22
  civics_cdf_validator.egg-info/PKG-INFO
22
23
  civics_cdf_validator.egg-info/SOURCES.txt
@@ -18,7 +18,6 @@ from __future__ import print_function
18
18
  import collections
19
19
  import datetime
20
20
  import enum
21
- import hashlib
22
21
  import re
23
22
 
24
23
  from botocore import utils as botocore_utils
@@ -26,6 +25,7 @@ from civics_cdf_validator import base
26
25
  from civics_cdf_validator import gpunit_rules
27
26
  from civics_cdf_validator import loggers
28
27
  from civics_cdf_validator.rulesets import common_rules
28
+ from civics_cdf_validator.rulesets import officeholder_rules
29
29
  from civics_cdf_validator.rulesets import rule_util
30
30
  import dateutil
31
31
  from frozendict import frozendict
@@ -962,63 +962,6 @@ class ContestHasMultipleOffices(base.BaseRule):
962
962
  )
963
963
 
964
964
 
965
- class PersonHasOffice(base.ValidReferenceRule):
966
- """Ensure that each non-party-leader Person object linked to one Office."""
967
-
968
- def _gather_reference_values(self):
969
- root = self.election_tree.getroot()
970
-
971
- person_ids = set()
972
- person_collection = root.find("PersonCollection")
973
- if person_collection is not None:
974
- person_ids = {p.attrib["objectId"] for p in person_collection}
975
- return person_ids
976
-
977
- def _gather_defined_values(self):
978
- root = self.election_tree.getroot()
979
-
980
- person_reference_ids = set()
981
- # Add party leaders provided in the External Identifier
982
- for external_id in root.findall(".//Party//ExternalIdentifier"):
983
- other_type = external_id.find("OtherType")
984
- if (
985
- other_type is not None
986
- and other_type.text in rule_util.PARTY_LEADERSHIP_TYPES
987
- ):
988
- person_reference_ids.add(external_id.find("Value").text)
989
- # Add party leaders provided in the Leadership entity
990
- for leader_id in root.findall(".//Party//PartyLeaderId"):
991
- if leader_id.text:
992
- person_reference_ids.add(leader_id.text)
993
-
994
- office_holder_tenure_collection = root.find("OfficeHolderTenureCollection")
995
- if office_holder_tenure_collection is not None:
996
- for office_holder_tenure in office_holder_tenure_collection.findall(
997
- "OfficeHolderTenure"
998
- ):
999
- id_obj = office_holder_tenure.find("OfficeHolderPersonId")
1000
- if id_obj is not None and id_obj.text:
1001
- person_reference_ids.add(id_obj.text.strip())
1002
-
1003
- office_collection = root.find("OfficeCollection")
1004
- if (
1005
- office_holder_tenure_collection is None
1006
- and office_collection is not None
1007
- ):
1008
- for office in office_collection.findall("Office"):
1009
- id_obj = office.find("OfficeHolderPersonIds")
1010
- if id_obj is not None and id_obj.text:
1011
- ids = id_obj.text.strip().split()
1012
- if len(ids) > 1:
1013
- msg = "Office has {} OfficeHolders. Must have exactly one.".format(
1014
- str(len(ids))
1015
- )
1016
- raise loggers.ElectionError.from_message(msg, [office])
1017
- person_reference_ids.update(ids)
1018
-
1019
- return person_reference_ids
1020
-
1021
-
1022
965
  class VoteCountTypesCoherency(base.BaseRule):
1023
966
  """Ensure VoteCount types describe the appropriate votable."""
1024
967
 
@@ -1136,24 +1079,6 @@ class ContestHasValidContestStage(base.BaseRule):
1136
1079
  )
1137
1080
 
1138
1081
 
1139
- class DateOfBirthIsInPast(base.DateRule):
1140
- """Date of Birth should not be in the future."""
1141
-
1142
- def elements(self):
1143
- return ["PersonCollection"]
1144
-
1145
- def check(self, element):
1146
- for person_element in element:
1147
- date_of_birthday = person_element.find("DateOfBirth")
1148
- if date_of_birthday is not None and date_of_birthday.text:
1149
- date_of_birthday = base.PartialDate.init_partial_date(
1150
- date_of_birthday.text
1151
- )
1152
- self.check_for_date_in_past(date_of_birthday, person_element)
1153
- if self.error_log:
1154
- raise loggers.ElectionError(self.error_log)
1155
-
1156
-
1157
1082
  class ElectionContainsStartAndEndDates(base.DateRule):
1158
1083
  """Election elements should have start and end dates populated."""
1159
1084
 
@@ -1468,349 +1393,6 @@ class DateStatusMatches(base.DateRule):
1468
1393
  raise loggers.ElectionInfo.from_message(msg, [election_elem])
1469
1394
 
1470
1395
 
1471
- class OfficeSelectionMethodMatch(base.BaseRule):
1472
- """Office and OfficeHolderTenure need to have matching selection methods.
1473
-
1474
- Ensure that the OfficeSelectionMethod of a given OfficeHolderTenure is
1475
- also
1476
- present in the list of SelectionMethods of the Office it points to.
1477
- """
1478
-
1479
- def __init__(self, election_tree, schema_tree, **kwargs):
1480
- self.office_selection_methods = {}
1481
- officeholder_tenure_elements = self.get_elements_by_class(
1482
- election_tree, "OfficeHolderTenure"
1483
- )
1484
- office_elements = self.get_elements_by_class(election_tree, "Office")
1485
- if officeholder_tenure_elements:
1486
- for element in office_elements:
1487
- office_id = element.get("objectId")
1488
- selection_methods = {
1489
- selection_method.text
1490
- for selection_method in element.findall("SelectionMethod")
1491
- }
1492
- self.office_selection_methods[office_id] = selection_methods
1493
-
1494
- def elements(self):
1495
- return ["OfficeHolderTenure"]
1496
-
1497
- def check(self, element):
1498
- office_id = element.find("OfficeId").text
1499
- office_selection_method = element.find("OfficeSelectionMethod").text
1500
- if (
1501
- office_id not in self.office_selection_methods
1502
- or office_selection_method
1503
- not in self.office_selection_methods[office_id]
1504
- ):
1505
- raise loggers.ElectionError.from_message(
1506
- "OfficeSelectionMethod does not have a matching SelectionMethod"
1507
- " in the corresponding Office element.",
1508
- [element],
1509
- )
1510
-
1511
-
1512
- class OfficeHolderTenureTermDates(base.DateRule):
1513
- """OfficeHolderTenure elements should contain valid term dates."""
1514
-
1515
- def elements(self):
1516
- return ["OfficeHolderTenure"]
1517
-
1518
- def check(self, element):
1519
- start_date = element.find("StartDate")
1520
- end_date = element.find("EndDate")
1521
- if rule_util.element_has_text(start_date) and rule_util.element_has_text(
1522
- end_date
1523
- ):
1524
- start_date_obj = base.PartialDate.init_partial_date(start_date.text)
1525
- end_date_obj = base.PartialDate.init_partial_date(end_date.text)
1526
- if end_date_obj < start_date_obj:
1527
- raise loggers.ElectionError.from_message(
1528
- "OfficeHolderTenure element has an EndDate that is before the"
1529
- " StartDate.",
1530
- [element],
1531
- )
1532
-
1533
-
1534
- class OfficeTermDates(base.DateRule):
1535
- """Office elements should contain valid term dates.
1536
-
1537
- Offices with OfficeHolderPersonIds should have a Term declared. Given
1538
- term should have a start date. If term also has an end date then end date
1539
- should come after start date. Post Office split feed office objects should
1540
- not have a Term element.
1541
- """
1542
-
1543
- def __init__(self, election_tree, schema_tree, **kwargs):
1544
- self.is_post_office_split_feed = False
1545
- officeholder_tenure_collection_element = self.get_elements_by_class(
1546
- election_tree, "OfficeHolderTenureCollection"
1547
- )
1548
- if officeholder_tenure_collection_element:
1549
- self.is_post_office_split_feed = True
1550
-
1551
- def elements(self):
1552
- return ["Office"]
1553
-
1554
- def check(self, element):
1555
- self.reset_instance_vars()
1556
- if self.is_post_office_split_feed:
1557
- term = element.find("Term")
1558
- if term is not None:
1559
- raise loggers.ElectionError.from_message(
1560
- "Office should not contain Term data in post Office split feed.",
1561
- [element],
1562
- )
1563
- if self.error_log:
1564
- raise loggers.ElectionError(self.error_log)
1565
- else:
1566
- off_per_id = element.find("OfficeHolderPersonIds")
1567
- if rule_util.element_has_text(off_per_id):
1568
- term = element.find("Term")
1569
- if term is None:
1570
- raise loggers.ElectionWarning.from_message(
1571
- "The Office is missing a Term.", [element]
1572
- )
1573
-
1574
- self.gather_dates(term)
1575
- if self.start_date is None:
1576
- raise loggers.ElectionWarning.from_message(
1577
- "The Office is missing a Term > StartDate.", [element]
1578
- )
1579
- elif self.end_date is not None:
1580
- self.check_end_after_start()
1581
-
1582
- if self.error_log:
1583
- raise loggers.ElectionError(self.error_log)
1584
-
1585
-
1586
- class RemovePersonAndOfficeHolderId60DaysAfterEndDate(base.TreeRule):
1587
- """Office elements should contain valid term dates.
1588
-
1589
- Check if 60 days after the specified office term EndDate,
1590
- the associated Person and OfficeholderId may be removed from the feed.
1591
- However, if the Person is elected to another office,
1592
- they will be kept in the feed with the same stable ID.
1593
- """
1594
-
1595
- def check(self):
1596
- info_log = []
1597
- persons = self.get_elements_by_class(self.election_tree, "Person")
1598
- offices = self.get_elements_by_class(self.election_tree, "Office")
1599
- officeholder_tenure_collection = self.get_elements_by_class(
1600
- self.election_tree, "OfficeHolderTenureCollection"
1601
- )
1602
- is_post_office_split_feed = bool(officeholder_tenure_collection)
1603
- person_office_dict = dict()
1604
- outdated_offices = []
1605
- outdated_officeholder_tenures = []
1606
- outdated_officetenure_persons = dict()
1607
- if is_post_office_split_feed:
1608
- officeholder_tenures = self.get_elements_by_class(
1609
- self.election_tree, "OfficeHolderTenure"
1610
- )
1611
- for officeholder_tenure in officeholder_tenures:
1612
- office_holder_person_elem = officeholder_tenure.find(
1613
- "OfficeHolderPersonId"
1614
- )
1615
- person_id = office_holder_person_elem.text
1616
- date_validator = base.DateRule(None, None)
1617
- date_validator.gather_dates(officeholder_tenure)
1618
- end_date = date_validator.end_date
1619
- if end_date is not None:
1620
- sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
1621
- days=-60
1622
- )
1623
- partial_date_sixty_days = base.PartialDate(
1624
- sixty_days_earlier.year,
1625
- sixty_days_earlier.month,
1626
- sixty_days_earlier.day,
1627
- )
1628
- if end_date < partial_date_sixty_days:
1629
- outdated_officeholder_tenures.append(officeholder_tenure)
1630
- if person_id in outdated_officetenure_persons.keys():
1631
- outdated_officetenure_persons[person_id].append(
1632
- (office_holder_person_elem, end_date)
1633
- )
1634
- else:
1635
- outdated_officetenure_persons[person_id] = [
1636
- (office_holder_person_elem, end_date)
1637
- ]
1638
- for outdated_officeholder_tenure in outdated_officeholder_tenures:
1639
- info_message = (
1640
- "The officeholder tenure end date is more than 60 days"
1641
- " in the past; this OfficeHolderTenure element can be removed"
1642
- " from the feed."
1643
- )
1644
- info_log.append(
1645
- loggers.LogEntry(info_message, [outdated_officeholder_tenure])
1646
- )
1647
- for person_id, value_list in outdated_officetenure_persons.items():
1648
- has_recent_tenure = False
1649
- office_holder_person_elem = None
1650
- for value in value_list:
1651
- office_holder_person_elem = value[0]
1652
- end_date = value[1]
1653
- sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
1654
- days=-60
1655
- )
1656
- partial_date_sixty_days = base.PartialDate(
1657
- sixty_days_earlier.year,
1658
- sixty_days_earlier.month,
1659
- sixty_days_earlier.day,
1660
- )
1661
- if end_date > partial_date_sixty_days:
1662
- has_recent_tenure = True
1663
- if not has_recent_tenure and office_holder_person_elem is not None:
1664
- info_message = (
1665
- "All officeholder tenures ended more than 60 days ago. "
1666
- "Therefore, you can remove the person and the "
1667
- "related officeholder tenures from the feed."
1668
- )
1669
- info_log.append(
1670
- loggers.LogEntry(info_message, [office_holder_person_elem])
1671
- )
1672
- else:
1673
- for office in offices:
1674
- person_id = office.find("OfficeHolderPersonIds").text
1675
- if person_id in person_office_dict:
1676
- person_office_dict[person_id].append(office.get("objectId"))
1677
- else:
1678
- person_office_dict[person_id] = [office.get("objectId")]
1679
- term = office.find(".//Term")
1680
- if term is not None:
1681
- date_validator = base.DateRule(None, None)
1682
- date_validator.gather_dates(term)
1683
- end_date_person = date_validator.end_date
1684
- if end_date_person is not None:
1685
- sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
1686
- days=-60
1687
- )
1688
- partial_date_sixty_days = base.PartialDate(
1689
- sixty_days_earlier.year,
1690
- sixty_days_earlier.month,
1691
- sixty_days_earlier.day,
1692
- )
1693
- if end_date_person < partial_date_sixty_days:
1694
- outdated_offices.append(office.get("objectId"))
1695
- for person in persons:
1696
- pid = person.get("objectId")
1697
- if person_office_dict.get(pid) is not None:
1698
- check_person_outdated = all(
1699
- item in outdated_offices for item in person_office_dict.get(pid)
1700
- )
1701
- if check_person_outdated:
1702
- info_message = (
1703
- "The officeholder mandates ended more than 60 days ago. "
1704
- "Therefore, you can remove the person and the related offices "
1705
- "from the feed."
1706
- )
1707
- info_log.append(loggers.LogEntry(info_message, [person]))
1708
- if info_log:
1709
- raise loggers.ElectionInfo(info_log)
1710
-
1711
-
1712
- class UniqueStartDatesForOfficeRoleAndJurisdiction(base.BaseRule):
1713
- """Office StartDates should be unique within a certain group.
1714
-
1715
- Office StartDates should be unique amongst a group of Office entries
1716
- with the same jurisdiction-id and office-role.
1717
- """
1718
-
1719
- def elements(self):
1720
- return ["OfficeCollection"]
1721
-
1722
- def _filter_out_past_end_dates(self, offices):
1723
- valid_offices = []
1724
- for office in offices:
1725
- term = office.find(".//Term")
1726
- if term is not None:
1727
- date_validator = base.DateRule(None, None, ocd_id_validator=None)
1728
- try:
1729
- date_validator.gather_dates(term)
1730
- if date_validator.end_date is not None:
1731
- date_validator.check_for_date_not_in_past(
1732
- date_validator.end_date, date_validator.end_elem
1733
- )
1734
- if not date_validator.error_log:
1735
- valid_offices.append(office)
1736
- except loggers.ElectionError:
1737
- continue
1738
- return valid_offices
1739
-
1740
- def _count_start_dates_by_jurisdiction_role(self, element):
1741
- offices = element.findall("Office")
1742
- offices = self._filter_out_past_end_dates(offices)
1743
- jurisdiction_role_mapping = {}
1744
- for office in offices:
1745
- office_role = ""
1746
- jurisdiction_id = ""
1747
- start_date = ""
1748
-
1749
- start_date_elem = office.find(".//Term//StartDate")
1750
- if not rule_util.element_has_text(start_date_elem):
1751
- continue
1752
- start_date = start_date_elem.text
1753
-
1754
- office_roles = rule_util.get_entity_info_for_value_type(
1755
- office, "office-role"
1756
- )
1757
- if office_roles:
1758
- office_role = office_roles[0]
1759
-
1760
- jurisdiction_ids = rule_util.get_entity_info_for_value_type(
1761
- office, "jurisdiction-id"
1762
- )
1763
- if jurisdiction_ids:
1764
- jurisdiction_id = jurisdiction_ids[0]
1765
-
1766
- office_hash = hashlib.sha256(
1767
- (office_role + jurisdiction_id).encode("utf-8")
1768
- ).hexdigest()
1769
- if office_hash not in jurisdiction_role_mapping.keys():
1770
- jurisdiction_role_mapping[office_hash] = dict({
1771
- "jurisdiction_id": jurisdiction_id,
1772
- "office_role": office_role,
1773
- "start_dates": dict({}),
1774
- })
1775
-
1776
- office_date_info = jurisdiction_role_mapping[office_hash]
1777
- if start_date not in office_date_info["start_dates"].keys():
1778
- office_date_info["start_dates"][start_date] = set()
1779
-
1780
- office_date_info["start_dates"][start_date].add(office)
1781
-
1782
- return jurisdiction_role_mapping
1783
-
1784
- def check(self, element):
1785
- warning_log = []
1786
-
1787
- start_counts = self._count_start_dates_by_jurisdiction_role(element)
1788
- for start_info in start_counts.values():
1789
- start_date_map = start_info["start_dates"]
1790
- if len(start_date_map.keys()) == 1:
1791
- start_date = list(start_date_map.keys())[0]
1792
- # this accounts for offices with only one entry (i.e. US Pres)
1793
- if len(start_date_map[start_date]) > 1:
1794
- warning_log.append(
1795
- loggers.LogEntry(
1796
- (
1797
- "Only one unique StartDate found for each"
1798
- " jurisdiction-id: {} and office-role: {}. {} appears {}"
1799
- " times."
1800
- ).format(
1801
- start_info["jurisdiction_id"],
1802
- start_info["office_role"],
1803
- start_date,
1804
- len(start_date_map[start_date]),
1805
- ),
1806
- start_date_map[start_date],
1807
- )
1808
- )
1809
-
1810
- if warning_log:
1811
- raise loggers.ElectionWarning(warning_log)
1812
-
1813
-
1814
1396
  class ValidateInfoUriAnnotation(base.BaseRule):
1815
1397
  """InfoUri is an annotated Uri that accepts the following annotations.
1816
1398
 
@@ -3209,33 +2791,6 @@ class UnreferencedEntitiesElectionDates(common_rules.UnreferencedEntitiesBase):
3209
2791
  )
3210
2792
 
3211
2793
 
3212
- class UnreferencedEntitiesOfficeholders(common_rules.UnreferencedEntitiesBase):
3213
- """CDF offices and party leadership entities are top-level in officeholders feeds.
3214
-
3215
- This rule is a warning for CDF parties since we ask for all parties for some
3216
- LatAm feeds.
3217
- """
3218
-
3219
- def __init__(self, election_tree, schema_tree, **kwargs):
3220
- office_holder_tenure_collection = self.get_elements_by_class(
3221
- election_tree, "OfficeHolderTenure"
3222
- )
3223
- is_post_office_split = False
3224
- if office_holder_tenure_collection:
3225
- is_post_office_split = True
3226
- super(UnreferencedEntitiesOfficeholders, self).__init__(
3227
- election_tree,
3228
- schema_tree,
3229
- (
3230
- frozenset(["OfficeHolderTenure", "Leadership"])
3231
- if is_post_office_split
3232
- else frozenset(["Office", "Leadership"])
3233
- ),
3234
- frozenset(["Party"]),
3235
- **kwargs,
3236
- )
3237
-
3238
-
3239
2794
  class ElectoralCommissionCollectionExists(base.BaseRule):
3240
2795
  """ElectoralCommissionCollection should exist."""
3241
2796
 
@@ -3765,6 +3320,7 @@ class ValidateIncludeInAggregationBallotSelections(base.BaseRule):
3765
3320
  # To add new rules, create a new class, inherit the base rule,
3766
3321
  # and add it to the correct rule list.
3767
3322
  COMMON_RULES = common_rules.COMMON_RULES
3323
+ OFFICEHOLDER_RULES = officeholder_rules.OFFICEHOLDER_RULES
3768
3324
 
3769
3325
  ELECTION_RULES = COMMON_RULES + (
3770
3326
  # go/keep-sorted start
@@ -3830,20 +3386,6 @@ ELECTION_RULES = COMMON_RULES + (
3830
3386
  )
3831
3387
 
3832
3388
 
3833
- OFFICEHOLDER_RULES = COMMON_RULES + (
3834
- # go/keep-sorted start
3835
- DateOfBirthIsInPast,
3836
- OfficeHolderTenureTermDates,
3837
- OfficeSelectionMethodMatch,
3838
- OfficeTermDates,
3839
- PersonHasOffice,
3840
- RemovePersonAndOfficeHolderId60DaysAfterEndDate,
3841
- UniqueStartDatesForOfficeRoleAndJurisdiction,
3842
- UnreferencedEntitiesOfficeholders,
3843
- common_rules.ProhibitElectionData,
3844
- # go/keep-sorted end
3845
- )
3846
-
3847
3389
  COMMITTEE_RULES = COMMON_RULES + (
3848
3390
  # go/keep-sorted start
3849
3391
  AffiliationEndDateOccursAfterStartDate,
@@ -0,0 +1,484 @@
1
+ # Copyright 2020 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """Validation rules for officeholder feeds of the CDF XML validator."""
15
+
16
+ from __future__ import print_function
17
+
18
+ import datetime
19
+ import hashlib
20
+
21
+ from civics_cdf_validator import base
22
+ from civics_cdf_validator import loggers
23
+ from civics_cdf_validator.rulesets import common_rules
24
+ from civics_cdf_validator.rulesets import rule_util
25
+
26
+
27
+ class PersonHasOffice(base.ValidReferenceRule):
28
+ """Ensure that each non-party-leader Person object linked to one Office."""
29
+
30
+ def _gather_reference_values(self):
31
+ root = self.election_tree.getroot()
32
+
33
+ person_ids = set()
34
+ person_collection = root.find("PersonCollection")
35
+ if person_collection is not None:
36
+ person_ids = {p.attrib["objectId"] for p in person_collection}
37
+ return person_ids
38
+
39
+ def _gather_defined_values(self):
40
+ root = self.election_tree.getroot()
41
+
42
+ person_reference_ids = set()
43
+ # Add party leaders provided in the External Identifier
44
+ for external_id in root.findall(".//Party//ExternalIdentifier"):
45
+ other_type = external_id.find("OtherType")
46
+ if (
47
+ other_type is not None
48
+ and other_type.text in rule_util.PARTY_LEADERSHIP_TYPES
49
+ ):
50
+ person_reference_ids.add(external_id.find("Value").text)
51
+ # Add party leaders provided in the Leadership entity
52
+ for leader_id in root.findall(".//Party//PartyLeaderId"):
53
+ if leader_id.text:
54
+ person_reference_ids.add(leader_id.text)
55
+
56
+ office_holder_tenure_collection = root.find("OfficeHolderTenureCollection")
57
+ if office_holder_tenure_collection is not None:
58
+ for office_holder_tenure in office_holder_tenure_collection.findall(
59
+ "OfficeHolderTenure"
60
+ ):
61
+ id_obj = office_holder_tenure.find("OfficeHolderPersonId")
62
+ if id_obj is not None and id_obj.text:
63
+ person_reference_ids.add(id_obj.text.strip())
64
+
65
+ office_collection = root.find("OfficeCollection")
66
+ if (
67
+ office_holder_tenure_collection is None
68
+ and office_collection is not None
69
+ ):
70
+ for office in office_collection.findall("Office"):
71
+ id_obj = office.find("OfficeHolderPersonIds")
72
+ if id_obj is not None and id_obj.text:
73
+ ids = id_obj.text.strip().split()
74
+ if len(ids) > 1:
75
+ msg = "Office has {} OfficeHolders. Must have exactly one.".format(
76
+ str(len(ids))
77
+ )
78
+ raise loggers.ElectionError.from_message(msg, [office])
79
+ person_reference_ids.update(ids)
80
+
81
+ return person_reference_ids
82
+
83
+
84
+ class DateOfBirthIsInPast(base.DateRule):
85
+ """Date of Birth should not be in the future."""
86
+
87
+ def elements(self):
88
+ return ["PersonCollection"]
89
+
90
+ def check(self, element):
91
+ for person_element in element:
92
+ date_of_birthday = person_element.find("DateOfBirth")
93
+ if date_of_birthday is not None and date_of_birthday.text:
94
+ date_of_birthday = base.PartialDate.init_partial_date(
95
+ date_of_birthday.text
96
+ )
97
+ self.check_for_date_in_past(date_of_birthday, person_element)
98
+ if self.error_log:
99
+ raise loggers.ElectionError(self.error_log)
100
+
101
+
102
+ class OfficeSelectionMethodMatch(base.BaseRule):
103
+ """Office and OfficeHolderTenure need to have matching selection methods.
104
+
105
+ Ensure that the OfficeSelectionMethod of a given OfficeHolderTenure is
106
+ also
107
+ present in the list of SelectionMethods of the Office it points to.
108
+ """
109
+
110
+ def __init__(self, election_tree, schema_tree, **kwargs):
111
+ self.office_selection_methods = {}
112
+ officeholder_tenure_elements = self.get_elements_by_class(
113
+ election_tree, "OfficeHolderTenure"
114
+ )
115
+ office_elements = self.get_elements_by_class(election_tree, "Office")
116
+ if officeholder_tenure_elements:
117
+ for element in office_elements:
118
+ office_id = element.get("objectId")
119
+ selection_methods = {
120
+ selection_method.text
121
+ for selection_method in element.findall("SelectionMethod")
122
+ }
123
+ self.office_selection_methods[office_id] = selection_methods
124
+
125
+ def elements(self):
126
+ return ["OfficeHolderTenure"]
127
+
128
+ def check(self, element):
129
+ office_id = element.find("OfficeId").text
130
+ office_selection_method = element.find("OfficeSelectionMethod").text
131
+ if (
132
+ office_id not in self.office_selection_methods
133
+ or office_selection_method
134
+ not in self.office_selection_methods[office_id]
135
+ ):
136
+ raise loggers.ElectionError.from_message(
137
+ "OfficeSelectionMethod does not have a matching SelectionMethod"
138
+ " in the corresponding Office element.",
139
+ [element],
140
+ )
141
+
142
+
143
+ class OfficeHolderTenureTermDates(base.DateRule):
144
+ """OfficeHolderTenure elements should contain valid term dates."""
145
+
146
+ def elements(self):
147
+ return ["OfficeHolderTenure"]
148
+
149
+ def check(self, element):
150
+ start_date = element.find("StartDate")
151
+ end_date = element.find("EndDate")
152
+ if rule_util.element_has_text(start_date) and rule_util.element_has_text(
153
+ end_date
154
+ ):
155
+ start_date_obj = base.PartialDate.init_partial_date(start_date.text)
156
+ end_date_obj = base.PartialDate.init_partial_date(end_date.text)
157
+ if end_date_obj < start_date_obj:
158
+ raise loggers.ElectionError.from_message(
159
+ "OfficeHolderTenure element has an EndDate that is before the"
160
+ " StartDate.",
161
+ [element],
162
+ )
163
+
164
+
165
+ class OfficeTermDates(base.DateRule):
166
+ """Office elements should contain valid term dates.
167
+
168
+ Offices with OfficeHolderPersonIds should have a Term declared. Given
169
+ term should have a start date. If term also has an end date then end date
170
+ should come after start date. Post Office split feed office objects should
171
+ not have a Term element.
172
+ """
173
+
174
+ def __init__(self, election_tree, schema_tree, **kwargs):
175
+ self.is_post_office_split_feed = False
176
+ officeholder_tenure_collection_element = self.get_elements_by_class(
177
+ election_tree, "OfficeHolderTenureCollection"
178
+ )
179
+ if officeholder_tenure_collection_element:
180
+ self.is_post_office_split_feed = True
181
+
182
+ def elements(self):
183
+ return ["Office"]
184
+
185
+ def check(self, element):
186
+ self.reset_instance_vars()
187
+ if self.is_post_office_split_feed:
188
+ term = element.find("Term")
189
+ if term is not None:
190
+ raise loggers.ElectionError.from_message(
191
+ "Office should not contain Term data in post Office split feed.",
192
+ [element],
193
+ )
194
+ if self.error_log:
195
+ raise loggers.ElectionError(self.error_log)
196
+ else:
197
+ off_per_id = element.find("OfficeHolderPersonIds")
198
+ if rule_util.element_has_text(off_per_id):
199
+ term = element.find("Term")
200
+ if term is None:
201
+ raise loggers.ElectionWarning.from_message(
202
+ "The Office is missing a Term.", [element]
203
+ )
204
+
205
+ self.gather_dates(term)
206
+ if self.start_date is None:
207
+ raise loggers.ElectionWarning.from_message(
208
+ "The Office is missing a Term > StartDate.", [element]
209
+ )
210
+ elif self.end_date is not None:
211
+ self.check_end_after_start()
212
+
213
+ if self.error_log:
214
+ raise loggers.ElectionError(self.error_log)
215
+
216
+
217
+ class RemovePersonAndOfficeHolderId60DaysAfterEndDate(base.TreeRule):
218
+ """Office elements should contain valid term dates.
219
+
220
+ Check if 60 days after the specified office term EndDate,
221
+ the associated Person and OfficeholderId may be removed from the feed.
222
+ However, if the Person is elected to another office,
223
+ they will be kept in the feed with the same stable ID.
224
+ """
225
+
226
+ def check(self):
227
+ info_log = []
228
+ persons = self.get_elements_by_class(self.election_tree, "Person")
229
+ offices = self.get_elements_by_class(self.election_tree, "Office")
230
+ officeholder_tenure_collection = self.get_elements_by_class(
231
+ self.election_tree, "OfficeHolderTenureCollection"
232
+ )
233
+ is_post_office_split_feed = bool(officeholder_tenure_collection)
234
+ person_office_dict = dict()
235
+ outdated_offices = []
236
+ outdated_officeholder_tenures = []
237
+ outdated_officetenure_persons = dict()
238
+ if is_post_office_split_feed:
239
+ officeholder_tenures = self.get_elements_by_class(
240
+ self.election_tree, "OfficeHolderTenure"
241
+ )
242
+ for officeholder_tenure in officeholder_tenures:
243
+ office_holder_person_elem = officeholder_tenure.find(
244
+ "OfficeHolderPersonId"
245
+ )
246
+ person_id = office_holder_person_elem.text
247
+ date_validator = base.DateRule(None, None)
248
+ date_validator.gather_dates(officeholder_tenure)
249
+ end_date = date_validator.end_date
250
+ if end_date is not None:
251
+ sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
252
+ days=-60
253
+ )
254
+ partial_date_sixty_days = base.PartialDate(
255
+ sixty_days_earlier.year,
256
+ sixty_days_earlier.month,
257
+ sixty_days_earlier.day,
258
+ )
259
+ if end_date < partial_date_sixty_days:
260
+ outdated_officeholder_tenures.append(officeholder_tenure)
261
+ if person_id in outdated_officetenure_persons.keys():
262
+ outdated_officetenure_persons[person_id].append(
263
+ (office_holder_person_elem, end_date)
264
+ )
265
+ else:
266
+ outdated_officetenure_persons[person_id] = [
267
+ (office_holder_person_elem, end_date)
268
+ ]
269
+ for outdated_officeholder_tenure in outdated_officeholder_tenures:
270
+ info_message = (
271
+ "The officeholder tenure end date is more than 60 days"
272
+ " in the past; this OfficeHolderTenure element can be removed"
273
+ " from the feed."
274
+ )
275
+ info_log.append(
276
+ loggers.LogEntry(info_message, [outdated_officeholder_tenure])
277
+ )
278
+ for person_id, value_list in outdated_officetenure_persons.items():
279
+ has_recent_tenure = False
280
+ office_holder_person_elem = None
281
+ for value in value_list:
282
+ office_holder_person_elem = value[0]
283
+ end_date = value[1]
284
+ sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
285
+ days=-60
286
+ )
287
+ partial_date_sixty_days = base.PartialDate(
288
+ sixty_days_earlier.year,
289
+ sixty_days_earlier.month,
290
+ sixty_days_earlier.day,
291
+ )
292
+ if end_date > partial_date_sixty_days:
293
+ has_recent_tenure = True
294
+ if not has_recent_tenure and office_holder_person_elem is not None:
295
+ info_message = (
296
+ "All officeholder tenures ended more than 60 days ago. "
297
+ "Therefore, you can remove the person and the "
298
+ "related officeholder tenures from the feed."
299
+ )
300
+ info_log.append(
301
+ loggers.LogEntry(info_message, [office_holder_person_elem])
302
+ )
303
+ else:
304
+ for office in offices:
305
+ person_id = office.find("OfficeHolderPersonIds").text
306
+ if person_id in person_office_dict:
307
+ person_office_dict[person_id].append(office.get("objectId"))
308
+ else:
309
+ person_office_dict[person_id] = [office.get("objectId")]
310
+ term = office.find(".//Term")
311
+ if term is not None:
312
+ date_validator = base.DateRule(None, None)
313
+ date_validator.gather_dates(term)
314
+ end_date_person = date_validator.end_date
315
+ if end_date_person is not None:
316
+ sixty_days_earlier = datetime.datetime.now() + datetime.timedelta(
317
+ days=-60
318
+ )
319
+ partial_date_sixty_days = base.PartialDate(
320
+ sixty_days_earlier.year,
321
+ sixty_days_earlier.month,
322
+ sixty_days_earlier.day,
323
+ )
324
+ if end_date_person < partial_date_sixty_days:
325
+ outdated_offices.append(office.get("objectId"))
326
+ for person in persons:
327
+ pid = person.get("objectId")
328
+ if person_office_dict.get(pid) is not None:
329
+ check_person_outdated = all(
330
+ item in outdated_offices for item in person_office_dict.get(pid)
331
+ )
332
+ if check_person_outdated:
333
+ info_message = (
334
+ "The officeholder mandates ended more than 60 days ago. "
335
+ "Therefore, you can remove the person and the related offices "
336
+ "from the feed."
337
+ )
338
+ info_log.append(loggers.LogEntry(info_message, [person]))
339
+ if info_log:
340
+ raise loggers.ElectionInfo(info_log)
341
+
342
+
343
+ class UniqueStartDatesForOfficeRoleAndJurisdiction(base.BaseRule):
344
+ """Office StartDates should be unique within a certain group.
345
+
346
+ Office StartDates should be unique amongst a group of Office entries
347
+ with the same jurisdiction-id and office-role.
348
+ """
349
+
350
+ def elements(self):
351
+ return ["OfficeCollection"]
352
+
353
+ def _filter_out_past_end_dates(self, offices):
354
+ valid_offices = []
355
+ for office in offices:
356
+ term = office.find(".//Term")
357
+ if term is not None:
358
+ date_validator = base.DateRule(None, None, ocd_id_validator=None)
359
+ try:
360
+ date_validator.gather_dates(term)
361
+ if date_validator.end_date is not None:
362
+ date_validator.check_for_date_not_in_past(
363
+ date_validator.end_date, date_validator.end_elem
364
+ )
365
+ if not date_validator.error_log:
366
+ valid_offices.append(office)
367
+ except loggers.ElectionError:
368
+ continue
369
+ return valid_offices
370
+
371
+ def _count_start_dates_by_jurisdiction_role(self, element):
372
+ offices = element.findall("Office")
373
+ offices = self._filter_out_past_end_dates(offices)
374
+ jurisdiction_role_mapping = {}
375
+ for office in offices:
376
+ office_role = ""
377
+ jurisdiction_id = ""
378
+ start_date = ""
379
+
380
+ start_date_elem = office.find(".//Term//StartDate")
381
+ if not rule_util.element_has_text(start_date_elem):
382
+ continue
383
+ start_date = start_date_elem.text
384
+
385
+ office_roles = rule_util.get_entity_info_for_value_type(
386
+ office, "office-role"
387
+ )
388
+ if office_roles:
389
+ office_role = office_roles[0]
390
+
391
+ jurisdiction_ids = rule_util.get_entity_info_for_value_type(
392
+ office, "jurisdiction-id"
393
+ )
394
+ if jurisdiction_ids:
395
+ jurisdiction_id = jurisdiction_ids[0]
396
+
397
+ office_hash = hashlib.sha256(
398
+ (office_role + jurisdiction_id).encode("utf-8")
399
+ ).hexdigest()
400
+ if office_hash not in jurisdiction_role_mapping.keys():
401
+ jurisdiction_role_mapping[office_hash] = dict({
402
+ "jurisdiction_id": jurisdiction_id,
403
+ "office_role": office_role,
404
+ "start_dates": dict({}),
405
+ })
406
+
407
+ office_date_info = jurisdiction_role_mapping[office_hash]
408
+ if start_date not in office_date_info["start_dates"].keys():
409
+ office_date_info["start_dates"][start_date] = set()
410
+
411
+ office_date_info["start_dates"][start_date].add(office)
412
+
413
+ return jurisdiction_role_mapping
414
+
415
+ def check(self, element):
416
+ warning_log = []
417
+
418
+ start_counts = self._count_start_dates_by_jurisdiction_role(element)
419
+ for start_info in start_counts.values():
420
+ start_date_map = start_info["start_dates"]
421
+ if len(start_date_map.keys()) == 1:
422
+ start_date = list(start_date_map.keys())[0]
423
+ # this accounts for offices with only one entry (i.e. US Pres)
424
+ if len(start_date_map[start_date]) > 1:
425
+ warning_log.append(
426
+ loggers.LogEntry(
427
+ (
428
+ "Only one unique StartDate found for each"
429
+ " jurisdiction-id: {} and office-role: {}. {} appears {}"
430
+ " times."
431
+ ).format(
432
+ start_info["jurisdiction_id"],
433
+ start_info["office_role"],
434
+ start_date,
435
+ len(start_date_map[start_date]),
436
+ ),
437
+ start_date_map[start_date],
438
+ )
439
+ )
440
+
441
+ if warning_log:
442
+ raise loggers.ElectionWarning(warning_log)
443
+
444
+
445
+ class UnreferencedEntitiesOfficeholders(common_rules.UnreferencedEntitiesBase):
446
+ """CDF offices and party leadership entities are top-level in officeholders feeds.
447
+
448
+ This rule is a warning for CDF parties since we ask for all parties for some
449
+ LatAm feeds.
450
+ """
451
+
452
+ def __init__(self, election_tree, schema_tree, **kwargs):
453
+ office_holder_tenure_collection = self.get_elements_by_class(
454
+ election_tree, "OfficeHolderTenure"
455
+ )
456
+ is_post_office_split = False
457
+ if office_holder_tenure_collection:
458
+ is_post_office_split = True
459
+ super(UnreferencedEntitiesOfficeholders, self).__init__(
460
+ election_tree,
461
+ schema_tree,
462
+ (
463
+ frozenset(["OfficeHolderTenure", "Leadership"])
464
+ if is_post_office_split
465
+ else frozenset(["Office", "Leadership"])
466
+ ),
467
+ frozenset(["Party"]),
468
+ **kwargs,
469
+ )
470
+
471
+
472
+ OFFICEHOLDER_RULES = common_rules.COMMON_RULES + (
473
+ # go/keep-sorted start
474
+ DateOfBirthIsInPast,
475
+ OfficeHolderTenureTermDates,
476
+ OfficeSelectionMethodMatch,
477
+ OfficeTermDates,
478
+ PersonHasOffice,
479
+ RemovePersonAndOfficeHolderId60DaysAfterEndDate,
480
+ UniqueStartDatesForOfficeRoleAndJurisdiction,
481
+ UnreferencedEntitiesOfficeholders,
482
+ common_rules.ProhibitElectionData,
483
+ # go/keep-sorted end
484
+ )
@@ -5,4 +5,4 @@ No dependencies should be added to this module.
5
5
  See https://packaging.python.org/guides/single-sourcing-package-version/
6
6
  """
7
7
 
8
- __version__ = '1.67.dev2'
8
+ __version__ = '1.67.dev3'