diff --git a/src/onegov/directory/migration.py b/src/onegov/directory/migration.py index b36c777559..cf1d79bfad 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 @@ -58,14 +59,21 @@ 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: - return True + if len(self.changes.renamed_options) > 1: + return False + + if self.multiple_option_changes_in_one_step(): + return False + + if self.added_required_fields(): + return False for changed in self.changes.changed_fields: old = self.changes.old[changed] @@ -142,6 +150,8 @@ 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) + self.remove_old_options(values) def add_new_fields(self, values: dict[str, Any]) -> None: for added in self.changes.added_fields: @@ -170,6 +180,55 @@ 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_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 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: + 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 + + 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 + + def added_required_fields(self) -> bool: + """ + Identify newly added fields that are set to be required. Newly added + fields shall not be required if entries exist, make them required + in a separate migration step. + """ + if self.directory.entries: + return any( + f.required for f in self.changes.new.values() + if f.human_id in self.changes.added_fields + ) + + return False class FieldTypeMigrations: """ Contains methods to migrate fields from one type to another. """ @@ -194,10 +253,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() @@ -214,16 +269,16 @@ 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] + return [value] if value else [] def text_to_url(self, value: str) -> str: return value @@ -255,6 +310,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_added_options() + self.detect_removed_options() + self.detect_renamed_options() def __bool__(self) -> bool: return bool( @@ -262,6 +320,9 @@ 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 ) def detect_removed_fieldsets(self) -> None: @@ -378,3 +439,69 @@ 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_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 + + # 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 + + 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/locale/de_CH/LC_MESSAGES/onegov.org.po b/src/onegov/org/locale/de_CH/LC_MESSAGES/onegov.org.po index d46fb2eb12..40d2f5cec1 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-16 13:26+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,28 @@ 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 "" +"New fields cannot be required initially. Require them in a separate " +"migration step." +msgstr "" +"Neue Felder können zunächst nicht als erforderlich markiert werden. Markieren " +"Sie diese in einem separaten Migrationsschritt als erforderlich." + +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 +6533,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 +6892,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 +7236,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 +7732,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..cec79828e1 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-16 13:26+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,27 @@ 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 "" +"New fields cannot be required initially. Require them in a separate " +"migration step." +msgstr "" +"Les nouveaux champs ne peuvent pas être requis initialement. Exigez-les dans " +"une étape de migration séparée." + +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 +6545,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 +6907,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 +7743,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..e3cb9b2351 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-16 13:26+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,27 @@ 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 "" +"New fields cannot be required initially. Require them in a separate " +"migration step." +msgstr "" +"I nuovi campi non possono essere inizialmente obbligatori. Richiedili in un " +"passaggio di migrazione separato." + +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 +6516,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 +7224,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 +7415,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 +7715,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}." diff --git a/src/onegov/org/views/directory.py b/src/onegov/org/views/directory.py index a1ad6f50a0..530d421781 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 @@ -202,6 +203,10 @@ 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) @@ -233,15 +238,15 @@ def handle_edit_directory( 'The requested change cannot be performed, ' 'as it is incompatible with existing entries' )) + alert_migration_errors(migration, request) else: if not request.params.get('confirm'): form.action += '&confirm=1' 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 @@ -298,6 +303,49 @@ def handle_edit_directory( } +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 migration.added_required_fields(): + request.alert( + _('New fields cannot be required initially. Require ' + 'them in a separate migration step.') + ) + + 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] + + 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, diff --git a/src/onegov/town6/templates/directory_form.pt b/src/onegov/town6/templates/directory_form.pt index 01295f0f09..605f9285e6 100644 --- a/src/onegov/town6/templates/directory_form.pt +++ b/src/onegov/town6/templates/directory_form.pt @@ -45,6 +45,15 @@
  • Changed: ${field}
  • +
  • + Added: ${field} - ${option} +
  • +
  • + Removed: ${field} - ${option} +
  • +
  • + Renamed: ${old[0]}: ${old[1]} ${new[0]}: ${new[1]} +
  • diff --git a/tests/onegov/directory/test_migration.py b/tests/onegov/directory/test_migration.py index 3140e40149..e4eb8b9ff8 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, Any -from typing import TYPE_CHECKING if TYPE_CHECKING: from sqlalchemy.orm import Session @@ -386,3 +386,389 @@ def test_directory_migration(session: Session) -> None: 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", + "", + ), + ], +) +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', + lead="The town's zoos", + structure=structure, + configuration=DirectoryConfiguration( + title='[Main/Name]', order=['Main/Name'] + ), + ) + zoo = zoos.add( + values={ + 'main_name': 'Luzerner Zoo', + f'general_{label}': '', # No value is set + } + ) + + 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 + + +def test_directory_migration_for_select(session: Session) -> None: + """ + Adding, removing, renaming or change a radio option or checkbox option + """ + 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='Desert', + general_animals=['Snakes'], + ) + ) + + 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.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' + assert zoo.values['general_animals'] == ['Snakes'] + + # 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 multiple -> not possible + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctica + ( ) Grasland + ( ) Great Desert + ( ) Marina + ( ) Tropical Rainforest + ( ) Tundra + Animals = + [ ] 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', 'Marine'): ('General/Landscapes', 'Marina'), + } + assert migration.possible + + migration.execute() + assert zoo.values['general_landscapes'] == 'Great Desert' + assert zoo.values['general_animals'] == ['Snakes'] + + # remove 1, 2 or more options + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + ( ) Arctic + ( ) Grasland + ( ) Marina + ( ) Tundra + Animals = + [ ] Gnus + """ + 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'] == [] + + # type change radio -> checkbox + new_structure = """ + # Main + Name *= ___ + # General + Landscapes = + [ ] Arctic + [ ] Grasland + [ ] Marina + [ ] Tundra + Animals = + [ ] Gnus + """ + 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 = + [ ] Arctic + [ ] Grasland + [ ] Marina + [ ] Tundra + Animals = + ( ) Gnus + """ + 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 ca36b061f2..724a300bc8 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,122 @@ 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]' + 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'] + page = page.form.submit() + assert not page.pyquery('.alert-box') + + # 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 + """) + page = page.form.submit() + page.forms['main-form'].submit() # confirm migration + + # rename multiple 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 Hole + [ ] Gummi Bears + [ ] Chocolate + [ ] Lolipop + [ ] Ice cream + """) + 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 + + # 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 + ( ) No + ( ) Not sure + Choice = + [ ] Donut Hole + [ ] Chocolate + [ ] Ice cream + """) + page = page.form.submit() + 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 + 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 "radio" ' + 'konvertiert werden') in page