Source code for org.views.form_definition
from __future__ import annotations
import morepath
import requests
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, log
from onegov.org.cli import close_ticket
from onegov.org.elements import Link
from onegov.org.forms import FormDefinitionForm
from onegov.org.forms.form_definition import FormDefinitionUrlForm
from onegov.org.layout import FormEditorLayout, FormSubmissionLayout
from onegov.org.models import BuiltinFormDefinition, CustomFormDefinition
from onegov.org.models.form import submission_deletable
from webob import exc
from webob import Response
from typing import 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, FormSubmission
from onegov.org.request import OrgRequest
from sqlalchemy.orm import Session
[docs]
FORMCODE_PROMPT = """\
You are an expert in onegov-cloud formcode syntax, the specialized Markdown
inspired syntax for defining forms.
Always include an email address field.
Special care to comments, please.
Don't use fieldset definitions/title as 'form title' and never put a fieldset
definition without a field.
Do not add any explanations, markdown code blocks, or preamble.
Only output the raw formcode as plain text.
Take care to only use the syntax described in the following specification:
{specification}
"""
[docs]
def get_form_class(
model: BuiltinFormDefinition | CustomFormDefinition | FormCollection,
request: OrgRequest
) -> type[FormDefinitionForm]:
if isinstance(model, FormCollection):
model = CustomFormDefinition()
form_classes = {
'builtin': FormDefinitionForm,
'custom': FormDefinitionForm
}
return model.with_content_extensions(form_classes[model.type], request)
# FIXME: format_date should really be a utility function on the request
# first and on the layout second, passing around layouts in contexts
# where we don't actually always have one is weird
[docs]
def get_hints(
layout: Layout,
window: FormRegistrationWindow | None
) -> Iterator[tuple[str, str]]:
if not window:
return
if window.in_the_past:
yield 'stop', _('The registration has ended')
elif not window.enabled:
yield 'stop', _('The registration is closed')
if window.enabled and window.in_the_future:
yield 'date', _('The registration opens on ${day}, ${date}', mapping={
'day': layout.format_date(window.start, 'weekday_long'),
'date': layout.format_date(window.start, 'date_long')
})
if window.enabled and window.in_the_present:
yield 'date', _('The registration closes on ${day}, ${date}', mapping={
'day': layout.format_date(window.end, 'weekday_long'),
'date': layout.format_date(window.end, 'date_long')
})
if window.limit and window.overflow:
yield 'count', _("There's a limit of ${count} attendees", mapping={
'count': window.limit
})
if window.limit and not window.overflow:
spots = window.available_spots
if spots == 0:
yield 'stop', _('There are no spots left')
elif spots == 1:
yield 'count', _('There is one spot left')
else:
yield 'count', _('There are ${count} spots left', mapping={
'count': spots
})
[docs]
def handle_form_change_name[T: FormDefinition](
form: T,
session: Session,
new_name: str
) -> T:
new_form = form.for_new_name(new_name)
session.add(new_form)
session.flush()
submissions = form.submissions
windows = form.registration_windows
with session.no_autoflush:
# This placed elsewhere will not work
form.submissions = []
form.registration_windows = []
# assigning the whole list directly will not work
for s in submissions:
s.name = new_name
new_form.submissions.append(s)
for w in windows:
w.name = new_name
new_form.registration_windows.append(w)
session.flush()
assert not form.submissions
assert not form.registration_windows
return new_form
@OrgApp.form(
model=FormDefinition, form=FormDefinitionUrlForm,
template='form.pt', permission=Private,
name='change-url'
)
[docs]
def handle_change_form_name(
self: FormDefinition,
request: OrgRequest,
form: FormDefinitionUrlForm,
layout: FormEditorLayout | None = None
) -> RenderData | Response:
"""Since the name used for the url is the primary key, we create a new
FormDefinition to make our live easier """
site_title = _('Change URL')
if form.submitted(request):
assert form.name.data is not None
new_form = handle_form_change_name(
self, request.session, form.name.data
)
request.session.delete(self)
return request.redirect(request.link(new_form))
elif not request.POST:
form.process(obj=self)
return {
'layout': layout or FormEditorLayout(self, request),
'form': form,
'title': site_title,
}
@OrgApp.form(
model=FormDefinition,
template='form.pt', permission=Public,
form=lambda self, request: self.form_class
)
[docs]
def handle_defined_form(
self: FormDefinition,
request: OrgRequest,
form: Form,
layout: FormSubmissionLayout | None = None
) -> RenderData | Response:
""" Renders the empty form 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 = FormCollection(request.session)
if not self.current_registration_window:
spots = 0
enabled = True
else:
spots = 1
enabled = self.current_registration_window.accepts_submissions(spots)
if enabled and request.POST:
submission = collection.submissions.add(
self.name, form, state='pending', spots=spots)
return morepath.redirect(request.link(submission))
layout = layout or FormSubmissionLayout(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.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_registration_window)),
'hints_callout': not enabled,
'button_text': _('Continue')
}
@OrgApp.form(
model=FormCollection,
name='new', template='form.pt',
permission=Private, form=get_form_class
)
[docs]
def handle_new_definition(
self: FormCollection,
request: OrgRequest,
form: FormDefinitionForm,
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 form with this name already exists'))
else:
definition = self.definitions.add(
title=form.title.data,
definition=form.definition.data,
type='custom'
)
form.populate_obj(definition)
request.success(_('Added a new form'))
return morepath.redirect(request.link(definition))
layout = layout or FormEditorLayout(self, request)
layout.breadcrumbs = [
Link(_('Homepage'), layout.homepage_url),
Link(_('Forms'), request.link(self)),
Link(_('New Form'), request.link(self, name='new'))
]
layout.edit_mode = True
return {
'layout': layout,
'title': _('New Form'),
'form': form,
'form_width': 'large',
}
@OrgApp.form(
model=FormDefinition,
template='form.pt', permission=Private,
form=get_form_class, name='edit'
)
[docs]
def handle_edit_definition(
self: FormDefinition,
request: OrgRequest,
form: FormDefinitionForm,
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 = FormCollection(request.session)
layout = layout or FormEditorLayout(self, request)
layout.breadcrumbs = [
Link(_('Homepage'), layout.homepage_url),
Link(_('Forms'), 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.view(
model=FormDefinition,
request_method='DELETE',
permission=Private
)
[docs]
def delete_form_definition(
self: FormDefinition,
request: OrgRequest
) -> None:
"""
With introduction of cancelling submissions over the registration window,
we encourage the user to use this functionality to cancel all form
submissions through the ticket system.
This ensures all submissions are cancelled/denied and the tickets are
closed.In that case the ticket itself attached to the submission is
deletable.
If the customer wants to delete the form directly, we allow it now even
when there are completed submissions. In each case there is a ticket
associated with it we might take a snapshot before deleting it.
"""
request.assert_valid_csrf_token()
if self.type != 'custom':
raise exc.HTTPMethodNotAllowed()
def handle_ticket(submission: FormSubmission) -> None:
ticket = submission_deletable(submission, request.session)
if ticket is False:
raise exc.HTTPMethodNotAllowed()
if ticket is not True:
assert request.current_user is not None
close_ticket(ticket, request.current_user, request)
ticket.create_snapshot(request)
def handle_submissions(submissions: Iterable[FormSubmission]) -> None:
for s in submissions:
handle_ticket(s)
FormCollection(request.session).definitions.delete(
self.name,
with_submissions=True,
with_registration_windows=True,
handle_submissions=handle_submissions
)
@OrgApp.view(
model=FormCollection,
name='formcoder',
request_method='POST',
permission=Private
)
[docs]
def formcoder(self: FormCollection, request: OrgRequest) -> Response:
"""Generate formcode for a new form being created or edited.
This handles POSTs to e.g. /forms/formcoder, requesting AI support
from infomaniak and finally returns plain text being replaced in the
form definition text field.
"""
token = request.app.infomaniak_api_token
product_id = request.app.infomaniak_product_id
snippet: str = str(request.params.get('snippet', ''))
if not snippet:
return Response(body='Missing prompt', content_type='text/plain')
if not token:
log.warning('Formcoder: Infomaniak API token not configured')
return Response(
body='INFOMANIAK_API_TOKEN_NOT_CONFIGURED',
content_type='text/plain')
if not product_id:
log.warning(
'Formcoder: Could not retrieve product id from Infomaniak API')
return Response(
body='Could not retrieve product id from Infomaniak API',
content_type='text/plain',
)
if not request.app.formcode_specification:
log.warning('Formcoder: Formcode specification not configured')
return Response(
body='Formcode specification not configured',
content_type='text/plain'
)
prompt = FORMCODE_PROMPT.format(
specification=request.app.formcode_specification
)
url = (
f'https://api.infomaniak.com/1/ai/{product_id}'
f'/openai/chat/completions'
)
try:
response = requests.post(
url=url,
headers={
'Authorization': f'Bearer {token}',
'Content-Type': 'application/json',
},
json={
'model': 'qwen3',
'messages': [
{'role': 'system', 'content': prompt},
{'role': 'user', 'content': snippet}
],
'temperature': 0,
},
timeout=(10, 30)
)
except Exception as e:
log.error(
f'Formcoder: Infomaniak API request failed: {e}', exc_info=True)
return Response(
body=f"Infomaniak API request failed with '{e}'",
content_type='text/plain')
if not response.ok:
log.error(f'Formcoder: Failed to generate form code. '
f'API error: {response.status_code}, {response.text}')
return Response(
body=f'Formcoder: Failed to generate form code. API error '
f'{response.text}',
content_type='text/plain')
data = response.json()
definition = data['choices'][0]['message']['content']
return Response(body=definition, content_type='text/plain')