From 6ba798c92369cff73d89b5e4168519b8a24e94da Mon Sep 17 00:00:00 2001
From: Jonathan Weth <git@jonathanweth.de>
Date: Tue, 31 May 2022 14:50:39 +0200
Subject: [PATCH] Use and manage card layouts

---
 aleksis/apps/kort/forms.py                    | 36 ++++++-
 aleksis/apps/kort/menus.py                    | 11 +++
 .../migrations/0012_auto_20220529_1435.py     | 50 ++++++++++
 .../migrations/0013_auto_20220530_1939.py     | 29 ++++++
 aleksis/apps/kort/models.py                   | 62 +++++++++++-
 aleksis/apps/kort/settings.py                 |  2 +
 aleksis/apps/kort/tables.py                   | 16 ++++
 aleksis/apps/kort/tasks.py                    |  6 +-
 .../templates/kort/card/detail_content.html   |  7 ++
 .../templates/kort/card_layout/actions.html   | 26 +++++
 .../templates/kort/card_layout/create.html    | 34 +++++++
 .../templates/kort/card_layout/delete.html    | 32 +++++++
 .../templates/kort/card_layout/detail.html    | 27 ++++++
 .../kort/card_layout/detail_content.html      | 54 +++++++++++
 .../kort/templates/kort/card_layout/edit.html | 34 +++++++
 .../kort/card_layout/instructions.html        | 28 ++++++
 .../kort/templates/kort/card_layout/list.html | 22 +++++
 .../templates/kort/card_layout/short.html     |  7 ++
 .../material/fields/django_ace_acewidget.html | 28 ++++++
 aleksis/apps/kort/urls.py                     |  9 ++
 aleksis/apps/kort/views.py                    | 95 ++++++++++++++++++-
 pyproject.toml                                |  1 +
 22 files changed, 605 insertions(+), 11 deletions(-)
 create mode 100644 aleksis/apps/kort/migrations/0012_auto_20220529_1435.py
 create mode 100644 aleksis/apps/kort/migrations/0013_auto_20220530_1939.py
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/actions.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/create.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/delete.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/detail.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/detail_content.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/edit.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/instructions.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/list.html
 create mode 100644 aleksis/apps/kort/templates/kort/card_layout/short.html
 create mode 100644 aleksis/apps/kort/templates/material/fields/django_ace_acewidget.html

diff --git a/aleksis/apps/kort/forms.py b/aleksis/apps/kort/forms.py
index 6b947e7..39781a6 100644
--- a/aleksis/apps/kort/forms.py
+++ b/aleksis/apps/kort/forms.py
@@ -1,16 +1,18 @@
 from django import forms
 from django.utils.translation import gettext as _
 
+from django_ace import AceWidget
 from django_select2.forms import ModelSelect2Widget
-from material import Fieldset, Layout
+from material import Fieldset, Layout, Row
 
-from aleksis.apps.kort.models import Card, CardPrinter
+from aleksis.apps.kort.models import Card, CardLayout, CardLayoutMediaFile, CardPrinter
 
 
 class CardForm(forms.ModelForm):
     class Meta:
         model = Card
-        fields = ["person", "valid_until"]
+        fields = ["person", "valid_until", "layout"]
+
         widgets = {
             "person": ModelSelect2Widget(
                 search_fields=[
@@ -22,6 +24,10 @@ class CardForm(forms.ModelForm):
             ),
         }
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields["layout"].required = True
+
 
 class CardPrinterForm(forms.ModelForm):
     layout = Layout(
@@ -53,3 +59,27 @@ class PrinterSelectForm(forms.Form):
         self.fields["printer"].queryset = printers
         if printers.count() == 1:
             self.fields["printer"].initial = printers.first()
+
+
+class CardLayoutMediaFileForm(forms.ModelForm):
+    layout = Layout(Row("media_file", "DELETE"))
+
+    class Meta:
+        model = CardLayoutMediaFile
+        fields = ["media_file"]
+
+
+class CardLayoutForm(forms.ModelForm):
+    layout = Layout(Row("name"), Row("width", "height"), Row("template"), "css")
+
+    template = forms.CharField(widget=AceWidget(mode="django"))
+    css = forms.CharField(widget=AceWidget(mode="css"))
+
+    class Meta:
+        model = CardLayout
+        fields = ["name", "template", "css", "width", "height"]
+
+
+CardLayoutMediaFileFormSet = forms.inlineformset_factory(
+    CardLayout, CardLayoutMediaFile, form=CardLayoutMediaFileForm
+)
diff --git a/aleksis/apps/kort/menus.py b/aleksis/apps/kort/menus.py
index 2116a83..2f6f644 100644
--- a/aleksis/apps/kort/menus.py
+++ b/aleksis/apps/kort/menus.py
@@ -34,6 +34,17 @@ MENUS = {
                         )
                     ],
                 },
