Skip to content

Commit 77ba0ef

Browse files
committed
feat(notification): export notification templates to CSV
KK-1441. Add an export to CSV button for notification templates in Django admin site.
1 parent cb01756 commit 77ba0ef

5 files changed

Lines changed: 237 additions & 8 deletions

File tree

kukkuu/urls.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@ def graphql_view(request, *args, **kwargs):
5353
*URL_PATTERNS_FOR_DJANGO_ADMIN_KEYCLOAK_LOGIN,
5454
path("admin/", admin.site.urls),
5555
re_path(r"^graphql/?$", graphql_view),
56+
path(
57+
"notification_importers/",
58+
include(
59+
("notification_importers.urls", "notification_importers"),
60+
namespace="notification_importers",
61+
),
62+
),
5663
path("reports/", include(router.urls)),
5764
path("reports/schema/", SpectacularAPIView.as_view(), name="schema"),
5865
path(

notification_importers/admin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,9 +82,17 @@ def get_urls(self):
8282
self.admin_site.admin_view(self.import_missing_notifications),
8383
name="import-missing-notifications",
8484
),
85+
path(
86+
"export-notification-templates/",
87+
self.admin_site.admin_view(self.export_notifications_csv),
88+
name="export-notification-templates",
89+
),
8590
]
8691
return custom_urls + urls
8792

93+
def export_notifications_csv(self, request):
94+
return HttpResponseRedirect(reverse("export-notification-templates-csv"))
95+
8896
def import_missing_notifications(self, request):
8997
if self.importer:
9098
try:

