From 013414c9703d71a3dc99e53508067e8ed718f7db Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 25 Jul 2025 15:18:30 +0200 Subject: [PATCH 1/8] Reproduce issue --- tests/onegov/swissvotes/test_views_votes.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/onegov/swissvotes/test_views_votes.py b/tests/onegov/swissvotes/test_views_votes.py index f4460ee495..c977893526 100644 --- a/tests/onegov/swissvotes/test_views_votes.py +++ b/tests/onegov/swissvotes/test_views_votes.py @@ -281,3 +281,16 @@ def test_view_update_external_resources(mfg, sa, bs, swissvotes_app): assert '15 hinzugefügt, 17 geändert, 19 gelöscht' in manage assert 'Quellen konnten nicht aktualisiert werden: 4, 8, 9' in manage + + +def test_view_votes_empty_policy_area(swissvotes_app): + """ Ensure that the votes view does not crash when the policy area is empty + """ + client = Client(swissvotes_app) + client.get('/locale/de_CH').follow() + + page = client.get('/votes') + assert page.status_code == 200 + + page = client.get('/votes?term=&policy_area=9&policy_area=') + assert page.status_code == 200 From cf3e613c53a011b4e0a8799d8fcf384e0147f0e9 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 25 Jul 2025 15:19:25 +0200 Subject: [PATCH 2/8] Ignore empty policy areas --- src/onegov/swissvotes/collections/votes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/onegov/swissvotes/collections/votes.py b/src/onegov/swissvotes/collections/votes.py index 31b1d60050..11104b2244 100644 --- a/src/onegov/swissvotes/collections/votes.py +++ b/src/onegov/swissvotes/collections/votes.py @@ -71,6 +71,10 @@ def __init__( sort_by: str | None = None, sort_order: str | None = None ) -> None: + # ignore empty policy areas swi-63 + if policy_area and '' in policy_area: + policy_area = [i for i in policy_area if i] + super().__init__(page) self.app = app self.session = app.session() From 0b1800b876a12aa98b826a9a4704b43eaef8c879 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 25 Jul 2025 15:21:57 +0200 Subject: [PATCH 3/8] Fix test --- tests/onegov/swissvotes/test_collections.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/onegov/swissvotes/test_collections.py b/tests/onegov/swissvotes/test_collections.py index 3bde208f92..aabb44502f 100644 --- a/tests/onegov/swissvotes/test_collections.py +++ b/tests/onegov/swissvotes/test_collections.py @@ -100,7 +100,7 @@ def test_votes_default(swissvotes_app): to_date=4, legal_form=5, result=6, - policy_area=7, + policy_area=['7'], term=8, full_text=9, position_federal_council=10, @@ -114,7 +114,7 @@ def test_votes_default(swissvotes_app): assert votes.to_date == 4 assert votes.legal_form == 5 assert votes.result == 6 - assert votes.policy_area == 7 + assert votes.policy_area == ['7'] assert votes.term == 8 assert votes.full_text == 9 assert votes.position_federal_council == 10 From f53e61d2de402155538c2296dad03370700aa407 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 12 Sep 2025 12:03:32 +0200 Subject: [PATCH 4/8] Adds converter --- src/onegov/swissvotes/converters.py | 48 ++++++++++++++++++++++ tests/onegov/swissvotes/test_converters.py | 40 ++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/onegov/swissvotes/converters.py create mode 100644 tests/onegov/swissvotes/test_converters.py diff --git a/src/onegov/swissvotes/converters.py b/src/onegov/swissvotes/converters.py new file mode 100644 index 0000000000..5e893476d4 --- /dev/null +++ b/src/onegov/swissvotes/converters.py @@ -0,0 +1,48 @@ +from __future__ import annotations +import re + +from morepath.converter import Converter + + +class PolicyAreaListConverter(Converter): + + def verify_format(self, s: str) -> bool: + # verify is a number or in '1.13.136' format, + # no alphanumeric character allowed + return bool(re.fullmatch(r'\d+(\.\d+)*', s)) + + def verify_components(self, s: str) -> bool: + # verify that componenet starts with previous component if + # split over `.` valid '1.12.123' but invalid '1.22.123' + components = s.split('.') + if len(components) == 1: + return True + + for component in components[1:]: + if not component.startswith( + components[components.index(component) - 1]): + return False + + return True + + def validate(self, s: str) -> bool: + if not s: + return False + + return self.verify_format(s) and self.verify_components(s) + + def decode(self, s: str) -> list[str]: + if not s: + return [] + return [item for item in s if self.validate(item)] + + def encode(self, l: list[str]) -> str: + if not l: + return [] + return [item for item in l if item] + + +policy_area_converter = PolicyAreaListConverter( + decode=PolicyAreaListConverter.decode, + encode=PolicyAreaListConverter.encode +) diff --git a/tests/onegov/swissvotes/test_converters.py b/tests/onegov/swissvotes/test_converters.py new file mode 100644 index 0000000000..9fc5c74264 --- /dev/null +++ b/tests/onegov/swissvotes/test_converters.py @@ -0,0 +1,40 @@ +from onegov.swissvotes.converters import policy_area_converter + + +def test_policy_area_converter(): + converter = policy_area_converter + + # assert converter.decode(['']) == [None] + # assert converter.decode([None]) == [None] + + assert converter.decode(['']) == [] + assert converter.decode([None]) == [] + assert converter.decode([]) == [] + assert converter.decode(['1']) == ['1'] + assert converter.decode(['1', '4']) == ['1', '4'] + assert converter.decode(['1', '4', '8', '10']) == ['1', '4', '8', '10'] + assert converter.decode(['1', '', '8', '22']) == ['1', '8', '22'] + assert converter.decode(['1', '', '', '22']) == ['1', '22'] + + assert converter.encode(None) == [] + assert converter.encode('') == [] + assert converter.encode([]) == [] + assert converter.encode(['1']) == ['1'] + assert converter.encode(['21']) == ['21'] + assert converter.encode(['1', '4']) == ['1', '4'] + assert converter.encode(['1', '4', '8', '10']) == ['1', '4', '8', '10'] + + assert converter.decode(['1.12']) == ['1.12'] + assert converter.decode(['1.12.121']) == ['1.12.121'] + assert converter.decode(['4.42.421']) == ['4.42.421'] + assert converter.decode(['10.102']) == ['10.102'] + assert converter.decode(['10.103.1035']) == ['10.103.1035'] + assert converter.decode(['12.125.1251']) == ['12.125.1251'] + assert converter.decode(['1.12.123.1231']) == ['1.12.123.1231'] + + # invalid policy area(s) + assert converter.decode(['z']) == [] + assert converter.decode(['1,12,121']) == [] + assert converter.decode(['1.32.121']) == [] + assert converter.decode(['4.92.421']) == [] + assert converter.decode(['a.a2.a21']) == [] From 10e322c3cd2deddf01cbf41bda7cfb992f323cec Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 12 Sep 2025 13:13:22 +0200 Subject: [PATCH 5/8] Activate policy area converter in votes collection --- src/onegov/swissvotes/path.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/onegov/swissvotes/path.py b/src/onegov/swissvotes/path.py index 3fc02b96b6..0f83263a55 100644 --- a/src/onegov/swissvotes/path.py +++ b/src/onegov/swissvotes/path.py @@ -7,6 +7,7 @@ from onegov.swissvotes.app import SwissvotesApp from onegov.swissvotes.collections import SwissVoteCollection from onegov.swissvotes.collections import TranslatablePageCollection +from onegov.swissvotes.converters import policy_area_converter from onegov.swissvotes.models import Principal from onegov.swissvotes.models import SwissVote from onegov.swissvotes.models import SwissVoteFile @@ -54,7 +55,7 @@ def get_locale(app: SwissvotesApp, locale: str) -> SiteLocale | None: 'to_date': extended_date_converter, 'legal_form': [int], 'result': [int], - 'policy_area': [str], + 'policy_area': policy_area_converter, 'term': str, 'full_text': bool, 'position_federal_council': [int], From 0e9525f43b3aaf3777a4906bf955b55ec846c16f Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 12 Sep 2025 13:55:29 +0200 Subject: [PATCH 6/8] Cleanup, annotations --- src/onegov/swissvotes/converters.py | 10 +++++----- tests/onegov/swissvotes/test_converters.py | 3 --- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/onegov/swissvotes/converters.py b/src/onegov/swissvotes/converters.py index 5e893476d4..06d481ad9e 100644 --- a/src/onegov/swissvotes/converters.py +++ b/src/onegov/swissvotes/converters.py @@ -4,7 +4,7 @@ from morepath.converter import Converter -class PolicyAreaListConverter(Converter): +class PolicyAreaListConverter(Converter): # type: ignore[type-arg] def verify_format(self, s: str) -> bool: # verify is a number or in '1.13.136' format, @@ -31,18 +31,18 @@ def validate(self, s: str) -> bool: return self.verify_format(s) and self.verify_components(s) - def decode(self, s: str) -> list[str]: + def decode(self, s: str) -> list[str]: # type: ignore[override] if not s: return [] return [item for item in s if self.validate(item)] - def encode(self, l: list[str]) -> str: + def encode(self, l: list[str]) -> list[str]: # type: ignore[override] if not l: return [] return [item for item in l if item] policy_area_converter = PolicyAreaListConverter( - decode=PolicyAreaListConverter.decode, - encode=PolicyAreaListConverter.encode + decode=PolicyAreaListConverter, # type:ignore[arg-type] + encode=PolicyAreaListConverter, # type:ignore[arg-type] ) diff --git a/tests/onegov/swissvotes/test_converters.py b/tests/onegov/swissvotes/test_converters.py index 9fc5c74264..eba5c9f38a 100644 --- a/tests/onegov/swissvotes/test_converters.py +++ b/tests/onegov/swissvotes/test_converters.py @@ -4,9 +4,6 @@ def test_policy_area_converter(): converter = policy_area_converter - # assert converter.decode(['']) == [None] - # assert converter.decode([None]) == [None] - assert converter.decode(['']) == [] assert converter.decode([None]) == [] assert converter.decode([]) == [] From bfbf22f85c03c1bdd68c84c52bb0b294a8954d8b Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 12 Sep 2025 14:10:47 +0200 Subject: [PATCH 7/8] Improvments --- src/onegov/swissvotes/converters.py | 18 ++++++++++-------- src/onegov/swissvotes/path.py | 4 ++-- tests/onegov/swissvotes/test_converters.py | 4 ++-- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/onegov/swissvotes/converters.py b/src/onegov/swissvotes/converters.py index 06d481ad9e..32f9bc2c3b 100644 --- a/src/onegov/swissvotes/converters.py +++ b/src/onegov/swissvotes/converters.py @@ -1,10 +1,17 @@ from __future__ import annotations import re +from typing import TYPE_CHECKING -from morepath.converter import Converter +import morepath -class PolicyAreaListConverter(Converter): # type: ignore[type-arg] +if TYPE_CHECKING: + PolicyAreaConverterBase = morepath.Converter[str] +else: + PolicyAreaConverterBase = morepath.Converter + + +class PolicyAreaListConverter(PolicyAreaConverterBase): def verify_format(self, s: str) -> bool: # verify is a number or in '1.13.136' format, @@ -26,6 +33,7 @@ def verify_components(self, s: str) -> bool: return True def validate(self, s: str) -> bool: + """ Basically drops invalid policy areas. """ if not s: return False @@ -40,9 +48,3 @@ def encode(self, l: list[str]) -> list[str]: # type: ignore[override] if not l: return [] return [item for item in l if item] - - -policy_area_converter = PolicyAreaListConverter( - decode=PolicyAreaListConverter, # type:ignore[arg-type] - encode=PolicyAreaListConverter, # type:ignore[arg-type] -) diff --git a/src/onegov/swissvotes/path.py b/src/onegov/swissvotes/path.py index 0f83263a55..e3c3f26682 100644 --- a/src/onegov/swissvotes/path.py +++ b/src/onegov/swissvotes/path.py @@ -7,7 +7,7 @@ from onegov.swissvotes.app import SwissvotesApp from onegov.swissvotes.collections import SwissVoteCollection from onegov.swissvotes.collections import TranslatablePageCollection -from onegov.swissvotes.converters import policy_area_converter +from onegov.swissvotes.converters import PolicyAreaListConverter from onegov.swissvotes.models import Principal from onegov.swissvotes.models import SwissVote from onegov.swissvotes.models import SwissVoteFile @@ -55,7 +55,7 @@ def get_locale(app: SwissvotesApp, locale: str) -> SiteLocale | None: 'to_date': extended_date_converter, 'legal_form': [int], 'result': [int], - 'policy_area': policy_area_converter, + 'policy_area': [PolicyAreaListConverter(str)], 'term': str, 'full_text': bool, 'position_federal_council': [int], diff --git a/tests/onegov/swissvotes/test_converters.py b/tests/onegov/swissvotes/test_converters.py index eba5c9f38a..c559fe8022 100644 --- a/tests/onegov/swissvotes/test_converters.py +++ b/tests/onegov/swissvotes/test_converters.py @@ -1,8 +1,8 @@ -from onegov.swissvotes.converters import policy_area_converter +from onegov.swissvotes.converters import PolicyAreaListConverter def test_policy_area_converter(): - converter = policy_area_converter + converter = PolicyAreaListConverter(str) assert converter.decode(['']) == [] assert converter.decode([None]) == [] From ddc3461e89cd0050c8d95a7d6b5c88d4d5ec6922 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 12 Sep 2025 15:45:05 +0200 Subject: [PATCH 8/8] Fix linter issues --- src/onegov/core/filters.py | 20 +++++++++++--------- stubs/more/webassets/directives.pyi | 2 +- stubs/more/webassets/tweens.pyi | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/onegov/core/filters.py b/src/onegov/core/filters.py index 08d4fc5677..5fcf247ca4 100644 --- a/src/onegov/core/filters.py +++ b/src/onegov/core/filters.py @@ -4,8 +4,8 @@ import os import rcssmin # type:ignore[import-untyped] -from webassets.filter import Filter, register_filter # type:ignore -from webassets.filter.datauri import ( # type:ignore[import-untyped] +from webassets.filter import Filter, register_filter +from webassets.filter.datauri import ( CSSDataUri, CSSUrlRewriter) from dukpy.webassets import BabelJSX # type:ignore[import-untyped] from dukpy import jsx_compile # type:ignore[import-untyped] @@ -49,10 +49,10 @@ def setup(self) -> None: self.transformer = jsx_compile -register_filter(JsxFilter) +register_filter(JsxFilter) # type:ignore[no-untyped-call] -class DataUriFilter(CSSDataUri): # type:ignore[misc] +class DataUriFilter(CSSDataUri): """ Overrides the default datauri filter to work around this issue: https://github.com/miracle2k/webassets/issues/387 @@ -67,26 +67,28 @@ def input(self, _in: IO[str], out: IO[str], **kw: Any) -> None: self.source_path = self.keywords['source_path'] self.output_path = self.keywords['output_path'] - return super(CSSUrlRewriter, self).input(_in, out, **kw) + return super(CSSUrlRewriter, self).input(_in, out, **kw) # type:ignore[no-untyped-call] @property def source_url(self) -> str: + assert self.ctx is not None return self.ctx.resolver.resolve_source_to_url( self.ctx, self.keywords['source_path'], self.keywords['source']) @property def output_url(self) -> str: + assert self.ctx is not None return self.ctx.resolver.resolve_output_to_url( self.ctx, self.keywords['output']) -register_filter(DataUriFilter) +register_filter(DataUriFilter) # type:ignore[no-untyped-call] -class RCSSMinFilter(Filter): # type:ignore[misc] +class RCSSMinFilter(Filter): """ Adds the rcssmin filter (not yet included in webassets) """ - name = 'custom-rcssmin' + name = 'custom-rcssmin' # type:ignore[assignment] def setup(self) -> None: self.rcssmin = rcssmin @@ -95,4 +97,4 @@ def output(self, _in: IO[str], out: IO[str], **kw: Any) -> None: out.write(self.rcssmin.cssmin(_in.read())) -register_filter(RCSSMinFilter) +register_filter(RCSSMinFilter) # type:ignore[no-untyped-call] diff --git a/stubs/more/webassets/directives.pyi b/stubs/more/webassets/directives.pyi index af3e657ee2..16cebd153b 100644 --- a/stubs/more/webassets/directives.pyi +++ b/stubs/more/webassets/directives.pyi @@ -3,7 +3,7 @@ from collections.abc import Callable, Collection, Iterator, Mapping, Sequence from typing_extensions import Self, TypeAlias from dectate import Action -from webassets import Bundle, Environment # type: ignore[import-untyped] +from webassets import Bundle, Environment _Filter: TypeAlias = str | Collection[str] | None diff --git a/stubs/more/webassets/tweens.pyi b/stubs/more/webassets/tweens.pyi index 3e4c65d94a..b410673edd 100644 --- a/stubs/more/webassets/tweens.pyi +++ b/stubs/more/webassets/tweens.pyi @@ -4,7 +4,7 @@ from urllib.parse import unquote as unquote from more.webassets.core import IncludeRequest from morepath.request import Request, Response -from webassets import Environment # type: ignore[import-untyped] +from webassets import Environment CONTENT_TYPES: set[str] METHODS: set[str]