+                {
+                    "name": _("Card Layouts"),
+                    "url": "card_layouts",
+                    "svg_icon": "mdi:card-account-details-star-outline",
+                    "validators": [
+                        (
+                            "aleksis.core.util.predicates.permission_validator",
+                            "core.view_cardlayouts_rule",
+                        )
+                    ],
+                },
             ],
         }
     ]
diff --git a/aleksis/apps/kort/migrations/0012_auto_20220529_1435.py b/aleksis/apps/kort/migrations/0012_auto_20220529_1435.py
new file mode 100644
index 0000000..da30961
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0012_auto_20220529_1435.py
@@ -0,0 +1,50 @@
+# Generated by Django 3.2.13 on 2022-05-29 12:35
+
+import django.contrib.sites.managers
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('sites', '0002_alter_domain_unique'),
+        ('kort', '0011_cardprinter_card_detector'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='cardlayout',
+            name='height',
+            field=models.PositiveIntegerField(default=1, verbose_name='Height'),
+            preserve_default=False,
+        ),
+        migrations.AddField(
+            model_name='cardlayout',
+            name='width',
+            field=models.PositiveIntegerField(default=1, verbose_name='Width'),
+            preserve_default=False,
+        ),
+        migrations.AlterField(
+            model_name='cardprintjob',
+            name='card',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='jobs', to='kort.card', verbose_name='Card'),
+        ),
+        migrations.CreateModel(
+            name='CardLayoutMediaFile',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('extended_data', models.JSONField(default=dict, editable=False)),
+                ('media_file', models.FileField(upload_to='card_layouts/media/', verbose_name='Media file')),
+                ('card_layout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='media_files', to='kort.cardlayout', verbose_name='Card layout')),
+                ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')),
+            ],
+            options={
+                'verbose_name': 'Media file for a card layout',
+                'verbose_name_plural': 'Media files for card layouts',
+            },
+            managers=[
+                ('objects', django.contrib.sites.managers.CurrentSiteManager()),
+            ],
+        ),
+    ]
diff --git a/aleksis/apps/kort/migrations/0013_auto_20220530_1939.py b/aleksis/apps/kort/migrations/0013_auto_20220530_1939.py
new file mode 100644
index 0000000..d7fbd10
--- /dev/null
+++ b/aleksis/apps/kort/migrations/0013_auto_20220530_1939.py
@@ -0,0 +1,29 @@
+# Generated by Django 3.2.13 on 2022-05-30 17:39
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('kort', '0012_auto_20220529_1435'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='card',
+            name='layout',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='kort.cardlayout', verbose_name='Card Layout'),
+        ),
+        migrations.AlterField(
+            model_name='cardlayout',
+            name='height',
+            field=models.PositiveIntegerField(help_text='in mm', verbose_name='Height'),
+        ),
+        migrations.AlterField(
+            model_name='cardlayout',
+            name='width',
+            field=models.PositiveIntegerField(help_text='in mm', verbose_name='Width'),
+        ),
+    ]
diff --git a/aleksis/apps/kort/models.py b/aleksis/apps/kort/models.py
index f419e58..83ea2c9 100644
--- a/aleksis/apps/kort/models.py
+++ b/aleksis/apps/kort/models.py
@@ -16,6 +16,7 @@ from model_utils.models import TimeStampedModel
 
 from aleksis.core.mixins import ExtensibleModel
 from aleksis.core.models import OAuthApplication, Person
