diff --git a/src/onegov/event/collections/occurrences.py b/src/onegov/event/collections/occurrences.py index 7f2fd9b0e5..172f40c38e 100644 --- a/src/onegov/event/collections/occurrences.py +++ b/src/onegov/event/collections/occurrences.py @@ -1,6 +1,7 @@ from collections import defaultdict from datetime import date, timedelta, datetime + import sqlalchemy from dateutil.relativedelta import relativedelta from enum import Enum diff --git a/src/onegov/form/__init__.py b/src/onegov/form/__init__.py index 66483687f8..1855260c2b 100644 --- a/src/onegov/form/__init__.py +++ b/src/onegov/form/__init__.py @@ -20,9 +20,12 @@ from onegov.form.integration import FormApp from onegov.form.models import ( FormDefinition, + SurveyDefinition, FormFile, FormSubmission, + SurveySubmission, FormRegistrationWindow, + SurveySubmissionWindow, PendingFormSubmission, CompleteFormSubmission ) @@ -58,6 +61,9 @@ 'parse_form', 'parse_formcode', 'PendingFormSubmission', + 'SurveyDefinition', + 'SurveySubmissionWindow', + 'SurveySubmission', 'render_field', 'WTFormsClassBuilder', ) diff --git a/src/onegov/form/assets/js/snippets.jsx b/src/onegov/form/assets/js/snippets.jsx index faf12359e2..c193e9b740 100644 --- a/src/onegov/form/assets/js/snippets.jsx +++ b/src/onegov/form/assets/js/snippets.jsx @@ -13,6 +13,7 @@ var FormSnippets = React.createClass({
{this.props.snippets.map(function(snippet, ix) { + var id = 'formcode-snippet-' + snippet[2].toLowerCase().replace(/[^a-z0-9]/g, '-'); return snippet[1] !== null && ( ) || ( -
+
{snippet[0]}
); @@ -33,7 +34,7 @@ var FormSnippets = React.createClass({
); } -}); +}); // Renders a single formsnippet and handles the insertion logic var FormSnippet = React.createClass({ @@ -61,9 +62,10 @@ var FormSnippet = React.createClass({ }, render: function() { var name = this.props.snippet[0]; + var id = 'formcode-snippet-' + this.props.snippet[2].toLowerCase().replace(/[^a-z0-9]/g, '-'); return ( -
+
{name}
{ (this.props.snippet[1] !== '#' && this.props.snippet[1] !== '<< >>') && diff --git a/src/onegov/form/collection.py b/src/onegov/form/collection.py index 09391c3623..9bffcf3673 100644 --- a/src/onegov/form/collection.py +++ b/src/onegov/form/collection.py @@ -19,6 +19,11 @@ from typing import overload, Any, Literal, TYPE_CHECKING + +from onegov.form.models.definition import SurveyDefinition +from onegov.form.models.submission import SurveySubmission +from onegov.form.models.survey_window import SurveySubmissionWindow + if TYPE_CHECKING: from collections.abc import Callable, Collection, Iterator from onegov.form import Form @@ -28,10 +33,16 @@ from typing_extensions import TypeAlias SubmissionHandler: TypeAlias = Callable[[Query[FormSubmission]], Any] + SurveySubmissionHandler: TypeAlias = Callable[[Query[SurveySubmission]], + Any] RegistrationWindowHandler: TypeAlias = Callable[ [Query[FormRegistrationWindow]], Any ] + SubmissionWindowHandler: TypeAlias = Callable[ + [Query[SurveySubmissionWindow]], + Any + ] class FormCollection: @@ -604,3 +615,384 @@ def query(self) -> 'Query[FormRegistrationWindow]': query = query.filter(FormRegistrationWindow.name == self.name) return query + + +class SurveyDefinitionCollection: + """ Manages a collection of surveys. """ + + def __init__(self, session: 'Session'): + self.session = session + + def query(self) -> 'Query[SurveyDefinition]': + return self.session.query(SurveyDefinition) + + def add( + self, + title: str, + definition: str, + type: str = 'generic', + meta: dict[str, Any] | None = None, + content: dict[str, Any] | None = None, + name: str | None = None, + ) -> SurveyDefinition: + """ Add the given survey to the database. """ + + # look up the right class depending on the type + survey = SurveyDefinition.get_polymorphic_class( + type, SurveyDefinition)() + survey.name = name or normalize_for_url(title) + survey.title = title + survey.definition = definition + survey.meta = meta or {} + survey.content = content or {} + + # try to parse the survey (which will throw errors if there are + # problems) + assert survey.form_class + + self.session.add(survey) + self.session.flush() + + return survey + + def delete( + self, + name: str, + with_submissions: bool = False, + with_submission_windows: bool = False, + handle_submissions: 'SurveySubmissionHandler | None' = None, + handle_submission_windows: 'SubmissionWindowHandler | None' = None, + ) -> None: + """ Delete the given form. Only possible if there are no submissions + associated with it, or if ``with_submissions`` is True. + + Note that pending submissions are removed automatically, only complete + submissions have a bearing on ``with_submissions``. + + Pass two callbacks to handle additional logic before deleting the + objects. + """ + submissions = self.session.query(SurveySubmission) + submissions = submissions.filter(SurveySubmission.name == name) + + if not with_submissions: + submissions = submissions.filter( + SurveySubmission.state == 'pending') + + if handle_submissions: + handle_submissions(submissions) + + submissions.delete() + + if with_submission_windows: + submission_windows = self.session.query(SurveySubmissionWindow) + submission_windows = submission_windows.filter_by(name=name) + + if handle_submission_windows: + handle_submission_windows(submission_windows) + + submission_windows.delete() + self.session.flush() + + # this will fail if there are any submissions left + self.query().filter(SurveyDefinition.name == name).delete('fetch') + self.session.flush() + + def by_name(self, name: str) -> SurveyDefinition | None: + """ Returns the given form by name or None. """ + return self.query().filter(SurveyDefinition.name == name).first() + + +class SurveySubmissionCollection: + """ Manages a collection of survey submissions. """ + + def __init__(self, session: 'Session', name: str | None = None): + self.session = session + self.name = name + + def query(self) -> 'Query[SurveySubmission]': + query = self.session.query(SurveySubmission) + + if self.name is not None: + query = query.filter(SurveySubmission.name == self.name) + + return query + + def add( + self, + name: str | None, + form: 'Form', + state: 'SubmissionState', + id: UUID | None = None, + meta: dict[str, Any] | None = None, + ) -> SurveySubmission: + """ Takes a filled-out survey instance and stores the submission + in the database. The survey instance is expected to have a ``_source`` + parameter, which contains the source used to build the szrvey (as only + surveys with this source may be stored). + + This method expects the name of the survey definition stored in the + database. Use :meth:`add_external` to add a submissions whose + definition is not stored in the form_definitions table. + + """ + + assert hasattr(form, '_source') + + # this should happen way earlier, we just double check here + if state == 'complete': + assert form.validate(), "the given form doesn't validate" + else: + form.validate() + + if name is None: + definition = None + else: + definition = ( + self.session.query(SurveyDefinition) + .filter_by(name=name).one()) + + if definition is None: + submission_window = None + else: + submission_window = definition.current_submission_window + + # look up the right class depending on the type + submission_class = SurveySubmission.get_polymorphic_class( + state, SurveySubmission + ) + + submission = submission_class() + submission.id = id or uuid4() + submission.name = name + submission.state = state + submission.meta = meta or {} + submission.submission_window = submission_window + + # extensions are inherited from definitions + if definition: + assert not submission.extensions, """ + For submissions based on definitions, the extensions need + to be defined on the definition! + """ + submission.extensions = definition.extensions + + self.update(submission, form) + + self.session.add(submission) + self.session.flush() + + # whenever we add a form submission, we remove all the old ones + # which were never completed (this is way easier than having to use + # some kind of cronjob ;) + self.remove_old_pending_submissions( + older_than=datetime.utcnow() - timedelta(days=1) + ) + + return submission + + def complete_submission(self, submission: SurveySubmission) -> None: + """ Changes the state to 'complete', if the data is valid. """ + + assert submission.state == 'pending' + + if not submission.form_obj.validate(): + raise UnableToComplete() + + submission.state = 'complete' + + # by completing a submission we are changing it's polymorphic identity, + # which is something SQLAlchemy rightly warns us about. Since we know + # about it however (and deal with it using self.session.expunge below), + # we should ignore this (and only this) warning. + with warnings.catch_warnings(): + warnings.filterwarnings( + action='ignore', + message=r'Flushing object', + category=exc.SAWarning + ) + self.session.flush() + + self.session.expunge(submission) + + def update( + self, + submission: SurveySubmission, + form: 'Form', + exclude: 'Collection[str] | None ' = None + ) -> None: + """ Takes a submission and a survey and updates the submission data + as well as the files stored in a separate table. + + """ + assert submission.id and submission.state + + # ignore certain fields + exclude = set(exclude) if exclude else set() + exclude.add(form.meta.csrf_field_name) # never include the csrf token + + assert hasattr(form, '_source') + submission.definition = form._source + submission.data = { + k: v for k, v in form.data.items() if k not in exclude + } + submission.update_title(form) + + def remove_old_pending_submissions( + self, + older_than: datetime, + include_external: bool = False + ) -> None: + """ Removes all pending submissions older than the given date. The + date is expected to be in UTC! + + """ + + if older_than.tzinfo is None: + older_than = replace_timezone(older_than, 'UTC') + + submissions = self.query() + + # delete the ones that were never modified + submissions = submissions.filter(SurveySubmission.state == 'pending') + submissions = submissions.filter( + SurveySubmission.last_change < older_than) + + if not include_external: + submissions = submissions.filter(SurveySubmission.name != None) + + for submission in submissions: + self.session.delete(submission) + + def by_state(self, state: 'SubmissionState') -> 'Query[SurveySubmission]': + return self.query().filter(SurveySubmission.state == state) + + # FIXME: Why are we returning a list here? + def by_name(self, name: str) -> list[SurveySubmission]: + """ Return all submissions for the given form-name. """ + return self.query().filter(SurveySubmission.name == name).all() + + def by_id( + self, + id: UUID, + state: 'SubmissionState | None' = None, + current_only: bool = False + ) -> SurveySubmission | None: + """ Return the submission by id. + + :state: + Only if the submission matches the given state. + + :current_only: + Only if the submission is not older than one hour. + """ + query = self.query().filter(SurveySubmission.id == id) + + if state is not None: + query = query.filter(SurveySubmission.state == state) + + if current_only: + an_hour_ago = utcnow() - timedelta(hours=1) + query = query.filter(SurveySubmission.last_change >= an_hour_ago) + + return query.first() + + def delete(self, submission: SurveySubmission) -> None: + """ Deletes the given submission and all the files belonging to it. """ + self.session.delete(submission) + self.session.flush() + + +class SurveySubmissionWindowCollection( + GenericCollection[SurveySubmissionWindow] +): + + def __init__(self, session: 'Session', name: str | None = None): + super().__init__(session) + self.name = name + + @property + def model_class(self) -> type[SurveySubmissionWindow]: + return SurveySubmissionWindow + + def query(self) -> 'Query[SurveySubmissionWindow]': + query = super().query() + + if self.name: + query = query.filter(SurveySubmissionWindow.name == self.name) + + return query + + +class SurveyCollection: + """ Manages a collection of surveys and survey-submissions. """ + + def __init__(self, session: 'Session'): + self.session = session + + @property + def definitions(self) -> SurveyDefinitionCollection: + return SurveyDefinitionCollection(self.session) + + @property + def submissions(self) -> SurveySubmissionCollection: + return SurveySubmissionCollection(self.session) + + @property + def submission_windows(self) -> SurveySubmissionWindowCollection: + return SurveySubmissionWindowCollection(self.session) + + @overload + def scoped_submissions( + self, + name: str, + ensure_existance: Literal[False] + ) -> SurveySubmissionCollection: ... + + @overload + def scoped_submissions( + self, + name: str, + ensure_existance: bool = True + ) -> SurveySubmissionCollection | None: ... + + def scoped_submissions( + self, + name: str, + ensure_existance: bool = True + ) -> SurveySubmissionCollection | None: + if not ensure_existance or self.definitions.by_name(name): + return SurveySubmissionCollection(self.session, name) + return None + + # FIXME: This should use Intersection[HasSubmissionsCount] since we + # add a temporary attribute to the returned form definitions. + # But we have to wait until this feature is available + def get_definitions_with_submission_count( + self + ) -> 'Iterator[FormDefinition]': + """ Returns all form definitions and the number of submissions + belonging to those definitions, in a single query. + + The number of submissions is stored on the form definition under the + ``submissions_count`` attribute. + + Only submissions which are 'complete' are considered. + + """ + submissions_ = self.session.query( + FormSubmission.name, + func.count(FormSubmission.id).label('count') + ) + submissions_ = submissions_.filter(FormSubmission.state == 'complete') + submissions = submissions_.group_by(FormSubmission.name).subquery() + + definitions = self.session.query(FormDefinition, submissions.c.count) + definitions = definitions.outerjoin( + submissions, submissions.c.name == FormDefinition.name + ) + definitions = definitions.order_by(FormDefinition.name) + + for survey, submissions_count in definitions.all(): + survey.submissions_count = submissions_count or 0 + yield survey diff --git a/src/onegov/form/locale/de_ch/LC_MESSAGES/onegov.form.po b/src/onegov/form/locale/de_ch/LC_MESSAGES/onegov.form.po index ac64fea033..2142b4c50b 100644 --- a/src/onegov/form/locale/de_ch/LC_MESSAGES/onegov.form.po +++ b/src/onegov/form/locale/de_ch/LC_MESSAGES/onegov.form.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2023-08-17 11:48+0200\n" +"POT-Creation-Date: 2024-07-02 14:42+0200\n" "PO-Revision-Date: 2022-03-07 13:56+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -56,6 +56,9 @@ msgstr "E-Mail" msgid "Website" msgstr "Webseite" +msgid "Video Link" +msgstr "Video Link" + msgid "Comment" msgstr "Kommentar" @@ -141,7 +144,9 @@ msgstr "Der Syntax in der {line}. Zeile ist ungültig." msgid "" "The indentation on line {line} is not valid. Please use a multiple of 4 " "spaces" -msgstr "Die Einrückung in Zeile {line} ist nicht gültig. Bitte verwenden Sie ein Vielfaches von 4 Leerzeichen" +msgstr "" +"Die Einrückung in Zeile {line} ist nicht gültig. Bitte verwenden Sie ein " +"Vielfaches von 4 Leerzeichen" #, python-format msgid "The field '{label}' exists more than once." @@ -178,6 +183,14 @@ msgstr "" "Ungültiger Feldtyp für Feld '{label}'. Für Filter sind nur 'Auswahl' oder " "'Mehrfach-Auswahl' Felder erlaubt." +#, python-format +msgid "" +"Invalid field type for field '{label}'. Please use the plus-icon to add " +"allowed field types." +msgstr "" +"Ungültiger Feldtyp für Feld '{label}'. Bitte verwenden Sie das Plus-Symbol " +"um erlaubte Feldtypen hinzuzufügen." + msgid "Not a valid phone number." msgstr "Ungültige Telefonnummer." diff --git a/src/onegov/form/locale/fr_CH/LC_MESSAGES/onegov.form.po b/src/onegov/form/locale/fr_CH/LC_MESSAGES/onegov.form.po index 12eae5c894..07be845909 100644 --- a/src/onegov/form/locale/fr_CH/LC_MESSAGES/onegov.form.po +++ b/src/onegov/form/locale/fr_CH/LC_MESSAGES/onegov.form.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2023-08-17 11:48+0200\n" +"POT-Creation-Date: 2024-07-02 14:42+0200\n" "PO-Revision-Date: 2019-01-31 08:31+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -55,6 +55,9 @@ msgstr "E-mail" msgid "Website" msgstr "Site internet" +msgid "Video Link" +msgstr "" + msgid "Comment" msgstr "Commentaire" @@ -140,10 +143,9 @@ msgstr "La syntaxe de la ligne {line} est invalide" msgid "" "The indentation on line {line} is not valid. Please use a multiple of 4 " "spaces" -msgstr "L'indentation de la ligne {ligne} n'est pas valide. Veuillez utiliser un multiple de 4 " -"espaces" - - +msgstr "" +"L'indentation de la ligne {ligne} n'est pas valide. Veuillez utiliser un " +"multiple de 4 espaces" #, python-format msgid "The field '{label}' exists more than once." @@ -180,6 +182,12 @@ msgstr "" "Type de champ non valide pour le champ '{label}'. Pour les filtres seuls les " "champs 'sélection' ou 'sélection multiple' sont autorisés." +#, python-format +msgid "" +"Invalid field type for field '{label}'. Please use the plus-icon to add " +"allowed field types." +msgstr "" + msgid "Not a valid phone number." msgstr "N'est pas un numéro de téléphone valide." diff --git a/src/onegov/form/locale/it_CH/LC_MESSAGES/onegov.form.po b/src/onegov/form/locale/it_CH/LC_MESSAGES/onegov.form.po index ef89f9fd83..a861e80bbd 100644 --- a/src/onegov/form/locale/it_CH/LC_MESSAGES/onegov.form.po +++ b/src/onegov/form/locale/it_CH/LC_MESSAGES/onegov.form.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2023-08-17 11:48+0200\n" +"POT-Creation-Date: 2024-07-02 14:42+0200\n" "PO-Revision-Date: 2022-03-07 13:55+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: Italian\n" @@ -53,6 +53,9 @@ msgstr "E-Mail" msgid "Website" msgstr "Sito web" +msgid "Video Link" +msgstr "" + msgid "Comment" msgstr "Commento" @@ -138,7 +141,9 @@ msgstr "La sintassi sulla linea {line} non è valida." msgid "" "The indentation on line {line} is not valid. Please use a multiple of 4 " "spaces" -msgstr "L'indentazione della riga {linea} non è valida. Si prega di utilizzare un multiplo di 4 spazi" +msgstr "" +"L'indentazione della riga {linea} non è valida. Si prega di utilizzare un " +"multiplo di 4 spazi" #, python-format msgid "The field '{label}' exists more than once." @@ -175,6 +180,12 @@ msgstr "" "Tipo di campo non valido per il campo '{label}'. Per i filtri solo i campi " "'selezionare' o 'selezionare più volte' sono consentiti." +#, python-format +msgid "" +"Invalid field type for field '{label}'. Please use the plus-icon to add " +"allowed field types." +msgstr "" + msgid "Not a valid phone number." msgstr "Non è un numero di telefono valido." diff --git a/src/onegov/form/locale/rm_CH/LC_MESSAGES/onegov.form.po b/src/onegov/form/locale/rm_CH/LC_MESSAGES/onegov.form.po index 029723cdd4..80a6522e4d 100644 --- a/src/onegov/form/locale/rm_CH/LC_MESSAGES/onegov.form.po +++ b/src/onegov/form/locale/rm_CH/LC_MESSAGES/onegov.form.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2023-08-17 11:48+0200\n" +"POT-Creation-Date: 2024-07-02 14:42+0200\n" "PO-Revision-Date: 2017-06-28 12:03+0200\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: Romansh\n" @@ -16,6 +16,9 @@ msgstr "" "Generated-By: Lingua 3.12\n" "X-Generator: Poedit 2.0.2\n" +msgid "Not a valid email" +msgstr "" + msgid "Not a valid color." msgstr "" @@ -48,6 +51,9 @@ msgstr "" msgid "Website" msgstr "" +msgid "Video Link" +msgstr "" + msgid "Comment" msgstr "Commentar" @@ -72,6 +78,9 @@ msgstr "" msgid "Multiple Choice" msgstr "" +msgid "Subfields depending on choice" +msgstr "" + msgid "Files" msgstr "" @@ -153,11 +162,18 @@ msgid "" "defined." msgstr "" +#, python-format msgid "" "Invalid field type for field '{label}'. For filters only 'select' or " "'multiple select' fields are allowed." msgstr "" +#, python-format +msgid "" +"Invalid field type for field '{label}'. Please use the plus-icon to add " +"allowed field types." +msgstr "" + msgid "Not a valid phone number." msgstr "Numer da telefon nunvalaivel." diff --git a/src/onegov/form/models/__init__.py b/src/onegov/form/models/__init__.py index 864e2d96a9..fc7edde1a4 100644 --- a/src/onegov/form/models/__init__.py +++ b/src/onegov/form/models/__init__.py @@ -1,9 +1,12 @@ from onegov.form.models.definition import FormDefinition from onegov.form.models.submission import FormSubmission +from onegov.form.models.definition import SurveyDefinition +from onegov.form.models.submission import SurveySubmission from onegov.form.models.submission import PendingFormSubmission from onegov.form.models.submission import CompleteFormSubmission from onegov.form.models.submission import FormFile from onegov.form.models.registration_window import FormRegistrationWindow +from onegov.form.models.survey_window import SurveySubmissionWindow __all__ = ( 'FormDefinition', @@ -12,4 +15,7 @@ 'PendingFormSubmission', 'CompleteFormSubmission', 'FormFile', + 'SurveyDefinition', + 'SurveySubmission', + 'SurveySubmissionWindow' ) diff --git a/src/onegov/form/models/definition.py b/src/onegov/form/models/definition.py index 56099ca27a..5527470746 100644 --- a/src/onegov/form/models/definition.py +++ b/src/onegov/form/models/definition.py @@ -1,11 +1,14 @@ +from wtforms import RadioField from onegov.core.orm import Base, observes from onegov.core.orm.mixins import ( ContentMixin, TimestampMixin, content_property, dict_markup_property, dict_property, meta_property) from onegov.core.utils import normalize_for_url from onegov.file import MultiAssociatedFiles -from onegov.form.models.submission import FormSubmission +from onegov.form.fields import MultiCheckboxField +from onegov.form.models.submission import FormSubmission, SurveySubmission from onegov.form.models.registration_window import FormRegistrationWindow +from onegov.form.models.survey_window import SurveySubmissionWindow from onegov.form.parser import parse_form from onegov.form.utils import hash_definition from onegov.form.extensions import Extendable @@ -15,17 +18,21 @@ # type gets shadowed in the model so we need an alias -from typing import Type, TYPE_CHECKING +from typing import Type, TYPE_CHECKING, Any + if TYPE_CHECKING: + from uuid import UUID from datetime import date from onegov.form import Form from onegov.form.types import SubmissionState from onegov.pay.types import PaymentMethod from typing_extensions import Self + from onegov.core.request import CoreRequest class FormDefinition(Base, ContentMixin, TimestampMixin, Extendable, MultiAssociatedFiles): + """ Defines a form stored in the database. """ __tablename__ = 'forms' @@ -214,3 +221,176 @@ def for_new_name(self, name: str) -> 'Self': payment_method=self.payment_method, created=self.created ) + + +class SurveyDefinition(Base, ContentMixin, TimestampMixin, + Extendable): + """ Defines a survey stored in the database. """ + + __tablename__ = 'surveys' + + # # for better compatibility with generic code that expects an id + # # this is just an alias for `name`, which is our primary key + @hybrid_property + def id(self) -> str: + return self.name + + #: the name of the form (key, part of the url) + name: 'Column[str]' = Column(Text, nullable=False, primary_key=True) + + #: the title of the form + title: 'Column[str]' = Column(Text, nullable=False) + + #: the form as parsable string + definition: 'Column[str]' = Column(Text, nullable=False) + + #: the group to which this resource belongs to (may be any kind of string) + group: 'Column[str | None]' = Column(Text, nullable=True) + + #: The normalized title for sorting + order: 'Column[str]' = Column(Text, nullable=False, index=True) + + #: the checksum of the definition, forms and submissions with matching + #: checksums are guaranteed to have the exact same definition + checksum: 'Column[str]' = Column(Text, nullable=False) + + #: link between surveys and submissions + submissions: 'relationship[list[SurveySubmission]]' = relationship( + SurveySubmission, + backref='survey' + ) + + #: link between surveys and submission windows + submission_windows: 'relationship[list[SurveySubmissionWindow]]' + submission_windows = relationship( + SurveySubmissionWindow, + backref='survey', + order_by='SurveySubmissionWindow.start', + cascade='all, delete-orphan' + ) + + current_submission_window: 'relationship[SurveySubmissionWindow | None]' + current_submission_window = relationship( + 'SurveySubmissionWindow', viewonly=True, uselist=False, + primaryjoin="""and_( + SurveySubmissionWindow.name == SurveyDefinition.name, + SurveySubmissionWindow.id == select(( + SurveySubmissionWindow.id, + )).where( + SurveySubmissionWindow.name == SurveyDefinition.name + ).order_by( + func.least( + cast( + func.now().op('AT TIME ZONE')( + SurveySubmissionWindow.timezone + ), Date + ).op('<->')(SurveySubmissionWindow.start), + cast( + func.now().op('AT TIME ZONE')( + SurveySubmissionWindow.timezone + ), Date + ).op('<->')(SurveySubmissionWindow.end) + ) + ).limit(1) + )""" + ) + + #: lead text describing the survey + lead: dict_property[str | None] = meta_property() + + #: content associated with the Survey + text: dict_property[str | None] = content_property() + + #: extensions + extensions: dict_property[list[str]] = meta_property(default=list) + + @property + def form_class(self) -> Type['Form']: + """ Parses the survey definition and returns a form class. """ + + return self.extend_form_class( + parse_form(self.definition), + self.extensions or [], + ) + + @observes('definition') + def definition_observer(self, definition: str) -> None: + self.checksum = hash_definition(definition) + + @observes('title') + def title_observer(self, title: str) -> None: + self.order = normalize_for_url(title) + + def has_submissions( + self, + with_state: 'SubmissionState | None' = None + ) -> bool: + + session = object_session(self) + query = session.query(SurveySubmission.id) + query = query.filter(SurveySubmission.name == self.name) + + if with_state is not None: + query = query.filter(SurveySubmission.state == with_state) + + return session.query(query.exists()).scalar() + + def add_submission_window( + self, + start: 'date', + end: 'date', + *, + enabled: bool = True, + timezone: str = 'Europe/Zurich', + ) -> SurveySubmissionWindow: + + window = SurveySubmissionWindow( + start=start, + end=end, + enabled=enabled, + timezone=timezone, + ) + self.submission_windows.append(window) + return window + + def get_results(self, request: 'CoreRequest', sw_id: ('UUID | None') = None + ) -> dict[str, Any]: + """ Returns the results of the survey. """ + + form = request.get_form(self.form_class) + + all_fields = form._fields + all_fields.pop('csrf_token', None) + fields = all_fields.values() + q = request.session.query(SurveySubmission) + q = q.filter_by(name=self.name) + if sw_id: + submissions = q.filter_by(submission_window_id=sw_id).all() + else: + submissions = q.all() + results: dict[str, Any] = {} + + aggregated = ['MultiCheckboxField', 'RadioField'] + + for field in fields: + if field.type not in aggregated: + results[field.id] = [] + elif isinstance(field, (MultiCheckboxField, RadioField)): + results[field.id] = {} + for choice in field.choices: + results[field.id][choice[0]] = 0 + + for submission in submissions: + for field in fields: + if submission.data.get(field.id): + if field.type not in aggregated: + results[field.id].append( + str(submission.data.get(field.id))) + else: + if isinstance(field, (MultiCheckboxField, RadioField)): + for choice in field.choices: + if choice[0] in submission.data.get(field.id, + []): + results[field.id][choice[0]] += 1 + + return results diff --git a/src/onegov/form/models/registration_window.py b/src/onegov/form/models/registration_window.py index 13a071a46f..f3c9b56872 100644 --- a/src/onegov/form/models/registration_window.py +++ b/src/onegov/form/models/registration_window.py @@ -130,6 +130,25 @@ def localized_end(self) -> 'datetime': ), self.timezone, 'up' ) + def disassociate(self) -> None: + """ Disassociates all records linked to this window. """ + + for submission in self.submissions: + submission.disclaim() + submission.registration_window_id = None + + @property + def in_the_future(self) -> bool: + return sedate.utcnow() <= self.localized_start + + @property + def in_the_past(self) -> bool: + return self.localized_end <= sedate.utcnow() + + @property + def in_the_present(self) -> bool: + return self.localized_start <= sedate.utcnow() <= self.localized_end + def accepts_submissions(self, required_spots: int = 1) -> bool: assert required_spots > 0 @@ -147,12 +166,26 @@ def accepts_submissions(self, required_spots: int = 1) -> bool: return self.available_spots >= required_spots - def disassociate(self) -> None: - """ Disassociates all records linked to this window. """ + @property + def next_submission(self) -> FormSubmission | None: + """ Returns the submission next in line. In other words, the next + submission in order of first come, first serve. - for submission in self.submissions: - submission.disclaim() - submission.registration_window_id = None + """ + + q = object_session(self).query(FormSubmission) + q = q.filter(FormSubmission.registration_window_id == self.id) + q = q.filter(FormSubmission.state == 'complete') + q = q.filter(or_( + FormSubmission.claimed == None, + and_( + FormSubmission.claimed > 0, + FormSubmission.claimed < FormSubmission.spots, + ) + )) + q = q.order_by(FormSubmission.created) + + return q.first() @property def available_spots(self) -> int: @@ -193,36 +226,3 @@ def requested_spots(self) -> int: WHERE registration_window_id = :id AND submissions.state = 'complete' """), {'id': self.id}).scalar() or 0 - - @property - def next_submission(self) -> FormSubmission | None: - """ Returns the submission next in line. In other words, the next - submission in order of first come, first serve. - - """ - - q = object_session(self).query(FormSubmission) - q = q.filter(FormSubmission.registration_window_id == self.id) - q = q.filter(FormSubmission.state == 'complete') - q = q.filter(or_( - FormSubmission.claimed == None, - and_( - FormSubmission.claimed > 0, - FormSubmission.claimed < FormSubmission.spots, - ) - )) - q = q.order_by(FormSubmission.created) - - return q.first() - - @property - def in_the_future(self) -> bool: - return sedate.utcnow() <= self.localized_start - - @property - def in_the_past(self) -> bool: - return self.localized_end <= sedate.utcnow() - - @property - def in_the_present(self) -> bool: - return self.localized_start <= sedate.utcnow() <= self.localized_end diff --git a/src/onegov/form/models/submission.py b/src/onegov/form/models/submission.py index 07fb3c4d32..420ec9d303 100644 --- a/src/onegov/form/models/submission.py +++ b/src/onegov/form/models/submission.py @@ -31,6 +31,7 @@ from datetime import datetime from onegov.form import Form from onegov.form.models import FormDefinition, FormRegistrationWindow + from onegov.form.models import SurveyDefinition, SurveySubmissionWindow from onegov.form.types import RegistrationState, SubmissionState from onegov.pay import Payment, PaymentError, PaymentProvider, Price from onegov.pay.types import PaymentMethod @@ -39,7 +40,8 @@ class FormSubmission(Base, TimestampMixin, Payable, AssociatedFiles, Extendable): - """ Defines a submitted form in the database. """ + + """ Defines a submitted form of any kind in the database. """ __tablename__ = 'submissions' @@ -80,11 +82,6 @@ class FormSubmission(Base, TimestampMixin, Payable, AssociatedFiles, #: metadata about this submission meta: 'Column[dict[str, Any]]' = Column(JSON, nullable=False) - #: Additional information about the submitee - submitter_name: dict_property[str | None] = meta_property() - submitter_address: dict_property[str | None] = meta_property() - submitter_phone: dict_property[str | None] = meta_property() - #: the submission data data: 'Column[dict[str, Any]]' = Column(JSON, nullable=False) @@ -133,10 +130,6 @@ class FormSubmission(Base, TimestampMixin, Payable, AssociatedFiles, form: relationship[FormDefinition | None] registration_window: relationship[FormRegistrationWindow | None] - __mapper_args__ = { - "polymorphic_on": 'state' - } - __table_args__ = ( CheckConstraint( 'COALESCE(claimed, 0) <= spots', @@ -144,14 +137,6 @@ class FormSubmission(Base, TimestampMixin, Payable, AssociatedFiles, ), ) - @property - def payable_reference(self) -> str: - assert self.received is not None - if self.name: - return f'{self.name}/{self.title}@{self.received.isoformat()}' - else: - return f'{self.title}@{self.received.isoformat()}' - @property def form_class(self) -> type['Form']: """ Parses the form definition and returns a form class. """ @@ -166,34 +151,6 @@ def form_obj(self) -> 'Form': """ Returns a form instance containing the submission data. """ return self.form_class(data=self.data) - if TYPE_CHECKING: - # HACK: hybrid_property won't work otherwise until 2.0 - registration_state: Column[RegistrationState | None] - else: - @hybrid_property - def registration_state(self) -> 'RegistrationState | None': - if not self.spots: - return None - if self.claimed is None: - return 'open' - if self.claimed == 0: - return 'cancelled' - if self.claimed == self.spots: - return 'confirmed' - if self.claimed < self.spots: - return 'partial' - return None - - @registration_state.expression # type:ignore[no-redef] - def registration_state(cls): - return case(( - (cls.spots == 0, None), - (cls.claimed == None, 'open'), - (cls.claimed == 0, 'cancelled'), - (cls.claimed == cls.spots, 'confirmed'), - (cls.claimed < cls.spots, 'partial') - ), else_=None) - def get_email_field_data(self, form: 'Form | None' = None) -> str | None: form = form or self.form_obj @@ -241,6 +198,50 @@ def update_title(self, form: 'Form') -> None: for id in title_fields )) + #: Additional information about the submitee + submitter_name: dict_property[str | None] = meta_property() + submitter_address: dict_property[str | None] = meta_property() + submitter_phone: dict_property[str | None] = meta_property() + __mapper_args__ = { + "polymorphic_on": 'state' + } + + if TYPE_CHECKING: + # HACK: hybrid_property won't work otherwise until 2.0 + registration_state: Column[RegistrationState | None] + else: + @hybrid_property + def registration_state(self) -> 'RegistrationState | None': + if not self.spots: + return None + if self.claimed is None: + return 'open' + if self.claimed == 0: + return 'cancelled' + if self.claimed == self.spots: + return 'confirmed' + if self.claimed < self.spots: + return 'partial' + return None + + @registration_state.expression # type:ignore[no-redef] + def registration_state(cls): + return case(( + (cls.spots == 0, None), + (cls.claimed == None, 'open'), + (cls.claimed == 0, 'cancelled'), + (cls.claimed == cls.spots, 'confirmed'), + (cls.claimed < cls.spots, 'partial') + ), else_=None) + + @property + def payable_reference(self) -> str: + assert self.received is not None + if self.name: + return f'{self.name}/{self.title}@{self.received.isoformat()}' + else: + return f'{self.title}@{self.received.isoformat()}' + def process_payment( self, price: 'Price | None', @@ -307,6 +308,94 @@ def disclaim(self, spots: int | None = None) -> None: self.claimed = max(0, self.claimed - spots) +class SurveySubmission(Base, TimestampMixin, AssociatedFiles, + Extendable): + """ Defines a submitted survey of any kind in the database. """ + + __tablename__ = 'survey_submissions' + + __mapper_args__ = { + "polymorphic_on": 'state' + } + + #: id of the form submission + id: 'Column[uuid.UUID]' = Column( + UUID, # type:ignore[arg-type] + primary_key=True, + default=uuid4 + ) + + #: name of the form this submission belongs to + name: 'Column[str | None]' = Column( + Text, + ForeignKey("surveys.name"), + nullable=True + ) + + #: the source code of the form at the moment of submission. This is stored + #: alongside the submission as the original form may change later. We + #: want to keep the old form around just in case. + definition: 'Column[str]' = Column(Text, nullable=False) + + #: the checksum of the definition, forms and submissions with matching + #: checksums are guaranteed to have the exact same definition + checksum: 'Column[str]' = Column(Text, nullable=False) + + #: metadata about this submission + meta: 'Column[dict[str, Any]]' = Column(JSON, nullable=False) + + #: the submission data + data: 'Column[dict[str, Any]]' = Column(JSON, nullable=False) + + #: the state of the submission + state: 'Column[SubmissionState]' = Column( + Enum('pending', 'complete', name='submission_state'), # type:ignore + nullable=False + ) + + #: the submission window linked with this submission + submission_window_id: 'Column[uuid.UUID | None]' = Column( + UUID, # type:ignore[arg-type] + ForeignKey("submission_windows.id"), + nullable=True + ) + + #: extensions + extensions: dict_property[list[str]] = meta_property(default=list) + + if TYPE_CHECKING: + # forward declare backrefs + survey: relationship[SurveyDefinition | None] + submission_window: relationship['SurveySubmissionWindow | None'] + + @property + def form_class(self) -> type['Form']: + """ Parses the form definition and returns a form class. """ + + return self.extend_form_class( + parse_form(self.definition), + self.extensions or [] + ) + + @property + def form_obj(self) -> 'Form': + """ Returns a form instance containing the submission data. """ + return self.form_class(data=self.data) + + @observes('definition') + def definition_observer(self, definition: str) -> None: + self.checksum = hash_definition(definition) + + def update_title(self, survey: 'Form') -> None: + title_fields = survey.title_fields + if title_fields: + # FIXME: Reconsider using unescape when consistently using Markup. + self.title = extract_text_from_html(', '.join( + html.unescape(render_field(survey._fields[id])) + for id in title_fields + )) + + class PendingFormSubmission(FormSubmission): __mapper_args__ = {'polymorphic_identity': 'pending'} @@ -315,6 +404,14 @@ class CompleteFormSubmission(FormSubmission): __mapper_args__ = {'polymorphic_identity': 'complete'} +class PendingSurveySubmission(SurveySubmission): + __mapper_args__ = {'polymorphic_identity': 'pending'} + + +class CompleteSurveySubmission(SurveySubmission): + __mapper_args__ = {'polymorphic_identity': 'complete'} + + class FormFile(File): __mapper_args__ = {'polymorphic_identity': 'formfile'} diff --git a/src/onegov/form/models/survey_window.py b/src/onegov/form/models/survey_window.py new file mode 100644 index 0000000000..a09ae6c18e --- /dev/null +++ b/src/onegov/form/models/survey_window.py @@ -0,0 +1,149 @@ +import sedate + +from onegov.core.orm import Base +from onegov.core.orm.mixins import TimestampMixin +from onegov.core.orm.types import UUID +from onegov.form.models.submission import SurveySubmission +from sqlalchemy import Boolean +from sqlalchemy import Column +from sqlalchemy import Date +from sqlalchemy import ForeignKey +from sqlalchemy import Text +from sqlalchemy.dialects.postgresql import ExcludeConstraint +from sqlalchemy.orm import relationship +from sqlalchemy.schema import CheckConstraint +from sqlalchemy.sql.elements import quoted_name +from uuid import uuid4 + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + import uuid + from datetime import date, datetime + from onegov.form.models.definition import SurveyDefinition + + +daterange = Column( # type:ignore[call-overload] + quoted_name('DATERANGE("start", "end")', quote=False)) + + +class SurveySubmissionWindow(Base, TimestampMixin): + """ Defines a submission window during which a form definition + may be used to create submissions. + + Submissions created thusly are attached to the currently active + survey window. + + submission windows may not overlap. + + """ + + __tablename__ = 'submission_windows' + + #: the public id of the submission window + id: 'Column[uuid.UUID]' = Column( + UUID, # type:ignore[arg-type] + primary_key=True, + default=uuid4 + ) + + #: the name of the form to which this submission window belongs + name: 'Column[str]' = Column( + Text, + ForeignKey("surveys.name"), + nullable=False + ) + + #: true if the submission window is enabled + enabled: 'Column[bool]' = Column(Boolean, nullable=False, default=True) + + #: the start date of the window + start: 'Column[date]' = Column(Date, nullable=False) + + #: the end date of the window + end: 'Column[date]' = Column(Date, nullable=False) + + #: the timezone of the window + timezone: 'Column[str]' = Column( + Text, + nullable=False, + default='Europe/Zurich' + ) + + #: submissions linked to this + submissions: 'relationship[list[SurveySubmission]]' = relationship( + SurveySubmission, + backref='submission_window' + ) + + if TYPE_CHECKING: + # forward declare backref + survey: relationship[SurveyDefinition] + + __table_args__ = ( + + # ensures that there are no overlapping date ranges within one form + ExcludeConstraint( + (name, '='), (daterange, '&&'), + name='no_overlapping_submission_windows', + using='gist' + ), + + # ensures that there are no adjacent date ranges + # (end on the same day as next start) + ExcludeConstraint( + (name, '='), (daterange, '-|-'), + name='no_adjacent_submission_windows', + using='gist' + ), + + # ensures that start <= end + CheckConstraint( + '"start" <= "end"', + name='start_smaller_than_end' + ), + ) + + @property + def localized_start(self) -> 'datetime': + return sedate.align_date_to_day( + sedate.standardize_date( + sedate.as_datetime(self.start), self.timezone + ), self.timezone, 'down' + ) + + @property + def localized_end(self) -> 'datetime': + return sedate.align_date_to_day( + sedate.standardize_date( + sedate.as_datetime(self.end), self.timezone + ), self.timezone, 'up' + ) + + def disassociate(self) -> None: + """ Disassociates all records linked to this window. """ + + for submission in self.submissions: + submission.submission_window_id = None + + @property + def in_the_future(self) -> bool: + return sedate.utcnow() <= self.localized_start + + @property + def in_the_past(self) -> bool: + return self.localized_end <= sedate.utcnow() + + @property + def in_the_present(self) -> bool: + return self.localized_start <= sedate.utcnow() <= self.localized_end + + def accepts_submissions(self) -> bool: + + if not self.enabled: + return False + + if not self.in_the_present: + return False + + return True diff --git a/src/onegov/form/parser/snippets.py b/src/onegov/form/parser/snippets.py index a1db7f6651..256bb0e8f2 100644 --- a/src/onegov/form/parser/snippets.py +++ b/src/onegov/form/parser/snippets.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: + from translationstring import TranslationString from collections.abc import Iterator from onegov.core.request import CoreRequest @@ -66,6 +67,6 @@ class Snippets: def translated( self, request: 'CoreRequest' - ) -> 'Iterator[tuple[str, str | None]]': + ) -> 'Iterator[tuple[str, str | None, TranslationString]]': for title, snippet in self.fragments: - yield request.translate(title), snippet + yield request.translate(title), snippet, title diff --git a/src/onegov/form/validators.py b/src/onegov/form/validators.py index 70b3043d8a..97744b1df8 100644 --- a/src/onegov/form/validators.py +++ b/src/onegov/form/validators.py @@ -16,7 +16,7 @@ from onegov.form.errors import InvalidFormSyntax from onegov.form.errors import MixedTypeError from stdnum.exceptions import ValidationError as StdnumValidationError -from wtforms import RadioField +from wtforms import DateField, DateTimeLocalField, RadioField, TimeField from wtforms.fields import SelectField from wtforms.validators import DataRequired from wtforms.validators import InputRequired @@ -344,6 +344,43 @@ def __call__(self, form: 'Form', field: 'Field') -> 'Form | None': return parsed_form +class ValidSurveyDefinition(ValidFormDefinition): + """ Makes sure the given text is a valid onegov.form definition for + surveys. + """ + + def __init__(self, require_email_field: bool = False): + super().__init__(require_email_field) + + invalid_field_type = _("Invalid field type for field '${label}'. Please " + "use the plus-icon to add allowed field types.") + + def __call__(self, form: 'Form', field: 'Field') -> 'Form | None': + from onegov.form.fields import UploadField + + parsed_form = super().__call__(form, field) + if parsed_form is None: + return None + + # Exclude fields that are not allowed in surveys + errors = None + for field in parsed_form._fields.values(): + if isinstance(field, (UploadField, DateField, TimeField, + DateTimeLocalField)): + error = field.gettext(self.invalid_field_type % + {'label': field.label.text}) + errors = form['definition'].errors + if not isinstance(errors, list): + errors = form['definition'].process_errors + assert isinstance(errors, list) + errors.append(error) + + if errors: + raise ValidationError() + + return parsed_form + + class LaxDataRequired(DataRequired): """ A copy of wtform's DataRequired validator, but with a more lax approach to required validation checking. It accepts some specific falsy values, diff --git a/src/onegov/fsi/custom.py b/src/onegov/fsi/custom.py index 506a31523f..d3eafe7453 100644 --- a/src/onegov/fsi/custom.py +++ b/src/onegov/fsi/custom.py @@ -1,4 +1,5 @@ from onegov.core.elements import Link +from onegov.form.collection import SurveyCollection from onegov.fsi import FsiApp from onegov.fsi.collections.attendee import CourseAttendeeCollection from onegov.fsi.collections.audit import AuditCollection @@ -75,6 +76,15 @@ def get_base_tools(request: 'FsiRequest') -> 'Iterator[Link | LinkGroup]': ) ) + links.append( + Link( + _("Surveys"), + request.class_link( + SurveyCollection), + attrs={'class': 'surveys'} + ) + ) + if request.is_admin: links.append( Link( diff --git a/src/onegov/fsi/forms/course.py b/src/onegov/fsi/forms/course.py index 825fd779bc..297d57a135 100644 --- a/src/onegov/fsi/forms/course.py +++ b/src/onegov/fsi/forms/course.py @@ -139,6 +139,14 @@ class CourseForm(Form): default=False, ) + evaluation_url = StringField( + label=_('Evaluation URL'), + description=_('URL to the evaluation form'), + validators=[ + Optional() + ] + ) + def get_useful_data( self, exclude: 'Collection[str] | None' = None @@ -165,6 +173,7 @@ def apply_model(self, model: 'Course') -> None: self.mandatory_refresh.data = model.mandatory_refresh self.refresh_interval.data = model.refresh_interval self.hidden_from_public.data = model.hidden_from_public + self.evaluation_url.data = model.evaluation_url def update_model(self, model: 'Course') -> None: assert self.name.data is not None @@ -172,6 +181,7 @@ def update_model(self, model: 'Course') -> None: model.description = linkify(self.description.data) model.mandatory_refresh = self.mandatory_refresh.data model.hidden_from_public = self.hidden_from_public.data + model.evaluation_url = self.evaluation_url.data if not self.mandatory_refresh.data: model.refresh_interval = None else: diff --git a/src/onegov/fsi/models/course.py b/src/onegov/fsi/models/course.py index 0810b29f05..6ed1f19c9f 100644 --- a/src/onegov/fsi/models/course.py +++ b/src/onegov/fsi/models/course.py @@ -54,6 +54,8 @@ class Course(Base, ORMSearchable): default=False ) + evaluation_url: 'Column[str | None]' = Column(Text) + events: 'relationship[AppenderQuery[CourseEvent]]' = relationship( 'CourseEvent', back_populates='course', diff --git a/src/onegov/fsi/templates/form.pt b/src/onegov/fsi/templates/form.pt index 12fe82c921..2a48c8b881 100644 --- a/src/onegov/fsi/templates/form.pt +++ b/src/onegov/fsi/templates/form.pt @@ -1,13 +1,63 @@ -
- +
+ ${title} - - -

${subtitle}

-
-
-
+ + + + + + +
+ ${item.group}
+ + + +
+ +
+

${form.callout|callout}

- + +
+

${form.helptext|helptext}

+
+ + + + +
+
+
${hint}
+
+
+
+
+
+
+
+
+
+
+
+ + +
diff --git a/src/onegov/fsi/templates/macros.pt b/src/onegov/fsi/templates/macros.pt index 2614832f84..4826782f00 100644 --- a/src/onegov/fsi/templates/macros.pt +++ b/src/onegov/fsi/templates/macros.pt @@ -134,6 +134,7 @@ + Course Evaluation

Description:

@@ -256,6 +257,5 @@
Attendee should have attended the course already, but didn't.
- diff --git a/src/onegov/fsi/upgrade.py b/src/onegov/fsi/upgrade.py index 5cd26a0260..99ff6b90c4 100644 --- a/src/onegov/fsi/upgrade.py +++ b/src/onegov/fsi/upgrade.py @@ -172,3 +172,13 @@ def add_active_property_to_attendees(context: UpgradeContext) -> None: Column('active', Boolean, nullable=False, default=True), default=lambda x: True ) + + +@upgrade_task('Adds evaluation_url to course') +def add_evaluation_url_to_course(context: UpgradeContext) -> None: + if not context.has_column('fsi_courses', 'evaluation_url'): + context.add_column_with_defaults( + 'fsi_courses', + Column('evaluation_url', Text, nullable=True), + default=lambda x: None + ) diff --git a/src/onegov/fsi/views/course.py b/src/onegov/fsi/views/course.py index 9691d6b8e9..6929eba4a3 100644 --- a/src/onegov/fsi/views/course.py +++ b/src/onegov/fsi/views/course.py @@ -207,6 +207,7 @@ def view_edit_course_event( layout = EditCourseLayout(self, request) layout.include_editor() + return { 'layout': layout, 'model': self, diff --git a/src/onegov/org/custom.py b/src/onegov/org/custom.py index 5fadfa009f..18b39094cf 100644 --- a/src/onegov/org/custom.py +++ b/src/onegov/org/custom.py @@ -1,5 +1,6 @@ from onegov.chat import MessageCollection, TextModuleCollection from onegov.core.elements import Link, LinkGroup +from onegov.form.collection import FormCollection, SurveyCollection from onegov.org import _, OrgApp from onegov.org.models import ( GeneralFileCollection, ImageFileCollection, Organisation) @@ -150,6 +151,24 @@ def get_global_tools(request: 'OrgRequest') -> 'Iterator[Link | LinkGroup]': ) ) + links.append( + Link( + _("Forms"), + request.class_link( + FormCollection), + attrs={'class': 'forms'} + ) + ) + + links.append( + Link( + _("Surveys"), + request.class_link( + SurveyCollection), + attrs={'class': 'surveys'} + ) + ) + yield LinkGroup(_("Management"), classes=('management', ), links=links) # Tickets diff --git a/src/onegov/org/forms/__init__.py b/src/onegov/org/forms/__init__.py index 9d72cdfc1e..2aba9958ff 100644 --- a/src/onegov/org/forms/__init__.py +++ b/src/onegov/org/forms/__init__.py @@ -33,6 +33,7 @@ from onegov.org.forms.settings import MapSettingsForm from onegov.org.forms.settings import ModuleSettingsForm from onegov.org.forms.signup import SignupForm +from onegov.org.forms.survey_submission import SurveySubmissionWindowForm from onegov.org.forms.text_module import TextModuleForm from onegov.org.forms.ticket import ( InternalTicketChatMessageForm, ExtendedInternalTicketChatMessageForm) @@ -88,6 +89,7 @@ 'RoomAllocationEditForm', 'RoomAllocationForm', 'SignupForm', + 'SurveySubmissionWindowForm', 'TextModuleForm', 'TicketAssignmentForm', 'TicketChatMessageForm', diff --git a/src/onegov/org/forms/form_definition.py b/src/onegov/org/forms/form_definition.py index 193850b1bf..ff3fdcde6b 100644 --- a/src/onegov/org/forms/form_definition.py +++ b/src/onegov/org/forms/form_definition.py @@ -1,6 +1,6 @@ from onegov.core.utils import normalize_for_url from onegov.form import Form, merge_forms, FormDefinitionCollection -from onegov.form.validators import ValidFormDefinition +from onegov.form.validators import ValidFormDefinition, ValidSurveyDefinition from onegov.org import _ from onegov.org.forms.fields import HtmlField from onegov.org.forms.generic import PaymentForm @@ -13,8 +13,6 @@ class FormDefinitionBaseForm(Form): - """ Form to edit defined forms. """ - title = StringField(_("Title"), [InputRequired()]) lead = TextAreaField( @@ -56,6 +54,32 @@ class FormDefinitionForm(merge_forms( pass +class SurveyDefinitionForm(Form): + """ Form to create surveys. """ + + # This class is needed to hide forbidden fields from the form editor + css_class = 'survey-definition' + + title = StringField(_("Title"), [InputRequired()]) + + lead = TextAreaField( + label=_("Lead"), + description=_("Short description of the survey"), + render_kw={'rows': 4}) + + text = HtmlField( + label=_("Text")) + + group = StringField( + label=_("Group"), + description=_("Used to group the form in the overview")) + + definition = TextAreaField( + label=_("Definition"), + validators=[InputRequired(), ValidSurveyDefinition()], + render_kw={'rows': 32, 'data-editor': 'form'}) + + class FormDefinitionUrlForm(Form): name = StringField( diff --git a/src/onegov/org/forms/survey_export.py b/src/onegov/org/forms/survey_export.py new file mode 100644 index 0000000000..010af3166b --- /dev/null +++ b/src/onegov/org/forms/survey_export.py @@ -0,0 +1,50 @@ +from onegov.form import merge_forms +from onegov.form.fields import MultiCheckboxField +from onegov.org import _ +from onegov.org.forms.generic import ExportForm +from operator import attrgetter + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from onegov.org.request import OrgRequest + + class SurveySubmissionsExportBase(ExportForm): + pass +else: + SurveySubmissionsExportBase = merge_forms(ExportForm) + + +class SurveySubmissionsExport(SurveySubmissionsExportBase): + + if TYPE_CHECKING: + request: OrgRequest + + submission_window = MultiCheckboxField( + label=_("Submission Window"), + choices=None, + ) + + def on_request(self) -> None: + if self.submission_window.choices is None: + self.load_submission_window() # type:ignore[unreachable] + + def load_submission_window(self) -> None: + # FIXME: circular import - a layout class just to get a date format? + from onegov.org.layout import DefaultLayout + + layout = DefaultLayout(self.model, self.request) + + windows = sorted( + self.model.submission_windows, + key=attrgetter('start') + ) + + if not windows: + self.hide(self.submission_window) + return + + self.submission_window.choices = [ + (window.id.hex, layout.format_date_range(window.start, window.end)) + for window in windows + ] diff --git a/src/onegov/org/forms/survey_submission.py b/src/onegov/org/forms/survey_submission.py new file mode 100644 index 0000000000..f74d3d6e42 --- /dev/null +++ b/src/onegov/org/forms/survey_submission.py @@ -0,0 +1,97 @@ +from onegov.form import Form +from onegov.org import _ +from wtforms.fields import DateField +from wtforms.validators import InputRequired + + +from typing import Any, TYPE_CHECKING +if TYPE_CHECKING: + from onegov.form import SurveySubmissionWindow, SurveyDefinition + from onegov.org.request import OrgRequest + + +class SurveySubmissionWindowForm(Form): + """ Form to edit submission windows. """ + + if TYPE_CHECKING: + model: SurveyDefinition | SurveySubmissionWindow + request: OrgRequest + + start = DateField( + label=_("Start"), + validators=[InputRequired()] + ) + + end = DateField( + label=_("End"), + validators=[InputRequired()] + ) + + def process_obj( + self, + obj: 'SurveySubmissionWindow' # type:ignore[override] + ) -> None: + + super().process_obj(obj) + + def populate_obj( # type:ignore[override] + self, + obj: 'SurveySubmissionWindow', # type:ignore[override] + *args: Any, + **kwargs: Any, + ) -> None: + + super().populate_obj(obj, *args, **kwargs) + + def ensure_start_before_end(self) -> bool | None: + """ Validate start and end for proper error message. + def ensure_start_end(self) would also be run in side the validate + function, but the error is not clear. """ + + if not self.start.data or not self.end.data: + return None + if self.start.data >= self.end.data: + assert isinstance(self.end.errors, list) + self.end.errors.append(_("Please use a stop date after the start")) + return False + return None + + def ensure_no_overlapping_windows(self) -> bool | None: + """ Ensure that this registration window does not overlap with other + already defined registration windows. + + """ + if not self.start.data or not self.end.data: + return None + + # FIXME: An isinstance check would be nicer but we would need to + # stop using Bunch in the tests + survey: SurveyDefinition + survey = getattr(self.model, 'survey', self.model) # type:ignore + for existing in survey.submission_windows: + if existing == self.model: + continue + + latest_start = max(self.start.data, existing.start) + earliest_end = min(self.end.data, existing.end) + delta = (earliest_end - latest_start).days + 1 + if delta > 0: + # circular + from onegov.org.layout import DefaultLayout + layout = DefaultLayout(self.model, self.request) + + msg = _( + "The date range overlaps with an existing submission " + "window (${range}).", + mapping={ + 'range': layout.format_date_range( + existing.start, existing.end + ) + } + ) + assert isinstance(self.start.errors, list) + self.start.errors.append(msg) + assert isinstance(self.end.errors, list) + self.end.errors.append(msg) + return False + return None diff --git a/src/onegov/org/layout.py b/src/onegov/org/layout.py index 5bc92e20e9..b15dfabd44 100644 --- a/src/onegov/org/layout.py +++ b/src/onegov/org/layout.py @@ -13,6 +13,7 @@ from onegov.core.custom import json from onegov.core.elements import Block, Button, Confirm, Intercooler from onegov.core.elements import Link, LinkGroup +from onegov.form.collection import SurveyCollection from onegov.org.elements import QrCodeLink, IFrameLink from onegov.core.i18n import SiteLocale from onegov.core.layout import ChameleonLayout @@ -69,6 +70,8 @@ from onegov.directory import DirectoryEntryCollection from onegov.event import Event, Occurrence from onegov.form import FormDefinition, FormSubmission + from onegov.form.models.definition import ( + SurveySubmission, SurveyDefinition) from onegov.org.models import ( ExtendedDirectory, ExtendedDirectoryEntry, ImageSet, Organisation) from onegov.org.app import OrgApp @@ -1033,11 +1036,13 @@ def breadcrumbs(self) -> list[Link]: class FormEditorLayout(DefaultLayout): - model: 'FormDefinition | FormCollection' + model: ('FormDefinition | FormCollection | SurveyCollection' + '| SurveyDefinition') def __init__( self, - model: 'FormDefinition | FormCollection', + model: ('FormDefinition | FormCollection | SurveyCollection' + '| SurveyDefinition'), request: 'OrgRequest' ) -> None: @@ -1204,7 +1209,11 @@ def breadcrumbs(self) -> list[Link]: def external_forms(self) -> ExternalLinkCollection: return ExternalLinkCollection(self.request.session) - @cached_property + @property + def form_definitions(self) -> FormCollection: + return FormCollection(self.request.session) + + @property def editbar_links(self) -> list[Link | LinkGroup] | None: if self.request.is_manager: return [ @@ -1214,7 +1223,7 @@ def editbar_links(self) -> list[Link | LinkGroup] | None: Link( text=_("Form"), url=self.request.link( - self.model, + self.form_definitions, name='new' ), attrs={'class': 'new-form'} @@ -1231,7 +1240,169 @@ def editbar_links(self) -> list[Link | LinkGroup] | None: name='new' ), attrs={'class': 'new-form'} - ) + ), + ] + ), + ] + return None + + +class SurveySubmissionLayout(DefaultLayout): + + model: 'SurveySubmission | SurveyDefinition' + + def __init__( + self, + model: 'SurveySubmission | SurveyDefinition', + request: 'OrgRequest', + title: str | None = None + ) -> None: + + super().__init__(model, request) + self.include_code_editor() + self.title = title or self.form.title + + @cached_property + def form(self) -> 'SurveyDefinition': + if hasattr(self.model, 'survey'): + return self.model.survey # type:ignore[return-value] + else: + return self.model + + @cached_property + def breadcrumbs(self) -> list[Link]: + collection = SurveyCollection(self.request.session) + + return [ + Link(_("Homepage"), self.homepage_url), + Link(_("Surveys"), self.request.link(collection)), + Link(self.title, self.request.link(self.model)) + ] + + @cached_property + def editbar_links(self) -> list[Link | LinkGroup] | None: + + if not self.request.is_manager: + return None + + # only show the edit bar links if the site is the base of the form + # -> if the user already entered some form data remove the edit bar + # because it makes it seem like it's there to edit the submission, + # not the actual form + if hasattr(self.model, 'form'): + return None + + collection = SurveyCollection(self.request.session) + + edit_link = Link( + text=_("Edit"), + url=self.request.link(self.form, name='edit'), + attrs={'class': 'edit-link'} + ) + + qr_link = QrCodeLink( + text=_("QR"), + url=self.request.link(self.model), + attrs={'class': 'qr-code-link'} + ) + + delete_link = Link( + text=_("Delete"), + url=self.csrf_protected_url( + self.request.link(self.form) + ), + attrs={'class': 'delete-link'}, + traits=( + Confirm( + _("Do you really want to delete this survey?"), + _("This cannot be undone. And all submissions will be " + "deleted with it."), + _("Delete survey"), + _("Cancel") + ), + Intercooler( + request_method='DELETE', + redirect_after=self.request.link(collection) + ) + ) + ) + + export_link = Link( + text=_("Export"), + url=self.request.link(self.form, name='export'), + attrs={'class': 'export-link'} + ) + + change_url_link = Link( + text=_("Change URL"), + url=self.request.link(self.form, name='change-url'), + attrs={'class': 'internal-url'} + ) + + results_link = Link( + text=_("Results"), + url=self.request.link(self.model, name='results'), + attrs={'class': 'results-link'} + ) + + submission_windows_link = LinkGroup( + title=_("Submission Windows"), + links=[ + Link( + text=_("Add"), + url=self.request.link( + self.model, 'new-submission-window' + ), + attrs={'class': 'new-submission-window'} + ), + *( + Link( + text=self.format_date_range(w.start, w.end), + url=self.request.link(w), + attrs={'class': 'view-link'} + ) for w in self.form.submission_windows + ) + ] + ) + + return [ + edit_link, + delete_link, + export_link, + change_url_link, + submission_windows_link, + qr_link, + results_link + ] + + +class SurveyCollectionLayout(DefaultLayout): + @property + def survey_definitions(self) -> SurveyCollection: + return SurveyCollection(self.request.session) + + @cached_property + def breadcrumbs(self) -> list[Link]: + return [ + Link(_("Homepage"), self.homepage_url), + Link(_("Surveys"), '#') + ] + + @property + def editbar_links(self) -> list[Link | LinkGroup] | None: + if self.request.is_manager: + return [ + LinkGroup( + title=_("Add"), + links=[ + Link( + text=_("Survey"), + url=self.request.link( + self.survey_definitions, + name='new' + ), + attrs={'class': 'new-form'} + ), ] ), ] @@ -2111,7 +2282,7 @@ def links() -> 'Iterator[Link | LinkGroup]': yield Link( text=_("Configure"), url=self.request.link(self.model, '+edit'), - attrs={'class': 'edit-link'} + attrs={'class': 'filters-link'} ) if self.request.is_manager: 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 7023ca4ffd..ba51a26b7f 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 @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE 1.0\n" -"POT-Creation-Date: 2024-06-27 12:50+0200\n" +"POT-Creation-Date: 2024-07-03 13:25+0200\n" "PO-Revision-Date: 2022-03-15 10:21+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: German\n" @@ -120,6 +120,12 @@ msgstr "Link-Prüfung" msgid "Archived Tickets" msgstr "Archivierte Tickets" +msgid "Forms" +msgstr "Formulare" + +msgid "Surveys" +msgstr "Umfragen" + msgid "Management" msgstr "Verwaltung" @@ -814,6 +820,9 @@ msgstr "Die Veranstaltungen werden nicht gespeichert." msgid "Expected header line with the following columns:" msgstr "Die erste Zeile soll folgende Spaltennamen haben:" +msgid "Documents" +msgstr "Dokumente" + msgid "Map" msgstr "Karte" @@ -864,6 +873,9 @@ msgstr "" "Dieser Text wird auf Ticket-Status-Seite genutzt, um den Nutzer zu " "informieren" +msgid "Short description of the survey" +msgstr "Kurze Beschreibung der Umfrage" + msgid "URL path" msgstr "URL Pfad" @@ -1846,21 +1858,6 @@ msgstr "Logo im Newsletter anzeigen" msgid "Allow secret content in newsletter" msgstr "Geheimen Inhalt im Newsletter erlauben" -msgid "You selected 'secret' content for your newsletter. Secret content will not be visible unless you enable it in" -msgstr "Sie haben 'geheime' Inhalte für Ihren Newsletter ausgewählt. Geheime Inhalte werden nicht sichtbar sein, es sei denn, Sie aktivieren sie in" - -msgid "newsletter settings" -msgstr "Newsletter Einstellungen" - -msgid "You selected 'private' content for your newsletter. Private content cannot be part of a newsletter." -msgstr "Sie haben 'privaten' Inhalt für Ihren Newsletter ausgewählt. Privater Inhalt kann nicht Teil eines Newsletters sein." - -msgid "Warning secret content" -msgstr "Achtung geheimer Inhalt" - -msgid "Warning private content" -msgstr "Achtung privater Inhalt" - msgid "Old domain" msgstr "Alte Domain" @@ -1932,6 +1929,15 @@ msgstr "Dauer vom archivierten Zustand bis zum automatischen Löschen" msgid "E-Mail Address" msgstr "E-Mail Adresse" +msgid "Submission Window" +msgstr "Befragungszeitraum" + +#, python-format +msgid "The date range overlaps with an existing submission window (${range})." +msgstr "" +"Der Datumsbereich überschneidet sich mit einem bestehenden " +"Befragungszeitraum (${range})." + msgid "Short name to identify the text module" msgstr "Kurzer Name um den Textbaustein zu identifizieren" @@ -2148,9 +2154,6 @@ msgstr "Startseite" msgid "Save" msgstr "Speichern" -msgid "Forms" -msgstr "Formulare" - msgid "Edit" msgstr "Bearbeiten" @@ -2197,6 +2200,25 @@ msgstr "Externes Formular" msgid "New external form" msgstr "Neues externes Formular" +msgid "Do you really want to delete this survey?" +msgstr "Möchten Sie die Umfrage wirklich löschen?" + +msgid "This cannot be undone. And all submissions will be deleted with it." +msgstr "" +"Dies kann nicht rückgängig gemacht werden. Alle Eingaben werden gelöscht." + +msgid "Delete survey" +msgstr "Umfrage löschen" + +msgid "Results" +msgstr "Resultate" + +msgid "Submission Windows" +msgstr "Befragungszeiträume" + +msgid "Survey" +msgstr "Umfrage" + msgid "Person" msgstr "Person" @@ -2578,9 +2600,6 @@ msgstr "Als erstes Element des Inhalts" msgid "As a full width header" msgstr "Als ein Header-Bild mit voller Breite" -msgid "Documents" -msgstr "Dokumente" - msgid "Show file links in sidebar" msgstr "Dateilinks in der Seitenleiste anzeigen" @@ -3537,6 +3556,12 @@ msgstr "Schliessen" msgid "Copy to clipboard" msgstr "In die Zwischenablage kopieren" +msgid "No submissions yet." +msgstr "Noch keine Eingaben." + +msgid "Number of participants" +msgstr "Anzahl Teilnehmer" + msgid "Hello!" msgstr "Guten Tag" @@ -4017,6 +4042,29 @@ msgstr "Ticket archiviert." msgid "Ticket recovered from archive." msgstr "Ticket aus dem Archiv geholt." +msgid "Warning secret content" +msgstr "Achtung geheimer Inhalt" + +msgid "" +"You selected 'secret' content for your newsletter. Secret content will not " +"be visible unless you enable it in" +msgstr "" +"Sie haben 'geheime' Inhalte für Ihren Newsletter ausgewählt. Geheime Inhalte " +"werden nicht sichtbar sein, es sei denn, Sie aktivieren sie in" + +msgid "newsletter settings" +msgstr "Newsletter Einstellungen" + +msgid "Warning private content" +msgstr "Achtung privater Inhalt" + +msgid "" +"You selected 'private' content for your newsletter. Private content cannot " +"be part of a newsletter." +msgstr "" +"Sie haben 'privaten' Inhalt für Ihren Newsletter ausgewählt. Privater Inhalt " +"kann nicht Teil eines Newsletters sein." + msgid "" "The newsletter is disabled. You can only see this page because you are " "logged in." @@ -4330,6 +4378,18 @@ msgstr "" msgid "Back to page" msgstr "Zurück zur Seite" +msgid "Fields" +msgstr "Felder" + +msgid "" +"Please review your answers and press \"Complete\" to complete the survey. If " +"there's anything you'd like to change, click on \"Edit\" to return to the " +"filled-out survey." +msgstr "" + +msgid "No surveys defined yet." +msgstr "Es wurden noch keine Umfragen definiert." + msgid "No activities yet." msgstr "Noch keine Aktivitäten." @@ -5587,6 +5647,48 @@ msgstr "Chat" msgid "Topics" msgstr "Themen" +msgid "The survey timeframe has ended" +msgstr "Der Befragungszeitraum ist abgelaufen" + +#, python-format +msgid "The survey timeframe opens on ${day}, ${date}" +msgstr "Der Befragungszeitraum beginnt am ${day}, ${date}" + +#, python-format +msgid "The survey timeframe closes on ${day}, ${date}" +msgstr "Der Befragungszeitraum endet am ${day}, ${date}" + +msgid "A survey with this name already exists" +msgstr "Eine Umfrage mit diesem Namen existiert bereits" + +msgid "Added a new survey" +msgstr "Eine neue Umfrage wurde hinzugefügt" + +msgid "New Survey" +msgstr "Neue Umfrage" + +msgid "Exports the submissions of the survey." +msgstr "Exportiert die Umfrageeinträge." + +msgid "New Submission Window" +msgstr "Neuer Befragungszeitraum" + +msgid "Submissions windows limit survey submissions to a specific time-range." +msgstr "" +"Befragungszeiträume limitieren Umfrageeinträge auf einen bestimmten Zeitraum." + +msgid "Do you really want to delete this submission window?" +msgstr "Möchten Sie diesen Befragungszeitraum wirklich löschen?" + +msgid "Delete submission window" +msgstr "Befragungszeitraum löschen" + +msgid "Edit Submission Window" +msgstr "Befragungszeitraum bearbeiten" + +msgid "The submission window was deleted" +msgstr "Der Befragungszeitraum wurde gelöscht" + msgid "Added a new text module" msgstr "Textbaustein hinzugefügt" 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 e77861a20f..f7e61b2565 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: 2024-06-27 12:50+0200\n" +"POT-Creation-Date: 2024-07-03 13:25+0200\n" "PO-Revision-Date: 2022-03-15 10:50+0100\n" "Last-Translator: Marc Sommerhalder \n" "Language-Team: French\n" @@ -121,6 +121,12 @@ msgstr "Vérification du lien" msgid "Archived Tickets" msgstr "Tickets archivés" +msgid "Forms" +msgstr "Formulaires" + +msgid "Surveys" +msgstr "Enquêtes" + msgid "Management" msgstr "Gestion" @@ -816,6 +822,9 @@ msgstr "Les événements ne sont pas sauvegardés." msgid "Expected header line with the following columns:" msgstr "La première ligne doit avoir les noms de colonne suivants:" +msgid "Documents" +msgstr "Documents" + msgid "Map" msgstr "Carte" @@ -865,6 +874,9 @@ msgstr "" "Décrit la manière dont cette ressource peut être récupérée. Ce texte est " "utilisé sur la page de statut du ticket pour informer le client" +msgid "Short description of the survey" +msgstr "Description courte de l'enquête" + msgid "URL path" msgstr "Chemin Url" @@ -1855,21 +1867,6 @@ msgstr "Afficher le logo dans la newsletter" msgid "Allow secret content in newsletter" msgstr "Autoriser le contenu secret dans la newsletter" -msgid "You selected 'secret' content for your newsletter. Secret content will not be visible unless you enable it in" -msgstr "Vous avez sélectionné un contenu 'secret' pour votre newsletter. Le contenu secret ne sera pas visible à moins que vous ne l'activiez dans les" - -msgid "newsletter settings" -msgstr "paramètres de la newsletter" - -msgid "You selected 'private' content for your newsletter. Private content cannot be part of a newsletter." -msgstr "Vous avez sélectionné un contenu 'privé' pour votre newsletter. Le contenu privé ne peut pas faire partie d'une newsletter." - -msgid "Warning secret content" -msgstr "Avertissement contenu secret" - -msgid "Warning private content" -msgstr "Attention contenu privé" - msgid "Old domain" msgstr "Ancien domaine" @@ -1941,6 +1938,14 @@ msgstr "Durée de l'état archivé jusqu'à la suppression automatique" msgid "E-Mail Address" msgstr "Adresse e-mail" +msgid "Submission Window" +msgstr "Fenêtre de soumission" + +#, python-format +msgid "The date range overlaps with an existing submission window (${range})." +msgstr "" +"La période donnée chevauche une fenêtre de soumission existante (${range})." + msgid "Short name to identify the text module" msgstr "Nom court pour identifier le module de texte" @@ -2155,9 +2160,6 @@ msgstr "Page d'accueil" msgid "Save" msgstr "Sauver" -msgid "Forms" -msgstr "Formulaires" - msgid "Edit" msgstr "Modifier" @@ -2204,6 +2206,25 @@ msgstr "Formulaire externe" msgid "New external form" msgstr "Nouveau formulaire externe" +msgid "Do you really want to delete this survey?" +msgstr "Voulez-vous vraiment supprimer ce sondage ?" + +msgid "This cannot be undone. And all submissions will be deleted with it." +msgstr "" +"Cela ne peut être annulé. Et toutes les soumissions seront supprimées avec." + +msgid "Delete survey" +msgstr "Supprimer le sondage" + +msgid "Results" +msgstr "Résultats" + +msgid "Submission Windows" +msgstr "Fenêtres de soumission" + +msgid "Survey" +msgstr "Sondage" + msgid "Person" msgstr "Personne" @@ -2583,9 +2604,6 @@ msgstr "Come primo elemento del contenuto" msgid "As a full width header" msgstr "Come immagine di intestazione a tutta larghezza" -msgid "Documents" -msgstr "Documents" - msgid "Show file links in sidebar" msgstr "Afficher les liens vers les fichiers dans la barre latérale" @@ -3546,6 +3564,12 @@ msgstr "Fermer" msgid "Copy to clipboard" msgstr "Copier dans le presse-papiers" +msgid "No submissions yet." +msgstr "Aucune soumission pour l'instant." + +msgid "Number of participants" +msgstr "Nombre de participants" + msgid "Hello!" msgstr "Bonjour" @@ -4027,6 +4051,29 @@ msgstr "Ticket a été archivé" msgid "Ticket recovered from archive." msgstr "Ticket récupéré dans les archives" +msgid "Warning secret content" +msgstr "Avertissement contenu secret" + +msgid "" +"You selected 'secret' content for your newsletter. Secret content will not " +"be visible unless you enable it in" +msgstr "" +"Vous avez sélectionné un contenu 'secret' pour votre newsletter. Le contenu " +"secret ne sera pas visible à moins que vous ne l'activiez dans les" + +msgid "newsletter settings" +msgstr "paramètres de la newsletter" + +msgid "Warning private content" +msgstr "Attention contenu privé" + +msgid "" +"You selected 'private' content for your newsletter. Private content cannot " +"be part of a newsletter." +msgstr "" +"Vous avez sélectionné un contenu 'privé' pour votre newsletter. Le contenu " +"privé ne peut pas faire partie d'une newsletter." + msgid "" "The newsletter is disabled. You can only see this page because you are " "logged in." @@ -4345,6 +4392,21 @@ msgstr "" msgid "Back to page" msgstr "Retour à la page" +msgid "Fields" +msgstr "Champs" + +msgid "" +"Please review your answers and press \"Complete\" to complete the survey. If " +"there's anything you'd like to change, click on \"Edit\" to return to the " +"filled-out survey." +msgstr "" +"Veuillez vérifier vos réponses et appuyez sur \"Compléter\" pour terminer le " +"sondage. Si vous souhaitez modifier quelque chose, cliquez sur \"Modifier\" " +"pour revenir au sondage rempli." + +msgid "No surveys defined yet." +msgstr "Aucun sondage n'est encore défini." + msgid "No activities yet." msgstr "Aucune activité pour le moment." @@ -5612,6 +5674,49 @@ msgstr "Chat" msgid "Topics" msgstr "Sujets" +msgid "The survey timeframe has ended" +msgstr "La période de l'enquête est terminée" + +#, python-format +msgid "The survey timeframe opens on ${day}, ${date}" +msgstr "La période d'enquête s'ouvre le ${day}, ${date}" + +#, python-format +msgid "The survey timeframe closes on ${day}, ${date}" +msgstr "La période d'enquête se termine le ${day}, ${date}" + +msgid "A survey with this name already exists" +msgstr "Une enquête avec ce nom existe déjà" + +msgid "Added a new survey" +msgstr "Ajout d'une nouvelle enquête" + +msgid "New Survey" +msgstr "Nouvelle enquête" + +msgid "Exports the submissions of the survey." +msgstr "Exporte les soumissions de l'enquête." + +msgid "New Submission Window" +msgstr "Nouvelle fenêtre de soumission" + +msgid "Submissions windows limit survey submissions to a specific time-range." +msgstr "" +"Les fenêtres de soumission limitent les soumissions d'enquêtes à une période " +"spécifique." + +msgid "Do you really want to delete this submission window?" +msgstr "Voulez-vous vraiment supprimer cette fenêtre de soumission ?" + +msgid "Delete submission window" +msgstr "Supprimer la fenêtre de soumission" + +msgid "Edit Submission Window" +msgstr "Modifier la fenêtre de soumission" + +msgid "The submission window was deleted" +msgstr "La fenêtre de soumission a été supprimée" + msgid "Added a new text module" msgstr "Ajout d'un nouveau module de texte" 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 60a6d13e16..6f9b7bc659 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: 2024-06-27 12:50+0200\n" +"POT-Creation-Date: 2024-07-03 13:25+0200\n" "PO-Revision-Date: 2022-03-15 10:52+0100\n" "Last-Translator: \n" "Language-Team: \n" @@ -120,6 +120,12 @@ msgstr "Controllo del collegamento" msgid "Archived Tickets" msgstr "Biglietto archiviati" +msgid "Forms" +msgstr "Moduli" + +msgid "Surveys" +msgstr "Sondaggi" + msgid "Management" msgstr "Gestione" @@ -816,6 +822,9 @@ msgstr "Gli eventi non vengono salvati." msgid "Expected header line with the following columns:" msgstr "La prima riga deve avere i seguenti nomi di colonna:" +msgid "Documents" +msgstr "Documenti" + msgid "Map" msgstr "Mappa" @@ -866,6 +875,9 @@ msgstr "" "Descrive il modo in cui questa risorsa può essere prelevata. Questo testo " "viene utilizzato nella pagina di stato del biglietto per informare l'utente" +msgid "Short description of the survey" +msgstr "Breve descrizione del sondaggio" + msgid "URL path" msgstr "Percorso URL" @@ -1858,21 +1870,6 @@ msgstr "Includi il logo nella newsletter" msgid "Allow secret content in newsletter" msgstr "Consentire contenuti segreti nella newsletter" -msgid "You selected 'secret' content for your newsletter. Secret content will not be visible unless you enable it in" -msgstr "Avete selezionato un contenuto 'segreto' per la vostra newsletter. I contenuti segreti non saranno visibili a meno che non vengano abilitati nelle" - -msgid "newsletter settings" -msgstr "impostazioni della newsletter" - -msgid "You selected 'private' content for your newsletter. Private content cannot be part of a newsletter." -msgstr "Avete selezionato un contenuto 'privato' per la vostra newsletter. I contenuti privati non possono far parte di una newsletter." - -msgid "Warning secret content" -msgstr "Avviso contenuto segreto" - -msgid "Warning private content" -msgstr "Avviso contenuto privato" - msgid "Old domain" msgstr "Vecchio dominio" @@ -1944,6 +1941,15 @@ msgstr "Durata dello stato di archiviazione fino alla cancellazione automatica" msgid "E-Mail Address" msgstr "Indirizzo e-mail" +msgid "Submission Window" +msgstr "Finestra di presentazione" + +#, python-format +msgid "The date range overlaps with an existing submission window (${range})." +msgstr "" +"L'intervallo di date si sovrappone a una finestra di presentazione esistente " +"(${range})." + msgid "Short name to identify the text module" msgstr "Nome breve per identificare il modulo di testo" @@ -2160,9 +2166,6 @@ msgstr "Pagina principale" msgid "Save" msgstr "Risparmiare" -msgid "Forms" -msgstr "Moduli" - msgid "Edit" msgstr "Modifica" @@ -2209,6 +2212,24 @@ msgstr "Modulo esterno" msgid "New external form" msgstr "Nuovo modulo esterno" +msgid "Do you really want to delete this survey?" +msgstr "Vuoi davvero eliminare questo sondaggio?" + +msgid "This cannot be undone. And all submissions will be deleted with it." +msgstr "Questa operazione non può essere annullata. E tutti gli invii saranno " + +msgid "Delete survey" +msgstr "Elimina sondaggio" + +msgid "Results" +msgstr "Risultati" + +msgid "Submission Windows" +msgstr "Finestre di invio" + +msgid "Survey" +msgstr "Sondaggio" + msgid "Person" msgstr "Persona" @@ -2589,9 +2610,6 @@ msgstr "En tant que premier élément du contenu" msgid "As a full width header" msgstr "En tant qu'image d'en-tête pleine largeur" -msgid "Documents" -msgstr "Documenti" - msgid "Show file links in sidebar" msgstr "Mostra i link ai file nella barra laterale" @@ -3543,6 +3561,12 @@ msgstr "Chiudi" msgid "Copy to clipboard" msgstr "Copia negli appunti" +msgid "No submissions yet." +msgstr "Ancora nessuna iscrizione." + +msgid "Number of participants" +msgstr "Numero di partecipanti" + msgid "Hello!" msgstr "Ciao!" @@ -4018,6 +4042,29 @@ msgstr "Biglietto archiviato." msgid "Ticket recovered from archive." msgstr "Biglietto recuperato dall'archivio." +msgid "Warning secret content" +msgstr "Avviso contenuto segreto" + +msgid "" +"You selected 'secret' content for your newsletter. Secret content will not " +"be visible unless you enable it in" +msgstr "" +"Avete selezionato un contenuto 'segreto' per la vostra newsletter. I " +"contenuti segreti non saranno visibili a meno che non vengano abilitati nelle" + +msgid "newsletter settings" +msgstr "impostazioni della newsletter" + +msgid "Warning private content" +msgstr "Avviso contenuto privato" + +msgid "" +"You selected 'private' content for your newsletter. Private content cannot " +"be part of a newsletter." +msgstr "" +"Avete selezionato un contenuto 'privato' per la vostra newsletter. I " +"contenuti privati non possono far parte di una newsletter." + msgid "" "The newsletter is disabled. You can only see this page because you are " "logged in." @@ -4335,6 +4382,21 @@ msgstr "" msgid "Back to page" msgstr "Torna alla pagina" +msgid "Fields" +msgstr "Campi" + +msgid "" +"Please review your answers and press \"Complete\" to complete the survey. If " +"there's anything you'd like to change, click on \"Edit\" to return to the " +"filled-out survey." +msgstr "" +"Rivedi le tue risposte e premi \"Completa\" per completare il sondaggio. Se " +"desideri apportare modifiche, fai clic su \"Modifica\" per tornare al " +"sondaggio compilato." + +msgid "No surveys defined yet." +msgstr "Ancora nessun sondaggio definito." + msgid "No activities yet." msgstr "Ancora nessuna attività." @@ -5604,6 +5666,49 @@ msgstr "Chat" msgid "Topics" msgstr "Argomenti" +msgid "The survey timeframe has ended" +msgstr "Il periodo di indagine è terminato" + +#, python-format +msgid "The survey timeframe opens on ${day}, ${date}" +msgstr "Il periodo di indagine si apre il giorno ${day}, ${date}" + +#, python-format +msgid "The survey timeframe closes on ${day}, ${date}" +msgstr "Il periodo di indagine si chiude il giorno ${day}, ${date}" + +msgid "A survey with this name already exists" +msgstr "Esiste già un'indagine con questo nome" + +msgid "Added a new survey" +msgstr "Aggiunta una nuova indagine" + +msgid "New Survey" +msgstr "Nuova indagine" + +msgid "Exports the submissions of the survey." +msgstr "Esporta gli invii dell'indagine." + +msgid "New Submission Window" +msgstr "Nuova finestra di invio" + +msgid "Submissions windows limit survey submissions to a specific time-range." +msgstr "" +"Le finestre di invio limitano l'invio delle indagini a un intervallo di " +"tempo specifico." + +msgid "Do you really want to delete this submission window?" +msgstr "Vuoi davvero eliminare questa finestra di invio?" + +msgid "Delete submission window" +msgstr "Elimina la finestra di invio" + +msgid "Edit Submission Window" +msgstr "Modifica la finestra di invio" + +msgid "The submission window was deleted" +msgstr "La finestra di invio è stata cancellata" + msgid "Added a new text module" msgstr "Aggiunto un nuovo modulo di testo" diff --git a/src/onegov/org/path.py b/src/onegov/org/path.py index 9ada8173f7..17072d4488 100644 --- a/src/onegov/org/path.py +++ b/src/onegov/org/path.py @@ -1,4 +1,5 @@ """ Contains the paths to the different models served by onegov.org. """ +from onegov.form.models.definition import SurveyDefinition import sedate from datetime import date @@ -28,6 +29,10 @@ from onegov.form import FormDefinition from onegov.form import FormRegistrationWindow from onegov.form import PendingFormSubmission +from onegov.form.collection import SurveyCollection +from onegov.form.models.submission import (CompleteSurveySubmission, + PendingSurveySubmission) +from onegov.form.models.survey_window import SurveySubmissionWindow from onegov.newsletter import Newsletter from onegov.newsletter import NewsletterCollection from onegov.newsletter import RecipientCollection @@ -234,6 +239,36 @@ def get_form(app: OrgApp, name: str) -> FormDefinition | None: return FormCollection(app.session()).definitions.by_name(name) +@OrgApp.path(model=SurveyCollection, path='/surveys') +def get_surveys(app: OrgApp) -> SurveyCollection | None: + return SurveyCollection(app.session()) + + +@OrgApp.path(model=SurveyDefinition, path='/survey/{name}') +def get_survey(app: OrgApp, name: str) -> SurveyDefinition | None: + return SurveyCollection(app.session()).definitions.by_name(name) + + +@OrgApp.path(model=PendingSurveySubmission, path='/survey-preview/{id}', + converters={'id': UUID}) +def get_pending_survey_submission( + app: OrgApp, + id: UUID +) -> PendingSurveySubmission | None: + return SurveyCollection(app.session()).submissions.by_id( # type:ignore + id, state='pending', current_only=True) + + +@OrgApp.path(model=CompleteSurveySubmission, path='/survey-submission/{id}', + converters={'id': UUID}) +def get_complete_survey_submission( + app: OrgApp, + id: UUID +) -> CompleteSurveySubmission | None: + return SurveyCollection(app.session()).submissions.by_id( # type:ignore + id, state='complete', current_only=False) + + @OrgApp.path(model=PendingFormSubmission, path='/form-preview/{id}', converters={'id': UUID}) def get_pending_form_submission( @@ -265,6 +300,17 @@ def get_form_registration_window( return FormCollection(request.session).registration_windows.by_id(id) +@OrgApp.path( + model=SurveySubmissionWindow, + path='/survey-submission-window/{id}', + converters={'id': UUID}) +def get_survey_submission_window( + request: 'OrgRequest', + id: UUID +) -> SurveySubmissionWindow | None: + return SurveyCollection(request.session).submission_windows.by_id(id) + + @OrgApp.path(model=File, path='/storage/{id}') def get_file_for_org( request: 'OrgRequest', diff --git a/src/onegov/org/templates/macros.pt b/src/onegov/org/templates/macros.pt index d1e9dc9d3c..6aae371c36 100644 --- a/src/onegov/org/templates/macros.pt +++ b/src/onegov/org/templates/macros.pt @@ -2216,3 +2216,40 @@ tal:condition="not: layout.org.disable_page_refs" >
+ + + No submissions yet. + Number of participants: ${submission_count} + +
+

${field.label.text}

+

+ + "${'", "'.join(results.get(field.id, []))}" + + +

"${value}"

+
+

+ +
+
+ + +
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ +
diff --git a/src/onegov/org/templates/submission_window.pt b/src/onegov/org/templates/submission_window.pt new file mode 100644 index 0000000000..8e82131ee3 --- /dev/null +++ b/src/onegov/org/templates/submission_window.pt @@ -0,0 +1,28 @@ +
+ + ${title} + + +
+ +
+

+ Results +

+ +
+ +
+
+

Fields

+ +
+
+ +
+
+
diff --git a/src/onegov/org/templates/survey_results.pt b/src/onegov/org/templates/survey_results.pt new file mode 100644 index 0000000000..d1418a1245 --- /dev/null +++ b/src/onegov/org/templates/survey_results.pt @@ -0,0 +1,35 @@ +
+ + ${title} + + +
+ +
+ +
+ +
+
+

Fields

+ +
+ + +
+ +
+ +
+
diff --git a/src/onegov/org/templates/survey_submission.pt b/src/onegov/org/templates/survey_submission.pt new file mode 100644 index 0000000000..3279d9e47a --- /dev/null +++ b/src/onegov/org/templates/survey_submission.pt @@ -0,0 +1,35 @@ +
+ + ${title} + + +
+
+
+
+
+
+
+
+

+ Please review your answers and press "Complete" to complete the survey. + If there's anything you'd like to change, click on "Edit" to return + to the filled-out survey. +

+
+
+
+
+
+
+ + + + Edit + + +
+
+ +
diff --git a/src/onegov/org/templates/surveys.pt b/src/onegov/org/templates/surveys.pt new file mode 100644 index 0000000000..3d5d3c52b6 --- /dev/null +++ b/src/onegov/org/templates/surveys.pt @@ -0,0 +1,28 @@ +
+ + ${title} + + +

No surveys defined yet.

+ +
+ +
+ +
+ +
+
+

Categories

+ +
+
+ +
+ +
+
diff --git a/src/onegov/org/theme/org_theme.py b/src/onegov/org/theme/org_theme.py index b104de9891..4c5d8275f4 100644 --- a/src/onegov/org/theme/org_theme.py +++ b/src/onegov/org/theme/org_theme.py @@ -66,7 +66,7 @@ def foundation_components(self) -> 'Sequence[str]': 'tooltips', 'top-bar', 'type', - 'visibility', + 'visibility' ) @property @@ -82,7 +82,8 @@ def pre_imports(self) -> list[str]: def post_imports(self) -> list[str]: return [ 'org', - 'chosen' + 'chosen', + 'bar-graph' ] @property diff --git a/src/onegov/org/theme/styles/bar-graph.scss b/src/onegov/org/theme/styles/bar-graph.scss new file mode 100644 index 0000000000..35dcfc0391 --- /dev/null +++ b/src/onegov/org/theme/styles/bar-graph.scss @@ -0,0 +1,85 @@ +$bar-size: 45px; +$bar-color: $primary-color; +$bar-rounded: 3px; + +$grid-color: #aaa; + +.chart-wrap { + font-family: sans-serif; + + .title { + text-align: center; + } +} + +.grid { + width: 100%; + height: 100%; + display: flex; + flex-direction: row; + justify-content: center; + border-bottom: 2px solid $grid-color; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 19.5%, + fadeout($grid-color, 30%) 20% + ); + + .bar { + background-color: $bar-color; + width: $bar-size; + height: var(--bar-value); + align-self: flex-end; + margin: 0 auto; + border-radius: $bar-rounded $bar-rounded 0 0; + position: relative; + + &.bar:hover { + opacity: 0.7; + } + + &::after { + content: attr(data-name); + top: -3em; + padding: 10px; + display: inline-block; + white-space: nowrap; + position: absolute; + transform: rotate(-45deg); + } + } + + &.horizontal { + flex-direction: column; + border-bottom: none; + border-left: 2px solid $grid-color; + background: repeating-linear-gradient( + 90deg, + transparent, + transparent 19.5%, + fadeout($grid-color, 30%) 20% + ); + + .bar { + height: $bar-size; + width: var(--bar-value); + align-self: flex-start; + margin: 5px 0 5px 0; + border-radius: 0 $bar-rounded $bar-rounded 0; + + &::after { + top: initial; + padding: 0 10px; + display: inline-block; + margin: 10px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.7); + white-space: nowrap; + position: absolute; + transform: rotate(0deg); + line-height: calc($bar-size - 10px); + } + } + } +} diff --git a/src/onegov/org/theme/styles/org.scss b/src/onegov/org/theme/styles/org.scss index 2ae6c0fe0e..e7f5a90e25 100644 --- a/src/onegov/org/theme/styles/org.scss +++ b/src/onegov/org/theme/styles/org.scss @@ -345,6 +345,8 @@ $globals-height: 2.5rem; .no-tickets::before { @include icon('\f145'); } .link-check::before { @include icon('\f0fa'); } .ticket-archive::before { @include icon('\f1c6'); } + .forms::before { @include icon('\f15c'); } + .surveys::before { @include icon('\f080'); } .with-count::before { border: 1px solid $primary-fg-color; @@ -1603,6 +1605,7 @@ $export-link-icon: '\f019'; $file-link-icon: '\f0c6'; $image-link-icon: '\f03e'; $import-link-icon: '\f093'; +$filters-link-icon: '\f02c'; $internal-link-icon: '\f0c1'; $manage-recipients: '\f003'; $manage-subscribers: '\f0c0'; @@ -1650,6 +1653,7 @@ $pdf-icon: '\f1c1'; $ticket-files-icon: '\f019'; $qr-code-icon: '\f029'; $envelope-icon: '\f0e0'; +$results-icon: '\f080'; $editbar-bg-color: $topbar-link-bg-hover; .edit-bar { @@ -1744,6 +1748,7 @@ $editbar-bg-color: $topbar-link-bg-hover; .file-url::before { @include icon($file-link-icon); } .image-url::before { @include icon($image-link-icon); } .import-link::before { @include icon($import-link-icon); } + .filters-link::before { @include icon($filters-link-icon); } .manage-subscribers::before { @include icon($manage-subscribers); } .new-daypass::before { @include icon($new-daypass-icon); } .new-directory::before { @include icon($new-directory-icon); } @@ -1786,6 +1791,7 @@ $editbar-bg-color: $topbar-link-bg-hover; .cancel::before { @include icon($disabled-icon); } .envelope::before { @include icon($envelope-icon); } .internal-link::before { @include icon($internal-link-icon); } + .results-link::before { @include icon($results-icon); } } .mark-as-paid::before { @include icon($mark-as-paid); } @@ -6753,8 +6759,24 @@ a#results { height: 100%; } - .iframe-panel { border: 1px solid $gray; padding: 1rem; +} + +.survey-definition { + #formcode-snippet-date-and-time, + #formcode-snippet-date, + #formcode-snippet-time, + #formcode-snippet-files, + #formcode-snippet-image, + #formcode-snippet-document, + #formcode-snippet-extended, + #formcode-snippet-iban, + #formcode-snippet-swiss-social-security-number, + #formcode-snippet-swiss-business-identifier, + #formcode-snippet-swiss-vat-number, + #formcode-snippet-markdown { + display: none; + } } \ No newline at end of file diff --git a/src/onegov/org/upgrade.py b/src/onegov/org/upgrade.py index 9ebc8f8ebd..c0008ee7f1 100644 --- a/src/onegov/org/upgrade.py +++ b/src/onegov/org/upgrade.py @@ -17,6 +17,8 @@ from onegov.page import Page, PageCollection from onegov.reservation import Resource from onegov.user import User +from sqlalchemy import Column, ForeignKey +from onegov.core.orm.types import UUID from sqlalchemy.orm import undefer @@ -337,6 +339,23 @@ def add_files_linked_in_content(context: UpgradeContext) -> None: obj.content_file_link_observer({'text'}) +@upgrade_task('Add submission window id to survey submissions') +def add_submission_window_id_to_survey_submissions( + context: UpgradeContext +) -> None: + if not context.has_column('survey_submissions', 'submission_window_id'): + context.add_column_with_defaults( + 'survey_submissions', + Column( + 'submission_window_id', + UUID, + ForeignKey('submission_windows.id'), + nullable=True + ), + default=None + ) + + @upgrade_task('Remove stored contact_html and opening_hours_html') def remove_stored_contact_html_and_opening_hours_html( context: UpgradeContext diff --git a/src/onegov/org/views/form_collection.py b/src/onegov/org/views/form_collection.py index 15d4f087b5..65e94228e3 100644 --- a/src/onegov/org/views/form_collection.py +++ b/src/onegov/org/views/form_collection.py @@ -2,10 +2,12 @@ import collections from markupsafe import Markup -from onegov.core.security import Public +from onegov.core.security import Public, Private from onegov.form import FormCollection, FormDefinition +from onegov.form.collection import SurveyCollection +from onegov.form.models.definition import SurveyDefinition from onegov.org import _, OrgApp -from onegov.org.layout import FormCollectionLayout +from onegov.org.layout import FormCollectionLayout, SurveyCollectionLayout from onegov.org.models.external_link import ( ExternalLinkCollection, ExternalLink) from onegov.org.views.form_definition import get_hints @@ -123,5 +125,38 @@ def hint(model: FormDefinition) -> str: 'link_func': link_func, 'edit_link': edit_link, 'lead_func': lead_func, - 'hint': hint + 'hint': hint, + } + + +@OrgApp.html(model=SurveyCollection, template='surveys.pt', permission=Private) +def view_survey_collection( + self: SurveyCollection, + request: 'OrgRequest', + layout: SurveyCollectionLayout | None = None +) -> 'RenderData': + + surveys = group_by_column( + request=request, + query=self.definitions.query(), + group_column=SurveyDefinition.group, + sort_column=SurveyDefinition.order + ) + + layout = layout or SurveyCollectionLayout(self, request) + + def link_func(model: SurveyDefinition) -> str: + return request.link(model) + + def lead_func(model: SurveyDefinition) -> str: + lead = model.meta.get('lead') + if not lead: + lead = '' + lead = layout.linkify(lead) + return lead + + return { + 'layout': layout, + 'title': _("Surveys"), + 'surveys': surveys, } diff --git a/src/onegov/org/views/form_definition.py b/src/onegov/org/views/form_definition.py index 034aaf1bf9..d1d1195979 100644 --- a/src/onegov/org/views/form_definition.py +++ b/src/onegov/org/views/form_definition.py @@ -3,6 +3,7 @@ from onegov.core.security import Private, Public from onegov.core.utils import normalize_for_url from onegov.form import FormCollection, FormDefinition +from onegov.form import FormRegistrationWindow from onegov.gis import Coordinates from onegov.org import _, OrgApp from onegov.org.cli import close_ticket @@ -16,11 +17,12 @@ from typing import TypeVar, TYPE_CHECKING + if TYPE_CHECKING: from collections.abc import Iterable, Iterator from onegov.core.layout import Layout from onegov.core.types import RenderData - from onegov.form import Form, FormRegistrationWindow, FormSubmission + from onegov.form import Form, FormSubmission from onegov.org.request import OrgRequest from sqlalchemy.orm import Session from webob import Response @@ -49,7 +51,7 @@ def get_form_class( # where we don't actually always have one is weird def get_hints( layout: 'Layout', - window: 'FormRegistrationWindow | None' + window: FormRegistrationWindow | None ) -> 'Iterator[tuple[str, str]]': if not window: @@ -74,8 +76,8 @@ def get_hints( if window.limit and window.overflow: yield 'count', _("There's a limit of ${count} attendees", mapping={ - 'count': window.limit - }) + 'count': window.limit + }) if window.limit and not window.overflow: spots = window.available_spots diff --git a/src/onegov/org/views/form_submission.py b/src/onegov/org/views/form_submission.py index 96cef7b2b9..70d8bbee76 100644 --- a/src/onegov/org/views/form_submission.py +++ b/src/onegov/org/views/form_submission.py @@ -3,7 +3,11 @@ import morepath from onegov.core.security import Public, Private +from onegov.form.collection import SurveyCollection +from onegov.form.models.submission import (CompleteSurveySubmission, + PendingSurveySubmission) from onegov.org.cli import close_ticket +from onegov.org.models.organisation import Organisation from onegov.ticket import TicketCollection from onegov.form import ( FormCollection, @@ -11,7 +15,7 @@ CompleteFormSubmission ) from onegov.org import _, OrgApp -from onegov.org.layout import FormSubmissionLayout +from onegov.org.layout import FormSubmissionLayout, SurveySubmissionLayout from onegov.org.mail import send_ticket_mail from onegov.org.utils import user_group_emails_for_new_ticket from onegov.org.models import TicketMessage, SubmissionMessage @@ -462,3 +466,121 @@ def execute() -> bool: return None return request.redirect(request.link(self)) + + +@OrgApp.html(model=PendingSurveySubmission, + template='survey_submission.pt', + permission=Public, request_method='GET') +@OrgApp.html(model=PendingSurveySubmission, + template='survey_submission.pt', + permission=Public, request_method='POST') +@OrgApp.html(model=CompleteSurveySubmission, + template='survey_submission.pt', + permission=Private, request_method='GET') +@OrgApp.html(model=CompleteSurveySubmission, + template='survey_submission.pt', + permission=Private, request_method='POST') +def handle_pending_survey_submission( + self: PendingSurveySubmission | CompleteSurveySubmission, + request: 'OrgRequest', + layout: SurveySubmissionLayout | None = None +) -> 'RenderData | Response': + """ Renders a pending submission, takes it's input and allows the + user to turn the submission into a complete submission, once all data + is valid. + """ + collection = SurveyCollection(request.session) + + form = request.get_form(self.form_class, data=self.data) + form.action = request.link(self) + form.model = self + + if 'edit' not in request.GET: + form.validate() + + if not request.POST: + form.ignore_csrf_error() + elif not form.errors: + collection.submissions.update(self, form) + + completable = not form.errors and 'edit' not in request.GET + + if completable and 'return-to' in request.GET: + + if 'quiet' not in request.GET: + request.success(_("Your changes were saved")) + + # the default url should actually never be called + return request.redirect(request.url) + + if 'title' in request.GET: + title = request.GET['title'] + else: + assert self.survey is not None + title = self.survey.title + + # retain some parameters in links (the rest throw away) + form.action = copy_query( + request, form.action, ('return-to', 'title', 'quiet')) + + edit_link = URL(copy_query( + request, request.link(self), ('title', ))) + + # the edit link always points to the editable state + edit_link = edit_link.query_param('edit', '') + edit_link = edit_link.as_string() + + checkout_button = None + + return { + 'layout': layout or SurveySubmissionLayout(self, request, title), + 'title': title, + 'form': form, + 'completable': completable, + 'edit_link': edit_link, + 'complete_link': request.link(self, 'complete'), + 'model': self, + 'price': None, + 'checkout_button': checkout_button + } + + +@OrgApp.view(model=PendingSurveySubmission, name='complete', + permission=Public, request_method='POST') +@OrgApp.view(model=CompleteSurveySubmission, name='complete', + permission=Private, request_method='POST') +def handle_complete_survey_submission( + self: PendingSurveySubmission | CompleteSurveySubmission, + request: 'OrgRequest' +) -> 'Response': + + form = request.get_form(self.form_class) + form.process(data=self.data) + form.model = self + + # we're not really using a csrf protected form here (the complete form + # button is basically just there so we can use a POST instead of a GET) + form.validate() + form.ignore_csrf_error() + + if form.errors: + return morepath.redirect(request.link(self)) + else: + if self.state == 'complete': + self.data.changed() # type:ignore[attr-defined] # trigger updates + request.success(_("Your changes were saved")) + + assert self.name is not None + return morepath.redirect(request.link( + SurveyCollection(request.session).scoped_submissions( + self.name, ensure_existance=False) + )) + else: + collection = SurveyCollection(request.session) + + # Expunges the submission from the session + collection.submissions.complete_submission(self) + + request.success(_("Thank you for your submission!")) + + return morepath.redirect(request.class_link(Organisation)) diff --git a/src/onegov/org/views/survey_definition.py b/src/onegov/org/views/survey_definition.py new file mode 100644 index 0000000000..8dec5cfbaa --- /dev/null +++ b/src/onegov/org/views/survey_definition.py @@ -0,0 +1,244 @@ +import morepath + +from onegov.core.security import Private, Public +from onegov.core.utils import normalize_for_url +from onegov.form.collection import SurveyCollection +from onegov.form.models.definition import SurveyDefinition +from onegov.gis import Coordinates +from onegov.org import _, OrgApp +from onegov.org.elements import Link +from onegov.org.forms.form_definition import SurveyDefinitionForm +from onegov.org.layout import FormEditorLayout, SurveySubmissionLayout + + +from typing import TypeVar, TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterator + from onegov.core.layout import Layout + from onegov.core.types import RenderData + from onegov.form import Form + from onegov.form import SurveySubmissionWindow + from onegov.org.request import OrgRequest + from webob import Response + + SurveyDefinitionT = TypeVar('SurveyDefinitionT', bound=SurveyDefinition) + + +def get_hints( + layout: 'Layout', + window: 'SurveySubmissionWindow | None' +) -> 'Iterator[tuple[str, str]]': + + if not window: + return + + if window.in_the_past: + yield 'stop', _("The survey timeframe has ended") + + if window.in_the_future: + yield 'date', _( + "The survey timeframe opens on ${day}, ${date}", mapping={ + 'day': layout.format_date(window.start, 'weekday_long'), + 'date': layout.format_date(window.start, 'date_long') + }) + + if window.in_the_present: + yield 'date', _( + "The survey timeframe closes on ${day}, ${date}", mapping={ + 'day': layout.format_date(window.end, 'weekday_long'), + 'date': layout.format_date(window.end, 'date_long') + }) + + +@OrgApp.form( + model=SurveyCollection, + name='new', template='form.pt', + permission=Private, form=SurveyDefinitionForm +) +def handle_new_survey_definition( + self: SurveyCollection, + request: 'OrgRequest', + form: SurveyDefinitionForm, + layout: FormEditorLayout | None = None +) -> 'RenderData | Response': + + if form.submitted(request): + assert form.title.data is not None + assert form.definition.data is not None + + if self.definitions.by_name(normalize_for_url(form.title.data)): + request.alert(_("A survey with this name already exists")) + else: + definition = self.definitions.add( + title=form.title.data, + definition=form.definition.data, + ) + form.populate_obj(definition) + + request.success(_("Added a new survey")) + return morepath.redirect(request.link(definition)) + + layout = layout or FormEditorLayout(self, request) + layout.breadcrumbs = [ + Link(_("Homepage"), layout.homepage_url), + Link(_("Surveys"), request.class_link(SurveyCollection)), + Link(_("New Survey"), request.link(self, name='new')) + ] + layout.edit_mode = True + + return { + 'layout': layout, + 'title': _("New Survey"), + 'form': form, + 'form_width': 'large', + } + + +@OrgApp.form( + model=SurveyDefinition, + template='form.pt', permission=Public, + form=lambda self, request: self.form_class +) +def handle_defined_survey( + self: SurveyDefinition, + request: 'OrgRequest', + form: 'Form', + layout: SurveySubmissionLayout | None = None +) -> 'RenderData | Response': + """ Renders the empty survey and takes input, even if it's not valid, + stores it as a pending submission and redirects the user to the view that + handles pending submissions. + + """ + + collection = SurveyCollection(request.session) + + if not self.current_submission_window: + enabled = True + else: + enabled = self.current_submission_window.accepts_submissions() + + if enabled and request.POST: + submission = collection.submissions.add( + self.name, form, state='pending') + + return morepath.redirect(request.link(submission)) + + layout = layout or SurveySubmissionLayout(self, request) + + return { + 'layout': layout, + 'title': self.title, + 'form': enabled and form, + 'definition': self, + 'form_width': 'small', + 'lead': layout.linkify(self.meta.get('lead')), + 'text': self.content.get('text'), + 'people': getattr(self, 'people', None), + 'files': getattr(self, 'files', None), + 'contact': getattr(self, 'contact_html', None), + 'coordinates': getattr(self, 'coordinates', Coordinates()), + 'hints': tuple(get_hints(layout, self.current_submission_window)), + 'hints_callout': not enabled, + 'button_text': _('Continue') + } + + +@OrgApp.form( + model=SurveyDefinition, + template='form.pt', permission=Private, + form=SurveyDefinitionForm, name='edit' +) +def handle_edit_survey_definition( + self: SurveyDefinition, + request: 'OrgRequest', + form: SurveyDefinitionForm, + layout: FormEditorLayout | None = None +) -> 'RenderData | Response': + + if form.submitted(request): + assert form.definition.data is not None + # why do we exclude definition here? we set it normally right after + # which is also what populate_obj should be doing + form.populate_obj(self, exclude={'definition'}) + self.definition = form.definition.data + + request.success(_("Your changes were saved")) + return morepath.redirect(request.link(self)) + elif not request.POST: + form.process(obj=self) + + collection = SurveyCollection(request.session) + + layout = layout or FormEditorLayout(self, request) + layout.breadcrumbs = [ + Link(_("Homepage"), layout.homepage_url), + Link(_("Surveys"), request.link(collection)), + Link(self.title, request.link(self)), + Link(_("Edit"), request.link(self, name='edit')) + ] + layout.edit_mode = True + + return { + 'layout': layout, + 'title': self.title, + 'form': form, + 'form_width': 'large', + } + + +@OrgApp.html(model=SurveyDefinition, template='survey_results.pt', + permission=Private, name='results') +def view_survey_results( + self: SurveyDefinition, + request: 'OrgRequest', + layout: SurveySubmissionLayout | None = None +) -> 'RenderData': + + submissions = self.submissions + results = self.get_results(request) + aggregated = ['MultiCheckboxField', 'CheckboxField', 'RadioField'] + + form = request.get_form(self.form_class) + all_fields = form._fields + all_fields.pop('csrf_token', None) + fields = all_fields.values() + + layout = layout or SurveySubmissionLayout(self, request) + layout.breadcrumbs.append( + Link(_("Results"), request.link(self, name='results')) + ) + layout.editbar_links = [] + + return { + 'layout': layout, + 'title': _("Results"), + 'results': results, + 'fields': fields, + 'aggregated': aggregated, + 'submission_count': len(submissions), + 'submission_windows': self.submission_windows + } + + +@OrgApp.view( + model=SurveyDefinition, + request_method='DELETE', + permission=Private +) +def delete_survey_definition( + self: SurveyDefinition, + request: 'OrgRequest' +) -> None: + """ + Deletes the survey along with all its submissions. + """ + + request.assert_valid_csrf_token() + + SurveyCollection(request.session).definitions.delete( + self.name, + with_submissions=True, + with_submission_windows=True, + ) diff --git a/src/onegov/org/views/survey_export.py b/src/onegov/org/views/survey_export.py new file mode 100644 index 0000000000..e968af9292 --- /dev/null +++ b/src/onegov/org/views/survey_export.py @@ -0,0 +1,133 @@ +from collections import OrderedDict +from onegov.core.security import Private +from onegov.form.collection import SurveySubmissionCollection +from onegov.form.models.definition import SurveyDefinition +from onegov.form.models.submission import SurveySubmission +from onegov.form.models.survey_window import SurveySubmissionWindow +from onegov.org import _ +from onegov.org import OrgApp +from onegov.org.forms.survey_export import SurveySubmissionsExport +from onegov.org.layout import SurveySubmissionLayout +from onegov.core.elements import Link +from onegov.org.utils import keywords_first + + +from typing import Any, NamedTuple, TYPE_CHECKING +if TYPE_CHECKING: + from collections.abc import Callable, Collection, Sequence + from datetime import date + from onegov.core.orm.types import UUID + from onegov.core.types import RenderData + from onegov.org.request import OrgRequest + from sqlalchemy.orm import Query + from webob import Response + + class SurveySubmissionRow(NamedTuple): + data: dict[str, Any] + submission_window_start: date + submission_window_end: date + + +@OrgApp.form( + model=SurveyDefinition, + name='export', + permission=Private, + form=SurveySubmissionsExport, + template='export.pt' +) +def handle_form_submissions_export( + self: SurveyDefinition, + request: 'OrgRequest', + form: SurveySubmissionsExport, + layout: SurveySubmissionLayout | None = None +) -> 'RenderData | Response': + + layout = layout or SurveySubmissionLayout(self, request) + layout.breadcrumbs.append(Link(_("Export"), '#')) + layout.editbar_links = None + + if form.submitted(request): + submissions = SurveySubmissionCollection( + request.session, name=self.name) + + if form.data['submission_window']: + query = subset_by_window( + submissions=submissions, + window_ids=form.data['submission_window']) + else: + query = submissions.query().filter_by(state='complete') + + subset = configure_subset(query) + + field_order, results = run_export( + subset=subset, + nested=form.format == 'json', + formatter=layout.export_formatter(form.format)) + + return form.as_export_response(results, self.title, key=field_order) + + return { + 'title': _("Export"), + 'layout': layout, + 'form': form, + 'explanation': _("Exports the submissions of the survey.") + } + + +def subset_by_window( + submissions: SurveySubmissionCollection, + window_ids: 'Collection[UUID]' +) -> 'Query[SurveySubmission]': + return ( + submissions.query() + .filter_by(state='complete') + .filter(SurveySubmission.submission_window_id.in_(window_ids)) + ) + + +def configure_subset( + subset: 'Query[SurveySubmission]', +) -> 'Query[SurveySubmissionRow]': + + subset = subset.join( + SurveySubmissionWindow, + SurveySubmission.submission_window_id == SurveySubmissionWindow.id, + isouter=True + ) + + return subset.with_entities( + SurveySubmission.data, + SurveySubmissionWindow.start.label('submission_window_start'), + SurveySubmissionWindow.end.label('submission_window_end'), + ) + + +def run_export( + subset: 'Query[SurveySubmissionRow]', + nested: bool, + formatter: 'Callable[[object], Any]' +) -> tuple['Callable[[str], tuple[int, str]]', 'Sequence[dict[str, Any]]']: + + keywords = ( + 'submission_window_start', + 'submission_window_end' + ) + + def transform(submission: 'SurveySubmissionRow') -> dict[str, Any]: + r = OrderedDict() + + for keyword in keywords: + r[keyword] = formatter(getattr(submission, keyword)) + + if nested: + r['survey'] = { + k: formatter(v) + for k, v in submission.data.items() + } + else: + for k, v in submission.data.items(): + r[f'{k}'] = formatter(v) + + return r + + return keywords_first(keywords), tuple(transform(s) for s in subset) diff --git a/src/onegov/org/views/survey_submission_window.py b/src/onegov/org/views/survey_submission_window.py new file mode 100644 index 0000000000..6da6abcf57 --- /dev/null +++ b/src/onegov/org/views/survey_submission_window.py @@ -0,0 +1,174 @@ +from onegov.core.security import Private +from onegov.form.models.definition import SurveyDefinition +from onegov.form.models.submission import SurveySubmission +from onegov.form.models.survey_window import SurveySubmissionWindow +from onegov.org import OrgApp, _ +from onegov.org.forms.survey_submission import SurveySubmissionWindowForm +from onegov.org.layout import SurveySubmissionLayout +from onegov.core.elements import Link, Confirm, Intercooler + + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from onegov.core.types import RenderData + from onegov.org.request import OrgRequest + from webob import Response + + +@OrgApp.form( + model=SurveyDefinition, + name='new-submission-window', + permission=Private, + form=SurveySubmissionWindowForm, + template='form.pt' +) +def handle_new_submision_form( + self: SurveyDefinition, + request: 'OrgRequest', + form: SurveySubmissionWindowForm, + layout: SurveySubmissionLayout | None = None +) -> 'RenderData | Response': + title = _("New Submission Window") + + layout = layout or SurveySubmissionLayout(self, request) + layout.edit_mode = True + layout.breadcrumbs.append(Link(title, '#')) + + if form.submitted(request): + assert form.start.data is not None + assert form.end.data is not None + form.populate_obj(self.add_submission_window( + form.start.data, + form.end.data + )) + + request.success(_("The registration window was added successfully")) + return request.redirect(request.link(self)) + + return { + 'layout': layout, + 'title': title, + 'form': form, + 'helptext': _( + "Submissions windows limit survey submissions to a specific " + "time-range." + ) + } + + +@OrgApp.html( + model=SurveySubmissionWindow, + permission=Private, + template='submission_window.pt' +) +def view_submission_window( + self: SurveySubmissionWindow, + request: 'OrgRequest', + layout: SurveySubmissionLayout | None = None +) -> 'RenderData': + + layout = layout or SurveySubmissionLayout(self.survey, request) + title = layout.format_date_range(self.start, self.end) + layout.breadcrumbs.append(Link(title, '#')) + layout.editbar_links = [ + Link( + text=_("Edit"), + url=request.link(self, 'edit'), + attrs={'class': 'edit-link'} + ), + Link( + text=_("Delete"), + url=layout.csrf_protected_url(request.link(self)), + attrs={'class': 'delete-link'}, + traits=( + Confirm( + _( + "Do you really want to delete " + "this submission window?" + ), + _("Existing submissions will be disassociated."), + _("Delete submission window"), + _("Cancel") + ), + Intercooler( + request_method='DELETE', + redirect_after=request.link(self.survey) + ) + ) + ) + ] + + q = request.session.query(SurveySubmission) + submissions = q.filter_by(submission_window_id=self.id).all() + + results = self.survey.get_results(request, self.id) + aggregated = ['MultiCheckboxField', 'CheckboxField', 'RadioField'] + + form = request.get_form(self.survey.form_class) + all_fields = form._fields + all_fields.pop('csrf_token', None) + fields = all_fields.values() + + return { + 'layout': layout, + 'title': title, + 'model': self, + 'results': results, + 'fields': fields, + 'aggregated': aggregated, + 'submission_count': len(submissions) + } + + +@OrgApp.form( + model=SurveySubmissionWindow, + permission=Private, + form=SurveySubmissionWindowForm, + template='form.pt', + name='edit' +) +def handle_edit_submission_window( + self: SurveySubmissionWindow, + request: 'OrgRequest', + form: SurveySubmissionWindowForm, + layout: SurveySubmissionLayout | None = None +) -> 'RenderData | Response': + + title = _("Edit Submission Window") + + layout = layout or SurveySubmissionLayout(self.survey, request) + layout.breadcrumbs.append(Link(title, '#')) + layout.edit_mode = True + + if form.submitted(request): + form.populate_obj(self) + + request.success(_("Your changes were saved")) + return request.redirect(request.link(self.survey)) + + elif not request.POST: + form.process(obj=self) + + return { + 'layout': layout, + 'title': title, + 'form': form + } + + +@OrgApp.view( + model=SurveySubmissionWindow, + permission=Private, + request_method='DELETE' +) +def delete_submission_window( + self: SurveySubmissionWindow, + request: 'OrgRequest' +) -> None: + + request.assert_valid_csrf_token() + + self.disassociate() + request.session.delete(self) + + request.success(_("The submission window was deleted")) diff --git a/src/onegov/town6/layout.py b/src/onegov/town6/layout.py index 8a254d42e1..1e988d7b12 100644 --- a/src/onegov/town6/layout.py +++ b/src/onegov/town6/layout.py @@ -27,8 +27,10 @@ ExternalLinkLayout as OrgExternalLinkLayout, FindYourSpotLayout as OrgFindYourSpotLayout, FormCollectionLayout as OrgFormCollectionLayout, + SurveyCollectionLayout as OrgSurveyCollectionLayout, FormEditorLayout as OrgFormEditorLayout, FormSubmissionLayout as OrgFormSubmissionLayout, + SurveySubmissionLayout as OrgSurveySubmissionLayout, HomepageLayout as OrgHomepageLayout, ImageSetCollectionLayout as OrgImageSetCollectionLayout, ImageSetLayout as OrgImageSetLayout, @@ -74,6 +76,8 @@ from collections.abc import Iterator from onegov.event import Event from onegov.form import FormDefinition, FormSubmission + from onegov.form.models.definition import SurveyDefinition + from onegov.form.models.submission import SurveySubmission from onegov.org.models import ExtendedDirectoryEntry from onegov.page import Page from onegov.reservation import Resource @@ -323,6 +327,35 @@ def step_position(self) -> int | None: return 2 +class SurveySubmissionLayout( + StepsLayoutExtension, + OrgSurveySubmissionLayout, + DefaultLayout +): + + app: 'TownApp' + request: 'TownRequest' + model: 'SurveySubmission | SurveyDefinition' + + if TYPE_CHECKING: + def __init__( + self, + model: 'SurveySubmission | SurveyDefinition', + request: 'TownRequest', + title: str | None = None, + *, + hide_steps: bool = False + ) -> None: ... + + @property + def step_position(self) -> int | None: + if self.request.view_name in ('send-message',): + return None + if self.model.__class__.__name__ == 'SurveyDefinition': + return 1 + return 2 + + class FormCollectionLayout(OrgFormCollectionLayout, DefaultLayout): app: 'TownApp' @@ -333,6 +366,12 @@ def forms_url(self) -> str: return self.request.class_link(FormCollection) +class SurveyCollectionLayout(OrgSurveyCollectionLayout, DefaultLayout): + + app: 'TownApp' + request: 'TownRequest' + + class PersonCollectionLayout(OrgPersonCollectionLayout, DefaultLayout): app: 'TownApp' diff --git a/src/onegov/town6/templates/macros.pt b/src/onegov/town6/templates/macros.pt index fd2d4f7d64..17609a484c 100644 --- a/src/onegov/town6/templates/macros.pt +++ b/src/onegov/town6/templates/macros.pt @@ -427,7 +427,11 @@
- ${error} + + + ${error} + +
@@ -669,7 +673,7 @@