-
Notifications
You must be signed in to change notification settings - Fork 26
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #4962 from open-formulieren/feature/4930-bulk-expo…
…rt-submission-stats Enable exporting (registered) submission metadata
- Loading branch information
Showing
9 changed files
with
526 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,4 @@ | ||
from .form_definition import FormDefinitionForm # noqa | ||
from .form_definition import FormDefinitionForm | ||
from .form_statistics import ExportStatisticsForm | ||
|
||
__all__ = ["FormDefinitionForm", "ExportStatisticsForm"] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
from __future__ import annotations | ||
|
||
from datetime import date | ||
|
||
from django import forms | ||
from django.contrib.admin.widgets import AdminDateWidget | ||
from django.utils import timezone | ||
from django.utils.translation import gettext_lazy as _ | ||
|
||
from dateutil.relativedelta import relativedelta | ||
from tablib import Dataset | ||
|
||
from ..models import Form | ||
from ..statistics import export_registration_statistics | ||
|
||
|
||
def get_first_of_previous_month() -> date: | ||
now = timezone.localtime() | ||
one_month_ago = now - relativedelta(months=1) | ||
first_of_previous_month = one_month_ago.replace(day=1) | ||
return first_of_previous_month.date() | ||
|
||
|
||
def get_last_of_previous_month() -> date: | ||
now = timezone.localtime() | ||
first_of_current_month = now.replace(day=1) | ||
get_last_of_previous_month = first_of_current_month - relativedelta(days=1) | ||
return get_last_of_previous_month.date() | ||
|
||
|
||
class ExportStatisticsForm(forms.Form): | ||
start_date = forms.DateField( | ||
label=_("From"), | ||
required=True, | ||
initial=get_first_of_previous_month, | ||
help_text=_( | ||
"Export form submission that were submitted on or after this date." | ||
), | ||
widget=AdminDateWidget, | ||
) | ||
end_date = forms.DateField( | ||
label=_("Until"), | ||
required=True, | ||
initial=get_last_of_previous_month, | ||
help_text=_( | ||
"Export form submission that were submitted before or on this date." | ||
), | ||
widget=AdminDateWidget, | ||
) | ||
limit_to_forms = forms.ModelMultipleChoiceField( | ||
label=_("Forms"), | ||
required=False, | ||
queryset=Form.objects.filter(_is_deleted=False), | ||
help_text=_( | ||
"Limit the export to the selected forms, if specified. Leave the field " | ||
"empty to export all forms. Hold CTRL (or COMMAND on Mac) to select " | ||
"multiple options." | ||
), | ||
) | ||
|
||
def export(self) -> Dataset: | ||
start_date: date = self.cleaned_data["start_date"] | ||
end_date: date = self.cleaned_data["end_date"] | ||
return export_registration_statistics( | ||
start_date, | ||
end_date, | ||
self.cleaned_data["limit_to_forms"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
from datetime import date, datetime, time | ||
|
||
from django.contrib.contenttypes.models import ContentType | ||
from django.db import models | ||
from django.utils.timezone import make_aware | ||
from django.utils.translation import gettext_lazy as _ | ||
|
||
from tablib import Dataset | ||
|
||
from openforms.logging import logevent | ||
from openforms.logging.models import TimelineLogProxy | ||
from openforms.submissions.models import Submission | ||
|
||
from .models import Form | ||
|
||
|
||
def export_registration_statistics( | ||
start_date: date, | ||
end_date: date, | ||
limit_to_forms: models.QuerySet[Form] | None = None, | ||
) -> Dataset: | ||
""" | ||
Export the form registration statistics to a tablib Dataset. | ||
The export retrieves log records within the specified date range (closed interval, | ||
[start_date, end_date]), optionally filtering them down to a set of forms. Only log | ||
records for successful registration are considered. | ||
:arg start_date: include log records starting from this date (midnight, in the local | ||
timezone). | ||
:arg end_date: include log records until (and including) this date. Log record up to | ||
midnight (in the local timezone) the next day are included, i.e. | ||
``$date, 23:59:59 999999us``. | ||
:arg limit_to_forms: A queryset of forms to limit the export to. If not provided or | ||
``None`` is given, all forms are included. | ||
""" | ||
dataset = Dataset( | ||
headers=( | ||
_("Public reference"), | ||
_("Form name (public)"), | ||
_("Form name (internal)"), | ||
_("Submitted on"), | ||
_("Registered on"), | ||
), | ||
title=_("Successfully registered submissions between {start} and {end}").format( | ||
start=start_date.isoformat(), | ||
end=end_date.isoformat(), | ||
), | ||
) | ||
|
||
_start_date = make_aware(datetime.combine(start_date, time.min)) | ||
_end_date = make_aware(datetime.combine(end_date, time.max)) | ||
|
||
log_records = TimelineLogProxy.objects.filter( | ||
content_type=ContentType.objects.get_for_model(Submission), | ||
timestamp__gte=_start_date, | ||
timestamp__lt=_end_date, | ||
# see openforms.logging.logevent for the data structure of the extra_data | ||
# JSONField | ||
extra_data__log_event=logevent.REGISTRATION_SUCCESS_EVENT, | ||
).order_by("timestamp") | ||
|
||
if limit_to_forms: | ||
form_ids = list(limit_to_forms.values_list("pk", flat=True)) | ||
log_records = log_records.filter(extra_data__form_id__in=form_ids) | ||
|
||
for record in log_records.iterator(): | ||
extra_data = record.extra_data | ||
# GFKs will be broken when the submissions are pruned, so prefer extracting | ||
# information from the extra_data snapshot | ||
submission: Submission | None = record.content_object | ||
dataset.append( | ||
( | ||
# public reference | ||
extra_data.get( | ||
"public_reference", | ||
( | ||
submission.public_registration_reference | ||
if submission | ||
else "-unknown-" | ||
), | ||
), | ||
# public form name | ||
extra_data.get( | ||
"form_name", submission.form.name if submission else "-unknown-" | ||
), | ||
# internal form name | ||
extra_data.get( | ||
"internal_form_name", | ||
submission.form.internal_name if submission else "-unknown-", | ||
), | ||
# when the user submitted the form | ||
extra_data.get( | ||
"submitted_on", | ||
submission.completed_on.isoformat() if submission else None, | ||
), | ||
# when the registration succeeeded - this must be close to when it was logged | ||
record.timestamp.isoformat(), | ||
) | ||
) | ||
|
||
return dataset |
11 changes: 11 additions & 0 deletions
11
src/openforms/forms/templates/admin/forms/formstatistics/change_list_object_tools.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
{% extends "admin/change_list_object_tools.html" %} | ||
{% load i18n %} | ||
|
||
{% block object-tools-items %} | ||
<li> | ||
<a href="{% url 'admin:formstatistics_export' %}" class="viewlink"> | ||
{% trans "Export submission statistics" %} | ||
</a> | ||
</li> | ||
{{ block.super }} | ||
{% endblock %} |
66 changes: 66 additions & 0 deletions
66
src/openforms/forms/templates/admin/forms/formstatistics/export_form.html
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
{% extends "admin/base_site.html" %} | ||
{% load static i18n django_admin_index %} | ||
|
||
{% block extrahead %}{{ block.super }} | ||
<script src="{% url 'admin:jsi18n' %}"></script> | ||
{{ media }} | ||
{% endblock %} | ||
|
||
{% block extrastyle %}{{ block.super }} | ||
<link rel="stylesheet" href="{% static "admin/css/forms.css" %}"> | ||
<link rel="stylesheet" href="{% static "admin/css/admin-index.css" %}">{% endblock %} | ||
|
||
{% block nav-global %}{% include "django_admin_index/includes/app_list.html" %}{% endblock nav-global %} | ||
|
||
{% block title %} {% trans "Export submission statistics" %} {{ block.super }} {% endblock %} | ||
|
||
{% block breadcrumbs %} | ||
<div class="breadcrumbs"> | ||
<a href="{% url 'admin:index' %}">{% trans 'Home' %}</a> | ||
› <a href="{% url 'admin:forms_formstatistics_changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a> | ||
› {% trans 'Export submission statistics' %} | ||
</div> | ||
{% endblock %} | ||
|
||
{% block content %} | ||
<h1>{% trans 'Export submission statistics' %}</h1> | ||
|
||
<div id="content-main"> | ||
<form action="." method="post" id="export-statistics"> | ||
{% csrf_token %} | ||
|
||
<fieldset class="module aligned"> | ||
<div class="description">{% blocktrans trimmed %} | ||
<p>Here you can create an export of successfully registered form submissions. The | ||
export file contains the following columns: public reference, form name, | ||
form internal name, the submission datetime and the timestamp of registration.</p> | ||
|
||
<p>You can use the filters below to limit the result set in the export.</p> | ||
{% endblocktrans %}</div> | ||
|
||
{# TODO: doesn't handle checkboxes, see admin/includes/fieldset.html for when this is necessary #} | ||
{% for field in form_fields %} | ||
<div class="form-row {% if field.errors %}errors{% endif %}"> | ||
{{ field.errors }} | ||
<div> | ||
<div class="flex-container {% if field.errors %}errors{% endif %}"> | ||
{{ field.label_tag }} | ||
{{ field.field }} | ||
</div> | ||
</div> | ||
|
||
{% if field.field.help_text %} | ||
<div class="help" {% if field.field.id_for_label %} id="{{ field.field.id_for_label }}_helptext"{% endif %}> | ||
<div>{{ field.field.help_text|safe }}</div> | ||
</div> | ||
{% endif %} | ||
</div> | ||
{% endfor %} | ||
</fieldset> | ||
|
||
<div class="submit-row"> | ||
<input type="submit" class="default" value="{% trans 'Export' %}"> | ||
</div> | ||
</form> | ||
</div> | ||
{% endblock %} |
Oops, something went wrong.