From 65a68edbdf805a5ff20b9fbcef5af7c852077e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Thu, 2 Apr 2026 14:21:24 +0200 Subject: [PATCH 1/2] Makes `reset_payment` test fixture more robust --- tests/onegov/pay/conftest.py | 43 +++++++++++++++--------- tests/onegov/town6/test_views_payment.py | 10 +++--- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/tests/onegov/pay/conftest.py b/tests/onegov/pay/conftest.py index 4af1d6c98a..0fd8119a66 100644 --- a/tests/onegov/pay/conftest.py +++ b/tests/onegov/pay/conftest.py @@ -13,25 +13,36 @@ @pytest.fixture(scope='function', autouse=True) def reset_payment() -> Iterator[None]: - yield - - # during testing we need to reset the links created on the payment - # model - in reality this is not an issue as we don't define the same - # models over and over classes = [Payment] - + registered_links: dict[type[Payment], set[str]] = {} while classes: cls = classes.pop() - - for key in (Payment.registered_links or ()): - try: - del cls.__mapper__._props[key] - except KeyError: - pass - + registered_links[cls] = set(cls.registered_links or ()) classes.extend(cls.__subclasses__()) - - if Payment.registered_links: - Payment.registered_links.clear() + yield transaction.abort() + + # during testing we need to reset the links created on the payment + # model by our test code - in reality this is not an issue as we + # don't define the same models over and over + for cls, links in registered_links.items(): + if cls.registered_links is None: + continue + + for link_name in tuple(cls.registered_links.keys()): + if link_name not in links: + if link_name in cls.__mapper__._props: + del cls.__mapper__._props[link_name] + # HACK: Disables protections against removal + type.__setattr__(cls, link_name, None) + mgr = cls.__mapper__.class_manager + mgr.uninstrument_attribute(link_name) # type: ignore[no-untyped-call] + + for cls, links in registered_links.items(): + if cls.registered_links is None: + continue + + for link_name in tuple(cls.registered_links.keys()): + if link_name not in links: + del cls.registered_links[link_name] diff --git a/tests/onegov/town6/test_views_payment.py b/tests/onegov/town6/test_views_payment.py index 857151fd9c..2572cad85d 100644 --- a/tests/onegov/town6/test_views_payment.py +++ b/tests/onegov/town6/test_views_payment.py @@ -1,10 +1,15 @@ from __future__ import annotations +import json import transaction from datetime import datetime, timezone from decimal import Decimal +from onegov.org.models.ticket import FormSubmissionTicket from onegov.pay import Payment, PaymentProvider +from onegov.ticket import TicketInvoice +from uuid import uuid4 + from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -144,11 +149,6 @@ def test_view_payments_filter_by_payment_type(client: Client) -> None: def test_view_payments_invoices_handle_batch_set(client: Client) -> None: - import json - from onegov.org.models.ticket import FormSubmissionTicket - from onegov.ticket import TicketInvoice - from uuid import uuid4 - client.login_admin() session = client.app.session() From 652622f90a5d9e6c44d8bfc9be3df949f0af2bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cyrill=20K=C3=BCttel?= Date: Thu, 2 Apr 2026 14:21:40 +0200 Subject: [PATCH 2/2] Pas: Enforce bulk/single edit separation, improve attendances UX, fix PDF spacing --- src/onegov/pas/collections/attendence.py | 7 +- src/onegov/pas/layouts/attendence.py | 21 +++ .../locale/de_CH/LC_MESSAGES/onegov.pas.po | 154 ++++++++++++------ src/onegov/pas/templates/attendences.pt | 12 +- src/onegov/pas/views/attendence.py | 61 +++---- .../parliamentarian_settlement_pdf.css | 6 +- 6 files changed, 161 insertions(+), 100 deletions(-) diff --git a/src/onegov/pas/collections/attendence.py b/src/onegov/pas/collections/attendence.py index 2f46780362..6ae4544d84 100644 --- a/src/onegov/pas/collections/attendence.py +++ b/src/onegov/pas/collections/attendence.py @@ -161,13 +161,10 @@ def for_commission_president( (Attendence.commission_id.in_(active_commission_ids)) ) - def view_for_parliamentarian( + def query_for_current_user( self, request: PasRequest ) -> list[Attendence]: - """ - Returns filtered attendances based on user role and permissions. - This encapsulates the filtering logic previously in the view. - """ + """Returns attendances filtered by the current user's role.""" user = request.current_user if not request.is_parliamentarian: diff --git a/src/onegov/pas/layouts/attendence.py b/src/onegov/pas/layouts/attendence.py index f6817cf7d9..49161ad79d 100644 --- a/src/onegov/pas/layouts/attendence.py +++ b/src/onegov/pas/layouts/attendence.py @@ -120,6 +120,27 @@ def breadcrumbs(self) -> list[Link]: @cached_property def editbar_links(self) -> list[Link] | None: + if self.model.bulk_edit_id: + name = ( + 'edit-plenary-bulk-attendences' + if self.model.type == 'plenary' + else 'edit-commission-bulk-attendences' + ) + if self.request.is_admin or ( + self.request.is_parliamentarian + and self.request.current_parliamentarian + and str(self.request.current_parliamentarian.id) + == str(self.model.parliamentarian_id) + ): + return [ + Link( + text=_('Edit bulk'), + url=self.request.link(self.model, name), + attrs={'class': 'edit-link'}, + ) + ] + return None + if self.request.is_admin: return [ Link( diff --git a/src/onegov/pas/locale/de_CH/LC_MESSAGES/onegov.pas.po b/src/onegov/pas/locale/de_CH/LC_MESSAGES/onegov.pas.po index 37dc6f96a6..ad5c60a77e 100644 --- a/src/onegov/pas/locale/de_CH/LC_MESSAGES/onegov.pas.po +++ b/src/onegov/pas/locale/de_CH/LC_MESSAGES/onegov.pas.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: OneGov Cloud 1.0\n" -"POT-Creation-Date: 2026-03-25 10:06+0100\n" +"POT-Creation-Date: 2026-04-01 16:39+0200\n" "PO-Revision-Date: 2021-03-03 16:24+0100\n" "Language-Team: German\n" "Language: de_CH\n" @@ -49,9 +49,8 @@ msgstr "Profil" msgid "Cannot create attendance in closed settlement run." msgstr "" -"Anwesenheit in geschlossenem Abrechnungslauf kann nicht erstellt " -"werden.Anwesenheit in geschlossenem Abrechnungslauf kann nicht erstellt " -"werden." +"Anwesenheit in geschlossenem Abrechnungslauf kann nicht erstellt werden." +"Anwesenheit in geschlossenem Abrechnungslauf kann nicht erstellt werden." msgid "Attendance date must be within a settlement run." msgstr "Anwesenheitsdatum muss innerhalb eines Abrechnungslaufs liegen." @@ -291,6 +290,24 @@ msgstr "Beschreibung" msgid "Year" msgstr "Jahr" +msgid "Quarter" +msgstr "Quartal" + +msgid "Recipient" +msgstr "Empfänger/in" + +#, python-format +msgid "" +"Yearly limit for ${role} is CHF ${limit}. Already allocated: CHF " +"${existing}. Remaining: CHF ${remaining}." +msgstr "" +"Jahreslimite für ${role} ist CHF ${limit}. Bereits vergeben: CHF " +"${existing}. Verbleibend: CHF ${remaining}." + +#, python-format +msgid "Allowance for Q${quarter} ${year} already exists for this role" +msgstr "Zulage für Q${quarter} ${year} existiert bereits für diese Rolle" + msgid "Cost of living adjustment" msgstr "Teuerungszulage" @@ -378,6 +395,9 @@ msgstr "Massenbuchung Plenarsitzung" msgid "Commission session (bulk)" msgstr "Massenbuchung Kommissionssitzung" +msgid "Edit bulk" +msgstr "Massenbuchung bearbeiten" + msgid "Edit" msgstr "Bearbeiten" @@ -501,6 +521,12 @@ msgstr "Möchten Sie diese Partei wirklich löschen?" msgid "Delete party" msgstr "Partei löschen" +msgid "Presidential allowances" +msgstr "Präsidialzulagen" + +msgid "Add quarterly allowance" +msgstr "Quartalszulage hinzufügen" + msgid "Rate sets" msgstr "Sätze" @@ -566,12 +592,15 @@ msgstr "Dauer" msgid "No meeting defined yet." msgstr "Noch keine Sitzung erfasst." -msgid "Bulk edits" -msgstr "Massenbuchungen" +msgid "Session date" +msgstr "Sitzungsdatum" msgid "edited" msgstr "bearbeitet" +msgid "Bulk edits" +msgstr "Massenbuchungen" + msgid "Timestamp" msgstr "Zeitpunkt" @@ -706,8 +735,8 @@ msgid "Hello!" msgstr "Hallo!" msgid "" -"${user_name} has closed commission ${commission_name} for settlement run $" -"{settlement_run_name}." +"${user_name} has closed commission ${commission_name} for settlement run " +"${settlement_run_name}." msgstr "" "${user_name} hat den Abschluss der Kommission ${commission_name} für den " "Abrechnungslauf ${settlement_run_name} gesetzt." @@ -749,6 +778,32 @@ msgstr "Noch keine Fraktionen erfasst." msgid "No parties defined yet." msgstr "Noch keine Parteien erfasst." +msgid "" +"Flat annual allowance per § 8 of the Nebenamtsgesetz for representing the " +"canton at events. Paid in quarterly installments via settlement runs." +msgstr "" +"Pauschale Jahreszulage gemäss § 8 Nebenamtsgesetz für die Vertretung des " +"Kantons an Anlässen. Auszahlung in vierteljährlichen Raten über " +"Abrechnungsläufe." + +msgid "Person" +msgstr "Person" + +msgid "Amount (CHF)" +msgstr "Betrag (CHF)" + +msgid "Wirklich löschen?" +msgstr "Wirklich löschen?" + +msgid "Löschen" +msgstr "Löschen" + +msgid "Abbrechen" +msgstr "Abbrechen" + +msgid "No allowances have been recorded yet." +msgstr "Es wurden noch keine Zulagen erfasst." + msgid "Amount" msgstr "Betrag" @@ -832,8 +887,8 @@ msgstr "Neue Massenbuchung Kommissionssitzung" #, python-format msgid "Cannot book attendance - abschluss already set for: ${names}" msgstr "" -"Anwesenheit kann nicht gebucht werden - Abschluss bereits gesetzt für: $" -"{names}" +"Anwesenheit kann nicht gebucht werden - Abschluss bereits gesetzt für: " +"${names}" msgid "No parliamentarians selected" msgstr "Keine Parlamentarier:innen ausgewählt" @@ -844,8 +899,8 @@ msgstr "Sitzung hinzugefügt" msgid "You do not have permission to edit plenary sessions." msgstr "Sie haben keine Berechtigung, Plenarsitzungen zu bearbeiten." -msgid "Edit plenary session" -msgstr "Plenarsitzung bearbeiten" +msgid "Edit bulk: plenary session" +msgstr "Massenbuchung: Plenarsitzung bearbeiten" msgid "Edited attendences" msgstr "Anwesenheiten bearbeitet" @@ -860,14 +915,14 @@ msgid "Delete Attendencess" msgstr "Anwesenheiten löschen" #, python-format -msgid "Edit ${type}" -msgstr "${type} bearbeiten" +msgid "Edit bulk: ${type}" +msgstr "Massenbuchung: ${type} bearbeiten" #, python-format msgid "Cannot edit attendance - abschluss already set for: ${names}" msgstr "" -"Anwesenheit kann nicht bearbeitet werden - Abschluss bereits gesetzt für: $" -"{names}" +"Anwesenheit kann nicht bearbeitet werden - Abschluss bereits gesetzt für: " +"${names}" msgid "You do not have permission to add plenary sessions." msgstr "Sie haben keine Berechtigung, Plenarsitzungen hinzuzufügen." @@ -886,11 +941,13 @@ msgstr "" msgid "Your changes were saved" msgstr "Änderungen gespeichert" +msgid "Cannot delete individual bulk attendance." +msgstr "Einzelne Massenbuchung kann nicht gelöscht werden." + msgid "Cannot delete attendance in closed settlement run." msgstr "" -"Anwesenheit in geschlossenem Abrechnungslauf kann nicht gelöscht " -"werden.Anwesenheit in geschlossenem Abrechnungslauf kann nicht gelöscht " -"werden." +"Anwesenheit in geschlossenem Abrechnungslauf kann nicht gelöscht werden." +"Anwesenheit in geschlossenem Abrechnungslauf kann nicht gelöscht werden." #, python-format msgid "Deleted ${count} attendeces" @@ -952,6 +1009,9 @@ msgstr "Partei hinzugefügt" msgid "New party" msgstr "Neue Partei" +msgid "Quarterly allowance added" +msgstr "Quartalszulage hinzugefügt" + msgid "Added a new rate set" msgstr "Neue Sätze hinzugefügt" @@ -1030,50 +1090,36 @@ msgstr "" "Geschlossener Abrechnungslauf kann nicht gelöscht werden. Bitte zuerst " "öffnen." -msgid "Presidential allowances" -msgstr "Präsidialzulagen" - -msgid "Flat annual allowance per § 8 of the Nebenamtsgesetz for representing the canton at events. Paid in quarterly installments via settlement runs." -msgstr "Pauschale Jahreszulage gemäss § 8 Nebenamtsgesetz für die Vertretung des Kantons an Anlässen. Auszahlung in vierteljährlichen Raten über Abrechnungsläufe." - -msgid "Year" -msgstr "Jahr" - -msgid "Add quarterly allowance" -msgstr "Quartalszulage hinzufügen" +#~ msgid "Edit plenary session" +#~ msgstr "Plenarsitzung bearbeiten" -msgid "Quarterly allowance added" -msgstr "Quartalszulage hinzugefügt" +#, python-format +#~ msgid "Edit ${type}" +#~ msgstr "${type} bearbeiten" -msgid "No president or vice president found" -msgstr "Kein Präsident oder Vizepräsident gefunden" +#~ msgid "User Account Sync" +#~ msgstr "Benutzerkonto-Synchronisation" -msgid "Allowance for Q${quarter} ${year} already exists for this role" -msgstr "Zulage für Q${quarter} ${year} existiert bereits für diese Rolle" +#~ msgid "Synced" +#~ msgstr "Synchronisiert" -msgid "Yearly limit for ${role} is CHF ${limit}. Already allocated: CHF ${existing}. Remaining: CHF ${remaining}." -msgstr "Jahreslimite für ${role} ist CHF ${limit}. Bereits vergeben: CHF ${existing}. Verbleibend: CHF ${remaining}." +#~ msgid "Skipped" +#~ msgstr "Übersprungen" -msgid "Recipient" -msgstr "Empfänger/in" +#~ msgid "Created" +#~ msgstr "Erstellt" -msgid "President: ${name} (CHF ${amount})" -msgstr "Präsident/in: ${name} (CHF ${amount})" +#~ msgid "New accounts" +#~ msgstr "Neue Konten" -msgid "Vice president: ${name} (CHF ${amount})" -msgstr "Vizepräsident/in: ${name} (CHF ${amount})" +#~ msgid "No president or vice president found" +#~ msgstr "Kein Präsident oder Vizepräsident gefunden" -msgid "No allowances have been recorded yet." -msgstr "Es wurden noch keine Zulagen erfasst." +#~ msgid "President: ${name} (CHF ${amount})" +#~ msgstr "Präsident/in: ${name} (CHF ${amount})" -msgid "Quarter" -msgstr "Quartal" - -msgid "Amount (CHF)" -msgstr "Betrag (CHF)" - -msgid "Date" -msgstr "Datum" +#~ msgid "Vice president: ${name} (CHF ${amount})" +#~ msgstr "Vizepräsident/in: ${name} (CHF ${amount})" #~ msgid "Commission meeting (bulk)" #~ msgstr "Massenbuchung Kommissionssitzung" diff --git a/src/onegov/pas/templates/attendences.pt b/src/onegov/pas/templates/attendences.pt index 988e7594d6..cfd23ae822 100644 --- a/src/onegov/pas/templates/attendences.pt +++ b/src/onegov/pas/templates/attendences.pt @@ -9,7 +9,7 @@ - + @@ -18,13 +18,19 @@ - + - + +
DateSession date Duration Parliamentarian Type
${layout.format_date(attendence.date, 'date')}${layout.format_date(attendence.date, 'date')} +
+ edited: ${layout.format_date(attendence.modified, 'date')} +
+
${(attendence.duration/60)} h ${attendence.parliamentarian.title} ${attendence.type_label}
${attendence.commission.title}
diff --git a/src/onegov/pas/views/attendence.py b/src/onegov/pas/views/attendence.py index 07e5d94623..ff8cedb4c2 100644 --- a/src/onegov/pas/views/attendence.py +++ b/src/onegov/pas/views/attendence.py @@ -1,11 +1,8 @@ from __future__ import annotations -from itertools import groupby -from operator import attrgetter +from collections import defaultdict import uuid -from more_itertools import flatten - from onegov.core.elements import BackLink, Confirm, Intercooler, Link from onegov.core.security import Private from onegov.pas import _ @@ -50,41 +47,23 @@ def view_attendences( layout = AttendenceCollectionLayout(self, request) - # Apply role-based filtering, then re-sort for bulk edit grouping - filtered_attendences = self.view_for_parliamentarian(request) - bulk_edit_attendences = sorted( - filtered_attendences, - key=lambda x: (str(x.bulk_edit_id) if x.bulk_edit_id else '', - x.created or x.modified), - reverse=True - ) + attendences = self.query_for_current_user(request) - bulk_edit_groups = [ - sorted(group, key=attrgetter('created', 'modified'), reverse=True) - for bulk_edit_id, group in groupby( - bulk_edit_attendences, key=attrgetter('bulk_edit_id')) - ] + groups: dict[str, list[Attendence]] = defaultdict(list) + for a in attendences: + if a.bulk_edit_id: + groups[str(a.bulk_edit_id)].append(a) - non_null_groups = [g for g in bulk_edit_groups if getattr( - g[0], 'bulk_edit_id', None) is not None] - null_groups = [g for g in bulk_edit_groups if getattr( - g[0], 'bulk_edit_id', None) is None] - - non_null_groups.sort( - key=lambda group: max( # type: ignore - (attendence.modified or attendence.created - for attendence in group), - default=None - ), - reverse=True + bulk_edit_groups = sorted( + groups.values(), + key=lambda g: max(a.modified or a.created for a in g), + reverse=True, ) - bulk_edit_groups = non_null_groups + null_groups - return { 'add_link': request.link(self, name='new'), 'layout': layout, - 'attendences': list(flatten(bulk_edit_groups)), + 'attendences': attendences, 'title': layout.title, 'bulk_edit_groups': bulk_edit_groups } @@ -290,7 +269,7 @@ def edit_plenary_bulk_attendence( request.alert(error) return { 'layout': AttendenceCollectionLayout(self, request), - 'title': _('Edit plenary session'), + 'title': _('Edit bulk: plenary session'), 'form': form, 'form_width': 'large' } @@ -366,7 +345,7 @@ def edit_plenary_bulk_attendence( return { 'layout': layout, - 'title': _('Edit plenary session'), + 'title': _('Edit bulk: plenary session'), 'form': form, 'form_width': 'large' } @@ -387,7 +366,7 @@ def edit_commission_bulk_attendence( request.include('custom') type_label = request.translate(self.type_label) - title = _('Edit ${type}', mapping={'type': type_label}) + title = _('Edit bulk: ${type}', mapping={'type': type_label}) if form.submitted(request): if form.date.data: @@ -616,6 +595,14 @@ def edit_attendence( form: AttendenceForm ) -> RenderData | Response: + if self.bulk_edit_id: + name = ( + 'edit-plenary-bulk-attendences' + if self.type == 'plenary' + else 'edit-commission-bulk-attendences' + ) + return request.redirect(request.link(self, name)) + if form.submitted(request): if form.date.data: if error := validate_attendance_date( @@ -682,6 +669,10 @@ def delete_attendence( request.assert_valid_csrf_token() + if self.bulk_edit_id: + request.alert(_('Cannot delete individual bulk attendance.')) + return + # Check if attendance is in a closed settlement run settlement_run = request.session.query(SettlementRun).filter( SettlementRun.start <= self.date, diff --git a/src/onegov/pas/views/templates/parliamentarian_settlement_pdf.css b/src/onegov/pas/views/templates/parliamentarian_settlement_pdf.css index fa25a12056..a382ead9c6 100644 --- a/src/onegov/pas/views/templates/parliamentarian_settlement_pdf.css +++ b/src/onegov/pas/views/templates/parliamentarian_settlement_pdf.css @@ -18,15 +18,15 @@ body { font-size: 7pt; text-decoration: underline; margin-left: 0; - margin-bottom: 0; + margin-top: -0.3cm; + margin-bottom: 0.1cm; } .address { margin-left: 0; - margin-top: -0.3cm; margin-bottom: 0.5cm; font-size: 8pt; - line-height: 1.4; + line-height: 1.2; } .date {