diff --git a/AKModel/forms.py b/AKModel/forms.py index 9ec3d24ec5f73148be7f135b8aa9f97965b3a4cc..eb60a04071ba1267681176ccd32d7e79e6437492 100644 --- a/AKModel/forms.py +++ b/AKModel/forms.py @@ -4,13 +4,17 @@ Central and admin forms import csv import io +import json from django import forms +from django.core.exceptions import ValidationError from django.forms.utils import ErrorList from django.utils.translation import gettext_lazy as _ +from jsonschema.exceptions import best_match from AKModel.availability.forms import AvailabilitiesFormMixin from AKModel.models import Event, AKCategory, AKRequirement, Room, AKType +from AKModel.utils import construct_schema_validator class DateTimeInput(forms.DateInput): @@ -286,8 +290,66 @@ class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm): class JSONScheduleImportForm(AdminIntermediateForm): """Form to import an AK schedule from a json file.""" json_data = forms.CharField( - required=True, + required=False, widget=forms.Textarea, label=_("JSON data"), help_text=_("JSON data from the scheduling solver"), ) + + json_file = forms.FileField( + required=False, + label=_("File with JSON data"), + help_text=_("File with JSON data from the scheduling solver"), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.json_schema_validator = construct_schema_validator( + schema="solver-output.schema.json" + ) + + def _check_json_data(self, data: str): + try: + schedule = json.loads(data) + except json.JSONDecodeError as ex: + raise ValidationError(_("Cannot decode as JSON"), "invalid") from ex + + error = best_match(self.json_schema_validator.iter_errors(schedule)) + if error: + raise ValidationError( + _("Invalid JSON format: %(msg)s at %(error_path)s"), + "invalid", + params={ + "msg": error.message, + "error_path": error.json_path + } + ) from error + + return schedule + + def clean(self): + cleaned_data = super().clean() + if cleaned_data.get("json_file") and cleaned_data.get("json_data"): + err = ValidationError( + _("Please enter data as a file OR via text, not both."), "invalid" + ) + self.add_error("json_data", err) + self.add_error("json_file", err) + elif not (cleaned_data.get("json_file") or cleaned_data.get("json_data")): + err = ValidationError( + _("No data entered. Please enter data as a file or via text."), "invalid" + ) + self.add_error("json_data", err) + self.add_error("json_file", err) + else: + source_field = "json_data" + data = cleaned_data.get(source_field) + if not data: + source_field = "json_file" + with cleaned_data.get(source_field).open() as ff: + data = ff.read() + try: + cleaned_data["data"] = self._check_json_data(data) + except ValidationError as ex: + self.add_error(source_field, ex) + return cleaned_data diff --git a/AKModel/locale/de_DE/LC_MESSAGES/django.po b/AKModel/locale/de_DE/LC_MESSAGES/django.po index 00409411289c42f855f3f8a048a4175d87424800..1c556efdaa0f51d695aad19a22b451a5b041d8d6 100644 --- a/AKModel/locale/de_DE/LC_MESSAGES/django.po +++ b/AKModel/locale/de_DE/LC_MESSAGES/django.po @@ -180,60 +180,60 @@ msgstr "AK-Kategorie, deren Verfügbarkeit hier abgebildet wird" msgid "Availabilities" msgstr "Verfügbarkeiten" -#: AKModel/forms.py:78 +#: AKModel/forms.py:80 msgid "Copy ak requirements and ak categories of existing event" msgstr "AK-Anforderungen und AK-Kategorien eines existierenden Events kopieren" -#: AKModel/forms.py:79 +#: AKModel/forms.py:81 msgid "You can choose what to copy in the next step" msgstr "" "Im nächsten Schritt kann ausgewählt werden, was genau kopiert werden soll" -#: AKModel/forms.py:93 +#: AKModel/forms.py:95 msgid "Copy ak categories" msgstr "AK-Kategorien kopieren" -#: AKModel/forms.py:100 +#: AKModel/forms.py:102 msgid "Copy ak requirements" msgstr "AK-Anforderungen kopieren" -#: AKModel/forms.py:107 +#: AKModel/forms.py:109 msgid "Copy types" msgstr "Typen kopieren" -#: AKModel/forms.py:133 +#: AKModel/forms.py:135 msgid "Copy dashboard buttons" msgstr "Dashboard-Buttons kopieren" -#: AKModel/forms.py:174 +#: AKModel/forms.py:176 msgid "# next AKs" msgstr "# nächste AKs" -#: AKModel/forms.py:175 +#: AKModel/forms.py:177 msgid "How many next AKs should be shown on a slide?" msgstr "Wie viele nächste AKs sollen auf einer Folie angezeigt werden?" -#: AKModel/forms.py:178 +#: AKModel/forms.py:180 msgid "Presentation only?" msgstr "Nur Vorstellung?" -#: AKModel/forms.py:180 AKModel/forms.py:187 +#: AKModel/forms.py:182 AKModel/forms.py:189 msgid "Yes" msgstr "Ja" -#: AKModel/forms.py:180 AKModel/forms.py:187 +#: AKModel/forms.py:182 AKModel/forms.py:189 msgid "No" msgstr "Nein" -#: AKModel/forms.py:182 +#: AKModel/forms.py:184 msgid "Restrict AKs to those that asked for chance to be presented?" msgstr "AKs auf solche, die um eine Vorstellung gebeten haben, einschränken?" -#: AKModel/forms.py:185 +#: AKModel/forms.py:187 msgid "Space for notes in wishes?" msgstr "Platz für Notizen bei den Wünschen?" -#: AKModel/forms.py:189 +#: AKModel/forms.py:191 msgid "" "Create symbols indicating space to note down owners and timeslots for " "wishes, e.g., to be filled out on a touch screen while presenting?" @@ -246,7 +246,7 @@ msgstr "" msgid "Default Slots" msgstr "Standardslots" -#: AKModel/forms.py:200 +#: AKModel/forms.py:202 msgid "" "Click and drag to add default slots, double-click to delete. Or use the " "start and end inputs to add entries to the calendar view." @@ -255,11 +255,11 @@ msgstr "" "Einträge zu löschen. Oder Start- und End-Eingabe verwenden, um der " "Kalenderansicht neue Einträge hinzuzufügen." -#: AKModel/forms.py:216 +#: AKModel/forms.py:218 msgid "New rooms" msgstr "Neue Räume" -#: AKModel/forms.py:217 +#: AKModel/forms.py:219 msgid "" "Enter room details in CSV format. Required colum is \"name\", optional " "colums are \"location\", \"capacity\", and \"url\" for online/hybrid rooms. " @@ -269,26 +269,51 @@ msgstr "" "Spalten sind \"location\", \"capacity\", und \"url\" for Online-/" "HybridräumeTrennzeichen: Semikolon" -#: AKModel/forms.py:223 +#: AKModel/forms.py:225 msgid "Default availabilities?" msgstr "Standardverfügbarkeiten?" -#: AKModel/forms.py:224 +#: AKModel/forms.py:226 msgid "Create default availabilities for all rooms?" msgstr "Standardverfügbarkeiten für alle Räume anlegen?" -#: AKModel/forms.py:240 +#: AKModel/forms.py:242 msgid "CSV must contain a name column" msgstr "CSV muss eine name-Spalte enthalten" -#: AKModel/forms.py:291 +#: AKModel/forms.py:293 msgid "JSON data" msgstr "JSON-Daten" -#: AKModel/forms.py:292 +#: AKModel/forms.py:294 msgid "JSON data from the scheduling solver" msgstr "JSON-Daten, die der scheduling-solver produziert hat" +#: AKModel/forms.py:299 +msgid "File with JSON data" +msgstr "Datei mit JSON-Daten" + +#: AKModel/forms.py:300 +msgid "File with JSON data from the scheduling solver" +msgstr "Datei mit JSON-Daten, die der scheduling-solver produziert hat" + +#: AKModel/forms.py:307 +msgid "Cannot decode as JSON" +msgstr "Dekodierung als JSON fehlgeschlagen" + +#: AKModel/forms.py:311 +#, python-format +msgid "Invalid JSON format: %(msg)s at %(error_path)s" +msgstr "Ungültige JSON-Eingabe: %(msg)s bei %(error_path)s" + +#: AKModel/forms.py:321 +msgid "Please enter data as a file OR via text, not both." +msgstr "Gib die Daten bitte als Datei oder als Text ein, nicht beides." + +#: AKModel/forms.py:327 +msgid "No data entered. Please enter data as a file or via text." +msgstr "Keine Daten eingegeben. Gib die Daten bitte als Datei oder als Text ein." + #: AKModel/metaviews/admin.py:156 AKModel/models.py:141 msgid "Start" msgstr "Start" @@ -478,6 +503,14 @@ msgstr "" msgid "Events" msgstr "Events" +#: AKModel/models.py:427 +msgid "Cannot parse malformed JSON input." +msgstr "Kann fehlerhafte JSON-Eingabe nicht verarbeiten" + +#: AKModel/models.py:430 +msgid "Data has changed since the export. Reexport and run the solver again." +msgstr "Seit dem Export wurden die Daten verändert. Wiederhole den Export und führe den Solver erneut aus." + #: AKModel/models.py:457 #, python-brace-format msgid "AK {ak_name} is not assigned any timeslot by the solver" @@ -1056,6 +1089,7 @@ msgid "Logout" msgstr "Ausloggen" #: AKModel/templates/admin/AKModel/action_intermediate.html:23 +#: AKModel/templates/admin/AKModel/import_json.html:23 msgid "Confirm" msgstr "Bestätigen" @@ -1063,6 +1097,7 @@ msgstr "Bestätigen" #: AKModel/templates/admin/AKModel/event_wizard/import.html:27 #: AKModel/templates/admin/AKModel/event_wizard/settings.html:32 #: AKModel/templates/admin/AKModel/event_wizard/start.html:28 +#: AKModel/templates/admin/AKModel/import_json.html:27 #: AKModel/templates/admin/AKModel/room_create.html:30 msgid "Cancel" msgstr "Abbrechen" @@ -1375,16 +1410,16 @@ msgid "Updated {u} slot(s). created {c} new slot(s) and deleted {d} slot(s)" msgstr "" "{u} Slot(s) aktualisiert, {c} Slot(s) hinzugefügt und {d} Slot(s) gelöscht" -#: AKModel/views/manage.py:257 +#: AKModel/views/manage.py:258 msgid "AK Schedule JSON Import" msgstr "AK-Plan JSON-Import" -#: AKModel/views/manage.py:265 +#: AKModel/views/manage.py:274 #, python-brace-format msgid "Successfully imported {n} slot(s)" msgstr "Erfolgreich {n} Slot(s) importiert" -#: AKModel/views/manage.py:271 +#: AKModel/views/manage.py:280 msgid "Importing an AK schedule failed! Reason: " msgstr "AK-Plan importieren fehlgeschlagen! Grund: " @@ -1463,6 +1498,10 @@ msgstr "Zu Anforderungen gehörige AKs anzeigen" msgid "Event Status" msgstr "Eventstatus" +#, python-format +#~ msgid "Invalid JSON format: field '%(field)s' is missing" +#~ msgstr "Ungültige JSON-Eingabe: das Feld '%(field)s' fehlt" + #~ msgid "Opening time for expression of interest." #~ msgstr "Öffnungszeitpunkt für die Angabe von Interesse an AKs." diff --git a/AKModel/models.py b/AKModel/models.py index 15da9170ff93ecb97144710203c2dfba8cf506a5..5e742434027b6c14c732f779d0f730175ee6d446 100644 --- a/AKModel/models.py +++ b/AKModel/models.py @@ -423,7 +423,9 @@ class Event(models.Model): yield from self.uniform_time_slots(slots_in_an_hour=slots_in_an_hour) @transaction.atomic - def schedule_from_json(self, schedule: str, *, check_for_data_inconsistency: bool = True) -> int: + def schedule_from_json( + self, schedule: str | dict[str, Any], *, check_for_data_inconsistency: bool = True + ) -> int: """Load AK schedule from a json string. :param schedule: A string that can be decoded to json, describing @@ -431,11 +433,15 @@ class Event(models.Model): following the output specification of the KoMa conference optimizer, cf. https://github.com/Die-KoMa/ak-plan-optimierung/wiki/Input-&-output-format """ - schedule = json.loads(schedule) + if isinstance(schedule, str): + schedule = json.loads(schedule) export_dict = self.as_json_dict() + if "input" not in schedule or "scheduled_aks" not in schedule: + raise ValueError(_("Cannot parse malformed JSON input.")) + if check_for_data_inconsistency and schedule["input"] != export_dict: - raise ValueError("Data has changed since the export. Reexport and run the solver again.") + raise ValueError(_("Data has changed since the export. Reexport and run the solver again.")) slots_in_an_hour = schedule["input"]["timeslots"]["info"]["duration"] diff --git a/AKModel/templates/admin/AKModel/import_json.html b/AKModel/templates/admin/AKModel/import_json.html new file mode 100644 index 0000000000000000000000000000000000000000..737331e89f5614b9ef22fd68cea2f669fc79f91b --- /dev/null +++ b/AKModel/templates/admin/AKModel/import_json.html @@ -0,0 +1,30 @@ +{% extends "admin/base_site.html" %} +{% load tags_AKModel %} + +{% load i18n %} +{% load django_bootstrap5 %} +{% load fontawesome_6 %} + + +{% block title %}{{event}}: {{ title }}{% endblock %} + +{% block content %} + {% block action_preview %} + <p> + {{ preview|linebreaksbr }} + </p> + {% endblock %} + + <form enctype="multipart/form-data" method="post">{% csrf_token %} + {% bootstrap_form form %} + + <div class="float-end"> + <button type="submit" class="save btn btn-success" value="Submit"> + {% fa6_icon "check" 'fas' %} {% trans "Confirm" %} + </button> + </div> + <a href="javascript:history.back()" class="btn btn-info"> + {% fa6_icon "times" 'fas' %} {% trans "Cancel" %} + </a> + </form> +{% endblock %} diff --git a/AKModel/views/manage.py b/AKModel/views/manage.py index 3acb05fd29a6e91cd17f45e9ed43d889a67da22c..1a7f1930b079edc4061ac5bc0a145466640bd574 100644 --- a/AKModel/views/manage.py +++ b/AKModel/views/manage.py @@ -253,12 +253,13 @@ class AKScheduleJSONImportView(EventSlugMixin, IntermediateAdminView): """ View: Import an AK schedule from a json file that can be pasted into this view. """ + template_name = "admin/AKModel/import_json.html" form_class = JSONScheduleImportForm title = _("AK Schedule JSON Import") def form_valid(self, form): try: - number_of_slots_changed = self.event.schedule_from_json(form.data["json_data"]) + number_of_slots_changed = self.event.schedule_from_json(form.cleaned_data["data"]) messages.add_message( self.request, messages.SUCCESS,