Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • hansegucker/AlekSIS-Core
  • pinguin/AlekSIS-Core
  • AlekSIS/official/AlekSIS-Core
  • sunweaver/AlekSIS-Core
  • sggua/AlekSIS-Core
  • edward/AlekSIS-Core
  • magicfelix/AlekSIS-Core
7 results
Show changes
Commits on Source (55)
Showing
with 311 additions and 121 deletions
AlekSIS — All-libre extensible kit for school information systems
=================================================================
AlekSIS (School Information System) — Core (Core functionality and app framework)
=================================================================================
Warning
-------
**This is an alpha version of AlekSIS, the free school information system.
The AlekSIS team is looking for schools who want to help shape the 2.0
final release and supports interested schools in operating AlekSIS.**
What AlekSIS is
----------------
`AlekSIS`_ is a web-based school information system (SIS) which can be used to
manage and/or publish organisational subjects of educational institutions.
Formerly two separate projects (BiscuIT and SchoolApps), developed by
`Teckids e.V.`_ and a team of students at `Katharineum zu Lübeck`_, they
were merged into the AlekSIS project in 2020.
AlekSIS standard distribution
-----------------------------
AlekSIS is a platform based on Django, that provides central funstions
and data structures that can be used by apps that are developed and provided
seperately. The AlekSIS team also maintains a set of official apps which
make AlekSIS a fully-featured software solutions for the information
management needs of schools.
The AlekSIS standard distribution with information about all official apps
can be found on `EduGit`_.
By design, the platform can be used by schools to write their own apps for
specific needs they face, also in coding classes. Students are empowered to
create real-world applications that bring direct value to their environment.
Features
--------
AlekSIS is part of the `schul-frei`_ project as a component in sustainable
educational networks.
Core features
--------------
The AlekSIS-Core currently provides the following features:
* For users:
......@@ -53,24 +31,6 @@ Core features
* Authentication via LDAP
* Automatic backup of database, static and media files
Official apps
-------------
+--------------------------------------+---------------------------------------------------------------------------------------------+
| App name | Purpose |
+======================================+=============================================================================================+
| `AlekSIS-App-Chronos`_ | The Chronos app provides functionality for digital timetables. |
+--------------------------------------+---------------------------------------------------------------------------------------------+
| `AlekSIS-App-DashboardFeeds`_ | The DashboardFeeds app provides functionality to add RSS or Atom feeds to dashboard |
+--------------------------------------+---------------------------------------------------------------------------------------------+
| `AlekSIS-App-Hjelp`_ | The Hjelp app provides functionality for aiding users. |
+--------------------------------------+---------------------------------------------------------------------------------------------+
| `AlekSIS-App-LDAP`_ | The LDAP app provides functionality to import users and groups from LDAP |
+--------------------------------------+---------------------------------------------------------------------------------------------+
| `AlekSIS-App-Untis`_ | This app provides import and export functions to interact with Untis, a timetable software. |
+--------------------------------------+---------------------------------------------------------------------------------------------+
Licence
-------
......@@ -91,13 +51,6 @@ full licence text or on the `European Union Public Licence`_ website
https://joinup.ec.europa.eu/collection/eupl/guidelines-users-and-developers
(including all other official language versions).
.. _AlekSIS: https://aleksis.org/
.. _Teckids e.V.: https://www.teckids.org/
.. _Katharineum zu Lübeck: https://www.katharineum.de/
.. _AlekSIS: https://edugit.org/AlekSIS/Official/AlekSIS
.. _European Union Public Licence: https://eupl.eu/
.. _schul-frei: https://schul-frei.org/
.. _AlekSIS-App-Chronos: https://edugit.org/AlekSIS/official/AlekSIS-App-Chronos
.. _AlekSIS-App-DashboardFeeds: https://edugit.org/AlekSIS/official/AlekSIS-App-DashboardFeeds
.. _AlekSIS-App-Hjelp: https://edugit.org/AlekSIS/official/AlekSIS-App-Hjelp
.. _AlekSIS-App-LDAP: https://edugit.org/AlekSIS/official/AlekSIS-App-LDAP
.. _AlekSIS-App-Untis: https://edugit.org/AlekSIS/official/AlekSIS-App-Untis
.. _EduGit: https://edugit.org/AlekSIS/official/AlekSIS
......@@ -10,7 +10,15 @@ from dynamic_preferences.forms import PreferenceForm
from material import Fieldset, Layout, Row
from .mixins import ExtensibleForm, SchoolTermRelatedExtensibleForm
from .models import AdditionalField, Announcement, Group, GroupType, Person, SchoolTerm
from .models import (
AdditionalField,
Announcement,
DashboardWidget,
Group,
GroupType,
Person,
SchoolTerm,
)
from .registries import (
group_preferences_registry,
person_preferences_registry,
......@@ -345,3 +353,20 @@ class SchoolTermForm(ExtensibleForm):
class Meta:
model = SchoolTerm
exclude = []
class DashboardWidgetOrderForm(ExtensibleForm):
pk = forms.ModelChoiceField(
queryset=DashboardWidget.objects.all(),
widget=forms.HiddenInput(attrs={"class": "pk-input"}),
)
order = forms.IntegerField(initial=0, widget=forms.HiddenInput(attrs={"class": "order-input"}))
class Meta:
model = DashboardWidget
fields = []
DashboardWidgetOrderFormSet = forms.formset_factory(
form=DashboardWidgetOrderForm, max_num=0, extra=0
)
......@@ -1506,8 +1506,8 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
......
......@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: AlekSIS (School Information System) 0.1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-08-02 16:29+0200\n"
"PO-Revision-Date: 2020-08-02 15:00+0000\n"
"PO-Revision-Date: 2020-12-19 12:57+0000\n"
"Last-Translator: Jonathan Weth <teckids@jonathanweth.de>\n"
"Language-Team: German <https://translate.edugit.org/projects/aleksis/aleksis/"
"de/>\n"
......@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.0.1\n"
"X-Generator: Weblate 4.3.2\n"
#: filters.py:37 templates/core/base.html:77 templates/core/group/list.html:20
#: templates/core/person/list.html:24 templates/search/search.html:7
......@@ -412,7 +412,7 @@ msgstr "Kann Kindgruppen zu Gruppen zuordnen"
#: models.py:330
msgid "Long name"
msgstr "Langer Name"
msgstr "Langname"
#: models.py:340 templates/core/group/full.html:65
msgid "Members"
......@@ -1655,14 +1655,14 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
"\n"
" Um mit dem Codegenerator zu starten, benutzen Sie bitte Ihr Smartphone,\n"
"um diesen QR-Code zu scannen (z. B. den Google Authenticator). Dann geben Sie \n"
"den in der App angezeigten Code an:\n"
" Um mit dem Codegenerator zu starten, benutzen Sie bitte Ihre\n"
"App für Zwei-Faktor-Authentifizierung (TOTP), um diesen QR-Code zu scannen.\n"
"Dann geben Sie den in der App angezeigten Code an:\n"
" "
#: templates/two_factor/core/setup.html:34
......
......@@ -1558,8 +1558,8 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
......
......@@ -8,27 +8,26 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-08-02 16:29+0200\n"
"PO-Revision-Date: 2020-04-27 13:03+0000\n"
"PO-Revision-Date: 2020-12-19 12:57+0000\n"
"Last-Translator: Julian <leuckerj@gmail.com>\n"
"Language-Team: Latin <https://translate.edugit.org/projects/aleksis/aleksis/la/>\n"
"Language-Team: Latin <https://translate.edugit.org/projects/aleksis/aleksis/"
"la/>\n"
"Language: la\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 4.0.1\n"
"X-Generator: Weblate 4.3.2\n"
#: filters.py:37 templates/core/base.html:77 templates/core/group/list.html:20
#: templates/core/person/list.html:24 templates/search/search.html:7
#: templates/search/search.html:22
msgid "Search"
msgstr ""
msgstr "Quaerere"
#: filters.py:53
#, fuzzy
#| msgid "Short name"
msgid "Search by name"
msgstr "Breve nomen"
msgstr "Quaerere cum breve nomine"
#: filters.py:65
#, fuzzy
......@@ -249,7 +248,7 @@ msgstr ""
#: models.py:36
msgid "Date and time"
msgstr ""
msgstr "Dies et hora"
#: models.py:37
msgid "Decimal number"
......@@ -1631,8 +1630,8 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
......
......@@ -1505,8 +1505,8 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
......
......@@ -1505,8 +1505,8 @@ msgstr ""
msgid ""
"\n"
" To start using a token generator, please use your\n"
" smartphone to scan the QR code below. For example, use Google\n"
" Authenticator. Then, enter the token generated by the app.\n"
" favourite two factor authentication (TOTP) app to scan the QR code below.\n"
" Then, enter the token generated by the app.\n"
" "
msgstr ""
......
......@@ -93,6 +93,17 @@ MENUS = {
),
],
},
{
"name": _("Dashboard widgets"),
"url": "dashboard_widgets",
"icon": "dashboard",
"validators": [
(
"aleksis.core.util.predicates.permission_validator",
"core.view_dashboardwidget",
),
],
},
{
"name": _("Data management"),
"url": "data_management",
......
# Generated by Django 3.1.4 on 2020-12-20 15:55
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('core', '0005_timestamped_activity_notification'),
]
operations = [
migrations.AddField(
model_name='dashboardwidget',
name='size_l',
field=models.PositiveSmallIntegerField(default=6, help_text='> 992 px, 12 columns', validators=[django.core.validators.MaxValueValidator(12)], verbose_name='Size on desktop devices'),
),
migrations.AddField(
model_name='dashboardwidget',
name='size_m',
field=models.PositiveSmallIntegerField(default=12, help_text='> 600 px, 12 columns', validators=[django.core.validators.MaxValueValidator(12)], verbose_name='Size on tablet devices'),
),
migrations.AddField(
model_name='dashboardwidget',
name='size_s',
field=models.PositiveSmallIntegerField(default=12, help_text='<= 600 px, 12 columns', validators=[django.core.validators.MaxValueValidator(12)], verbose_name='Size on mobile devices'),
),
migrations.AddField(
model_name='dashboardwidget',
name='size_xl',
field=models.PositiveSmallIntegerField(default=4, help_text='> 1200 px>, 12 columns', validators=[django.core.validators.MaxValueValidator(12)], verbose_name='Size on large desktop devices'),
),
]
# Generated by Django 3.1.4 on 2020-12-21 13:38
import django.contrib.sites.managers
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('sites', '0002_alter_domain_unique'),
('core', '0006_dashboard_widget_size'),
]
operations = [
migrations.CreateModel(
name='DashboardWidgetOrder',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('extended_data', models.JSONField(default=dict, editable=False)),
('order', models.PositiveIntegerField(verbose_name='Order')),
('person', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.person', verbose_name='Person')),
('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
('widget', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.dashboardwidget', verbose_name='Dashboard widget')),
],
options={
'verbose_name': 'Dashboard widget order',
'verbose_name_plural': 'Dashboard widget orders',
},
managers=[
('objects', django.contrib.sites.managers.CurrentSiteManager()),
],
),
]
......@@ -9,6 +9,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator
from django.db import models, transaction
from django.db.models import QuerySet
from django.forms.widgets import Media
......@@ -233,6 +234,12 @@ class Person(ExtensibleModel):
years -= 1
return years
@property
def dashboard_widgets(self):
return [
w.widget for w in DashboardWidgetOrder.objects.filter(person=self).order_by("order")
]
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
......@@ -681,6 +688,31 @@ class DashboardWidget(PolymorphicModel, PureDjangoModel):
title = models.CharField(max_length=150, verbose_name=_("Widget Title"))
active = models.BooleanField(verbose_name=_("Activate Widget"))
size_s = models.PositiveSmallIntegerField(
verbose_name=_("Size on mobile devices"),
help_text=_("<= 600 px, 12 columns"),
validators=[MaxValueValidator(12)],
default=12,
)
size_m = models.PositiveSmallIntegerField(
verbose_name=_("Size on tablet devices"),
help_text=_("> 600 px, 12 columns"),
validators=[MaxValueValidator(12)],
default=12,
)
size_l = models.PositiveSmallIntegerField(
verbose_name=_("Size on desktop devices"),
help_text=_("> 992 px, 12 columns"),
validators=[MaxValueValidator(12)],
default=6,
)
size_xl = models.PositiveSmallIntegerField(
verbose_name=_("Size on large desktop devices"),
help_text=_("> 1200 px>, 12 columns"),
validators=[MaxValueValidator(12)],
default=4,
)
def get_context(self):
"""Get the context dictionary to pass to the widget template."""
raise NotImplementedError("A widget subclass needs to implement the get_context method.")
......@@ -701,6 +733,18 @@ class DashboardWidget(PolymorphicModel, PureDjangoModel):
verbose_name_plural = _("Dashboard Widgets")
class DashboardWidgetOrder(ExtensibleModel):
widget = models.ForeignKey(
DashboardWidget, on_delete=models.CASCADE, verbose_name=_("Dashboard widget")
)
person = models.ForeignKey(Person, on_delete=models.CASCADE, verbose_name=_("Person"))
order = models.PositiveIntegerField(verbose_name=_("Order"))
class Meta:
verbose_name = _("Dashboard widget order")
verbose_name_plural = _("Dashboard widget orders")
class CustomMenu(ExtensibleModel):
"""A custom menu to display in the footer."""
......
......@@ -278,3 +278,16 @@ view_group_stats_predicate = has_person & (
has_global_perm("core.view_group_stats") | has_object_perm("core.view_group_stats")
)
rules.add_perm("core.view_group_stats", view_group_stats_predicate)
view_dashboard_widget_predicate = has_person & has_global_perm("core.view_dashboardwidget")
rules.add_perm("core.view_dashboardwidget", view_dashboard_widget_predicate)
create_dashboard_widget_predicate = has_person & has_global_perm("core.add_dashboardwidget")
rules.add_perm("core.create_dashboardwidget", create_dashboard_widget_predicate)
edit_dashboard_widget_predicate = has_person & has_global_perm("core.change_dashboardwidget")
rules.add_perm("core.edit_dashboardwidget", edit_dashboard_widget_predicate)
delete_dashboard_widget_predicate = has_person & has_global_perm("core.delete_dashboardwidget")
rules.add_perm("core.delete_dashboardwidget", delete_dashboard_widget_predicate)
......@@ -44,6 +44,23 @@ DEBUG_TOOLBAR_CONFIG = {
"SHOW_TOOLBAR_CALLBACK": "aleksis.core.util.core_helpers.dt_show_toolbar",
}
DEBUG_TOOLBAR_PANELS = [
"debug_toolbar.panels.versions.VersionsPanel",
"debug_toolbar.panels.timer.TimerPanel",
"debug_toolbar.panels.settings.SettingsPanel",
"debug_toolbar.panels.headers.HeadersPanel",
"debug_toolbar.panels.request.RequestPanel",
"debug_toolbar.panels.sql.SQLPanel",
"debug_toolbar.panels.cache.CachePanel",
"debug_toolbar.panels.staticfiles.StaticFilesPanel",
"debug_toolbar.panels.templates.TemplatesPanel",
"debug_toolbar.panels.signals.SignalsPanel",
"debug_toolbar.panels.logging.LoggingPanel",
"debug_toolbar.panels.redirects.RedirectsPanel",
"debug_toolbar.panels.profiling.ProfilingPanel",
]
ALLOWED_HOSTS = _settings.get("http.allowed_hosts", [])
# Application definition
......@@ -99,6 +116,7 @@ INSTALLED_APPS = [
"colorfield",
"django_bleach",
"favicon",
"django_filters",
]
merge_app_settings("INSTALLED_APPS", INSTALLED_APPS, True)
......@@ -183,6 +201,11 @@ if _settings.get("caching.memcached.enabled", False):
"LOCATION": _settings.get("caching.memcached.address", "127.0.0.1:11211"),
}
}
INSTALLED_APPS.append("cachalot")
DEBUG_TOOLBAR_PANELS.append("cachalot.panels.CachalotPanel")
CACHALOT_TIMEOUT = _settings.get("caching.cachalot.timeout", None)
CACHALOT_DATABASES = set(["default"])
SILENCED_SYSTEM_CHECKS.append("cachalot.W001")
# Password validation
# https://docs.djangoproject.com/en/2.1/ref/settings/#auth-password-validators
......@@ -329,6 +352,8 @@ YARN_INSTALLED_APPS = [
"select2",
"select2-materialize",
"paper-css",
"jquery-sortablejs",
"sortablejs",
]
merge_app_settings("YARN_INSTALLED_APPS", YARN_INSTALLED_APPS, True)
......@@ -352,6 +377,8 @@ ANY_JS = {
"css_url": JS_URL + "/select2-materialize/select2-materialize.css",
"js_url": JS_URL + "/select2-materialize/index.js",
},
"sortablejs": {"js_url": JS_URL + "/sortablejs/dist/sortable.umd.js"},
"jquery-sortablejs": {"js_url": JS_URL + "/jquery-sortablejs/jquery-sortable.js"},
}
merge_app_settings("ANY_JS", ANY_JS, True)
......
function refreshOrder() {
$(".order-input").val(0);
$("#widgets > .col").each(function (index) {
const order = (index + 1) * 10;
let pk = $(this).attr("data-pk");
let sel = $("#order-form input[value=" + pk + "].pk-input").next();
sel.val(order);
})
}
$(document).ready(function () {
$('#not-used-widgets').sortable({
group: 'widgets',
animation: 150,
onEnd: refreshOrder
});
$('#widgets').sortable({
group: 'widgets',
animation: 150,
onEnd: refreshOrder
});
});
// This is the AlekSIS service worker
const CACHE = "aleksis-cache";
const precacheFiles = [
'',
];
const offlineFallbackPage = '/offline';
const avoidCachingPaths = [
'/admin',
'/settings',
'/accounts/login'
]; // TODO: More paths are needed
function pathComparer(requestUrl, pathRegEx) {
return requestUrl.match(new RegExp(pathRegEx));
}
// This is the AlekSIS service worker
function comparePaths(requestUrl, pathsArray) {
if (requestUrl) {
for (let index = 0; index < pathsArray.length; index++) {
const pathRegEx = pathsArray[index];
if (pathComparer(requestUrl, pathRegEx)) {
return true;
}
}
}
const CACHE = 'aleksis-cache';
return false;
}
const offlineFallbackPage = 'offline/';
self.addEventListener("install", function (event) {
console.log("[AlekSIS PWA] Install Event processing.");
......@@ -40,10 +14,7 @@ self.addEventListener("install", function (event) {
event.waitUntil(
caches.open(CACHE).then(function (cache) {
console.log("[AlekSIS PWA] Caching pages during install.");
return cache.addAll(precacheFiles).then(function () {
return cache.add(offlineFallbackPage);
});
return cache.add(offlineFallbackPage);
})
);
});
......@@ -95,11 +66,11 @@ function fromCache(event) {
}
function updateCache(request, response) {
if (!comparePaths(request.url, avoidCachingPaths)) {
if (response.headers.get('cache-control') && response.headers.get('cache-control').includes('no-cache')) {
return Promise.resolve();
} else {
return caches.open(CACHE).then(function (cache) {
return cache.put(request, response);
});
}
return Promise.resolve();
}
......@@ -70,6 +70,10 @@ header, main, footer {
}
}
.materialize-circle {
@extend .circle;
}
/**********/
/* HEADER */
/**********/
......@@ -622,3 +626,7 @@ main .alert p:first-child, main .alert div:first-child {
overflow: visible;
width: 100%;
}
.draggable {
cursor: grab;
}
......@@ -70,3 +70,24 @@ class GroupTypesTable(tables.Table):
delete = tables.LinkColumn(
"delete_group_type_by_id", args=[A("id")], verbose_name=_("Delete"), text=_("Delete")
)
class DashboardWidgetTable(tables.Table):
"""Table to list dashboard widgets."""
class Meta:
attrs = {"class": "responsive-table highlight"}
widget_name = tables.Column(accessor="pk")
title = tables.LinkColumn("edit_dashboard_widget", args=[A("id")])
active = tables.BooleanColumn(yesno="check,cancel", attrs={"span": {"class": "material-icons"}})
delete = tables.LinkColumn(
"delete_dashboard_widget",
args=[A("id")],
text=_("Delete"),
attrs={"a": {"class": "btn-flat waves-effect waves-red red-text"}},
verbose_name=_("Actions"),
)
def render_widget_name(self, value, record):
return record._meta.verbose_name
......@@ -170,6 +170,8 @@
{% include_js "materialize" %}
{% include_js "sortablejs" %}
{% include_js "jquery-sortablejs" %}
<script type="text/javascript" src="{% static 'js/search.js' %}"></script>
<script type="text/javascript" src="{% static 'js/main.js' %}"></script>
</body>
......
{# -*- engine:django -*- #}
{% extends "core/base.html" %}
{% load material_form i18n data_helpers %}
{% block browser_title %}
{% verbose_name_object model as widget_title %}
{% blocktrans with widget=widget_title %}Create {{ widget }}{% endblocktrans %}
{% endblock %}
{% block page_title %}
{% verbose_name_object model as widget_title %}
{% blocktrans with widget=widget_title %}Create {{ widget }}{% endblocktrans %}
{% endblock %}
{% block content %}
<form method="post">
{% csrf_token %}
{% form form=form %}{% endform %}
{% include "core/partials/save_button.html" %}
</form>
{% endblock %}