diff --git a/Pipfile.lock b/Pipfile.lock index 4f93bb7..5b3a8ad 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -5,7 +5,7 @@ }, "pipfile-spec": 6, "requires": { - "python_version": "3.10" + "python_version": "3.12" }, "sources": [ { @@ -443,10 +443,11 @@ }, "uk-election-timetables": { "hashes": [ - "sha256:303733e644c0c3747dc73cdf522f8105e9393fbff492bda732b73cd8adc676c3" + "sha256:12d7b8b141254fb9a02114438c6db4063dce2867bf42205da0b7d2bea787150e", + "sha256:9a4e857ae1465bd6c6cc0918722b0dc9373577794aa5d60c3ac7363270b66470" ], "index": "pypi", - "version": "==3.0.0" + "version": "==4.0.0" }, "urllib3": { "hashes": [ diff --git a/postcode_lookup/mock_responses.py b/postcode_lookup/mock_responses.py index ccf9b93..f4f0237 100644 --- a/postcode_lookup/mock_responses.py +++ b/postcode_lookup/mock_responses.py @@ -1,3 +1,9 @@ +from response_builder.v1.builders.ballots import ( + LocalBallotBuilder, + ParlBallotBuilder, +) +from response_builder.v1.builders.base import RootBuilder +from response_builder.v1.generated_responses import candidates from response_builder.v1.generated_responses.root_responses import ( CANCELLED_BALLOT_CANDIDATE_DEATH, CANCELLED_BALLOT_EQUAL_CANDIDATES, @@ -11,6 +17,49 @@ SINGLE_LOCAL_FUTURE_BALLOT_WITHOUT_POLLING_STATION, ) + +class CityOfLondonParlBallot(ParlBallotBuilder): + def __init__(self, poll_open_date, **kwargs): + super().__init__(**kwargs) + self.with_ballot_paper_id( + f"parl.cities-of-london-and-westminster.{poll_open_date}" + ) + self.with_ballot_title( + "UK Parliamentary general election Cities of London and Westminster" + ) + self.with_date(poll_open_date) + self.with_post_name("Cities of London and Westminster") + self.with_election_name("UK Parliamentary general election") + self.with_election_id(f"parl.{poll_open_date}") + self.with_candidates(candidates.all_candidates) + + +class CityOfLondonLocalBallot(LocalBallotBuilder): + def __init__(self, poll_open_date, **kwargs): + super().__init__(**kwargs) + self.with_ballot_paper_id( + f"local.city-of-london.aldersgate.{poll_open_date}" + ) + self.with_ballot_title("City of London local election Aldersgate") + self.with_date(poll_open_date) + self.with_post_name("Aldersgate") + self.with_election_name("City of London local election") + self.with_election_id(f"local.city-of-london.{poll_open_date}") + self.with_candidates(candidates.all_candidates) + + +CITY_OF_LONDON_COUNCIL_AND_PARL_DIFFERENT_DAYS = ( + RootBuilder() + .with_ballot(CityOfLondonLocalBallot("2025-03-20").build()) + .with_ballot(CityOfLondonParlBallot("2025-05-01").build()) +) +CITY_OF_LONDON_COUNCIL_AND_PARL_SAME_DAY = ( + RootBuilder() + .with_ballot(CityOfLondonLocalBallot("2025-03-20").build()) + .with_ballot(CityOfLondonParlBallot("2025-03-20").build()) +) + + __ALL__ = ("example_responses",) example_responses = { "AA1 1AA": { @@ -69,4 +118,12 @@ # "description": "Police and Crime Commissioner ballot", # "response": PCC_BALLOT, # }, + "AA1 1AL": { + "description": "City of London (Common Councilman) and UK Parl ballots on different upcoming dates", + "response": CITY_OF_LONDON_COUNCIL_AND_PARL_DIFFERENT_DAYS, + }, + "AA1 1AM": { + "description": "City of London (Common Councilman) and UK Parl ballots on the same date", + "response": CITY_OF_LONDON_COUNCIL_AND_PARL_SAME_DAY, + }, } diff --git a/postcode_lookup/template_sorter.py b/postcode_lookup/template_sorter.py index 6735ce9..eb8b64a 100644 --- a/postcode_lookup/template_sorter.py +++ b/postcode_lookup/template_sorter.py @@ -151,9 +151,12 @@ def context(self): context["can_register_vac"] = self.timetable.is_before( TimetableEvent.VAC_APPLICATION_DEADLINE ) - context["htag"] = "h2" + context["htag_primary"] = "h2" + context["htag_secondary"] = "h3" if self.response_type == ResponseTypes.MULTIPLE_DATES: - context["htag"] = "h3" + context["htag_primary"] = "h3" + context["htag_secondary"] = "h4" + context["toc_id"] = self.toc_id return context @property @@ -162,7 +165,34 @@ def toc_label(self): @property def toc_id(self): - return "voter-registration" + return f"voter-registration-{self.timetable.poll_date}-{self.timetable.registration_deadline}" + + +class CityOfLondonRegistrationDateSection(RegistrationDateSection): + template_name = "includes/registration_timetable_city_of_london.html" + + def __init__(self, *args, **kwargs) -> None: + self.with_headers = kwargs.pop("with_headers") + super().__init__(*args, **kwargs) + + @property + def weight(self): + parent_weight = super().weight + return 0 if parent_weight == 0 else parent_weight + 1 + + @property + def context(self): + context = super().context + context["with_headers"] = self.with_headers + return context + + @property + def toc_label(self): + return _("Voter registration") + + @property + def toc_id(self): + return f"voter-registration-col-{self.timetable.poll_date}-{self.timetable.registration_deadline}" class ElectionDateTemplateSorter: @@ -199,7 +229,7 @@ def __init__( self.polling_station_opening_times_str = _("7am – 10pm") if any( - "city-of-london" in ballot.ballot_paper_id + ballot.ballot_paper_id.startswith("local.city-of-london.") for ballot in self.date_data.ballots ): self.polling_station_opening_times_str = _("8am – 8pm") @@ -217,18 +247,50 @@ def __init__( "timetable": self.timetable, } - enabled_sections = [BallotSection] - if not self.all_cancelled: - enabled_sections.append(RegistrationDateSection) + enabled_sections = [BallotSection(**section_kwargs)] + + city_of_london_ballots = [ + b + for b in self.date_data.ballots + if not b.cancelled + and b.ballot_paper_id.startswith("local.city-of-london.") + ] + other_ballots = [ + b + for b in self.date_data.ballots + if not b.cancelled + and not b.ballot_paper_id.startswith("local.city-of-london.") + ] + if len(other_ballots) > 0: + enabled_sections.append( + RegistrationDateSection( + data=self.date_data, + mode=self.current_mode, + response_type=self.response_type, + current_date=self.current_date, + timetable=from_election_id( + other_ballots[0].election_id, country=country + ), + ) + ) + if len(city_of_london_ballots) > 0: + enabled_sections.append( + CityOfLondonRegistrationDateSection( + data=self.date_data, + mode=self.current_mode, + response_type=self.response_type, + current_date=self.current_date, + timetable=from_election_id( + city_of_london_ballots[0].election_id, country=country + ), + with_headers=len(other_ballots) == 0, + ) + ) if self.first_upcoming_date: - enabled_sections.append(PollingStationSection) + enabled_sections.append(PollingStationSection(**section_kwargs)) - self.sections = sorted( - [section(**section_kwargs) for section in enabled_sections], - key=lambda sec: sec.weight, - # reverse=True, - ) + self.sections = sorted(enabled_sections, key=lambda sec: sec.weight) class TemplateSorter: diff --git a/postcode_lookup/templates/includes/registration_timetable.html b/postcode_lookup/templates/includes/registration_timetable.html index 7e814dc..95cbaa8 100644 --- a/postcode_lookup/templates/includes/registration_timetable.html +++ b/postcode_lookup/templates/includes/registration_timetable.html @@ -1,11 +1,22 @@ -<{{ section.context.htag }} id="voter-registration">{% trans %}Voter registration{% endtrans %} +<{{ section.context.htag_primary }} id="{{toc_id}}">{% trans %}Voter registration{% endtrans %} {% if section.context.can_register %} -

Register to vote

-

You have until {{ section.timetable.registration_deadline|date_filter }}

-

- Register to vote

+ <{{ section.context.htag_secondary }}>{% trans %}Register to vote{% endtrans %} +

+ {% trans date=section.timetable.registration_deadline|date_filter %} + You have until {{ date }} + {% endtrans %} +

+

+ + {% trans %}Register to vote{% endtrans %} + +

{% else %} - {% trans date=section.data.date|date_filter, registration_deadline=section.timetable.registration_deadline|date_filter %} + {% + trans + date=section.data.date|date_filter, + registration_deadline=section.timetable.registration_deadline|date_filter + %}

The deadline to register to vote on {{ date }} was {{ registration_deadline }}.

If you registered to vote before the deadline, then you will be able to vote in this election. {% trans %}Voter registration{% endtrans %} +{% endif %} +{% if section.context.can_register %} + {% if section.context.with_headers %} + <{{ section.context.htag_secondary }}>{% trans %}Register to vote{% endtrans %} + {% endif %} +

{% trans %}City of London council elections do not use the same electoral register as other elections.{% endtrans %}

+

+ {% trans %} + Both residents and city workers are eligible to vote. There is one register + published annually, and the deadline to apply is 30 November each year. + The new register comes into force on 16 February each year, + and cannot be modified after that date. + {% endtrans %} +

+

+ + {% trans %}Register to vote in the City of London{% endtrans %} + +

+{% else %} + <{{ section.context.htag_secondary }} id="{{toc_id}}">{% trans %}Registration in the City of London{% endtrans %} +

{% trans %}City of London council elections do not use the same electoral register as other elections.{% endtrans %}

+ {% + trans + date=section.data.date|date_filter, + registration_deadline=section.timetable.registration_deadline|date_filter + %} +

The deadline to register to vote in City of London local elections on {{ date }} + was {{ registration_deadline }}.

+

If you registered to vote before the deadline, then you will be able to vote in this election. Contact City of London to check if you are on the register.

+

You can register to vote in the City of London in future elections.

+ {% endtrans %} +{% endif %} diff --git a/tests/conftest.py b/tests/conftest.py index c51dd26..6cc0de7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,6 +5,13 @@ import uvicorn from app import app from starlette.testclient import TestClient +from template_sorter import ( + ApiModes, + ElectionDateTemplateSorter, + TemplateSorter, +) +from uk_election_timetables.calendars import Country +from uk_election_timetables.election_ids import from_election_id @pytest.fixture(scope="function") @@ -33,3 +40,46 @@ def uvicorn_server(): time.sleep(0.3) yield f"http://localhost:{port}" proc.kill() + + +@pytest.fixture +def template_sorter(): + def get_template_sorter(mock_response, date): + api_response = mock_response + # The dates exist in the api_response, but not + # in the format that matches the RootBuilder: + # dates = api_response._values["dates"] vs + # dates = api_response.dates so I've set it here. + api_response.dates = api_response._values["dates"] + mode = ApiModes.UPCOMING_ELECTIONS + + sorter = TemplateSorter( + api_response=api_response, mode=mode, current_date=date + ) + sorter.country = Country.ENGLAND + sorter.dates = api_response.dates + + return sorter + + return get_template_sorter + + +@pytest.fixture +def election_date_template_sorter(): + def get_election_date_template_sorter(template_sorter, date): + election_date_sorter = ElectionDateTemplateSorter( + date_data=date, + country=template_sorter.country, + current_date=template_sorter.current_date, + response_type=template_sorter.response_type, + ) + election_date_sorter.current_date = template_sorter.current_date + + election_date_sorter.timetable = from_election_id( + template_sorter.dates[0].ballots[0].election_id, + country=template_sorter.country, + ) + + return election_date_sorter + + return get_election_date_template_sorter diff --git a/tests/test_registration.py b/tests/test_registration.py new file mode 100644 index 0000000..3b032c8 --- /dev/null +++ b/tests/test_registration.py @@ -0,0 +1,77 @@ +import datetime + +from freezegun import freeze_time +from mock_responses import ( + CITY_OF_LONDON_COUNCIL_AND_PARL_DIFFERENT_DAYS, + CITY_OF_LONDON_COUNCIL_AND_PARL_SAME_DAY, + SINGLE_LOCAL_FUTURE_BALLOT_WITH_POLLING_STATION, +) +from template_sorter import ( + CityOfLondonRegistrationDateSection, + RegistrationDateSection, +) + + +def _sections_of_type(sections, type_): + return [s for s in sections if type(s) == type_] + + +@freeze_time("2024-04-16") +def test_single_ballot_not_city_of_london( + template_sorter, election_date_template_sorter +): + single_ballot_sorter_before_deadline = template_sorter( + SINGLE_LOCAL_FUTURE_BALLOT_WITH_POLLING_STATION, + date=datetime.date(2024, 3, 16), + ) + single_election_date_template_sorter = election_date_template_sorter( + single_ballot_sorter_before_deadline, + single_ballot_sorter_before_deadline.dates[0], + ) + sections = single_election_date_template_sorter.sections + assert len(_sections_of_type(sections, RegistrationDateSection)) == 1 + assert ( + len(_sections_of_type(sections, CityOfLondonRegistrationDateSection)) + == 0 + ) + + +@freeze_time("2024-04-16") +def test_city_of_london_and_parl_different_days( + template_sorter, election_date_template_sorter +): + single_ballot_sorter_before_deadline = template_sorter( + CITY_OF_LONDON_COUNCIL_AND_PARL_DIFFERENT_DAYS, + date=datetime.date(2024, 3, 16), + ) + single_election_date_template_sorter = election_date_template_sorter( + single_ballot_sorter_before_deadline, + single_ballot_sorter_before_deadline.dates[0], + ) + sections = single_election_date_template_sorter.sections + assert len(_sections_of_type(sections, RegistrationDateSection)) == 0 + assert ( + len(_sections_of_type(sections, CityOfLondonRegistrationDateSection)) + == 1 + ) + # ignore the parl ballot in this test + + +@freeze_time("2024-04-16") +def test_city_of_london_and_parl_same_day( + template_sorter, election_date_template_sorter +): + single_ballot_sorter_before_deadline = template_sorter( + CITY_OF_LONDON_COUNCIL_AND_PARL_SAME_DAY, + date=datetime.date(2024, 3, 16), + ) + single_election_date_template_sorter = election_date_template_sorter( + single_ballot_sorter_before_deadline, + single_ballot_sorter_before_deadline.dates[0], + ) + sections = single_election_date_template_sorter.sections + assert len(_sections_of_type(sections, RegistrationDateSection)) == 1 + assert ( + len(_sections_of_type(sections, CityOfLondonRegistrationDateSection)) + == 1 + ) diff --git a/tests/test_result.py b/tests/test_result.py index 7ec8d5d..93e2d08 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -7,12 +7,6 @@ NO_LOCAL_BALLOTS, SINGLE_LOCAL_FUTURE_BALLOT_WITH_POLLING_STATION, ) -from template_sorter import ( - ApiModes, - ElectionDateTemplateSorter, - TemplateSorter, -) -from uk_election_timetables.calendars import Country from uk_election_timetables.election_ids import from_election_id @@ -59,49 +53,6 @@ def test_uk_election_timetable(): # https://election-timetable.democracyclub.org.uk/?election_date=2024-05-02 -@pytest.fixture -def template_sorter(): - def get_template_sorter(mock_response, date): - api_response = mock_response - # The dates exist in the api_response, but not - # in the format that matches the RootBuilder: - # dates = api_response._values["dates"] vs - # dates = api_response.dates so I've set it here. - api_response.dates = api_response._values["dates"] - mode = ApiModes.UPCOMING_ELECTIONS - - sorter = TemplateSorter( - api_response=api_response, mode=mode, current_date=date - ) - sorter.country = Country.ENGLAND - sorter.dates = api_response.dates - - return sorter - - return get_template_sorter - - -@pytest.fixture -def election_date_template_sorter(): - def get_election_date_template_sorter(template_sorter, date): - election_date_sorter = ElectionDateTemplateSorter( - date_data=date, - country=template_sorter.country, - current_date=template_sorter.current_date, - response_type=template_sorter.response_type, - ) - election_date_sorter.current_date = template_sorter.current_date - - election_date_sorter.timetable = from_election_id( - template_sorter.dates[0].ballots[0].election_id, - country=template_sorter.country, - ) - - return election_date_sorter - - return get_election_date_template_sorter - - @freeze_time("2024-04-04") def test_sopn_day(template_sorter, election_date_template_sorter): """this tests is after postal vote deadline, but