+from aleksis.core.util.pdf import process_context_for_pdf
 
 
 class CardPrinterStatus(models.TextChoices):
@@ -172,19 +173,63 @@ class CardPrinter(ExtensibleModel):
         verbose_name_plural = _("Card printers")
 
 
+class CardLayoutMediaFile(ExtensibleModel):
+    media_file = models.FileField(upload_to="card_layouts/media/", verbose_name=_("Media file"))
+    card_layout = models.ForeignKey(
+        "CardLayout",
+        on_delete=models.CASCADE,
+        related_name="media_files",
+        verbose_name=_("Card layout"),
+    )
+
+    def __str__(self):
+        return self.media_file.name
+
+    class Meta:
+        verbose_name = _("Media file for a card layout")
+        verbose_name_plural = _("Media files for card layouts")
+
+
 class CardLayout(ExtensibleModel):
+    BASE_TEMPLATE = """
+    {{% extends "core/base_simple_print.html" %}}
+    {{% load i18n static barcode %}}
+
+    {{% block size %}}
+      {{% with width={width} height={height} %}}
+        {{{{ block.super }}}}
+      {{% endwith %}}
+    {{% endblock %}}
+
+    {{% block extra_head %}}
+      <style>
+        {css}
+      </style>
+    {{% endblock %}}
+
+    {{% block content %}}
+      {template}
+    {{% endblock %}}
+    """
     name = models.CharField(max_length=255, verbose_name=_("Name"))
     template = models.TextField(verbose_name=_("Template"))
     css = models.TextField(verbose_name=_("Custom CSS"), blank=True)
+    width = models.PositiveIntegerField(verbose_name=_("Width"), help_text=_("in mm"))
+    height = models.PositiveIntegerField(verbose_name=_("Height"), help_text=_("in mm"))
 
     def get_template(self) -> Template:
-        return Template(self.template)
+        template = self.BASE_TEMPLATE.format(
+            width=self.width, height=self.height, css=self.css, template=self.template
+        )
+        print(template)
+        return Template(template)
 
     def render(self, card: "Card"):
         t = self.get_template()
         context = card.get_context()
+        processed_context = process_context_for_pdf(context)
 
-        return t.render(Context(context))
+        return t.render(Context(processed_context))
 
     def validate_template(self):
         try:
@@ -193,6 +238,13 @@ class CardLayout(ExtensibleModel):
         except Exception as e:
             raise ValidationError(_("Template is invalid: {}").format(e))
 
