From 0be851eae86ae0d4841c42907c3827edaaabf8f9 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 16 Dec 2025 07:36:48 +0100 Subject: [PATCH 01/24] Adds test --- tests/onegov/directory/test_migration.py | 54 +++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 3140e40149..fc956bd4f7 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -10,8 +10,8 @@ from tempfile import NamedTemporaryFile from tests.shared.utils import create_image - from typing import TYPE_CHECKING + if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -386,3 +386,55 @@ def test_directory_migration(session: Session) -> None: assert migration.possible migration.execute() + + +def test_directory_fieldtype_migrations(session: Session) -> None: + """ + The issue with migrations is that if one directory entry does not specify + a value for a field the migration ends up in a `ValidationError` + """ + + structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Tundra + ( ) Arctic + ( ) Desert + """ + + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + [ ] Tundra + [ ] Arctic + [ ] Desert + """ + + directories = DirectoryCollection(session) + zoos = directories.add( + title="Zoos", + lead="The town's zoos", + structure=structure, + configuration=DirectoryConfiguration( + title="[Main/Name]", + order=['Main/Name'] + ) + ) + zoo = zoos.add(values=dict( + main_name="Luzerner Zoo", + general_landscapes='', # No value is set + )) + + assert zoo.values['general_landscapes'] == '' # radio + + migration = zoos.migration(new_structure, None) + changes = migration.changes + assert migration.fieldtype_migrations.possible('radio', 'checkbox') + + migration.execute() + + assert zoo.values['general_landscapes'] == [] # checkbox From d304594cb8170406da716f3eb97df147ee1d2623 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 16 Dec 2025 15:38:37 +0100 Subject: [PATCH 02/24] Fix field type migration for radio to checkbox --- src/onegov/directory/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index b36c777559..71a3e3c251 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -223,7 +223,7 @@ def time_to_text(self, value: time) -> str: return '{:%H:%M}'.format(value) def radio_to_checkbox(self, value: str) -> list[str]: - return [value] + return [value] if value else [] def text_to_url(self, value: str) -> str: return value From 9f5ca2e69380ed369ef08752bc2b8f1c95e9d5be Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 16 Dec 2025 15:45:45 +0100 Subject: [PATCH 03/24] Adds another test --- tests/onegov/directory/test_migration.py | 110 +++++++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index fc956bd4f7..4e4ae58f12 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -388,7 +388,7 @@ def test_directory_migration(session: Session) -> None: migration.execute() -def test_directory_fieldtype_migrations(session: Session) -> None: +def test_directory_field_type_migrations(session: Session) -> None: """ The issue with migrations is that if one directory entry does not specify a value for a field the migration ends up in a `ValidationError` @@ -416,18 +416,19 @@ def test_directory_fieldtype_migrations(session: Session) -> None: directories = DirectoryCollection(session) zoos = directories.add( - title="Zoos", + title='Zoos', lead="The town's zoos", structure=structure, configuration=DirectoryConfiguration( - title="[Main/Name]", - order=['Main/Name'] + title='[Main/Name]', order=['Main/Name'] + ), + ) + zoo = zoos.add( + values=dict( + main_name='Luzerner Zoo', + general_landscapes='', # No value is set ) ) - zoo = zoos.add(values=dict( - main_name="Luzerner Zoo", - general_landscapes='', # No value is set - )) assert zoo.values['general_landscapes'] == '' # radio @@ -438,3 +439,96 @@ def test_directory_fieldtype_migrations(session: Session) -> None: migration.execute() assert zoo.values['general_landscapes'] == [] # checkbox + + +def test_directory_migrations_removing_radio(session: Session) -> None: + """ + Renaming a radio option, (checkbox option?) leads to a `ValidationError` + if the renamed option was selected in at least one entry. There is no + error for the user on the UI. + """ + structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Tundra + ( ) Arctic + ( ) Desert + """ + + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Tundra + ( ) Arctic + ( ) Great Desert + """ + + directories = DirectoryCollection(session) + zoos = directories.add( + title='Zoos', + lead="The town's zoos", + structure=structure, + configuration=DirectoryConfiguration( + title='[Main/Name]', order=['Main/Name'] + ), + ) + zoo = zoos.add( + values=dict( + main_name='Luzerner Zoo', + general_landscapes='Desert', + ) + ) + + assert zoo.values['general_landscapes'] == 'Desert' + + migration = zoos.migration(new_structure, None) + assert migration.changes.renamed_options == [('Desert', 'Great Desert')] + + assert migration.possible + migration.execute() + + assert zoo.values['general_landscapes'] == 'Great Desert' + + # two option changes will fail + structure = """ + Name *= ___ + Animal = + ( ) Cat + ( ) Dog + ( ) Hamster + """ + new_structure = """ + Name *= ___ + Animal = + ( ) Cat + ( ) Doggy + ( ) Hamsterli + """ + xxs = directories.add( + title='Animals', + lead='Our animals', + structure=structure, + configuration=DirectoryConfiguration( + title='[Animal]', order=['Animal'] + ), + ) + xx = xxs.add( + values=dict( + name='Corgi', + animal='Dog' + ) + ) + + assert xx.values['name'] == 'Corgi' + + migration = xxs.migration(new_structure, None) + assert migration.changes.renamed_options == [ + ("Dog", "Doggy"), + ("Hamster", "Hamsterli"), + ] + + assert not migration.possible From 437df27c3255c081335d852d59e8f80b093e7927 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Thu, 18 Dec 2025 08:10:39 +0100 Subject: [PATCH 04/24] Supports renaming radio or checkbox label --- src/onegov/directory/migration.py | 35 ++++++++++++++++++-- src/onegov/org/views/directory.py | 14 +++++--- src/onegov/town6/templates/directory_form.pt | 3 ++ tests/onegov/directory/test_migration.py | 12 +++---- 4 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index 71a3e3c251..deb0eca6e8 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -5,11 +5,12 @@ from onegov.form import flatten_fieldsets from onegov.form import parse_form from onegov.form import parse_formcode +from onegov.form.parser.core import OptionsField from sqlalchemy.orm import object_session, joinedload, undefer from sqlalchemy.orm.attributes import get_history - from typing import Any, TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Callable, Iterable from datetime import date, datetime, time @@ -61,12 +62,17 @@ def possible(self) -> bool: if not self.directory.entries: return True + # tschupre changes to detect renamed options if not self.changes: return True - if not self.changes.changed_fields: + if (not self.changes.changed_fields and + not self.changes.renamed_options): return True + if len(self.changes.renamed_options) > 1: + return False + for changed in self.changes.changed_fields: old = self.changes.old[changed] new = self.changes.new[changed] @@ -142,6 +148,7 @@ def migrate_values(self, values: dict[str, Any]) -> None: self.remove_old_fields(values) self.rename_fields(values) self.convert_fields(values) + self.rename_options(values) def add_new_fields(self, values: dict[str, Any]) -> None: for added in self.changes.added_fields: @@ -170,6 +177,12 @@ def convert_fields(self, values: dict[str, Any]) -> None: changed = as_internal_id(changed) values[changed] = convert(values[changed]) + def rename_options(self, values: dict[str, Any]) -> None: + for old_label, new_label in self.changes.renamed_options: + for key, val in list(values.items()): + if val == old_label: + values[key] = new_label + class FieldTypeMigrations: """ Contains methods to migrate fields from one type to another. """ @@ -255,6 +268,7 @@ def __init__(self, old_structure: str, new_structure: str) -> None: self.detect_removed_fields() self.detect_renamed_fields() # modifies added/removed fields self.detect_changed_fields() + self.detect_changed_options() def __bool__(self) -> bool: return bool( @@ -262,6 +276,7 @@ def __bool__(self) -> bool: or self.removed_fields or self.renamed_fields or self.changed_fields + or self.renamed_options # radio and checkboxes ) def detect_removed_fieldsets(self) -> None: @@ -378,3 +393,19 @@ def detect_changed_fields(self) -> None: new = self.new[new_id] if old.required != new.required or old.type != new.type: self.changed_fields.append(new_id) + + def detect_changed_options(self) -> None: + self.renamed_options: list[tuple[str, str]] = [] + + for old_id, old_field in self.old.items(): + if isinstance(old_field, OptionsField) and old_id in self.new: + new_field = self.new[old_id] + if isinstance(new_field, OptionsField): + old_labels = [r.label for r in old_field.choices] + new_labels = [r.label for r in new_field.choices] + + for o, n in zip(old_labels, new_labels): + if o != n: + self.renamed_options.append((o, n)) + + print('*** tschupre detect changed options', self.renamed_options) diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index a1ad6f50a0..efbdee487f 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -229,10 +229,16 @@ def handle_edit_directory( if migration.changes: if not migration.possible: save_changes = False - request.alert(_( - 'The requested change cannot be performed, ' - 'as it is incompatible with existing entries' - )) + if len(migration.changes.renamed_options) > 1: + request.alert(_( + 'Please rename only one radio or checkbox ' + 'label at a time.' + )) + else: + request.alert(_( + 'The requested change cannot be performed, ' + 'as it is incompatible with existing entries' + )) else: if not request.params.get('confirm'): form.action += '&confirm=1' diff --git a/src/onegov/town6/templates/directory_form.pt b/src/onegov/town6/templates/directory_form.pt index 01295f0f09..283d12eb63 100644 --- a/src/onegov/town6/templates/directory_form.pt +++ b/src/onegov/town6/templates/directory_form.pt @@ -45,6 +45,9 @@
  • Changed: ${field}
  • +
  • + Renamed option: ${pair[0]} -> ${pair[1]} +
  • diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 4e4ae58f12..82b7b645a6 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -441,11 +441,9 @@ def test_directory_field_type_migrations(session: Session) -> None: assert zoo.values['general_landscapes'] == [] # checkbox -def test_directory_migrations_removing_radio(session: Session) -> None: +def test_directory_migration_renaming_select(session: Session) -> None: """ - Renaming a radio option, (checkbox option?) leads to a `ValidationError` - if the renamed option was selected in at least one entry. There is no - error for the user on the UI. + Renaming a radio option or checkbox option """ structure = """ # Main @@ -486,7 +484,9 @@ def test_directory_migrations_removing_radio(session: Session) -> None: assert zoo.values['general_landscapes'] == 'Desert' migration = zoos.migration(new_structure, None) - assert migration.changes.renamed_options == [('Desert', 'Great Desert')] + assert migration.changes.renamed_options == [ # type:ignore[attr-defined] + ('Desert', 'Great Desert') + ] assert migration.possible migration.execute() @@ -526,7 +526,7 @@ def test_directory_migrations_removing_radio(session: Session) -> None: assert xx.values['name'] == 'Corgi' migration = xxs.migration(new_structure, None) - assert migration.changes.renamed_options == [ + assert migration.changes.renamed_options == [ # type:ignore[attr-defined] ("Dog", "Doggy"), ("Hamster", "Hamsterli"), ] From 32a4ef6a30b7caea05770814314f42337e926dde Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Thu, 18 Dec 2025 08:13:48 +0100 Subject: [PATCH 05/24] Resolve related fixme --- src/onegov/directory/migration.py | 12 +- tests/onegov/directory/test_migration.py | 145 +++++++++++++++++++---- 2 files changed, 124 insertions(+), 33 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index deb0eca6e8..ebb37f20a3 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -207,10 +207,6 @@ def get_converter( return getattr(self, explicit, getattr(self, generic, None)) - # FIXME: A lot of these converters currently don't work if the value - # happens to be None, which should be possible for every field - # as long as its optional, or do we skip converting None - # explicitly somewhere?! def any_to_text(self, value: Any) -> str: return str(value if value is not None else '').strip() @@ -227,13 +223,13 @@ def text_to_code(self, value: str) -> str: return value def date_to_text(self, value: date) -> str: - return '{:%d.%m.%Y}'.format(value) + return '{:%d.%m.%Y}'.format(value) if value else '' def datetime_to_text(self, value: datetime) -> str: - return '{:%d.%m.%Y %H:%M}'.format(value) + return '{:%d.%m.%Y %H:%M}'.format(value) if value else '' def time_to_text(self, value: time) -> str: - return '{:%H:%M}'.format(value) + return '{:%H:%M}'.format(value) if value else '' def radio_to_checkbox(self, value: str) -> list[str]: return [value] if value else [] @@ -407,5 +403,3 @@ def detect_changed_options(self) -> None: for o, n in zip(old_labels, new_labels): if o != n: self.renamed_options.append((o, n)) - - print('*** tschupre detect changed options', self.renamed_options) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 82b7b645a6..b65de82416 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -10,7 +10,7 @@ from tempfile import NamedTemporaryFile from tests.shared.utils import create_image -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -388,32 +388,130 @@ def test_directory_migration(session: Session) -> None: migration.execute() -def test_directory_field_type_migrations(session: Session) -> None: +@pytest.mark.parametrize('old,new,label,expected_value', [ + ( # any to textarea - url """ - The issue with migrations is that if one directory entry does not specify - a value for a field the migration ends up in a `ValidationError` + description = http:// + """, + """ + description = ...[5] + """, + 'description', + '', + ), + ( # textarea to text + """ + description = ...[5] + """, + """ + description = ___ + """, + 'description', + '', + ), + ( # textarea to code + """ + description = ...[5] + """, + """ + description = + """, + 'description', + '', + ), + ( # text to code + """ + description = ___ + """, + """ + description = + """, + 'description', + '', + ), + ( # radio to checkbox """ - - structure = """ - # Main - Name *= ___ - # General Landscapes = ( ) Tundra ( ) Arctic ( ) Desert + """, """ - - new_structure = """ - # Main - Name *= ___ - # General Landscapes = [ ] Tundra [ ] Arctic [ ] Desert + """, + 'landscapes', + [], # checkbox + ), + ( # date to text + """ + Date = YYYY.MM.DD + """, + """ + Date = ___ + """, + 'date', + '' + ), + ( # datetime to text + """ + Date/Time = YYYY.MM.DD HH:MM + """, + """ + Date/Time = ___ + """, + 'date_time', + '' + ), + ( # time to text + """ + Time = HH:MM + """, + """ + Time = ___ + """, + 'time', + '', + ), + ( # text to url + """ + my text = ___ + """, + """ + my text = http:// + """, + 'my_text', + '', + ), +]) +def test_directory_field_type_migrations( + old: str, + new: str, + label: str, + expected_value: Any, + session: Session +) -> None: + """ + The issue with migrations is that if one directory entry does not specify + a value for a field the migration ends up in a `ValidationError` """ + structure = textwrap.dedent(f""" + # Main + Name *= ___ + # General + {old} + """) + + new_structure = textwrap.dedent(f""" + # Main + Name *= ___ + # General + {new} + """) + directories = DirectoryCollection(session) zoos = directories.add( title='Zoos', @@ -424,21 +522,20 @@ def test_directory_field_type_migrations(session: Session) -> None: ), ) zoo = zoos.add( - values=dict( - main_name='Luzerner Zoo', - general_landscapes='', # No value is set - ) + values={ + 'main_name': 'Luzerner Zoo', + f'general_{label}': '', # No value is set + } ) - assert zoo.values['general_landscapes'] == '' # radio + assert zoo.values[f'general_{label}'] == '' # radio migration = zoos.migration(new_structure, None) - changes = migration.changes - assert migration.fieldtype_migrations.possible('radio', 'checkbox') + assert migration.possible migration.execute() - assert zoo.values['general_landscapes'] == [] # checkbox + assert zoo.values[f'general_{label}'] == expected_value def test_directory_migration_renaming_select(session: Session) -> None: @@ -484,7 +581,7 @@ def test_directory_migration_renaming_select(session: Session) -> None: assert zoo.values['general_landscapes'] == 'Desert' migration = zoos.migration(new_structure, None) - assert migration.changes.renamed_options == [ # type:ignore[attr-defined] + assert migration.changes.renamed_options == [ ('Desert', 'Great Desert') ] @@ -526,7 +623,7 @@ def test_directory_migration_renaming_select(session: Session) -> None: assert xx.values['name'] == 'Corgi' migration = xxs.migration(new_structure, None) - assert migration.changes.renamed_options == [ # type:ignore[attr-defined] + assert migration.changes.renamed_options == [ ("Dog", "Doggy"), ("Hamster", "Hamsterli"), ] From 87beef23e9316e438c24a913ba5291a38df40cef Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 22 Dec 2025 16:02:11 +0100 Subject: [PATCH 06/24] Ensure form validation lead to user error message --- src/onegov/org/views/directory.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index efbdee487f..e1a2188fac 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -245,9 +245,8 @@ def handle_edit_directory( save_changes = False if save_changes: - form.populate_obj(self.directory) - try: + form.populate_obj(self.directory) self.session.flush() except ValidationError as e: error = e From 560ef8806af9290a4c01fb9eba5cefc4723762bb Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 22 Dec 2025 16:06:15 +0100 Subject: [PATCH 07/24] Detect added, removed and renamed options --- src/onegov/directory/migration.py | 67 ++++++++++++++++++-- src/onegov/town6/templates/directory_form.pt | 4 +- 2 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index ebb37f20a3..a0cd311256 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -149,6 +149,7 @@ def migrate_values(self, values: dict[str, Any]) -> None: self.rename_fields(values) self.convert_fields(values) self.rename_options(values) + self.remove_old_options(values) def add_new_fields(self, values: dict[str, Any]) -> None: for added in self.changes.added_fields: @@ -178,11 +179,22 @@ def convert_fields(self, values: dict[str, Any]) -> None: values[changed] = convert(values[changed]) def rename_options(self, values: dict[str, Any]) -> None: - for old_label, new_label in self.changes.renamed_options: + for old_option, new_option in self.changes.renamed_options.items(): + old_label = old_option[1] + new_label = new_option[1] for key, val in list(values.items()): if val == old_label: values[key] = new_label + def remove_old_options(self, values: dict[str, Any]) -> None: + for human_id, label in self.changes.removed_options: + id = as_internal_id(human_id) + if id in values: + if isinstance(values[id], list): + values[id] = [v for v in values[id] if v != label] + elif values[id] == label: + values[id] = None + class FieldTypeMigrations: """ Contains methods to migrate fields from one type to another. """ @@ -264,7 +276,9 @@ def __init__(self, old_structure: str, new_structure: str) -> None: self.detect_removed_fields() self.detect_renamed_fields() # modifies added/removed fields self.detect_changed_fields() - self.detect_changed_options() + self.detect_added_options() + self.detect_removed_options() + self.detect_renamed_options() def __bool__(self) -> bool: return bool( @@ -390,16 +404,55 @@ def detect_changed_fields(self) -> None: if old.required != new.required or old.type != new.type: self.changed_fields.append(new_id) - def detect_changed_options(self) -> None: - self.renamed_options: list[tuple[str, str]] = [] + def detect_added_options(self) -> None: + self.added_options = [] for old_id, old_field in self.old.items(): if isinstance(old_field, OptionsField) and old_id in self.new: new_field = self.new[old_id] if isinstance(new_field, OptionsField): + new_labels = [r.label for r in new_field.choices] old_labels = [r.label for r in old_field.choices] + + for n in new_labels: + if n not in old_labels: + self.added_options.append((old_id, n)) + + def detect_removed_options(self) -> None: + self.removed_options = [] + + for old_id, old_field in self.old.items(): + if isinstance(old_field, OptionsField) and old_id in self.new: + new_field = self.new[old_id] + if isinstance(new_field, OptionsField): new_labels = [r.label for r in new_field.choices] + old_labels = [r.label for r in old_field.choices] + + for o in old_labels: + if o not in new_labels: + self.removed_options.append((old_id, o)) + + def detect_renamed_options(self) -> None: + self.renamed_options = {} + + for old_id, old_field in self.old.items(): + if isinstance(old_field, OptionsField) and old_id in self.new: + new_field = self.new[old_id] + if isinstance(new_field, OptionsField): + old_labels = [r.label for r in old_field.choices] + new_labels = [r.label for r in new_field.choices] + + if old_labels == new_labels: + continue + + for r, a in zip(self.removed_options, self.added_options): + self.renamed_options[r] = a - for o, n in zip(old_labels, new_labels): - if o != n: - self.renamed_options.append((o, n)) + self.added_options = [ + f for f in self.added_options + if f not in set(self.renamed_options.values()) + ] + self.removed_options = [ + f for f in self.removed_options + if f not in self.renamed_options + ] diff --git a/src/onegov/town6/templates/directory_form.pt b/src/onegov/town6/templates/directory_form.pt index 283d12eb63..c8fa2fd724 100644 --- a/src/onegov/town6/templates/directory_form.pt +++ b/src/onegov/town6/templates/directory_form.pt @@ -45,8 +45,8 @@
  • Changed: ${field}
  • -
  • - Renamed option: ${pair[0]} -> ${pair[1]} +
  • + Renamed: ${old[0]}: ${old[1]} -> ${new[0]}: ${new[1]}
  • From ced71927c22669155a60f0dc4529b99f4b56c905 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 22 Dec 2025 16:06:57 +0100 Subject: [PATCH 08/24] Revert --- src/onegov/directory/migration.py | 4 ---- src/onegov/org/views/directory.py | 14 ++++---------- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index a0cd311256..da7d2fcd2c 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -62,7 +62,6 @@ def possible(self) -> bool: if not self.directory.entries: return True - # tschupre changes to detect renamed options if not self.changes: return True @@ -70,9 +69,6 @@ def possible(self) -> bool: not self.changes.renamed_options): return True - if len(self.changes.renamed_options) > 1: - return False - for changed in self.changes.changed_fields: old = self.changes.old[changed] new = self.changes.new[changed] diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index e1a2188fac..94186c8d28 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -229,16 +229,10 @@ def handle_edit_directory( if migration.changes: if not migration.possible: save_changes = False - if len(migration.changes.renamed_options) > 1: - request.alert(_( - 'Please rename only one radio or checkbox ' - 'label at a time.' - )) - else: - request.alert(_( - 'The requested change cannot be performed, ' - 'as it is incompatible with existing entries' - )) + request.alert(_( + 'The requested change cannot be performed, ' + 'as it is incompatible with existing entries' + )) else: if not request.params.get('confirm'): form.action += '&confirm=1' From db829ed6be47bc2351e0eefa5621caf19d049b70 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 22 Dec 2025 16:17:55 +0100 Subject: [PATCH 09/24] Extend test --- tests/onegov/directory/test_migration.py | 426 ++++++++++++++--------- tests/onegov/org/test_views_directory.py | 82 +++++ 2 files changed, 338 insertions(+), 170 deletions(-) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index b65de82416..024ae918f5 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -377,170 +377,173 @@ def test_directory_migration(session: Session) -> None: # First migrates directory, then updates each entry. migration.execute() - new_structure = new_structure.replace('# Cost (A,B;C/D)', '') + new_structure = new_structure.replace("# Cost (A,B;C/D)", "") migration = zoos.migration(new_structure, None) changes = migration.changes - assert changes.renamed_fields['Cost (A,B;C/D)/Currency'] == 'Main/Currency' - assert changes.renamed_fields['Cost (A,B;C/D)/Cost'] == 'Main/Cost' + assert changes.renamed_fields["Cost (A,B;C/D)/Currency"] == "Main/Currency" + assert changes.renamed_fields["Cost (A,B;C/D)/Cost"] == "Main/Cost" assert not changes.changed_fields assert migration.possible migration.execute() -@pytest.mark.parametrize('old,new,label,expected_value', [ - ( # any to textarea - url - """ - description = http:// - """, - """ - description = ...[5] - """, - 'description', - '', - ), - ( # textarea to text - """ - description = ...[5] - """, - """ - description = ___ - """, - 'description', - '', - ), - ( # textarea to code - """ - description = ...[5] - """, - """ - description = - """, - 'description', - '', - ), - ( # text to code - """ - description = ___ - """, - """ - description = - """, - 'description', - '', - ), - ( # radio to checkbox - """ - Landscapes = - ( ) Tundra - ( ) Arctic - ( ) Desert - """, - """ - Landscapes = - [ ] Tundra - [ ] Arctic - [ ] Desert - """, - 'landscapes', - [], # checkbox - ), - ( # date to text - """ - Date = YYYY.MM.DD - """, - """ - Date = ___ - """, - 'date', - '' - ), - ( # datetime to text - """ - Date/Time = YYYY.MM.DD HH:MM - """, - """ - Date/Time = ___ - """, - 'date_time', - '' - ), - ( # time to text - """ - Time = HH:MM - """, - """ - Time = ___ - """, - 'time', - '', - ), - ( # text to url - """ - my text = ___ - """, - """ - my text = http:// - """, - 'my_text', - '', - ), -]) +@pytest.mark.parametrize( + "old,new,label,expected_value", + [ + ( # any to textarea - url + """ + description = http:// + """, + """ + description = ...[5] + """, + "description", + "", + ), + ( # textarea to text + """ + description = ...[5] + """, + """ + description = ___ + """, + "description", + "", + ), + ( # textarea to code + """ + description = ...[5] + """, + """ + description = + """, + "description", + "", + ), + ( # text to code + """ + description = ___ + """, + """ + description = + """, + "description", + "", + ), + ( # radio to checkbox + """ + Landscapes = + ( ) Tundra + ( ) Arctic + ( ) Desert + """, + """ + Landscapes = + [ ] Tundra + [ ] Arctic + [ ] Desert + """, + "landscapes", + [], # checkbox + ), + ( # date to text + """ + Date = YYYY.MM.DD + """, + """ + Date = ___ + """, + "date", + "", + ), + ( # datetime to text + """ + Date/Time = YYYY.MM.DD HH:MM + """, + """ + Date/Time = ___ + """, + "date_time", + "", + ), + ( # time to text + """ + Time = HH:MM + """, + """ + Time = ___ + """, + "time", + "", + ), + ( # text to url + """ + my text = ___ + """, + """ + my text = http:// + """, + "my_text", + "", + ), + ], +) def test_directory_field_type_migrations( - old: str, - new: str, - label: str, - expected_value: Any, - session: Session + old: str, new: str, label: str, expected_value: Any, session: Session ) -> None: """ The issue with migrations is that if one directory entry does not specify a value for a field the migration ends up in a `ValidationError` """ - structure = textwrap.dedent(f""" + structure = textwrap.dedent( + f""" # Main Name *= ___ # General {old} - """) + """ + ) - new_structure = textwrap.dedent(f""" + new_structure = textwrap.dedent( + f""" # Main Name *= ___ # General {new} - """) + """ + ) directories = DirectoryCollection(session) zoos = directories.add( - title='Zoos', + title="Zoos", lead="The town's zoos", structure=structure, configuration=DirectoryConfiguration( - title='[Main/Name]', order=['Main/Name'] + title="[Main/Name]", order=["Main/Name"] ), ) zoo = zoos.add( values={ - 'main_name': 'Luzerner Zoo', - f'general_{label}': '', # No value is set + "main_name": "Luzerner Zoo", + f"general_{label}": "", # No value is set } ) - assert zoo.values[f'general_{label}'] == '' # radio + assert zoo.values[f"general_{label}"] == "" # radio migration = zoos.migration(new_structure, None) assert migration.possible migration.execute() - assert zoo.values[f'general_{label}'] == expected_value + assert zoo.values[f"general_{label}"] == expected_value -def test_directory_migration_renaming_select(session: Session) -> None: +def test_directory_migration_for_select(session: Session) -> None: """ - Renaming a radio option or checkbox option + Adding, removing, renaming or change a radio option or checkbox option """ structure = """ # Main @@ -552,80 +555,163 @@ def test_directory_migration_renaming_select(session: Session) -> None: ( ) Desert """ - new_structure = """ - # Main - Name *= ___ - # General - Landscapes = - ( ) Tundra - ( ) Arctic - ( ) Great Desert - """ - directories = DirectoryCollection(session) zoos = directories.add( - title='Zoos', + title="Zoos", lead="The town's zoos", structure=structure, configuration=DirectoryConfiguration( - title='[Main/Name]', order=['Main/Name'] + title="[Main/Name]", order=["Main/Name"] ), ) zoo = zoos.add( values=dict( - main_name='Luzerner Zoo', - general_landscapes='Desert', + main_name="Luzerner Zoo", + general_landscapes="Desert", + general_animals=["Snakes"], ) ) - assert zoo.values['general_landscapes'] == 'Desert' + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Tundra + ( ) Arctic + ( ) Great Desert + Animals = + [ ] Snakes + """ + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == { + ("General/Landscapes", "Desert"): + ("General/Landscapes", "Great Desert") + } + assert migration.possible + + migration.execute() + assert zoo.values["general_landscapes"] == "Great Desert" + assert zoo.values["general_animals"] == None + + # add snakes to zoo lucerne + zoo.values["general_animals"] = ["Snakes"] + session.flush() + # add 1, 2 or more options + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Tundra + ( ) Marine + ( ) Tropical Rainforest + ( ) Arctic + ( ) Great Desert + ( ) Grasland + Animals = + [ ] Snakes + [ ] Gnus + """ migration = zoos.migration(new_structure, None) - assert migration.changes.renamed_options == [ - ('Desert', 'Great Desert') + assert migration.changes.added_options == [ + ("General/Landscapes", "Marine"), + ("General/Landscapes", "Tropical Rainforest"), + ("General/Landscapes", "Grasland"), + ("General/Animals", "Gnus"), ] - + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == {} assert migration.possible - migration.execute() - assert zoo.values['general_landscapes'] == 'Great Desert' + migration.execute() + assert zoo.values["general_landscapes"] == "Great Desert" + assert zoo.values["general_animals"] == ["Snakes"] - # two option changes will fail - structure = """ - Name *= ___ - Animal = - ( ) Cat - ( ) Dog - ( ) Hamster + # re-order options + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctic + ( ) Grasland + ( ) Great Desert + ( ) Marine + ( ) Tropical Rainforest + ( ) Tundra + Animals = + [ ] Gnus + [ ] Snakes """ + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == {} + assert migration.possible + + migration.execute() + assert zoo.values["general_landscapes"] == "Great Desert" + assert zoo.values["general_animals"] == ["Snakes"] + + # rename options new_structure = """ - Name *= ___ - Animal = - ( ) Cat - ( ) Doggy - ( ) Hamsterli + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctica + ( ) Grasland + ( ) Great Desert + ( ) Marina + ( ) Tropical Rainforest + ( ) Tundra + Animals = + [ ] Gnu + [ ] Snakes """ - xxs = directories.add( - title='Animals', - lead='Our animals', - structure=structure, - configuration=DirectoryConfiguration( - title='[Animal]', order=['Animal'] - ), - ) - xx = xxs.add( - values=dict( - name='Corgi', - animal='Dog' - ) - ) + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == { + ("General/Landscapes", "Arctic"): ("General/Landscapes", "Arctica"), + ("General/Landscapes", "Marine"): ("General/Landscapes", "Marina"), + ("General/Animals", "Gnus"): ("General/Animals", "Gnu"), + } + assert migration.possible - assert xx.values['name'] == 'Corgi' + migration.execute() + assert zoo.values["general_landscapes"] == "Great Desert" + assert zoo.values["general_animals"] == ["Snakes"] - migration = xxs.migration(new_structure, None) - assert migration.changes.renamed_options == [ - ("Dog", "Doggy"), - ("Hamster", "Hamsterli"), + # remove 1, 2 or more options + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctica + ( ) Grasland + ( ) Marina + ( ) Tundra + Animals = + [ ] Gnu + """ + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [ + ("General/Landscapes", "Great Desert"), + ("General/Landscapes", "Tropical Rainforest"), + ("General/Animals", "Snakes"), ] + assert migration.changes.renamed_options == {} + assert migration.possible + + migration.execute() + assert zoo.values["general_landscapes"] is None + assert zoo.values["general_animals"] == [] - assert not migration.possible + # type change diff --git a/tests/onegov/org/test_views_directory.py b/tests/onegov/org/test_views_directory.py index ca36b061f2..cf5ff48d6d 100644 --- a/tests/onegov/org/test_views_directory.py +++ b/tests/onegov/org/test_views_directory.py @@ -19,12 +19,15 @@ from onegov.org.models import ExtendedDirectoryEntry from purl import URL from pytz import UTC +from textwrap import dedent from sedate import standardize_date, utcnow, to_timezone, replace_timezone from tests.shared.utils import ( create_image, get_meta, extract_filename_from_response) from webtest import Upload from typing import TYPE_CHECKING + + if TYPE_CHECKING: from onegov.org.models import ExtendedDirectory from sedate.types import TzInfoOrName @@ -1061,3 +1064,82 @@ def create_directory(client: Client, title: str) -> ExtendedResponse: q2 = q2.form.submit().follow() assert question in q2 assert answer not in q2 + + +def test_directory_migration(client: Client) -> None: + # tests changing radio and checkbox options in directory structure + + client.login_admin() + page = (client.get('/directories').click('Verzeichnis')) + page.form['title'] = 'Order sweets' + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + (x) Yes + ( ) No + Choice = + [ ] Gummi Bear + [ ] Lolipop + """) + page.form['title_format'] = '[Nickname]' + assert page.form.submit() + + page = client.get('/directories/order-sweets') + page = page.click('Eintrag') + page.form['nickname'] = 'Max' + page.form['do_you_want_sweets_'] = 'Yes' + page.form['choice'] = ['Lolipop', 'Gummi Bear'] + assert page.form.submit() + + # get_xxx() + + # add options + page = client.get('/directories/order-sweets').click('Konfigurieren') + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + (x) Yes + ( ) No + ( ) Not sure + Choice = + [ ] Donut + [ ] Gummi Bear + [ ] Chocolate + [ ] Lolipop + [ ] Ice cream + """) + assert page.form.submit().follow().status_code == 200 + + # rename options + page = client.get('/directories/order-sweets').click('Konfigurieren') + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + ( ) Yes! + ( ) No + ( ) Not sure. Moom? + Choice = + [ ] Donut Hole + [ ] Gummi Bears + [ ] Chocolate + [ ] Lolipop + [ ] Ice cream + """) + confirm = page.form.submit() + # confirm migration + assert confirm.status_code == 200 + # assert confirm.click('Bestätigen') + + # remove (selected) options + page = client.get('/directories/order-sweets').click('Konfigurieren') + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + ( ) Yes! + ( ) Not sure. Moom? + Choice = + [ ] Donut Hole + [ ] Chocolate + [ ] Ice cream + """) + assert page.form.submit() From 58eec5d9c1ecab32f3a1d9ae52d42e691146c9cd Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 29 Dec 2025 10:22:24 +0100 Subject: [PATCH 10/24] Extend test --- tests/onegov/directory/test_migration.py | 110 +++++++++++++++-------- tests/onegov/org/test_views_directory.py | 9 +- 2 files changed, 77 insertions(+), 42 deletions(-) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 024ae918f5..cc3dc57a4f 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -517,28 +517,28 @@ def test_directory_field_type_migrations( directories = DirectoryCollection(session) zoos = directories.add( - title="Zoos", + title='Zoos', lead="The town's zoos", structure=structure, configuration=DirectoryConfiguration( - title="[Main/Name]", order=["Main/Name"] + title='[Main/Name]', order=['Main/Name'] ), ) zoo = zoos.add( values={ - "main_name": "Luzerner Zoo", - f"general_{label}": "", # No value is set + 'main_name': 'Luzerner Zoo', + f'general_{label}': '', # No value is set } ) - assert zoo.values[f"general_{label}"] == "" # radio + assert zoo.values[f'general_{label}'] == '' # radio migration = zoos.migration(new_structure, None) assert migration.possible migration.execute() - assert zoo.values[f"general_{label}"] == expected_value + assert zoo.values[f'general_{label}'] == expected_value def test_directory_migration_for_select(session: Session) -> None: @@ -557,18 +557,18 @@ def test_directory_migration_for_select(session: Session) -> None: directories = DirectoryCollection(session) zoos = directories.add( - title="Zoos", + title='Zoos', lead="The town's zoos", structure=structure, configuration=DirectoryConfiguration( - title="[Main/Name]", order=["Main/Name"] + title='[Main/Name]', order=['Main/Name'] ), ) zoo = zoos.add( values=dict( - main_name="Luzerner Zoo", - general_landscapes="Desert", - general_animals=["Snakes"], + main_name='Luzerner Zoo', + general_landscapes='Desert', + general_animals=['Snakes'], ) ) @@ -587,17 +587,17 @@ def test_directory_migration_for_select(session: Session) -> None: assert migration.changes.added_options == [] assert migration.changes.removed_options == [] assert migration.changes.renamed_options == { - ("General/Landscapes", "Desert"): - ("General/Landscapes", "Great Desert") + ('General/Landscapes', 'Desert'): + ('General/Landscapes', 'Great Desert') } assert migration.possible migration.execute() - assert zoo.values["general_landscapes"] == "Great Desert" - assert zoo.values["general_animals"] == None + assert zoo.values['general_landscapes'] == 'Great Desert' + assert zoo.values['general_animals'] == None # add snakes to zoo lucerne - zoo.values["general_animals"] = ["Snakes"] + zoo.values['general_animals'] = ['Snakes'] session.flush() # add 1, 2 or more options @@ -618,18 +618,18 @@ def test_directory_migration_for_select(session: Session) -> None: """ migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [ - ("General/Landscapes", "Marine"), - ("General/Landscapes", "Tropical Rainforest"), - ("General/Landscapes", "Grasland"), - ("General/Animals", "Gnus"), + ('General/Landscapes', 'Marine'), + ('General/Landscapes', 'Tropical Rainforest'), + ('General/Landscapes', 'Grasland'), + ('General/Animals', 'Gnus'), ] assert migration.changes.removed_options == [] assert migration.changes.renamed_options == {} assert migration.possible migration.execute() - assert zoo.values["general_landscapes"] == "Great Desert" - assert zoo.values["general_animals"] == ["Snakes"] + assert zoo.values['general_landscapes'] == 'Great Desert' + assert zoo.values['general_animals'] == ['Snakes'] # re-order options new_structure = """ @@ -654,8 +654,8 @@ def test_directory_migration_for_select(session: Session) -> None: assert migration.possible migration.execute() - assert zoo.values["general_landscapes"] == "Great Desert" - assert zoo.values["general_animals"] == ["Snakes"] + assert zoo.values['general_landscapes'] == 'Great Desert' + assert zoo.values['general_animals'] == ['Snakes'] # rename options new_structure = """ @@ -677,15 +677,15 @@ def test_directory_migration_for_select(session: Session) -> None: assert migration.changes.added_options == [] assert migration.changes.removed_options == [] assert migration.changes.renamed_options == { - ("General/Landscapes", "Arctic"): ("General/Landscapes", "Arctica"), - ("General/Landscapes", "Marine"): ("General/Landscapes", "Marina"), - ("General/Animals", "Gnus"): ("General/Animals", "Gnu"), + ('General/Landscapes', 'Arctic'): ('General/Landscapes', 'Arctica'), + ('General/Landscapes', 'Marine'): ('General/Landscapes', 'Marina'), + ('General/Animals', 'Gnus'): ('General/Animals', 'Gnu'), } assert migration.possible migration.execute() - assert zoo.values["general_landscapes"] == "Great Desert" - assert zoo.values["general_animals"] == ["Snakes"] + assert zoo.values['general_landscapes'] == 'Great Desert' + assert zoo.values['general_animals'] == ['Snakes'] # remove 1, 2 or more options new_structure = """ @@ -703,15 +703,55 @@ def test_directory_migration_for_select(session: Session) -> None: migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [] assert migration.changes.removed_options == [ - ("General/Landscapes", "Great Desert"), - ("General/Landscapes", "Tropical Rainforest"), - ("General/Animals", "Snakes"), + ('General/Landscapes', 'Great Desert'), + ('General/Landscapes', 'Tropical Rainforest'), + ('General/Animals', 'Snakes'), ] assert migration.changes.renamed_options == {} assert migration.possible migration.execute() - assert zoo.values["general_landscapes"] is None - assert zoo.values["general_animals"] == [] + assert zoo.values['general_landscapes'] is None + assert zoo.values['general_animals'] == [] - # type change + # type change radio -> checkbox + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + [ ] Arctica + [ ] Grasland + [ ] Marina + [ ] Tundra + Animals = + [ ] Gnu + """ + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == {} + assert migration.possible + + migration.execute() + assert zoo.values['general_landscapes'] == [] + assert zoo.values['general_animals'] == [] + + # type change checkbox -> radio not possible + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + [ ] Arctica + [ ] Grasland + [ ] Marina + [ ] Tundra + Animals = + ( ) Gnu + """ + migration = zoos.migration(new_structure, None) + assert migration.changes.added_options == [] + assert migration.changes.removed_options == [] + assert migration.changes.renamed_options == {} + assert not migration.possible diff --git a/tests/onegov/org/test_views_directory.py b/tests/onegov/org/test_views_directory.py index cf5ff48d6d..34e899fcae 100644 --- a/tests/onegov/org/test_views_directory.py +++ b/tests/onegov/org/test_views_directory.py @@ -1091,8 +1091,6 @@ def test_directory_migration(client: Client) -> None: page.form['choice'] = ['Lolipop', 'Gummi Bear'] assert page.form.submit() - # get_xxx() - # add options page = client.get('/directories/order-sweets').click('Konfigurieren') page.form['structure'] = dedent(""" @@ -1125,10 +1123,7 @@ def test_directory_migration(client: Client) -> None: [ ] Lolipop [ ] Ice cream """) - confirm = page.form.submit() - # confirm migration - assert confirm.status_code == 200 - # assert confirm.click('Bestätigen') + assert page.form.submit().status_code == 200 # remove (selected) options page = client.get('/directories/order-sweets').click('Konfigurieren') @@ -1142,4 +1137,4 @@ def test_directory_migration(client: Client) -> None: [ ] Chocolate [ ] Ice cream """) - assert page.form.submit() + assert page.form.submit().status_code == 200 From e1efe9f9a4e8ecb9813416ba6fbcaea3403eec15 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 29 Dec 2025 12:29:53 +0100 Subject: [PATCH 11/24] Alert user about migration issues --- src/onegov/directory/migration.py | 23 +++++++++++++++++++ .../locale/de_CH/LC_MESSAGES/onegov.org.po | 10 +++++++- .../locale/fr_CH/LC_MESSAGES/onegov.org.po | 10 +++++++- .../locale/it_CH/LC_MESSAGES/onegov.org.po | 10 +++++++- src/onegov/org/views/directory.py | 1 + 5 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index da7d2fcd2c..72f0b43c77 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -6,6 +6,7 @@ from onegov.form import parse_form from onegov.form import parse_formcode from onegov.form.parser.core import OptionsField +from onegov.org import _ from sqlalchemy.orm import object_session, joinedload, undefer from sqlalchemy.orm.attributes import get_history @@ -16,6 +17,7 @@ from datetime import date, datetime, time from onegov.directory.models import Directory from onegov.directory.types import DirectoryConfiguration + from onegov.org.request import OrgRequest class DirectoryMigration: @@ -87,6 +89,27 @@ def possible(self) -> bool: return False + def alert_migration_issues(self, request: OrgRequest) -> None: + """ Alerts the user via the request about the migration issues.""" + if self.possible: + return None + + for changed in self.changes.changed_fields: + old = self.changes.old[changed] + new = self.changes.new[changed] + + if not self.fieldtype_migrations.possible(old.type, new.type): + request.alert(_( + 'Cannot convert field "${field}" from type "${old_type}" ' + 'to "${new_type}".', mapping={ + 'field': changed, + 'old_type': old.type, + 'new_type': new.type + } + )) + + return None + @property def entries(self) -> Iterable[DirectoryEntry]: session = object_session(self.directory) diff --git a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index 01422b7d85..93e994795d 100644 --- a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2025-12-19 12:13+0100\n" +"POT-Creation-Date: 2025-12-29 12:23+0100\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -6341,6 +6341,14 @@ msgstr "Ein neues Verzeichnis wurde hinzugefügt" msgid "New Directory" msgstr "Neues Verzeichnis" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Feld \"${field}\" kann nicht von Typ \"${old_type}\" zu Typ \"${new_type}\" " +"konvertiert werden." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" diff --git a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po index 54b722ffde..d789838a96 100644 --- a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2025-12-19 12:13+0100\n" +"POT-Creation-Date: 2025-12-29 12:23+0100\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -6355,6 +6355,14 @@ msgstr "Ajout d'un nouveau dossier" msgid "New Directory" msgstr "Nouveau dossier" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Impossible de convertir le champ \"${field}\" du type \"${old_type}\" en " +"\"${new_type}\"." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" diff --git a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po index 25627fcfa7..f4199322c6 100644 --- a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2025-12-19 12:13+0100\n" +"POT-Creation-Date: 2025-12-29 12:23+0100\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -6330,6 +6330,14 @@ msgstr "Aggiunta una nuova cartella" msgid "New Directory" msgstr "Nuova cartella" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Impossibile convertire il campo \"${field}\" dal tipo \"${old_type}\" a " +"\"${new_type}\"." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index 94186c8d28..eb09130361 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -233,6 +233,7 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) + migration.alert_migration_issues(request) else: if not request.params.get('confirm'): form.action += '&confirm=1' From c0d9f5cc0b6a7c48f30bb40a4e2398d92966ce77 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 29 Dec 2025 13:50:29 +0100 Subject: [PATCH 12/24] Workaround to keep translation --- src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po | 2 +- src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po | 2 +- src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po | 2 +- src/onegov/org/views/directory.py | 3 +++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index 93e994795d..a3109cd349 100644 --- a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2025-12-29 12:23+0100\n" +"POT-Creation-Date: 2025-12-29 12:33+0100\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" diff --git a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po index d789838a96..6a42791d66 100644 --- a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2025-12-29 12:23+0100\n" +"POT-Creation-Date: 2025-12-29 12:33+0100\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" diff --git a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po index f4199322c6..6e75315af1 100644 --- a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2025-12-29 12:23+0100\n" +"POT-Creation-Date: 2025-12-29 12:33+0100\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index eb09130361..980e9a66ee 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -202,6 +202,9 @@ def handle_new_directory( } +# no op call to make translators aware of this string used in migration.reason +_('Cannot convert field "${field}" from type "${old_type}" to "${new_type}".') + @OrgApp.form(model=ExtendedDirectoryEntryCollection, name='edit', template='directory_form.pt', permission=Secret, form=get_directory_form_class) From 85f426d2eb2e7248f9bd61b234c87b2141d5d807 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 29 Dec 2025 13:54:52 +0100 Subject: [PATCH 13/24] Minor changes --- src/onegov/org/views/directory.py | 1 + tests/onegov/directory/test_migration.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index 980e9a66ee..ccf7b16c23 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -205,6 +205,7 @@ def handle_new_directory( # no op call to make translators aware of this string used in migration.reason _('Cannot convert field "${field}" from type "${old_type}" to "${new_type}".') + @OrgApp.form(model=ExtendedDirectoryEntryCollection, name='edit', template='directory_form.pt', permission=Secret, form=get_directory_form_class) diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index cc3dc57a4f..ea5543d322 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -377,11 +377,11 @@ def test_directory_migration(session: Session) -> None: # First migrates directory, then updates each entry. migration.execute() - new_structure = new_structure.replace("# Cost (A,B;C/D)", "") + new_structure = new_structure.replace('# Cost (A,B;C/D)', '') migration = zoos.migration(new_structure, None) changes = migration.changes - assert changes.renamed_fields["Cost (A,B;C/D)/Currency"] == "Main/Currency" - assert changes.renamed_fields["Cost (A,B;C/D)/Cost"] == "Main/Cost" + assert changes.renamed_fields['Cost (A,B;C/D)/Currency'] == 'Main/Currency' + assert changes.renamed_fields['Cost (A,B;C/D)/Cost'] == 'Main/Cost' assert not changes.changed_fields assert migration.possible From f9571da80cda84fb161620d4235ec39912a08ca2 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 29 Dec 2025 14:29:45 +0100 Subject: [PATCH 14/24] Rename method --- src/onegov/directory/migration.py | 2 +- src/onegov/org/views/directory.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index 72f0b43c77..d89916f30e 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -89,7 +89,7 @@ def possible(self) -> bool: return False - def alert_migration_issues(self, request: OrgRequest) -> None: + def alert_issues(self, request: OrgRequest) -> None: """ Alerts the user via the request about the migration issues.""" if self.possible: return None diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index ccf7b16c23..6edb2d90e4 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -237,7 +237,7 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) - migration.alert_migration_issues(request) + migration.alert_issues(request) else: if not request.params.get('confirm'): form.action += '&confirm=1' From 9416f14729a8726f55b4453924defd71dbaf659a Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 30 Dec 2025 08:27:59 +0100 Subject: [PATCH 15/24] Disable alert invalid conversion --- src/onegov/directory/migration.py | 42 +++++++++++++++---------------- src/onegov/org/views/directory.py | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index d89916f30e..491a74acc9 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -6,7 +6,7 @@ from onegov.form import parse_form from onegov.form import parse_formcode from onegov.form.parser.core import OptionsField -from onegov.org import _ +# from onegov.org import _ from sqlalchemy.orm import object_session, joinedload, undefer from sqlalchemy.orm.attributes import get_history @@ -89,26 +89,26 @@ def possible(self) -> bool: return False - def alert_issues(self, request: OrgRequest) -> None: - """ Alerts the user via the request about the migration issues.""" - if self.possible: - return None - - for changed in self.changes.changed_fields: - old = self.changes.old[changed] - new = self.changes.new[changed] - - if not self.fieldtype_migrations.possible(old.type, new.type): - request.alert(_( - 'Cannot convert field "${field}" from type "${old_type}" ' - 'to "${new_type}".', mapping={ - 'field': changed, - 'old_type': old.type, - 'new_type': new.type - } - )) - - return None + # def alert_issues(self, request: OrgRequest) -> None: + # """ Alerts the user via the request about the migration issues.""" + # if self.possible: + # return None + # + # for changed in self.changes.changed_fields: + # old = self.changes.old[changed] + # new = self.changes.new[changed] + # + # if not self.fieldtype_migrations.possible(old.type, new.type): + # request.alert(_( + # 'Cannot convert field "${field}" from type "${old_type}" ' + # 'to "${new_type}".', mapping={ + # 'field': changed, + # 'old_type': old.type, + # 'new_type': new.type + # } + # )) + # + # return None @property def entries(self) -> Iterable[DirectoryEntry]: diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index 6edb2d90e4..d9b93d9d12 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -237,7 +237,7 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) - migration.alert_issues(request) + # migration.alert_issues(request) else: if not request.params.get('confirm'): form.action += '&confirm=1' From f4a88e682a658e3a880f22635bd6a7af351425cc Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 30 Dec 2025 08:42:38 +0100 Subject: [PATCH 16/24] Move alert logic to view preventing hierarchy issue --- src/onegov/directory/migration.py | 23 ----------------- src/onegov/org/views/directory.py | 16 +++++++++++- tests/onegov/org/test_views_directory.py | 33 ++++++++++++++++++++---- 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index 491a74acc9..da7d2fcd2c 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -6,7 +6,6 @@ from onegov.form import parse_form from onegov.form import parse_formcode from onegov.form.parser.core import OptionsField -# from onegov.org import _ from sqlalchemy.orm import object_session, joinedload, undefer from sqlalchemy.orm.attributes import get_history @@ -17,7 +16,6 @@ from datetime import date, datetime, time from onegov.directory.models import Directory from onegov.directory.types import DirectoryConfiguration - from onegov.org.request import OrgRequest class DirectoryMigration: @@ -89,27 +87,6 @@ def possible(self) -> bool: return False - # def alert_issues(self, request: OrgRequest) -> None: - # """ Alerts the user via the request about the migration issues.""" - # if self.possible: - # return None - # - # for changed in self.changes.changed_fields: - # old = self.changes.old[changed] - # new = self.changes.new[changed] - # - # if not self.fieldtype_migrations.possible(old.type, new.type): - # request.alert(_( - # 'Cannot convert field "${field}" from type "${old_type}" ' - # 'to "${new_type}".', mapping={ - # 'field': changed, - # 'old_type': old.type, - # 'new_type': new.type - # } - # )) - # - # return None - @property def entries(self) -> Iterable[DirectoryEntry]: session = object_session(self.directory) diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index d9b93d9d12..a5d2bfbac2 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -237,7 +237,21 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) - # migration.alert_issues(request) + for changed in migration.changes.changed_fields: + old = migration.changes.old[changed] + new = migration.changes.new[changed] + + if not migration.fieldtype_migrations.possible( + old.type, new.type): + request.alert(_( + 'Cannot convert field "${field}" from ' + 'type "${old_type}" to "${new_type}".', + mapping={ + 'field': changed, + 'old_type': old.type, + 'new_type': new.type + } + )) else: if not request.params.get('confirm'): form.action += '&confirm=1' diff --git a/tests/onegov/org/test_views_directory.py b/tests/onegov/org/test_views_directory.py index 34e899fcae..21bc7cd305 100644 --- a/tests/onegov/org/test_views_directory.py +++ b/tests/onegov/org/test_views_directory.py @@ -1082,14 +1082,16 @@ def test_directory_migration(client: Client) -> None: [ ] Lolipop """) page.form['title_format'] = '[Nickname]' - assert page.form.submit() + page = page.form.submit() + assert not page.pyquery('.alert-box') page = client.get('/directories/order-sweets') page = page.click('Eintrag') page.form['nickname'] = 'Max' page.form['do_you_want_sweets_'] = 'Yes' page.form['choice'] = ['Lolipop', 'Gummi Bear'] - assert page.form.submit() + page = page.form.submit() + assert not page.pyquery('.alert-box') # add options page = client.get('/directories/order-sweets').click('Konfigurieren') @@ -1106,7 +1108,8 @@ def test_directory_migration(client: Client) -> None: [ ] Lolipop [ ] Ice cream """) - assert page.form.submit().follow().status_code == 200 + page = page.form.submit().follow() + assert page.pyquery('.alert-box') # rename options page = client.get('/directories/order-sweets').click('Konfigurieren') @@ -1123,7 +1126,8 @@ def test_directory_migration(client: Client) -> None: [ ] Lolipop [ ] Ice cream """) - assert page.form.submit().status_code == 200 + page = page.form.submit() + assert not page.pyquery('.alert-box') # remove (selected) options page = client.get('/directories/order-sweets').click('Konfigurieren') @@ -1137,4 +1141,23 @@ def test_directory_migration(client: Client) -> None: [ ] Chocolate [ ] Ice cream """) - assert page.form.submit().status_code == 200 + page = page.form.submit() + assert not page.pyquery('.alert-box') + + # switch checkbox -> radio which is invalid + page = client.get('/directories/order-sweets').click('Konfigurieren') + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + ( ) Yes! + ( ) Not sure. Moom? + Choice = + ( ) Donut Hole + ( ) Chocolate + ( ) Ice cream + """) + page = page.form.submit() + assert page.pyquery('.alert') + assert 'Die verlangte Änderung kann nicht durchgeführt' in page + assert ('Feld "Choice" kann nicht von Typ "checkbox" zu Typ "radio" ' + 'konvertiert werden') in page From 3e342acf998c46393fe89045350ffeba0b83f967 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Tue, 30 Dec 2025 08:54:47 +0100 Subject: [PATCH 17/24] Refactor type migration alerting --- src/onegov/org/views/directory.py | 36 ++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index a5d2bfbac2..b2ac8f9915 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -51,6 +51,7 @@ if TYPE_CHECKING: from collections.abc import Mapping, Sequence, Iterator from onegov.core.types import JSON_ro, RenderData, EmailJsonDict + from onegov.directory.migration import DirectoryMigration from onegov.directory.models.directory import DirectoryEntryForm from onegov.org.models.directory import ExtendedDirectoryEntryForm from onegov.org.request import OrgRequest @@ -237,21 +238,7 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) - for changed in migration.changes.changed_fields: - old = migration.changes.old[changed] - new = migration.changes.new[changed] - - if not migration.fieldtype_migrations.possible( - old.type, new.type): - request.alert(_( - 'Cannot convert field "${field}" from ' - 'type "${old_type}" to "${new_type}".', - mapping={ - 'field': changed, - 'old_type': old.type, - 'new_type': new.type - } - )) + alert_migration_type_errors(migration, request) else: if not request.params.get('confirm'): form.action += '&confirm=1' @@ -316,6 +303,25 @@ def handle_edit_directory( } +def alert_migration_type_errors( + migration: DirectoryMigration, + request: OrgRequest +) -> None: + for changed in migration.changes.changed_fields: + old = migration.changes.old[changed] + new = migration.changes.new[changed] + + if not migration.fieldtype_migrations.possible(old.type, new.type): + request.alert(_( + 'Cannot convert field "${field}" from type "${old_type}" ' + 'to "${new_type}".', mapping={ + 'field': changed, + 'old_type': old.type, + 'new_type': new.type + } + )) + + @OrgApp.view( model=ExtendedDirectoryEntryCollection, permission=Secret, From a49d45e92c93797b1b7b7e585b4c8154a99dd587 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert <124258444+Tschuppi81@users.noreply.github.com> Date: Thu, 8 Jan 2026 02:32:20 -0500 Subject: [PATCH 18/24] Remove useless set conversion Co-authored-by: David Salvisberg --- src/onegov/directory/migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index da7d2fcd2c..96a89792db 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -446,7 +446,7 @@ def detect_renamed_options(self) -> None: self.added_options = [ f for f in self.added_options - if f not in set(self.renamed_options.values()) + if f not in self.renamed_options.values() ] self.removed_options = [ f for f in self.removed_options From 423d47b8848168967274ac73ccdf35d5a10272f0 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Fri, 9 Jan 2026 15:54:09 +0100 Subject: [PATCH 19/24] Fix renamed option recognition --- src/onegov/directory/migration.py | 65 +++++++++++++++++++----- src/onegov/org/views/directory.py | 22 +++++++- tests/onegov/directory/test_migration.py | 37 ++++++++++---- 3 files changed, 98 insertions(+), 26 deletions(-) diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index 96a89792db..8a496b7068 100644 --- a/src/onegov/directory/migration.py +++ b/src/onegov/directory/migration.py @@ -59,15 +59,18 @@ def old_directory_structure(self) -> str: @property def possible(self) -> bool: + """ Returns True if the migration is possible, False otherwise. """ if not self.directory.entries: return True if not self.changes: return True - if (not self.changes.changed_fields and - not self.changes.renamed_options): - return True + if len(self.changes.renamed_options) > 1: + return False + + if self.multiple_option_changes_in_one_step(): + return False for changed in self.changes.changed_fields: old = self.changes.old[changed] @@ -179,7 +182,12 @@ def rename_options(self, values: dict[str, Any]) -> None: old_label = old_option[1] new_label = new_option[1] for key, val in list(values.items()): - if val == old_label: + if isinstance(val, list): + values[key] = [ + new_label if v == old_label else v for v in val + ] + + elif val == old_label: values[key] = new_label def remove_old_options(self, values: dict[str, Any]) -> None: @@ -191,6 +199,20 @@ def remove_old_options(self, values: dict[str, Any]) -> None: elif values[id] == label: values[id] = None + def multiple_option_changes_in_one_step(self) -> bool: + """ + Returns True if there are multiple changes e.g. added and + removed options. + """ + + if ( + (self.changes.added_options and self.changes.removed_options) + or (self.changes.added_options and self.changes.renamed_options) + or (self.changes.removed_options and self.changes.renamed_options) + ): + return True + return False + class FieldTypeMigrations: """ Contains methods to migrate fields from one type to another. """ @@ -282,6 +304,8 @@ def __bool__(self) -> bool: or self.removed_fields or self.renamed_fields or self.changed_fields + or self.added_options + or self.removed_options or self.renamed_options # radio and checkboxes ) @@ -441,14 +465,27 @@ def detect_renamed_options(self) -> None: if old_labels == new_labels: continue - for r, a in zip(self.removed_options, self.added_options): - self.renamed_options[r] = a + # test if re-ordered + if set(old_labels) == set(new_labels): + continue + + # only consider renames if the number of options + # remains the same + if len(old_labels) != len(new_labels): + continue - self.added_options = [ - f for f in self.added_options - if f not in self.renamed_options.values() - ] - self.removed_options = [ - f for f in self.removed_options - if f not in self.renamed_options - ] + for o_label, n_label in zip(old_labels, new_labels): + if o_label != n_label: + self.renamed_options[(old_id, o_label)] = ( + old_id, + n_label + ) + + self.added_options = [ + ao for ao in self.added_options + if ao not in self.renamed_options.values() + ] + self.removed_options = [ + ro for ro in self.removed_options + if ro not in self.renamed_options.keys() + ] diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index b2ac8f9915..5ef65f1f2e 100644 --- a/src/onegov/org/views/directory.py +++ b/src/onegov/org/views/directory.py @@ -238,7 +238,7 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) - alert_migration_type_errors(migration, request) + alert_migration_errors(migration, request) else: if not request.params.get('confirm'): form.action += '&confirm=1' @@ -303,10 +303,28 @@ def handle_edit_directory( } -def alert_migration_type_errors( +def alert_migration_errors( migration: DirectoryMigration, request: OrgRequest ) -> None: + if migration.multiple_option_changes_in_one_step(): + request.alert( + _( + 'Do not mix adding, removing, and renaming options in the ' + 'same migration. Please use separate migrations for each ' + 'option.' + ) + ) + + if len(migration.changes.renamed_options) > 1: + request.alert( + _( + 'Renaming multiple options in the same migration is not ' + 'supported. Please use separate migrations for each option.' + ) + ) + + # check for incompatible type changes for changed in migration.changes.changed_fields: old = migration.changes.old[changed] new = migration.changes.new[changed] diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index ea5543d322..e4eb8b9ff8 100644 --- a/tests/onegov/directory/test_migration.py +++ b/tests/onegov/directory/test_migration.py @@ -657,7 +657,7 @@ def test_directory_migration_for_select(session: Session) -> None: assert zoo.values['general_landscapes'] == 'Great Desert' assert zoo.values['general_animals'] == ['Snakes'] - # rename options + # rename options multiple -> not possible new_structure = """ # Main Name *= ___ @@ -670,16 +670,33 @@ def test_directory_migration_for_select(session: Session) -> None: ( ) Tropical Rainforest ( ) Tundra Animals = - [ ] Gnu + [ ] Gnus + [ ] Snakes + """ + migration = zoos.migration(new_structure, None) + assert not migration.possible + + # rename + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctic + ( ) Grasland + ( ) Great Desert + ( ) Marina + ( ) Tropical Rainforest + ( ) Tundra + Animals = + [ ] Gnus [ ] Snakes """ migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [] assert migration.changes.removed_options == [] assert migration.changes.renamed_options == { - ('General/Landscapes', 'Arctic'): ('General/Landscapes', 'Arctica'), ('General/Landscapes', 'Marine'): ('General/Landscapes', 'Marina'), - ('General/Animals', 'Gnus'): ('General/Animals', 'Gnu'), } assert migration.possible @@ -693,12 +710,12 @@ def test_directory_migration_for_select(session: Session) -> None: Name *= ___ # General Landscapes = - ( ) Arctica + ( ) Arctic ( ) Grasland ( ) Marina ( ) Tundra Animals = - [ ] Gnu + [ ] Gnus """ migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [] @@ -720,12 +737,12 @@ def test_directory_migration_for_select(session: Session) -> None: Name *= ___ # General Landscapes = - [ ] Arctica + [ ] Arctic [ ] Grasland [ ] Marina [ ] Tundra Animals = - [ ] Gnu + [ ] Gnus """ migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [] @@ -743,12 +760,12 @@ def test_directory_migration_for_select(session: Session) -> None: Name *= ___ # General Landscapes = - [ ] Arctica + [ ] Arctic [ ] Grasland [ ] Marina [ ] Tundra Animals = - ( ) Gnu + ( ) Gnus """ migration = zoos.migration(new_structure, None) assert migration.changes.added_options == [] From 8abbd8c5158239479c40b0afcf51387149b50a4e Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 12 Jan 2026 09:09:26 +0100 Subject: [PATCH 20/24] Adjust test --- tests/onegov/org/test_views_directory.py | 45 +++++++++++++++++------- 1 file changed, 33 insertions(+), 12 deletions(-) diff --git a/tests/onegov/org/test_views_directory.py b/tests/onegov/org/test_views_directory.py index 21bc7cd305..c293bcc458 100644 --- a/tests/onegov/org/test_views_directory.py +++ b/tests/onegov/org/test_views_directory.py @@ -1108,17 +1108,17 @@ def test_directory_migration(client: Client) -> None: [ ] Lolipop [ ] Ice cream """) - page = page.form.submit().follow() - assert page.pyquery('.alert-box') + page = page.form.submit() + page.forms['main-form'].submit() # confirm migration - # rename options + # rename multiple options page = client.get('/directories/order-sweets').click('Konfigurieren') page.form['structure'] = dedent(""" Nickname *= ___ Do you want sweets? = - ( ) Yes! + (x) Yes ( ) No - ( ) Not sure. Moom? + ( ) Not sure Choice = [ ] Donut Hole [ ] Gummi Bears @@ -1127,30 +1127,51 @@ def test_directory_migration(client: Client) -> None: [ ] Ice cream """) page = page.form.submit() - assert not page.pyquery('.alert-box') + assert page.pyquery('.alert-box') + assert 'Die verlangte Änderung kann nicht durchgeführt werden' in page + assert 'Das Umbenennen mehrerer Optionen in derselben Migration wird nicht unterstützt' in page + + # rename single options + page = client.get('/directories/order-sweets').click('Konfigurieren') + page.form['structure'] = dedent(""" + Nickname *= ___ + Do you want sweets? = + ( ) Yes + ( ) No + ( ) Not sure + Choice = + [ ] Donut Hole + [ ] Gummi Bear + [ ] Chocolate + [ ] Lolipop + [ ] Ice cream + """) + page = page.form.submit() + page.forms['main-form'].submit() # confirm migration # remove (selected) options page = client.get('/directories/order-sweets').click('Konfigurieren') page.form['structure'] = dedent(""" Nickname *= ___ Do you want sweets? = - ( ) Yes! - ( ) Not sure. Moom? + ( ) Yes + ( ) No + ( ) Not sure Choice = [ ] Donut Hole [ ] Chocolate [ ] Ice cream """) page = page.form.submit() - assert not page.pyquery('.alert-box') + page.forms['main-form'].submit() # confirm migration # switch checkbox -> radio which is invalid page = client.get('/directories/order-sweets').click('Konfigurieren') page.form['structure'] = dedent(""" Nickname *= ___ Do you want sweets? = - ( ) Yes! - ( ) Not sure. Moom? + ( ) Yes + ( ) Not sure Choice = ( ) Donut Hole ( ) Chocolate @@ -1159,5 +1180,5 @@ def test_directory_migration(client: Client) -> None: page = page.form.submit() assert page.pyquery('.alert') assert 'Die verlangte Änderung kann nicht durchgeführt' in page - assert ('Feld "Choice" kann nicht von Typ "checkbox" zu Typ "radio" ' + assert ('Feld "Choice" kann nicht von Typ "checkbox" zu "radio" ' 'konvertiert werden') in page From 7ee83e3ac615db4ae98c1a4198def6698d632ca8 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 12 Jan 2026 09:29:21 +0100 Subject: [PATCH 21/24] Adds missing translation and adds new once --- .../locale/de_CH/LC_MESSAGES/onegov.org.po | 113 +++++++++++------- .../locale/fr_CH/LC_MESSAGES/onegov.org.po | 110 ++++++++++------- .../locale/it_CH/LC_MESSAGES/onegov.org.po | 98 +++++++++------ 3 files changed, 194 insertions(+), 127 deletions(-) diff --git a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index d46fb2eb12..d84a8426bc 100644 --- a/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2026-01-07 17:25+0100\n" +"POT-Creation-Date: 2026-01-12 09:17+0100\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -1189,21 +1189,21 @@ msgstr "Bitte benutzen sie ein End-Datum, welches vor dem Start-Datum liegt" msgid "" "The date range overlaps with an existing registration window (${range})." msgstr "" -"Der Datumsbereich überschneidet sich mit einem bestehenden Anmeldezeitraum ($" -"{range})." +"Der Datumsbereich überschneidet sich mit einem bestehenden Anmeldezeitraum " +"(${range})." #, python-format msgid "" -"The limit cannot be lower than the already confirmed number of attendees ($" -"{claimed_spots})" +"The limit cannot be lower than the already confirmed number of attendees " +"(${claimed_spots})" msgstr "" "Das Limit kann nicht tiefer sein als die Anzahl bereits angemeldeter " "Teilnehmer (${claimed_spots})" #, python-format msgid "" -"The limit cannot be lower than the already confirmed number attendees ($" -"{claimed_spots}) and the number of pending requests (${pending_requests}). " +"The limit cannot be lower than the already confirmed number attendees " +"(${claimed_spots}) and the number of pending requests (${pending_requests}). " "Either enable the waiting list, process the pending requests or increase the " "limit. " msgstr "" @@ -1428,8 +1428,8 @@ msgstr "Die folgenden Domänen sind für iFrames erlaubt:" msgid "To allow more domains for iFrames, please contact info@seantis.ch." msgstr "" -"Um mehr Domains für iFrames zuzulassen, kontaktieren Sie bitte " -"info@seantis.ch." +"Um mehr Domains für iFrames zuzulassen, kontaktieren Sie bitte info@seantis." +"ch." msgid "The domain of the URL is not allowed for iFrames." msgstr "Die Domäne der URL ist für iFrames nicht zulässig." @@ -4017,8 +4017,8 @@ msgstr "Alle absagen mit Kommentar" msgid "Add reservation" msgstr "Reservation hinzufügen" -#. #. Used in sentence: "${event} published." +#. msgid "Event" msgstr "Veranstaltung" @@ -4942,8 +4942,8 @@ msgstr "Guten Tag" msgid "Your e-mail address was just used to create an account on ${homepage}." msgstr "" -"Ihre E-Mail Adresse wurde soeben zur Erstellung eines Accounts auf $" -"{homepage} verwendet." +"Ihre E-Mail Adresse wurde soeben zur Erstellung eines Accounts auf " +"${homepage} verwendet." msgid "To activate your account, click confirm below:" msgstr "Um Ihren Account zu aktivieren, bestätigen Sie bitte die Anmeldung:" @@ -4960,8 +4960,8 @@ msgstr "" msgid "Your e-mail address was just used to send a login link to ${homepage}." msgstr "" -"Ihre E-Mail Adresse wurde soeben zum Senden eines Anmeldelinks auf $" -"{homepage} verwendet." +"Ihre E-Mail Adresse wurde soeben zum Senden eines Anmeldelinks auf " +"${homepage} verwendet." msgid "Use the token below or click on the link to complete your login." msgstr "" @@ -5027,8 +5027,8 @@ msgstr "Neue Kunden Nachricht in Ticket ${link}" msgid "${author} wrote" msgstr "${author} schrieb" -#. Canonical text for ${link} is: "visit the request status page" #. Canonical text for ${link} is: "visit the request page" +#. Canonical text for ${link} is: "visit the request status page" msgid "Please ${link} to reply." msgstr "Bitte ${link} um zu antworten" @@ -5040,9 +5040,9 @@ msgid "Have a great day!" msgstr "Wir wünschen Ihnen einen schönen Tag!" msgid "" -"This is the notification for customer messages on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for customer messages on reservations for ${request." +"app.org.title}. If you no longer want to receive this e-mail please contact " +"an administrator so they can remove you from the recipients list." msgstr "" "Dies ist die tägliche Reservations-Übersicht für ${organisation}. Falls Sie " "dieses E-Mail nicht mehr bekommen möchten, melden Sie sich bitte bei einem " @@ -5062,8 +5062,8 @@ msgid "" "want to receive this e-mail please contact an administrator so they can " "remove you from the recipients list." msgstr "" -"Dies ist die Benachrichtigung für Kunden Nachrichten zu Reservierungen für $" -"{request.app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " +"Dies ist die Benachrichtigung für Kunden Nachrichten zu Reservierungen für " +"${request.app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " "kontaktieren Sie bitte einen Administrator, damit er Sie aus der Liste " "entfernen kann." @@ -5169,12 +5169,12 @@ msgid "New note in Ticket ${link}" msgstr "Neue Notiz in Ticket ${link}" msgid "" -"This is the notification for notes on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for notes on reservations for ${request.app.org." +"title}. If you no longer want to receive this e-mail please contact an " +"administrator so they can remove you from the recipients list." msgstr "" -"Dies ist die Benachrichtigung für Notizen zu Reservierungen für $" -"{request.app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " +"Dies ist die Benachrichtigung für Notizen zu Reservierungen für ${request." +"app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " "kontaktieren Sie bitte einen Administrator, damit er Sie aus der Liste " "entfernen kann." @@ -5213,8 +5213,8 @@ msgid "" "you no longer want to receive this e-mail please contact an administrator so " "they can remove you from the recipients list." msgstr "" -"Dies ist die Benachrichtigung für Notizen zu Reservierungen für $" -"{request.app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " +"Dies ist die Benachrichtigung für Notizen zu Reservierungen für ${request." +"app.org.title}. Wenn Sie diese E-Mail nicht mehr erhalten möchten, " "kontaktieren Sie bitte einen Administrator, damit er Sie aus der Liste " "entfernen kann." @@ -5239,8 +5239,8 @@ msgstr "" msgid "To use your account you need the Yubikey with the serial ${number}" msgstr "" -"Um Ihr Konto zu verwenden benötigen Sie den YubiKey mit der Seriennummer $" -"{number}" +"Um Ihr Konto zu verwenden benötigen Sie den YubiKey mit der Seriennummer " +"${number}" msgid "read more" msgstr "mehr lesen" @@ -5313,8 +5313,8 @@ msgid "" "you no longer wish to receive these notifications, please contact an " "administrator so they can remove you from the recipients list." msgstr "" -"Dies ist eine Benachrichtigung über die abgelehnten Reservierungen für $" -"{Organisation}. Wenn Sie diese Benachrichtigungen nicht mehr erhalten " +"Dies ist eine Benachrichtigung über die abgelehnten Reservierungen für " +"${Organisation}. Wenn Sie diese Benachrichtigungen nicht mehr erhalten " "möchten, kontaktieren Sie bitte einen Administrator, damit er Sie aus der " "Empfängerliste entfernen kann." @@ -6204,8 +6204,8 @@ msgid "" "Availability period updated. ${deleted} allocations removed, ${updated} " "allocations adjusted and ${created} new allocations created." msgstr "" -"Verfügbarkeitszeitraum aktualisiert. ${deleted} Verfügbarkeiten entfernt, $" -"{updated} Verfügbarkeiten angepasst und ${created} neue Verfügbarkeiten " +"Verfügbarkeitszeitraum aktualisiert. ${deleted} Verfügbarkeiten entfernt, " +"${updated} Verfügbarkeiten angepasst und ${created} neue Verfügbarkeiten " "erstellt." msgid "Availability period not found" @@ -6380,6 +6380,14 @@ msgstr "Ein neues Verzeichnis wurde hinzugefügt" msgid "New Directory" msgstr "Neues Verzeichnis" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Feld \"${field}\" kann nicht von Typ \"${old_type}\" zu \"${new_type}\" " +"konvertiert werden." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" @@ -6402,6 +6410,21 @@ msgstr "Syntaxfehler im Feld ${field_name}" msgid "Error: Duplicate label ${label}" msgstr "Fehler: ${label} zweifach erfasst" +msgid "" +"Do not mix adding, removing, and renaming options in the same migration. " +"Please use separate migrations for each option." +msgstr "" +"Mischen Sie nicht das Hinzufügen, Entfernen und Umbenennen von Optionen in " +"derselben Migration. Bitte verwenden Sie für jede Option separate " +"Migrationen." + +msgid "" +"Renaming multiple options in the same migration is not supported. Please use " +"separate migrations for each option." +msgstr "" +"Das Umbenennen mehrerer Optionen in derselben Migration wird nicht " +"unterstützt. Bitte verwenden Sie für jede Option separate Migrationen." + msgid "The directory was deleted" msgstr "Das Verzeichnis wurde gelöscht" @@ -6503,19 +6526,19 @@ msgstr "Neuer Empfänger" #, python-format msgid "" -"Registration for notifications on new entries in the directory \"${directory}" -"\"" +"Registration for notifications on new entries in the directory " +"\"${directory}\"" msgstr "" -"Anmeldung für Benachrichtigungen bei neuen Einträgen im Verzeichnis \"$" -"{directory}\"" +"Anmeldung für Benachrichtigungen bei neuen Einträgen im Verzeichnis " +"\"${directory}\"" #, python-format msgid "" "Success! We have sent a confirmation link to ${address}, if we didn't send " "you one already." msgstr "" -"Erfolg! Wir senden eine E-Mail zur Bestätigung Ihres Abonnements an $" -"{address}, sofern Sie noch nicht angemeldet sind." +"Erfolg! Wir senden eine E-Mail zur Bestätigung Ihres Abonnements an " +"${address}, sofern Sie noch nicht angemeldet sind." msgid "Notification for new entries" msgstr "Benachrichtigung bei neuen Einträgen" @@ -6862,8 +6885,8 @@ msgid "" "Success! We have sent a confirmation link to ${address}, if we didn't send " "you one already. Your subscribed categories are ${subscribed}." msgstr "" -"Erfolg! Wir senden eine E-Mail zur Bestätigung Ihres Abonnements an $" -"{address}, sofern Sie noch nicht angemeldet sind. Ihre abonnierten " +"Erfolg! Wir senden eine E-Mail zur Bestätigung Ihres Abonnements an " +"${address}, sofern Sie noch nicht angemeldet sind. Ihre abonnierten " "Kategorien sind ${subscribed}." # python-format @@ -7206,8 +7229,8 @@ msgid "" "Failed to create visits using the dormakaba API for site ID ${site_id} " "please make sure your credentials are still valid." msgstr "" -"Das Delegieren der Entriegelung der Türen via dormakaba API für Anlage $" -"{site_id} ist fehlgeschlagen. Bitte stellen Sie sicher, dass die " +"Das Delegieren der Entriegelung der Türen via dormakaba API für Anlage " +"${site_id} ist fehlgeschlagen. Bitte stellen Sie sicher, dass die " "hinterlegten Zugangsdaten immer noch gültig sind." msgid "Your reservations were accepted" @@ -7702,8 +7725,8 @@ msgstr "Beim Hochladen auf Gever ist ein Fehler aufgetreten." #, python-format msgid "" -"Encountered an error while uploading to Gever. Response status code is $" -"{status}." +"Encountered an error while uploading to Gever. Response status code is " +"${status}." msgstr "" "Beim Hochladen auf Gever ist ein Fehler aufgetreten. Der Statuscode der " "Antwort lautet ${status}." diff --git a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po index 13a6b02973..044768bf1a 100644 --- a/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/fr_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2026-01-07 17:25+0100\n" +"POT-Creation-Date: 2026-01-12 09:17+0100\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -1193,22 +1193,22 @@ msgstr "" #, python-format msgid "" -"The limit cannot be lower than the already confirmed number of attendees ($" -"{claimed_spots})" +"The limit cannot be lower than the already confirmed number of attendees " +"(${claimed_spots})" msgstr "" -"La limite ne peut être inférieure au nombre de participants déjà confirmés ($" -"{claimed_spots})" +"La limite ne peut être inférieure au nombre de participants déjà confirmés " +"(${claimed_spots})" #, python-format msgid "" -"The limit cannot be lower than the already confirmed number attendees ($" -"{claimed_spots}) and the number of pending requests (${pending_requests}). " +"The limit cannot be lower than the already confirmed number attendees " +"(${claimed_spots}) and the number of pending requests (${pending_requests}). " "Either enable the waiting list, process the pending requests or increase the " "limit. " msgstr "" "La limite ne peut pas être inférieure au nombre de participants déjà " -"confirmés (${claims_spots}) et au nombre de demandes en attente ($" -"{pending_requests}). Activez la liste d'attente, traitez les demandes en " +"confirmés (${claims_spots}) et au nombre de demandes en attente " +"(${pending_requests}). Activez la liste d'attente, traitez les demandes en " "attente ou augmentez la limite." msgid "The end date must be later than the start date" @@ -2918,8 +2918,8 @@ msgid "" "Invalid format. Please define at least one sub-organisation for '${topic}' " "or remove the ':'" msgstr "" -"Format non valide. Veuillez définir au moins une sous-organisation pour '$" -"{topic}' ou supprimer le ':'" +"Format non valide. Veuillez définir au moins une sous-organisation pour " +"'${topic}' ou supprimer le ':'" msgid "" "Invalid format. Only organisations and sub-organisations are allowed - no " @@ -2980,9 +2980,9 @@ msgid "" "Either choose a different date range or give this window a title to " "differenciate it from other windows." msgstr "" -"L'intervalle de dates chevauche une fenêtre de soumission existante ($" -"{range}). Choisissez un autre intervalle de dates ou donnez un titre à cette " -"fenêtre pour la différencier des autres fenêtres." +"L'intervalle de dates chevauche une fenêtre de soumission existante " +"(${range}). Choisissez un autre intervalle de dates ou donnez un titre à " +"cette fenêtre pour la différencier des autres fenêtres." msgid "Short name to identify the text module" msgstr "Nom court pour identifier le module de texte" @@ -4023,8 +4023,8 @@ msgstr "Tout refuser avec message" msgid "Add reservation" msgstr "Ajouter une réservation" -#. #. Used in sentence: "${event} published." +#. msgid "Event" msgstr "Événement" @@ -4431,8 +4431,8 @@ msgid "" "filled-out form." msgstr "" "Veuillez vérifier vos données et appuyez sur « Compléter » pour finaliser le " -"processus. S'il y a quelque chose que vous souhaitez modifier, cliquez sur " -"« Modifier » pour retourner sur le formulaire complété." +"processus. S'il y a quelque chose que vous souhaitez modifier, cliquez sur « " +"Modifier » pour retourner sur le formulaire complété." msgid "" "The image shown in the list view is a square. To have your image shown fully " @@ -4493,8 +4493,8 @@ msgstr "" "Nous n'avons pas pu réserver certains créneaux correspondant à vos critères " "pour les dates${and_rooms} suivantes. Veuillez noter que certaines de ces " "dates peuvent encore avoir un ou plusieurs créneaux disponibles qui sont " -"soit plus courts, soit plus longs que la durée que vous avez choisie. $" -"{dates}" +"soit plus courts, soit plus longs que la durée que vous avez choisie. " +"${dates}" #. Used in sentence: "We were unable to reserve some slots matching your #. criteria for the following dates${and_rooms}, please note that some of those @@ -4948,8 +4948,8 @@ msgstr "Bonjour" msgid "Your e-mail address was just used to create an account on ${homepage}." msgstr "" -"Votre adresse e-mail vient d'être utilisée pour créer un compte sur $" -"{homepage}." +"Votre adresse e-mail vient d'être utilisée pour créer un compte sur " +"${homepage}." msgid "To activate your account, click confirm below:" msgstr "Pour activer votre compte, cliquer sur confirmer ci-dessous:" @@ -5032,8 +5032,8 @@ msgstr "Nouveau message du client dans le ticket ${link}" msgid "${author} wrote" msgstr "${author} a écrit" -#. Canonical text for ${link} is: "visit the request status page" #. Canonical text for ${link} is: "visit the request page" +#. Canonical text for ${link} is: "visit the request status page" msgid "Please ${link} to reply." msgstr "Veuillez vous rendre sur ${link} pour répondre." @@ -5045,9 +5045,9 @@ msgid "Have a great day!" msgstr "Passez une bonne journée!" msgid "" -"This is the notification for customer messages on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for customer messages on reservations for ${request." +"app.org.title}. If you no longer want to receive this e-mail please contact " +"an administrator so they can remove you from the recipients list." msgstr "" "Ceci est une notification concernant les messages clients relatifs aux " "réservations pour ${request.app.org.title}. Si vous ne souhaitez plus " @@ -5175,14 +5175,14 @@ msgid "New note in Ticket ${link}" msgstr "Nouvelle note dans le billet ${link}" msgid "" -"This is the notification for notes on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for notes on reservations for ${request.app.org." +"title}. If you no longer want to receive this e-mail please contact an " +"administrator so they can remove you from the recipients list." msgstr "" -"Il s'agit de la notification de notes sur les réservations pour $" -"{request.app.org.title}. Si vous ne souhaitez plus recevoir cet e-mail, " -"veuillez contacter un administrateur afin qu'il puisse vous retirer de la " -"liste des destinataires." +"Il s'agit de la notification de notes sur les réservations pour ${request." +"app.org.title}. Si vous ne souhaitez plus recevoir cet e-mail, veuillez " +"contacter un administrateur afin qu'il puisse vous retirer de la liste des " +"destinataires." msgid "Pending approval" msgstr "En attente d'approbation" @@ -5218,10 +5218,10 @@ msgid "" "you no longer want to receive this e-mail please contact an administrator so " "they can remove you from the recipients list." msgstr "" -"Ceci est la notification pour les réservations pour $" -"{request.app.org.title}. Si vous ne souhaitez plus recevoir cet e-mail, " -"veuillez contacter un administrateur afin qu'il puisse vous supprimer de la " -"liste des destinataires." +"Ceci est la notification pour les réservations pour ${request.app.org." +"title}. Si vous ne souhaitez plus recevoir cet e-mail, veuillez contacter un " +"administrateur afin qu'il puisse vous supprimer de la liste des " +"destinataires." msgid "An administrator just created a new account on ${org} for you." msgstr "" @@ -5319,8 +5319,8 @@ msgid "" "you no longer wish to receive these notifications, please contact an " "administrator so they can remove you from the recipients list." msgstr "" -"Il s'agit d'une notification concernant les réservations rejetées pour $" -"{organisation}. Si vous ne souhaitez plus recevoir ces notifications, " +"Il s'agit d'une notification concernant les réservations rejetées pour " +"${organisation}. Si vous ne souhaitez plus recevoir ces notifications, " "veuillez contacter un administrateur afin qu'il vous retire de la liste des " "destinataires." @@ -6393,6 +6393,14 @@ msgstr "Ajout d'un nouveau dossier" msgid "New Directory" msgstr "Nouveau dossier" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Impossible de convertir le champ \"${field}\" du type \"${old_type}\" en " +"\"${new_type}\"." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" @@ -6415,6 +6423,20 @@ msgstr "Erreur de syntaxe dans le champ ${field_name}" msgid "Error: Duplicate label ${label}" msgstr "Erreur: Duplication de l'étiquette ${label}" +msgid "" +"Do not mix adding, removing, and renaming options in the same migration. " +"Please use separate migrations for each option." +msgstr "" +" Ne mélangez pas l'ajout, la suppression et le renommage des options dans la " +"même migration. Veuillez utiliser des migrations séparées pour chaque option." + +msgid "" +"Renaming multiple options in the same migration is not supported. Please use " +"separate migrations for each option." +msgstr "" +"Le renommage de plusieurs options dans la même migration n'est pas " +"pris en charge. Veuillez utiliser des migrations séparées pour chaque option." + msgid "The directory was deleted" msgstr "Le dossier a été supprimé" @@ -6516,8 +6538,8 @@ msgstr "Nouveau destinataire" #, python-format msgid "" -"Registration for notifications on new entries in the directory \"${directory}" -"\"" +"Registration for notifications on new entries in the directory " +"\"${directory}\"" msgstr "" "Inscription pour les notifications sur les nouvelles entrées dans le dossier " "\"${directory}\"" @@ -6878,8 +6900,8 @@ msgid "" "you one already. Your subscribed categories are ${subscribed}." msgstr "" "C'est fait! Nous avons envoyé un lien de confirmation vers ${address}, si " -"nous ne vous en avions pas déjà envoyé un. Vos catégories abonnées sont $" -"{subscribed}." +"nous ne vous en avions pas déjà envoyé un. Vos catégories abonnées sont " +"${subscribed}." # python-format #, python-format @@ -7714,8 +7736,8 @@ msgstr "Une erreur s'est produite lors du téléchargement sur Gever." #, python-format msgid "" -"Encountered an error while uploading to Gever. Response status code is $" -"{status}." +"Encountered an error while uploading to Gever. Response status code is " +"${status}." msgstr "" "Une erreur s'est produite lors du téléchargement sur Gever. Le code d'état " "de la réponse est ${status}." diff --git a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po index e3676ac43e..e641e3d3c4 100644 --- a/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po +++ b/src/onegov/org/locale/it_CH/LC_MESSAGES/onegov.org.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: \n" -"POT-Creation-Date: 2026-01-07 17:25+0100\n" +"POT-Creation-Date: 2026-01-12 09:17+0100\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -1198,22 +1198,22 @@ msgstr "" #, python-format msgid "" -"The limit cannot be lower than the already confirmed number of attendees ($" -"{claimed_spots})" +"The limit cannot be lower than the already confirmed number of attendees " +"(${claimed_spots})" msgstr "" -"Il limite non può essere inferiore numero di partecipanti già confermato ($" -"{claimed_spots})" +"Il limite non può essere inferiore numero di partecipanti già confermato " +"(${claimed_spots})" #, python-format msgid "" -"The limit cannot be lower than the already confirmed number attendees ($" -"{claimed_spots}) and the number of pending requests (${pending_requests}). " +"The limit cannot be lower than the already confirmed number attendees " +"(${claimed_spots}) and the number of pending requests (${pending_requests}). " "Either enable the waiting list, process the pending requests or increase the " "limit. " msgstr "" "Il limite non può essere inferiore al numero di partecipanti già confermato " -"(${claimed_spots}) e al numero di richieste in sospeso ($" -"{pending_requests}). Abilita la lista d'attesa, elabora le richieste in " +"(${claimed_spots}) e al numero di richieste in sospeso " +"(${pending_requests}). Abilita la lista d'attesa, elabora le richieste in " "sospeso o aumenta il limite. " msgid "The end date must be later than the start date" @@ -4025,8 +4025,8 @@ msgstr "Rifiuta tutto con messaggio" msgid "Add reservation" msgstr "Aggiungi prenotazione" -#. #. Used in sentence: "${event} published." +#. msgid "Event" msgstr "Evento" @@ -4938,8 +4938,8 @@ msgstr "Ciao!" msgid "Your e-mail address was just used to create an account on ${homepage}." msgstr "" -"Il tuo indirizzo e-mail è stato appena utilizzato per creare un account su $" -"{homepage}." +"Il tuo indirizzo e-mail è stato appena utilizzato per creare un account su " +"${homepage}." msgid "To activate your account, click confirm below:" msgstr "Per attivare il tuo account, fai clic su conferma qui di seguito:" @@ -5019,8 +5019,8 @@ msgstr "Nuovo messaggio del cliente nel ticket ${link}" msgid "${author} wrote" msgstr "${author} ha scritto" -#. Canonical text for ${link} is: "visit the request status page" #. Canonical text for ${link} is: "visit the request page" +#. Canonical text for ${link} is: "visit the request status page" msgid "Please ${link} to reply." msgstr "Per favore ${link} per rispondere." @@ -5032,9 +5032,9 @@ msgid "Have a great day!" msgstr "Ti auguro una buona giornata!" msgid "" -"This is the notification for customer messages on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for customer messages on reservations for ${request." +"app.org.title}. If you no longer want to receive this e-mail please contact " +"an administrator so they can remove you from the recipients list." msgstr "" "Questa è la notifica relativa ai messaggi dei clienti sulle prenotazioni per " "${request.app.org.title}. Se non desideri più ricevere questa e-mail, " @@ -5163,13 +5163,13 @@ msgid "New note in Ticket ${link}" msgstr "Nuova nota nel ticket ${link}" msgid "" -"This is the notification for notes on reservations for $" -"{request.app.org.title}. If you no longer want to receive this e-mail please " -"contact an administrator so they can remove you from the recipients list." +"This is the notification for notes on reservations for ${request.app.org." +"title}. If you no longer want to receive this e-mail please contact an " +"administrator so they can remove you from the recipients list." msgstr "" -"Questa è la notifica per le note sulle prenotazioni per $" -"{request.app.org.title}. Se non si desidera più ricevere questo messaggio di " -"posta elettronica si prega di contattare un amministratore in modo che possa " +"Questa è la notifica per le note sulle prenotazioni per ${request.app.org." +"title}. Se non si desidera più ricevere questo messaggio di posta " +"elettronica si prega di contattare un amministratore in modo che possa " "rimuovervi dall'elenco dei destinatari lista" msgid "Pending approval" @@ -5740,8 +5740,8 @@ msgstr "ore" #, python-format msgid "Reservations can only be made at most ${n} ${unit} in advance." msgstr "" -"Le prenotazioni possono essere effettuate solo con un anticipo massimo di $" -"{n} ${unit}." +"Le prenotazioni possono essere effettuate solo con un anticipo massimo di " +"${n} ${unit}." msgid "" "Reservations must be made at least ${n1} ${unit1} and at most ${n2} ${unit2} " @@ -6198,8 +6198,8 @@ msgid "" "Availability period updated. ${deleted} allocations removed, ${updated} " "allocations adjusted and ${created} new allocations created." msgstr "" -"Periodo di disponibilità aggiornato. ${deleted} allocazioni rimosse, $" -"{updated} allocazioni modificate e ${created} nuove allocazioni create." +"Periodo di disponibilità aggiornato. ${deleted} allocazioni rimosse, " +"${updated} allocazioni modificate e ${created} nuove allocazioni create." msgid "Availability period not found" msgstr "Periodo di disponibilità non trovato" @@ -6295,8 +6295,8 @@ msgid "" "A password reset link has been sent to ${email}, provided an active account " "exists for this email address." msgstr "" -"Il collegamento per reimpostare la password è stato inviato all'indirizzo $" -"{email} (se si tratta di un account attivo esistente)." +"Il collegamento per reimpostare la password è stato inviato all'indirizzo " +"${email} (se si tratta di un account attivo esistente)." msgid "Password changed." msgstr "Password modificata." @@ -6368,6 +6368,14 @@ msgstr "Aggiunta una nuova cartella" msgid "New Directory" msgstr "Nuova cartella" +#, python-format +msgid "" +"Cannot convert field \"${field}\" from type \"${old_type}\" to " +"\"${new_type}\"." +msgstr "" +"Impossibile convertire il campo \"${field}\" dal tipo \"${old_type}\" a " +"\"${new_type}\"." + msgid "" "The requested change cannot be performed, as it is incompatible with " "existing entries" @@ -6390,6 +6398,20 @@ msgstr "Errore di sintassi nel campo ${field_name}" msgid "Error: Duplicate label ${label}" msgstr "Errore: etichetta ${label} duplicata" +msgid "" +"Do not mix adding, removing, and renaming options in the same migration. " +"Please use separate migrations for each option." +msgstr "" +"Non mescolare l'aggiunta, la rimozione e la ridenominazione delle opzioni " +"nella stessa migrazione. Utilizzare migrazioni separate per ogni opzione." + +msgid "" +"Renaming multiple options in the same migration is not supported. Please use " +"separate migrations for each option." +msgstr "" +"Rinominare più opzioni nella stessa migrazione non è supportato. Utilizzare " +"migrazioni separate per ogni opzione." + msgid "The directory was deleted" msgstr "La cartella è stata cancellata" @@ -6487,11 +6509,11 @@ msgstr "Nuovo destinatario" #, python-format msgid "" -"Registration for notifications on new entries in the directory \"${directory}" -"\"" +"Registration for notifications on new entries in the directory " +"\"${directory}\"" msgstr "" -"Registrazione per le notifiche sui nuovi elementi nella cartella \"$" -"{directory}\"" +"Registrazione per le notifiche sui nuovi elementi nella cartella " +"\"${directory}\"" #, python-format msgid "" @@ -7195,8 +7217,8 @@ msgid "" "Failed to create visits using the dormakaba API for site ID ${site_id} " "please make sure your credentials are still valid." msgstr "" -"Impossibile creare visite con l'API di dormakaba per l'ID del sito $" -"{site_id}. Assicurarsi che le credenziali siano ancora valide." +"Impossibile creare visite con l'API di dormakaba per l'ID del sito " +"${site_id}. Assicurarsi che le credenziali siano ancora valide." msgid "Your reservations were accepted" msgstr "Le tue prenotazioni sono state accettate" @@ -7386,8 +7408,8 @@ msgstr "Totale di ${number} collegamenti trovati." msgid "" "Migrates links from the given domain to the current domain \"${domain}\"." msgstr "" -"Migra i collegamenti dal dominio specificato al dominio corrente \"${domain}" -"\"." +"Migra i collegamenti dal dominio specificato al dominio corrente " +"\"${domain}\"." msgid "OneGov API" msgstr "API OneGov" @@ -7686,8 +7708,8 @@ msgstr "Si è verificato un errore durante il caricamento su Gever." #, python-format msgid "" -"Encountered an error while uploading to Gever. Response status code is $" -"{status}." +"Encountered an error while uploading to Gever. Response status code is " +"${status}." msgstr "" "Si è verificato un errore durante il caricamento su Gever.Il codice di stato " "della risposta è ${status}." From 98226df12e4780b8cf4447bac6886a4b5697f746 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 12 Jan 2026 12:14:52 +0100 Subject: [PATCH 22/24] Shows changed options for migrations --- src/onegov/town6/templates/directory_form.pt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/onegov/town6/templates/directory_form.pt b/src/onegov/town6/templates/directory_form.pt index c8fa2fd724..426d3aa9f0 100644 --- a/src/onegov/town6/templates/directory_form.pt +++ b/src/onegov/town6/templates/directory_form.pt @@ -45,8 +45,14 @@
  • Changed: ${field}
  • +
  • + Added option to field ${field}: ${option} +
  • +
  • + Removed option from field ${field}: ${option} +
  • - Renamed: ${old[0]}: ${old[1]} -> ${new[0]}: ${new[1]} + Renamed: ${old[0]}: ${old[1]} ${new[0]}: ${new[1]}
  • From 20c81f15f84b8d1abc95b2d2c8038e7b08b4c4d3 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 12 Jan 2026 13:13:20 +0100 Subject: [PATCH 23/24] Fix linter issue --- tests/onegov/org/test_views_directory.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/onegov/org/test_views_directory.py b/tests/onegov/org/test_views_directory.py index c293bcc458..724a300bc8 100644 --- a/tests/onegov/org/test_views_directory.py +++ b/tests/onegov/org/test_views_directory.py @@ -1129,7 +1129,8 @@ def test_directory_migration(client: Client) -> None: page = page.form.submit() assert page.pyquery('.alert-box') assert 'Die verlangte Änderung kann nicht durchgeführt werden' in page - assert 'Das Umbenennen mehrerer Optionen in derselben Migration wird nicht unterstützt' in page + assert ('Das Umbenennen mehrerer Optionen in derselben Migration ' + 'wird nicht unterstützt') in page # rename single options page = client.get('/directories/order-sweets').click('Konfigurieren') From 6c428e4b568e561f05661fbc574bebdf9b7e4f53 Mon Sep 17 00:00:00 2001 From: Reto Tschuppert Date: Mon, 12 Jan 2026 13:54:20 +0100 Subject: [PATCH 24/24] Align translations --- src/onegov/town6/templates/directory_form.pt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/onegov/town6/templates/directory_form.pt b/src/onegov/town6/templates/directory_form.pt index 426d3aa9f0..605f9285e6 100644 --- a/src/onegov/town6/templates/directory_form.pt +++ b/src/onegov/town6/templates/directory_form.pt @@ -46,10 +46,10 @@ Changed: ${field}
  • - Added option to field ${field}: ${option} + Added: ${field} - ${option}
  • - Removed option from field ${field}: ${option} + Removed: ${field} - ${option}
  • Renamed: ${old[0]}: ${old[1]} ${new[0]}: ${new[1]}