Skip to content

Commit b500b24

Browse files
authored
Merge pull request #207 from openstates/DATA-5365/USA-events-and-committee-match
DATA-5365: Fix case/punctuation-sensitive committee matching for USA events
2 parents c3ba999 + 80ebd68 commit b500b24

2 files changed

Lines changed: 111 additions & 14 deletions

File tree

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,65 @@
11
import re
2-
from django.db.models import Q
2+
from django.db.models import BooleanField, Q
3+
from django.db.models.expressions import RawSQL
34
from ._types import _JsonDict
45
from .base import BaseImporter
56
from ..data.models import Organization
67

8+
# Matches any "&" surrounded by optional whitespace, so it can be expanded
9+
# to " and " before punctuation is stripped (otherwise "Fish & Game" and
10+
# "Fish and Game" would normalize to different strings: "FISH GAME" vs
11+
# "FISH AND GAME").
12+
_AMPERSAND_RE = re.compile(r"\s*&\s*")
13+
14+
# Matches everything except letters, digits, and whitespace - used to
15+
# compare organization names without regard to punctuation (commas,
16+
# apostrophes, periods, etc). Keeping the Python and SQL normalization in
17+
# sync is important: both sides of the comparison must use the same rule.
18+
_NON_ALPHANUMERIC_RE = re.compile(r"[^A-Za-z0-9 ]")
19+
20+
# Collapses runs of whitespace down to a single space (ampersand expansion
21+
# above can introduce extra spaces, e.g. "A&B" -> "A and B").
22+
_EXTRA_WHITESPACE_RE = re.compile(r"\s+")
23+
24+
# The equivalent normalization expressed as a chain of Postgres
25+
# regexp_replace calls, so we can apply it to `name` and to each entry of
26+
# the `other_names` JSONB array. `{column}` is substituted with the SQL
27+
# expression for the column/value being normalized.
28+
_NORMALIZE_SQL_TEMPLATE = """
29+
upper(trim(regexp_replace(
30+
regexp_replace(
31+
regexp_replace({column}, '\\s*&\\s*', ' and ', 'g'),
32+
'[^A-Za-z0-9 ]', '', 'g'
33+
),
34+
'\\s+', ' ', 'g'
35+
)))
36+
"""
37+
38+
# Raw SQL boolean expression used to find organizations whose `name` OR any
39+
# entry in the `other_names` JSONB array matches a normalized name. This
40+
# avoids trying to guess the exact case/punctuation used in `other_names`
41+
# (which is free-form, human-entered data from the people repo) - instead
42+
# both sides are normalized (uppercased, punctuation stripped, "&"
43+
# expanded to "and") before comparison.
44+
_NORMALIZED_NAME_MATCH_SQL = f"""
45+
{_NORMALIZE_SQL_TEMPLATE.format(column="name")} = %s
46+
OR EXISTS (
47+
SELECT 1 FROM jsonb_array_elements(other_names) AS other_name
48+
WHERE {_NORMALIZE_SQL_TEMPLATE.format(column="other_name ->> 'name'")} = %s
49+
)
50+
"""
51+
52+
53+
def _normalize_name(name: str) -> str:
54+
"""Normalize an organization name for comparison purposes: expand "&"
55+
to "and", strip punctuation, collapse whitespace, and uppercase. This
56+
must stay equivalent to the Postgres expression in
57+
_NORMALIZE_SQL_TEMPLATE."""
58+
name = _AMPERSAND_RE.sub(" and ", name)
59+
name = _NON_ALPHANUMERIC_RE.sub("", name)
60+
name = _EXTRA_WHITESPACE_RE.sub(" ", name)
61+
return name.strip().upper()
62+
763

864
class OrganizationImporter(BaseImporter):
965
_type = "organization"
@@ -13,29 +69,27 @@ def limit_spec(self, spec: _JsonDict) -> _JsonDict:
1369
if spec.get("classification") != "party":
1470
spec["jurisdiction_id"] = self.jurisdiction_id
1571

16-
org_name_prepositions = ["and", "at", "by", "for", "in", "on", "of", "the"]
1772
name = spec.pop("name", None)
1873
# if chamber is included in pseudo_person_id, we assume this is a committee
1974
# and chamber is here to help us find its parent
2075
chamber_classification = spec.pop("chamber", None)
2176
if name:
22-
# __icontains doesn't work for JSONField ArrayField
23-
# so name follows "title" naming pattern
24-
name = name.title()
25-
pattern = "(" + "|".join(org_name_prepositions) + ")"
26-
name = re.sub(
27-
pattern, lambda match: match.group(0).lower(), name, flags=re.IGNORECASE
28-
)
29-
name = name.replace(" & ", " and ")
77+
normalized_name = _normalize_name(name)
78+
matching_orgs = Organization.objects.annotate(
79+
_normalized_name_match=RawSQL(
80+
_NORMALIZED_NAME_MATCH_SQL,
81+
(normalized_name, normalized_name),
82+
output_field=BooleanField(),
83+
)
84+
).filter(_normalized_name_match=True)
85+
name_q = Q(pk__in=matching_orgs)
3086

3187
if chamber_classification:
3288
return (
3389
Q(**spec)
34-
& (Q(name__iexact=name) | Q(other_names__contains=[{"name": name}]))
90+
& name_q
3591
& Q(parent__classification=chamber_classification)
3692
)
3793
else:
38-
return Q(**spec) & (
39-
Q(name__iexact=name) | Q(other_names__contains=[{"name": name}])
40-
)
94+
return Q(**spec) & name_q
4195
return spec

openstates/importers/tests/test_event_importer.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,49 @@ def test_related_committee_event():
220220
)
221221

222222

223+
@pytest.mark.django_db
224+
def test_related_committee_event_other_names_case_mismatch():
225+
"""Reproduces the bug where a committee is stored with an other_name
226+
that differs only in case/punctuation from what the event scraper
227+
reports, and the match should still succeed."""
228+
j = create_jurisdiction()
229+
j.legislative_sessions.create(name="1900", identifier="1900")
230+
org = Organization.objects.create(
231+
id="org-id", name="Senate", classification="upper", jurisdiction=j
232+
)
233+
Organization.objects.create(
234+
id="ag-forestry",
235+
name="Committee on Agriculture, Nutrition, and Forestry",
236+
classification="committee",
237+
parent=org,
238+
jurisdiction=j,
239+
# other_names as it might really be entered by a human in the
240+
# people repo YAML -- different case/punctuation than what the
241+
# scraper reports below.
242+
other_names=[{"name": "Senate Agriculture, Nutrition, AND Forestry"}],
243+
)
244+
245+
event = ge()
246+
item = event.add_agenda_item("Cookies will be served")
247+
# scraper-reported name has different case ("and" lowercase already
248+
# matches after .title()-based normalization, but let's use a variant
249+
# that the current .title()-based heuristic can't reconcile, e.g. an
250+
# apostrophe or different casing on a "preposition" word not in the
251+
# hardcoded list, or simply an all-different case).
252+
item.add_committee(committee="senate agriculture, nutrition, and forestry")
253+
254+
result = EventImporter(jid, vei).import_data([event.as_dict()])
255+
assert result["event"]["insert"] == 1
256+
257+
assert (
258+
Event.objects.get(name="America's Birthday")
259+
.agenda.first()
260+
.related_entities.first()
261+
.organization_id
262+
== "ag-forestry"
263+
)
264+
265+
223266
@pytest.mark.django_db
224267
def test_media_event():
225268
create_jurisdiction()

0 commit comments

Comments
 (0)