+    def __str__(self):
+        return self.name
+
+    class Meta:
+        verbose_name = _("Card Layout")
+        verbose_name_plural = _("Card Layouts")
+
 
 class Card(ExtensibleModel):
     person = models.ForeignKey(
@@ -202,6 +254,9 @@ class Card(ExtensibleModel):
     valid_until = models.DateField(verbose_name=_("Valid until"))
     deactivated = models.BooleanField(verbose_name=_("Deactivated"), default=False)
 
+    layout = models.ForeignKey(
+        CardLayout, on_delete=models.SET_NULL, blank=True, null=True, verbose_name=_("Card Layout")
+    )
     pdf_file = models.FileField(
         verbose_name=_("PDF file"),
         blank=True,
@@ -224,6 +279,7 @@ class Card(ExtensibleModel):
             "person": self.person,
             "chip_number": self.chip_number,
             "valid_until": self.valid_until,
+            "media_files": self.layout.media_files.all(),
         }
 
     def generate_pdf(self) -> Union[bool, AsyncResult]:
@@ -234,6 +290,8 @@ class Card(ExtensibleModel):
         return generate_card_pdf.delay(self.pk)
 
     def print_card(self, printer: CardPrinter):
+        if not self.layout:
+            raise ValueError(_("There is no layout provided for the card."))
         job = CardPrintJob(card=self, printer=printer)
         job.save()
         if not self.chip_number and printer.generate_number_on_server:
diff --git a/aleksis/apps/kort/settings.py b/aleksis/apps/kort/settings.py
index 1cbd74e..279ef09 100644
--- a/aleksis/apps/kort/settings.py
+++ b/aleksis/apps/kort/settings.py
@@ -5,3 +5,5 @@ YARN_INSTALLED_APPS = ["pdfobject"]
 ANY_JS = {
     "pdfobject": {"js_url": JS_URL + "/pdfobject/pdfobject.min.js"},
 }
+
+INSTALLED_APPS = ["django_ace"]
diff --git a/aleksis/apps/kort/tables.py b/aleksis/apps/kort/tables.py
index dedcab6..7a1f0e7 100644
--- a/aleksis/apps/kort/tables.py
+++ b/aleksis/apps/kort/tables.py
@@ -66,3 +66,19 @@ class CardPrinterTable(Table):
 
     def render_actions(self, value, record):
         return render_to_string("kort/printer/actions.html", dict(pk=value, printer=record))
+
+
+class CardLayoutTable(Table):
+    """Table to list card layouts."""
+
+    class Meta:
+        attrs = {"class": "highlight"}
+
+    name = LinkColumn("card_layout", verbose_name=_("Layout name"), args=[A("pk")])
+    width = Column()
+    height = Column()
+
+    actions = Column(verbose_name=_("Actions"), accessor=A("pk"))
+
+    def render_actions(self, value, record):
+        return render_to_string("kort/card_layout/actions.html", dict(pk=value, card_layout=record))
diff --git a/aleksis/apps/kort/tasks.py b/aleksis/apps/kort/tasks.py
index 82beaa0..dc7efed 100644
--- a/aleksis/apps/kort/tasks.py
+++ b/aleksis/apps/kort/tasks.py
@@ -3,15 +3,15 @@ from celery.states import SUCCESS
 
 from aleksis.apps.kort.models import Card
 from aleksis.core.celery import app
-from aleksis.core.util.pdf import generate_pdf_from_template
+from aleksis.core.util.pdf import generate_pdf_from_html
 
 
 @app.task
 def generate_card_pdf(card_pk: int):
     card = Card.objects.get(pk=card_pk)
 
-    context = card.get_context()
-    file_object, result = generate_pdf_from_template("kort/pdf.html", context)
+    html = card.layout.render(card)
+    file_object, result = generate_pdf_from_html(html)
 
     with allow_join_result():
         result.wait()
diff --git a/aleksis/apps/kort/templates/kort/card/detail_content.html b/aleksis/apps/kort/templates/kort/card/detail_content.html
index 23b6227..e87525f 100644
--- a/aleksis/apps/kort/templates/kort/card/detail_content.html
+++ b/aleksis/apps/kort/templates/kort/card/detail_content.html
@@ -25,6 +25,13 @@
             </th>
             <td>{{ card.valid_until }}</td>
           </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:card-account-details-star-outline"></i>
+              {% trans "Card Layout" %}
+            </th>
+            <td>{{ card.layout|default:"–" }}</td>
+          </tr>
           <tr>
             <th>
               <i class="material-icons left iconify" data-icon="mdi:checkbox-blank-badge-outline"></i>
diff --git a/aleksis/apps/kort/templates/kort/card_layout/actions.html b/aleksis/apps/kort/templates/kort/card_layout/actions.html
new file mode 100644
index 0000000..f8220b2
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/actions.html
@@ -0,0 +1,26 @@
+{% load i18n %}
+
+<div id="detail-modal-{{ card_layout.pk }}" class="modal">
+  <div class="modal-content">
+    <h4>{{ card_layout.name }}</h4>
+    {% include "kort/card_layout/detail_content.html" %}
+  </div>
+  <div class="modal-footer">
+    <a href="#!" class="modal-close waves-effect waves-green btn-flat">
+      <i class="material-icons left iconify" data-icon="mdi:close"></i>
+      {% trans "Close" %}
+    </a>
+  </div>
+</div>
+<a class="btn-flat waves-effect waves-green green-text modal-trigger" href="#detail-modal-{{ card_layout.pk }}">
+  <i class="material-icons left iconify" data-icon="mdi:play-box-outline"></i>
+  {% trans "Show" %}
+</a>
+<a class="btn-flat waves-effect waves-orange orange-text modal-trigger" href="{% url "edit_card_layout" card_layout.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i>
+  {% trans "Edit" %}
+</a>
+<a class="btn-flat waves-effect waves-red red-text modal-trigger" href="{% url "delete_card_layout" card_layout.pk %}">
+  <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+  {% trans "Delete" %}
+</a>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/card_layout/create.html b/aleksis/apps/kort/templates/kort/card_layout/create.html
new file mode 100644
index 0000000..8cd9a44
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/create.html
@@ -0,0 +1,34 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Create card layout{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Create card layout{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  {% include "kort/card_layout/instructions.html" %}
+
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+
+    <h5>{% trans "Media Files" %}</h5>
+    {{ formset.management_form }}
+
+    {% for inline_form in formset.forms %}
+      {% form form=inline_form %}{% endform %}
+    {% endfor %}
+
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/card_layout/delete.html b/aleksis/apps/kort/templates/kort/card_layout/delete.html
new file mode 100644
index 0000000..7e668c3
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/delete.html
@@ -0,0 +1,32 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Delete Card Layout{% endblocktrans %}{% endblock %}
+{% block no_page_title %}{% endblock %}
+
+{% block content %}
+  <p class="flow-text">{% trans "Do you really want to delete the following card layout?" %}</p>
+  {% include "kort/card_layout/short.html" with card_layout=object %}
+  <figure class="alert warning">
+    <i class="material-icons left iconify" data-icon="mdi:alert-octagon-outline"></i>
+    {% blocktrans %}
+      Please pay attention that this also will prevent the successful finishing of all active print jobs
+      which use these template.
+    {% endblocktrans %}
+  </figure>
+  <form method="post" action="">
+    {% csrf_token %}
+    <a href="{% url "card_layouts" %}" class="modal-close waves-effect waves-green btn">
+      <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+      {% trans "Go back" %}
+    </a>
+    <button type="submit" name="delete" class="modal-close waves-effect waves-light red btn">
+      <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+      {% trans "Delete" %}
+    </button>
+  </form>
+{% endblock %}
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/card_layout/detail.html b/aleksis/apps/kort/templates/kort/card_layout/detail.html
new file mode 100644
index 0000000..5a7fb24
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/detail.html
@@ -0,0 +1,27 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+
+{% block browser_title %}{% blocktrans %}Card Layout{% endblocktrans %}{% endblock %}
+{% block page_title %}{{ object.name }}{% endblock %}
+
+
+{% block content %}
+  <a class="btn waves-effect waves-light secondary margin-bottom" href="{% url "card_layouts" %}">
+    <i class="material-icons left iconify" data-icon="mdi:arrow-left"></i>
+    {% trans "Back to all layouts" %}
+  </a>
+  <a class="btn waves-effect waves-light margin-bottom orange modal-trigger"
+     href="{% url "edit_card_layout" object.pk %}">
+    <i class="material-icons left iconify" data-icon="mdi:pencil-outline"></i>
+    {% trans "Edit" %}
+  </a>
+  <a class="btn waves-effect waves-light margin-bottom red modal-trigger" href="{% url "delete_card_layout" object.pk %}">
+    <i class="material-icons left iconify" data-icon="mdi:delete-outline"></i>
+    {% trans "Delete" %}
+  </a>
+  {% include "kort/card_layout/detail_content.html" with card_layout=object %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/card_layout/detail_content.html b/aleksis/apps/kort/templates/kort/card_layout/detail_content.html
new file mode 100644
index 0000000..8d20468
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/detail_content.html
@@ -0,0 +1,54 @@
+{% load i18n %}
+<div class="row no-margin">
+  <div class="col s12 m12 l12 xl6 no-padding">
+    <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Layout details" %}</div>
+        <table>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:label-outline"></i>
+              {% trans "Name" %}</th>
+            <td>{{ card_layout.name }}</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:image-size-select-large"></i>
+              {% trans "Size (w × h)" %}
+            </th>
+            <td>{{ card_layout.width }} mm × {{ card_layout.height }} mm</td>
+          </tr>
+          <tr>
+            <th>
+              <i class="material-icons left iconify" data-icon="mdi:folder-multiple-image"></i>
+              {% trans "Media files" %}
+            </th>
+            <td>
+              <ul class="collection">
+                {% for file in card_layout.media_files.all %}
+                    <a class="collection-item" href="{{ file.media_file.url }}">{{ file.media_file.name }}</a>
+                  {% empty %}
+                  <li class="collection-item">{% trans "No media files uploaded." %}</li>
+                {% endfor %}
+              </ul>
+            </td>
+          </tr>
+        </table>
+      </div>
+    </div>
+  </div>
+  <div class="col s12 m12 l12 xl6">
+    <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Template" %}</div>
+        <pre>{{ card_layout.template|linebreaksbr }}</pre>
+      </div>
+    </div>
+       <div class="card">
+      <div class="card-content">
+        <div class="card-title">{% trans "Custom CSS" %}</div>
+        <pre>{{ card_layout.css|linebreaksbr }}</pre>
+      </div>
+    </div>
+  </div>
+</div>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/kort/card_layout/edit.html b/aleksis/apps/kort/templates/kort/card_layout/edit.html
new file mode 100644
index 0000000..f58916c
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/edit.html
@@ -0,0 +1,34 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load material_form i18n any_js %}
+
+{% block extra_head %}
+  {{ form.media.css }}
+  {% include_css "select2-materialize" %}
+{% endblock %}
+
+{% block browser_title %}{% blocktrans %}Edit card layout{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Edit card layout{% endblocktrans %}{% endblock %}
+
+
+{% block content %}
+  {% include "kort/card_layout/instructions.html" %}
+
+  <form method="post" enctype="multipart/form-data">
+    {% csrf_token %}
+    {% form form=form %}{% endform %}
+
+    <h5>{% trans "Media Files" %}</h5>
+    {{ formset.management_form }}
+
+    {% for inline_form in formset.forms %}
+      {% form form=inline_form %}{% endform %}
+    {% endfor %}
+
+    {% include "core/partials/save_button.html" %}
+  </form>
+  {% include_js "select2-materialize" %}
+  {{ form.media.js }}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/card_layout/instructions.html b/aleksis/apps/kort/templates/kort/card_layout/instructions.html
new file mode 100644
index 0000000..93a6497
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/instructions.html
@@ -0,0 +1,28 @@
+{% load i18n %}
+<div class="card">
+  <div class="card-content">
+    <div class="card-title">{% trans "Instructions on templating the card" %}</div>
+    {% blocktrans %}
+      You will be able to use the following data in your individual template. The template has to been written in the
+      Django template language.
+    {% endblocktrans %}
+    <div class="collection">
+      <div class="collection-item">
+        <code>{% verbatim %}{{ chip_number }}{% endverbatim %}</code> - {% trans "The chip number as string" %}
+      </div>
+      <div class="collection-item">
+        <code>{% verbatim %}{{ person.x }}{% endverbatim %}</code>
+        - {% trans "This will give you access to any attributes of the person object like name, personal data or contact data." %}
+      </div>
+      <div class="collection-item">
+        <code>{% verbatim %}{{ valid_until }}{% endverbatim %}</code>
+        - {% trans "The date until when the card is valid" %}
+      </div>
+      <div class="collection-item">
+        <code>{% verbatim %}
+          {{ media_files.0.media_file.url }}{% endverbatim %}</code> -
+        {% trans "This will give you access to the uploaded media files. Replace 0 by the number of your media file (starting with 0)." %}
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/aleksis/apps/kort/templates/kort/card_layout/list.html b/aleksis/apps/kort/templates/kort/card_layout/list.html
new file mode 100644
index 0000000..c12a4bd
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/list.html
@@ -0,0 +1,22 @@
+{# -*- engine:django -*- #}
+
+{% extends "core/base.html" %}
+
+{% load i18n rules material_form any_js %}
+{% load render_table from django_tables2 %}
+
+{% block browser_title %}{% blocktrans %}Card layouts{% endblocktrans %}{% endblock %}
+{% block page_title %}{% blocktrans %}Card layouts{% endblocktrans %}{% endblock %}
+
+{% block content %}
+  {% has_perm 'core.create_cardlayout_rule' user person as can_create %}
+
+  {% if can_create %}
+    <a class="btn green waves-effect waves-light" href="{% url 'create_card_layout' %}">
+      <i class="material-icons left iconify" data-icon="mdi:plus"></i>
+      {% trans "Create new card layout" %}
+    </a>
+  {% endif %}
+
+  {% render_table table %}
+{% endblock %}
diff --git a/aleksis/apps/kort/templates/kort/card_layout/short.html b/aleksis/apps/kort/templates/kort/card_layout/short.html
new file mode 100644
index 0000000..d2bc3e4
--- /dev/null
+++ b/aleksis/apps/kort/templates/kort/card_layout/short.html
@@ -0,0 +1,7 @@
+{% load i18n %}
+<table>
+  <tr>
+    <th>{% trans "Name" %}</th>
+    <td>{{ card_layout.name }}</td>
+  </tr>
+</table>
\ No newline at end of file
diff --git a/aleksis/apps/kort/templates/material/fields/django_ace_acewidget.html b/aleksis/apps/kort/templates/material/fields/django_ace_acewidget.html
new file mode 100644
index 0000000..73f5717
--- /dev/null
+++ b/aleksis/apps/kort/templates/material/fields/django_ace_acewidget.html
@@ -0,0 +1,28 @@
+{% load material_form material_form_internal %}
+{% part bound_field.field %}<{{ field.widget.component|default:'dmc-textarea' }}>
+  <div class="row">
+    <div{% attrs bound_field 'group' %}
+      id="id_{{ bound_field.html_name }}_container"
+      class="input-field col s12{% if field.required %} required{% endif %}{% if bound_field.errors %} has-error{% endif %}"
+    {% endattrs %}>
+      {% part field label %}
+        <label{% attrs bound_field 'label' %}
+          for="{{ bound_field.id_for_label }}"
+          {% if bound_field.value %}class="active"{% endif %}
+        {% endattrs %}>{{ bound_field.label }}</label>
+      {% endpart %}
+      <div class="margin-top-35">
+      {% part field prefix %}{% endpart %}{% part field control %}
+        {{ bound_field }}
+      {% endpart %}
+      </div>
+      {% part field help_text %}{% if field.help_text %}
+        <div class="help-block">{{ bound_field.help_text|safe }}</div>
+      {% endif %}
+      {% endpart %}{% part field errors %}
+        {% if bound_field.errors %}
+          {% include  'material/field_errors.html' %}
+        {% endif %}
+      {% endpart %}{{ hidden_initial }}
+    </div>
+  </div></{{ field.widget.component|default:'dmc-textarea' }}>{% endpart %}
diff --git a/aleksis/apps/kort/urls.py b/aleksis/apps/kort/urls.py
index 1cc3abc..1d31213 100644
--- a/aleksis/apps/kort/urls.py
+++ b/aleksis/apps/kort/urls.py
@@ -29,6 +29,15 @@ urlpatterns = [
         views.CardPrinterConfigView.as_view(),
         name="card_printer_config",
     ),
+    path("layouts/", views.CardLayoutListView.as_view(), name="card_layouts"),
+    path("layouts/create/", views.CardLayoutCreateView.as_view(), name="create_card_layout"),
+    path("layouts/<int:pk>/", views.CardLayoutDetailView.as_view(), name="card_layout"),
+    path("layouts/<int:pk>/edit/", views.CardLayoutEditView.as_view(), name="edit_card_layout"),
+    path(
+        "layouts/<int:pk>/delete/",
+        views.CardLayoutDeleteView.as_view(),
+        name="delete_card_layout",
+    ),
     path("api/v1/printers/", api.CardPrinterDetails.as_view(), name="api_card_printer"),
     path(
         "api/v1/printers/<int:pk>/status/",
diff --git a/aleksis/apps/kort/views.py b/aleksis/apps/kort/views.py
index 833ea29..787408a 100644
--- a/aleksis/apps/kort/views.py
+++ b/aleksis/apps/kort/views.py
@@ -13,9 +13,15 @@ from django_tables2 import SingleTableView
 from reversion.views import RevisionMixin
 from rules.contrib.views import PermissionRequiredMixin
 
-from aleksis.apps.kort.forms import CardForm, CardPrinterForm, PrinterSelectForm
-from aleksis.apps.kort.models import Card, CardPrinter, PrintStatus
-from aleksis.apps.kort.tables import CardPrinterTable, CardTable
+from aleksis.apps.kort.forms import (
+    CardForm,
+    CardLayoutForm,
+    CardLayoutMediaFileFormSet,
+    CardPrinterForm,
+    PrinterSelectForm,
+)
+from aleksis.apps.kort.models import Card, CardLayout, CardPrinter, PrintStatus
+from aleksis.apps.kort.tables import CardLayoutTable, CardPrinterTable, CardTable
 from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView
 from aleksis.core.util.celery_progress import render_progress_page
 from aleksis.core.views import RenderPDFView
@@ -215,6 +221,89 @@ class CardPrinterDetailView(PermissionRequiredMixin, RevisionMixin, DetailView):
         return context
 
 
+class CardLayoutFormMixin:
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        self.formset = CardLayoutMediaFileFormSet(
+            self.request.POST or None, self.request.FILES or None, instance=self.object
+        )
+        context["formset"] = self.formset
+        return context
+
+    def post(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        context = self.get_context_data(**kwargs)
+        form = self.get_form()
+        if form.is_valid() and self.formset.is_valid():
+            return self.form_valid(form)
+        else:
+            return self.form_invalid(form)
+
+    def form_valid(self, form):
+        self.object = form.save()
+        self.formset.instance = self.object
+        self.formset.save()
+        return super().form_valid(form)
+
+
+class CardLayoutListView(PermissionRequiredMixin, RevisionMixin, SingleTableView):
+    """List view for all card layouts."""
+
+    permission_required = "core.view_cardlayouts_rule"
+    template_name = "kort/card_layout/list.html"
+    model = CardLayout
+    table_class = CardLayoutTable
+
+
+class CardLayoutCreateView(
+    PermissionRequiredMixin, CardLayoutFormMixin, RevisionMixin, AdvancedCreateView
+):
+    """View used to create a card layout."""
+
+    permission_required = "core.create_cardlayout_rule"
+    template_name = "kort/card_layout/create.html"
+    form_class = CardLayoutForm
+    model = CardLayout
+    success_message = _("The card layout has been created successfully.")
+
+    def get_success_url(self):
+        return reverse("card_layout", args=[self.object.pk])
+
+    def get_object(self):
+        return None
+
+
+class CardLayoutEditView(
+    PermissionRequiredMixin, CardLayoutFormMixin, RevisionMixin, AdvancedEditView
+):
+    """View used to edit a card layout."""
+
+    permission_required = "core.edit_cardlayout_rule"
+    template_name = "kort/card_layout/edit.html"
+    form_class = CardLayoutForm
+    model = CardLayout
+    success_message = _("The card layout has been changed successfully.")
+
+    def get_success_url(self):
+        return reverse("card_layout", args=[self.object.pk])
+
+
+class CardLayoutDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView):
+    """View used to delete a card layout."""
+
+    permission_required = "core.delete_cardlayout_rule"
+    success_url = reverse_lazy("card_layouts")
+    template_name = "kort/card_layout/delete.html"
+    model = CardLayout
+    success_message = _("The card layout has been deleted successfully.")
+
+
+class CardLayoutDetailView(PermissionRequiredMixin, RevisionMixin, DetailView):
+    permission_required = "core.view_cardlayout_rule"
+    model = CardLayout
+    template_name = "kort/card_layout/detail.html"
+
+
 class CardPrinterConfigView(PermissionRequiredMixin, RevisionMixin, SingleObjectMixin, View):
     permission_required = "core.view_cardprinter_rule"
     model = CardPrinter
diff --git a/pyproject.toml b/pyproject.toml
index 95eb428..a70a628 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,6 +27,7 @@ secondary = true
 python = "^3.9"
 aleksis-core = "^2.8"
 python-barcode = "^0.14.0"
+django-ace = "^1.0.12"
 
 [tool.poetry.dev-dependencies]
 aleksis-builddeps = "*"
-- 
GitLab