notification_importers/csv_api.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
import csv
2+
import logging
3+
4+
from django.http import HttpResponse
5+
from django_ilmoitin.models import NotificationTemplate
6+
from rest_framework.authentication import SessionAuthentication
7+
from rest_framework.generics import GenericAPIView
8+
from rest_framework.permissions import IsAdminUser
9+
10+
from kukkuu.oidc import BrowserTestAwareJWTAuthentication
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
class ExportCsvMixin:
16+
"""
17+
Mixin to provide CSV export functionality.
18+
This mixin can be used in views to export data as a CSV file.
19+
"""
20+
21+
def get_attributes(self) -> list[str]:
22+
"""
23+
Returns the attributes to be exported in the CSV file.
24+
This can be overridden in subclasses to customize the attributes.
25+
"""
26+
return self.attributes
27+
28+
def get_languages(self) -> list[str]:
29+
"""
30+
Returns the languages to be used for translated fields in the CSV file.
31+
This can be overridden in subclasses to customize the languages.
32+
"""
33+
return self.languages
34+
35+
def create_csv_response_writer(self, filename):
36+
response = HttpResponse(content_type="text/csv; charset=utf-8")
37+
response["Content-Disposition"] = "attachment; filename={}.csv".format(filename)
38+
39+
"""
40+
Adding BOM is not advicable, and UTF-8 shouldn't even have that,
41+
but Microsoft product seems to sometimes need it.
42+
"""
43+
response.write("\ufeff")
44+
45+
"""
46+
CSV is a delimited text file that uses a comma to separate values
47+
(many implementations of CSV import/export tools allow other
48+
separators to be used; for example, the use of a "Sep=^" row
49+
as the first row in the csv file will cause Excel to open
50+
the file expecting caret "^" to be the separator instead of comma ",").
51+
~ https://en.wikipedia.org/wiki/Comma-separated_values.
52+
NOTE: At least when using a comma as a delimiter,
53+
the separator is needed to be defined for Microsoft Excel.
54+
NOTE: When tested with Microsoft Excel for Mac, strangely,
55+
it seems to help Excel to choose the delimiter, but seems to break encoding
56+
and the scandinavian letters are not shown properly.
57+
"""
58+
# response.write(f"sep={self.csv_delimiter}{self.csv_dialect.lineterminator}")
59+
60+
"""
61+
The CSV library uses Excel as the default dialect,
62+
but Excel still seems not to work properly with it,
63+
since there were issues with the separator and the encoding.
64+
Using the semicolon (";") as a delimiter
65+
seems to fix UTF-8 issues with Microsoft Excel.
66+
"""
67+
writer = csv.writer(
68+
response, dialect=self.csv_dialect, delimiter=self.csv_delimiter
69+
)
70+
71+
return writer, response
72+
73+
def write_csv_header_row(self, writer: csv.writer) -> None:
74+
"""
75+
Writes the header row for the CSV file.
76+
This row contains the column names for the notification templates.
77+
78+
@example:
79+
header_row = [
80+
"Notification Type",
81+
"Subject | FI",
82+
"Subject | SV",
83+
"Subject | EN",
84+
"Body Text | FI",
85+
"Body Text | SV",
86+
"Body Text | EN"
87+
]
88+
"""
89+
logger.debug("Writing header row for CSV file")
90+
header_row = []
91+
for attr in self.get_attributes():
92+
verbose_field_name = self._get_verbose_field_name(attr).capitalize()
93+
94+
if not self._is_translated_field(attr):
95+
header_row.append(verbose_field_name)
96+
else:
97+
for lang in self.get_languages():
98+
header_row.append(f"{verbose_field_name} | {lang.upper()}")
99+
# Write the header row to the CSV file
100+
writer.writerow(header_row)
101+
102+
def write_csv_data_row(
103+
self, writer: csv.writer, template: NotificationTemplate
104+
) -> None:
105+
"""
106+
Writes a single row of data for a notification template to the CSV file.
107+
"""
108+
logger.debug(f"Writing data row for template: {template.type}")
109+
data_row = []
110+
for attr in self.get_attributes():
111+
if not self._is_translated_field(attr):
112+
data_row.append(getattr(template, attr))
113+
else:
114+
for lang in self.get_languages():
115+
data_row.append(
116+
template.safe_translation_getter(
117+
attr, language_code=lang, default=""
118+
)
119+
)
120+
writer.writerow(data_row)
121+
122+
def _is_translated_field(self, field_name: str) -> bool:
123+
"""
124+
Checks if the given field name is a translated field in the model.
125+
Returns True if it is a translated field, otherwise False.
126+
"""
127+
return field_name in self.model._parler_meta.get_translated_fields()
128+
129+
def _get_verbose_field_name(self, field_name: str) -> str:
130+
"""
131+
Returns the verbose name of a field from the model's metadata.
132+
If the field does not exist, it returns the field name itself.
133+
"""
134+
logger.debug(f"Getting verbose name for field: {field_name}")
135+
try:
136+
if self._is_translated_field(field_name):
137+
# If the field is a translated field, get the verbose name
138+
# from the parler model
139+
parler_model = self.model._parler_meta.get_model_by_field(field_name)
140+
field = parler_model._meta.get_field(field_name)
141+
else:
142+
# If the field is a regular field, get the verbose name from the model
143+
field = self.model._meta.get_field(field_name)
144+
if hasattr(field, "verbose_name"):
145+
if field.verbose_name:
146+
return field.verbose_name
147+
return field.name
148+
149+
except Exception as e:
150+
logger.error(f"Error getting verbose name for field '{field_name}': {e}")
151+
return field_name
152+
153+
154+
class ExportNotificationTemplatesCsvView(ExportCsvMixin, GenericAPIView):
155+
"""
156+
View to export notification templates as a CSV file.
157+
"""
158+
159+
model = NotificationTemplate
160+
queryset = NotificationTemplate.objects.all()
161+
serializer_class = None
162+
authentication_classes = [BrowserTestAwareJWTAuthentication, SessionAuthentication]
163+
permission_classes = [IsAdminUser]
164+
csv_dialect = csv.excel
165+
csv_delimiter = ";"
166+
167+
attributes = [
168+
"type",
169+
"subject",
170+
"body_text",
171+
"body_html",
172+
]
173+
languages = ["fi", "sv", "en"]
174+
csv_filename = "kukkuu_notification_templates"
175+
176+
def get_queryset(self):
177+
return self.queryset.order_by("type")
178+
179+
def get(self, request, *args, **kwargs):
180+
return self._write_csv_response()
181+
182+
def _write_csv_response(self) -> HttpResponse:
183+
"""
184+
Writes the CSV response for the notification templates.
185+
This method creates a CSV response with the notification templates data.
186+
It uses the `create_csv_response_writer` method to create a CSV writer
187+
and writes the header row and data rows using `write_csv_header_row` and
188+
`write_csv_data_row` methods respectively.
189+
"""
190+
logger.debug("Creating CSV response for notification templates")
191+
writer, response = self.create_csv_response_writer(filename=self.csv_filename)
192+
self.write_csv_header_row(writer)
193+
for template in self.get_queryset():
194+
self.write_csv_data_row(writer, template)
195+
return response
Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1-
{% extends "admin/change_list.html" %}
2-
{% load i18n static %}
3-
4-
{% block object-tools-items %}
5-
{{ block.super }}
1+
{% extends "admin/change_list.html" %} {% load i18n static %}
2+
{% block object-tools-items %} {{ block.super }}
63
<li>
7-
<a href="{% url 'admin:import-missing-notifications' %}" class="btn btn-high btn-success">
8-
{% trans "Import missing notifications with the importer" %}
9-
</a>
4+
<a
5+
href="{% url 'notification_importers:export-notification-templates-csv' %}"
6+
class="btn btn-high btn-success"
7+
>
8+
{% trans "Export notification templates in CSV" %}
9+
</a>
10+
</li>
11+
<li>
12+
<a
13+
href="{% url 'admin:import-missing-notifications' %}"
14+
class="btn btn-high btn-success"
15+
>
16+
{% trans "Import missing notifications with the importer" %}
17+
</a>
1018
</li>
1119
{% endblock %}

notification_importers/urls.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.urls import path
2+
3+
from notification_importers.csv_api import ExportNotificationTemplatesCsvView
4+
5+
urlpatterns = [
6+
path(
7+
"csv_api/export-notification-templates/",
8+
ExportNotificationTemplatesCsvView.as_view(),
9+
name="export-notification-templates-csv",
10+
),
11+
]

0 commit comments

Comments
 (0)