From bb57c17e11a8762a35d02d397feecb331b06b45c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Benjamin=20H=C3=A4ttasch?=
 <benjamin.haettasch@fachschaft.informatik.tu-darmstadt.de>
Date: Thu, 23 Mar 2023 00:48:33 +0100
Subject: [PATCH] Improve virtual rooms

Use One2One relationship instead of inheritance
This allows to add and also remove the virtual features of a room

Introduce new model
Create migrations to migrate from existing to new model + a squashed version knowing only about the new model for fresh installations
Adapt admin views
Add django-betterforms as dependency to simply create a form allowing to generate rooms with optionally a virtual component in a single view and create that form
Add view to create rooms in that view and use instead of default django creation form
Use the new name/structure in templates
Move RoomForm to forms.py to make imports easier
Update batch creation of rooms

This resolves #150 and also resolves #179 since now rooms and virtual rooms (rooms with virtual features) are created using the same view
---
 AKModel/admin.py                              | 42 +++++++------------
 AKModel/forms.py                              | 35 +++++++++++++++-
 .../templates/admin/AKModel/room_create.html  | 25 +++++++++++
 AKModel/views.py                              | 39 ++++++++++++-----
 AKOnline/admin.py                             | 27 ++++--------
 AKOnline/forms.py                             | 36 ++++++++++++++++
 AKOnline/migrations/0001_virtualroom_new.py   | 30 +++++++++++++
 AKOnline/migrations/0002_rework_virtual.py    | 19 +++++++++
 AKOnline/migrations/0003_rework_virtual_2.py  | 27 ++++++++++++
 AKOnline/migrations/0004_rework_virtual_3.py  | 28 +++++++++++++
 AKOnline/migrations/0005_rework_virtual_4.py  | 16 +++++++
 AKOnline/models.py                            | 11 ++++-
 .../AKOnline/room_create_with_virtual.html    | 26 ++++++++++++
 AKPlan/templates/AKPlan/plan_room.html        |  4 +-
 .../templates/AKSubmission/ak_detail.html     |  8 ++--
 requirements.txt                              |  1 +
 16 files changed, 309 insertions(+), 65 deletions(-)
 create mode 100644 AKModel/templates/admin/AKModel/room_create.html
 create mode 100644 AKOnline/forms.py
 create mode 100644 AKOnline/migrations/0001_virtualroom_new.py
 create mode 100644 AKOnline/migrations/0002_rework_virtual.py
 create mode 100644 AKOnline/migrations/0003_rework_virtual_2.py
 create mode 100644 AKOnline/migrations/0004_rework_virtual_3.py
 create mode 100644 AKOnline/migrations/0005_rework_virtual_4.py
 create mode 100644 AKOnline/templates/admin/AKOnline/room_create_with_virtual.html

diff --git a/AKModel/admin.py b/AKModel/admin.py
index a6aae5ae..08abba39 100644
--- a/AKModel/admin.py
+++ b/AKModel/admin.py
@@ -13,13 +13,14 @@ from django.utils.translation import gettext_lazy as _
 from rest_framework.reverse import reverse
 from simple_history.admin import SimpleHistoryAdmin
 
-from AKModel.availability.forms import AvailabilitiesFormMixin
 from AKModel.availability.models import Availability
+from AKModel.forms import RoomFormWithAvailabilities
 from AKModel.models import Event, AKOwner, AKCategory, AKTrack, AKRequirement, AK, AKSlot, Room, AKOrgaMessage, \
     ConstraintViolation, DefaultSlot
 from AKModel.urls import get_admin_urls_event_wizard, get_admin_urls_event
 from AKModel.views import CVMarkResolvedView, CVSetLevelViolationView, CVSetLevelWarningView, AKResetInterestView, \
-    AKResetInterestCounterView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, RoomBatchCreationView
+    AKResetInterestCounterView, PlanPublishView, PlanUnpublishView, DefaultSlotEditorView, RoomBatchCreationView, \
+    RoomCreationView
 
 
 class EventRelatedFieldListFilter(RelatedFieldListFilter):
@@ -235,30 +236,6 @@ class AKAdmin(SimpleHistoryAdmin):
         return HttpResponseRedirect(f"{reverse_lazy('admin:ak-reset-interest-counter')}?pks={','.join(str(pk) for pk in selected)}")
 
 
-class RoomForm(AvailabilitiesFormMixin, forms.ModelForm):
-    class Meta:
-        model = Room
-        fields = ['name',
-                  'location',
-                  'capacity',
-                  'properties',
-                  'event',
-                  ]
-
-        widgets = {
-            'properties': forms.CheckboxSelectMultiple,
-        }
-
-    def __init__(self, *args, **kwargs):
-        # Init availability mixin
-        kwargs['initial'] = dict()
-        super().__init__(*args, **kwargs)
-        self.initial = {**self.initial, **kwargs['initial']}
-        # Filter possible values for m2m when event is specified
-        if hasattr(self.instance, "event") and self.instance.event is not None:
-            self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event)
-
-
 @admin.register(Room)
 class RoomAdmin(admin.ModelAdmin):
     model = Room
@@ -268,9 +245,13 @@ class RoomAdmin(admin.ModelAdmin):
     ordering = ['location', 'name']
     change_form_template = "admin/AKModel/room_change_form.html"
 
+    def add_view(self, request, form_url='', extra_context=None):
+        # Use custom view for room creation (either room form or combined form if virtual rooms are supported)
+        return redirect("admin:room-new")
+
     def get_form(self, request, obj=None, change=False, **kwargs):
         if obj is not None:
-            return RoomForm
+            return RoomFormWithAvailabilities
         return super().get_form(request, obj, change, **kwargs)
 
     def formfield_for_foreignkey(self, db_field, request, **kwargs):
@@ -280,6 +261,13 @@ class RoomAdmin(admin.ModelAdmin):
             db_field, request, **kwargs
         )
 
+    def get_urls(self):
+        urls = [
+            path('new/', self.admin_site.admin_view(RoomCreationView.as_view()), name="room-new"),
+        ]
+        urls.extend(super().get_urls())
+        return urls
+
 
 class AKSlotAdminForm(forms.ModelForm):
     def __init__(self, *args, **kwargs):
diff --git a/AKModel/forms.py b/AKModel/forms.py
index bfa327e8..83d084da 100644
--- a/AKModel/forms.py
+++ b/AKModel/forms.py
@@ -6,7 +6,8 @@ from django import forms
 from django.forms.utils import ErrorList
 from django.utils.translation import gettext_lazy as _
 
-from AKModel.models import Event, AKCategory, AKRequirement
+from AKModel.availability.forms import AvailabilitiesFormMixin
+from AKModel.models import Event, AKCategory, AKRequirement, Room
 
 
 class NewEventWizardStartForm(forms.ModelForm):
@@ -148,3 +149,35 @@ class RoomBatchCreationForm(AdminIntermediateForm):
             raise forms.ValidationError(_("CSV must contain a name column"))
 
         return rooms_raw_dict
+
+
+class RoomForm(forms.ModelForm):
+    class Meta:
+        model = Room
+        fields = ['name',
+                  'location',
+                  'capacity',
+                  'properties',
+                  'event',
+                  ]
+
+        widgets = {
+            'properties': forms.CheckboxSelectMultiple,
+        }
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Filter possible values for m2m when event is specified
+        if hasattr(self.instance, "event") and self.instance.event is not None:
+            self.fields["properties"].queryset = AKRequirement.objects.filter(event=self.instance.event)
+
+    def save(self, commit=True):
+        return super().save(commit)
+
+
+class RoomFormWithAvailabilities(AvailabilitiesFormMixin, RoomForm):
+    def __init__(self, *args, **kwargs):
+        # Init availability mixin
+        kwargs['initial'] = dict()
+        super().__init__(*args, **kwargs)
+        self.initial = {**self.initial, **kwargs['initial']}
diff --git a/AKModel/templates/admin/AKModel/room_create.html b/AKModel/templates/admin/AKModel/room_create.html
new file mode 100644
index 00000000..58ebdae6
--- /dev/null
+++ b/AKModel/templates/admin/AKModel/room_create.html
@@ -0,0 +1,25 @@
+{% extends "admin/base_site.html" %}
+{% load tags_AKModel %}
+
+{% load i18n %}
+{% load django_bootstrap5 %}
+{% load fontawesome_6 %}
+
+
+{% block title %}{{event}}: {{ title }}{% endblock %}
+
+{% block content %}
+    <h2>{% trans "Create room" %}</h2>
+    <form method="post">{% csrf_token %}
+        {% bootstrap_form form %}
+
+        <div class="float-end">
+            <button type="submit" class="save btn btn-success" value="Submit">
+                {% fa6_icon "check" 'fas' %} {% trans "Add" %}
+            </button>
+        </div>
+        <a href="javascript:history.back()" class="btn btn-info">
+            {% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
+        </a>
+    </form>
+{% endblock %}
diff --git a/AKModel/views.py b/AKModel/views.py
index 68da9f43..9587704f 100644
--- a/AKModel/views.py
+++ b/AKModel/views.py
@@ -21,7 +21,7 @@ from rest_framework import viewsets, permissions, mixins
 
 from AKModel.forms import NewEventWizardStartForm, NewEventWizardSettingsForm, NewEventWizardPrepareImportForm, \
     NewEventWizardImportForm, NewEventWizardActivateForm, AdminIntermediateForm, SlideExportForm, \
-    AdminIntermediateActionForm, DefaultSlotEditorForm, RoomBatchCreationForm
+    AdminIntermediateActionForm, DefaultSlotEditorForm, RoomBatchCreationForm, RoomForm
 from AKModel.models import Event, AK, AKSlot, Room, AKTrack, AKCategory, AKOwner, AKOrgaMessage, AKRequirement, \
     ConstraintViolation, DefaultSlot
 from AKModel.serializers import AKSerializer, AKSlotSerializer, RoomSerializer, AKTrackSerializer, AKCategorySerializer, \
@@ -588,6 +588,26 @@ class DefaultSlotEditorView(EventSlugMixin, IntermediateAdminView):
         return super().form_valid(form)
 
 
+class RoomCreationView(AdminViewMixin, CreateView):
+    success_url = reverse_lazy('admin:AKModel_room_changelist') # TODO Go to change view of created Room?
+
+    def get_form_class(self):
+        if apps.is_installed("AKOnline"):
+            from AKOnline.forms import RoomWithVirtualForm
+            return RoomWithVirtualForm
+        return RoomForm
+
+    def get_template_names(self):
+        if apps.is_installed("AKOnline"):
+            return 'admin/AKOnline/room_create_with_virtual.html'
+        return 'admin/AKModel/room_create.html'
+
+    def form_valid(self, form):
+        r = super().form_valid(form)
+        messages.success(self.request, _("Created room")) # TODO Improve message, add detail information about room and if applicable virtual room
+        return r
+
+
 class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView):
     form_class = RoomBatchCreationForm
     title = _("Import Rooms from CSV")
@@ -611,17 +631,14 @@ class RoomBatchCreationView(EventSlugMixin, IntermediateAdminView):
             capacity = raw_room["capacity"] if "capacity" in rooms_raw_dict.fieldnames else -1
 
             try:
+                # TODO Test
+                r = Room.objects.create(name=name,
+                                    location=location,
+                                    capacity=capacity,
+                                    event=self.event)
                 if virtual_rooms_support and raw_room["url"] != "":
-                    VirtualRoom.objects.create(name=name,
-                                               location=location,
-                                               capacity=capacity,
-                                               url=raw_room["url"],
-                                               event=self.event)
-                else:
-                    Room.objects.create(name=name,
-                                        location=location,
-                                        capacity=capacity,
-                                        event=self.event)
+                    VirtualRoom.objects.create(room=r,
+                                               url=raw_room["url"])
                 created_count += 1
             except django.db.Error as e:
                 messages.add_message(self.request, messages.WARNING,
diff --git a/AKOnline/admin.py b/AKOnline/admin.py
index a6d94333..69f4bed7 100644
--- a/AKOnline/admin.py
+++ b/AKOnline/admin.py
@@ -1,26 +1,17 @@
 from django.contrib import admin
 
-from AKModel.admin import RoomAdmin, RoomForm
 from AKOnline.models import VirtualRoom
 
 
-class VirtualRoomForm(RoomForm):
-    class Meta(RoomForm.Meta):
-        model = VirtualRoom
-        fields = ['name',
-                  'location',
-                  'url',
-                  'capacity',
-                  'properties',
-                  'event',
-                  ]
-
-
 @admin.register(VirtualRoom)
-class VirtualRoomAdmin(RoomAdmin):
+class VirtualRoomAdmin(admin.ModelAdmin):
     model = VirtualRoom
+    list_display = ['room', 'event', 'url']
+    list_filter = ['room__event']
 
-    def get_form(self, request, obj=None, change=False, **kwargs):
-        if obj is not None:
-            return VirtualRoomForm
-        return super().get_form(request, obj, change, **kwargs)
+    def get_readonly_fields(self, request, obj=None):
+        # Don't allow changing the room on existing virtual rooms
+        # Instead, a link to the room editing form will be displayed automatically
+        if obj:
+            return self.readonly_fields + ('room', )
+        return self.readonly_fields
diff --git a/AKOnline/forms.py b/AKOnline/forms.py
new file mode 100644
index 00000000..4a84958c
--- /dev/null
+++ b/AKOnline/forms.py
@@ -0,0 +1,36 @@
+from betterforms.multiform import MultiModelForm
+from django.forms import ModelForm
+
+from AKModel.forms import RoomForm
+from AKOnline.models import VirtualRoom
+
+
+class VirtualRoomForm(ModelForm):
+    class Meta:
+        model = VirtualRoom
+        exclude = ['room']
+
+    def __init__(self, *args, **kwargs):
+        super(VirtualRoomForm, self).__init__(*args, **kwargs)
+        self.fields['url'].required = False
+
+
+class RoomWithVirtualForm(MultiModelForm):
+    form_classes = {
+        'room': RoomForm,
+        'virtual': VirtualRoomForm
+    }
+
+    def save(self, commit=True):
+        objects = super(RoomWithVirtualForm, self).save(commit=False)
+
+        if commit:
+            room = objects['room']
+            room.save()
+
+            virtual = objects['virtual']
+            if virtual.url != "":
+                virtual.room = room
+                virtual.save()
+
+        return objects
diff --git a/AKOnline/migrations/0001_virtualroom_new.py b/AKOnline/migrations/0001_virtualroom_new.py
new file mode 100644
index 00000000..941441d4
--- /dev/null
+++ b/AKOnline/migrations/0001_virtualroom_new.py
@@ -0,0 +1,30 @@
+# Generated by Django 4.1.5 on 2023-03-22 12:22
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    replaces = [('AKOnline', '0001_AKOnline'), ('AKOnline', '0002_rework_virtual'), ('AKOnline', '0003_rework_virtual_2'), ('AKOnline', '0004_rework_virtual_3'), ('AKOnline', '0005_rework_virtual_4')]
+
+    initial = True
+
+    dependencies = [
+        ('AKModel', '0033_AKOnline'),
+        ('AKModel', '0057_upgrades'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualRoom',
+            fields=[
+                ('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, related_name='virtual', serialize=False, to='AKModel.room', verbose_name='Room')),
+                ('url', models.URLField(blank=True, help_text='URL to the room or server', verbose_name='URL')),
+            ],
+            options={
+                'verbose_name': 'Virtual Room',
+                'verbose_name_plural': 'Virtual Rooms',
+            },
+        ),
+    ]
diff --git a/AKOnline/migrations/0002_rework_virtual.py b/AKOnline/migrations/0002_rework_virtual.py
new file mode 100644
index 00000000..0b4d69be
--- /dev/null
+++ b/AKOnline/migrations/0002_rework_virtual.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.1.5 on 2023-03-21 23:14
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('AKModel', '0057_upgrades'),
+        ('AKOnline', '0001_AKOnline'),
+    ]
+
+    operations = [
+        migrations.RenameModel(
+            'VirtualRoom',
+            'VirtualRoomOld'
+        ),
+
+    ]
diff --git a/AKOnline/migrations/0003_rework_virtual_2.py b/AKOnline/migrations/0003_rework_virtual_2.py
new file mode 100644
index 00000000..5a996bdf
--- /dev/null
+++ b/AKOnline/migrations/0003_rework_virtual_2.py
@@ -0,0 +1,27 @@
+# Generated by Django 4.1.5 on 2023-03-21 23:16
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('AKOnline', '0002_rework_virtual'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualRoom',
+            fields=[
+                ('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True,
+                                              related_name='virtual', serialize=False, to='AKModel.room',
+                                              verbose_name='Room')),
+                ('url', models.URLField(blank=True, help_text='URL to the room or server', verbose_name='URL')),
+            ],
+            options={
+                'verbose_name': 'Virtual Room',
+                'verbose_name_plural': 'Virtual Rooms',
+            },
+        ),
+    ]
diff --git a/AKOnline/migrations/0004_rework_virtual_3.py b/AKOnline/migrations/0004_rework_virtual_3.py
new file mode 100644
index 00000000..b92d61a4
--- /dev/null
+++ b/AKOnline/migrations/0004_rework_virtual_3.py
@@ -0,0 +1,28 @@
+# Generated by Django 4.1.5 on 2023-03-21 23:21
+
+from django.db import migrations
+
+
+
+class Migration(migrations.Migration):
+
+    atomic = False
+
+    dependencies = [
+        ('AKOnline', '0003_rework_virtual_2'),
+    ]
+
+    def copy_rooms(apps, schema_editor):
+        VirtualRoomOld = apps.get_model('AKOnline', 'VirtualRoomOld')
+        VirtualRoom = apps.get_model('AKOnline', 'VirtualRoom')
+        for row in VirtualRoomOld.objects.all():
+            v = VirtualRoom(room_id=row.pk, url=row.url)
+            v.save()
+
+    operations = [
+        migrations.RunPython(
+            copy_rooms,
+            reverse_code=migrations.RunPython.noop,
+            elidable=True,
+        )
+    ]
diff --git a/AKOnline/migrations/0005_rework_virtual_4.py b/AKOnline/migrations/0005_rework_virtual_4.py
new file mode 100644
index 00000000..1c5d2eae
--- /dev/null
+++ b/AKOnline/migrations/0005_rework_virtual_4.py
@@ -0,0 +1,16 @@
+# Generated by Django 4.1.5 on 2023-03-21 23:41
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('AKOnline', '0004_rework_virtual_3'),
+    ]
+
+    operations = [
+        migrations.DeleteModel(
+            name='VirtualRoomOld',
+        ),
+    ]
diff --git a/AKOnline/models.py b/AKOnline/models.py
index c3496b01..bd3bd8cf 100644
--- a/AKOnline/models.py
+++ b/AKOnline/models.py
@@ -4,11 +4,18 @@ from django.utils.translation import gettext_lazy as _
 from AKModel.models import Event, Room
 
 
-class VirtualRoom(Room):
-    """ A virtual room where an AK can be held.
+class VirtualRoom(models.Model):
+    """
+    Add details about a virtual or hybrid version of a room to it
     """
     url = models.URLField(verbose_name=_("URL"), help_text=_("URL to the room or server"), blank=True)
+    room = models.OneToOneField(Room, verbose_name=_("Room"), on_delete=models.CASCADE,
+                                related_name='virtual', primary_key=True)
 
     class Meta:
         verbose_name = _('Virtual Room')
         verbose_name_plural = _('Virtual Rooms')
+
+    @property
+    def event(self):
+        return self.room.event
diff --git a/AKOnline/templates/admin/AKOnline/room_create_with_virtual.html b/AKOnline/templates/admin/AKOnline/room_create_with_virtual.html
new file mode 100644
index 00000000..e352a121
--- /dev/null
+++ b/AKOnline/templates/admin/AKOnline/room_create_with_virtual.html
@@ -0,0 +1,26 @@
+{% extends "admin/base_site.html" %}
+{% load tags_AKModel %}
+
+{% load i18n %}
+{% load django_bootstrap5 %}
+{% load fontawesome_6 %}
+
+
+{% block title %}{{event}}: {{ title }}{% endblock %}
+
+{% block content %}
+    <h2>{% trans "Create room" %}</h2>
+    <form method="post">{% csrf_token %}
+        {% bootstrap_form form.room %}
+        {% bootstrap_form form.virtual %}
+
+        <div class="float-end">
+            <button type="submit" class="save btn btn-success" value="Submit">
+                {% fa6_icon "check" 'fas' %} {% trans "Add" %}
+            </button>
+        </div>
+        <a href="javascript:history.back()" class="btn btn-info">
+            {% fa6_icon "times" 'fas' %} {% trans "Cancel" %}
+        </a>
+    </form>
+{% endblock %}
diff --git a/AKPlan/templates/AKPlan/plan_room.html b/AKPlan/templates/AKPlan/plan_room.html
index 6620a5d7..430fd9b0 100644
--- a/AKPlan/templates/AKPlan/plan_room.html
+++ b/AKPlan/templates/AKPlan/plan_room.html
@@ -58,8 +58,8 @@
 
     <h1>{% trans "Room" %}: {{ room.name }} {% if room.location != '' %}({{ room.location }}){% endif %}</h1>
 
-    {% if "AKOnline"|check_app_installed and room.virtualroom and room.virtualroom.url != '' %}
-        <a class="btn btn-success" target="_parent" href="{{ room.virtualroom.url }}">
+    {% if "AKOnline"|check_app_installed and room.virtual and room.virtual.url != '' %}
+        <a class="btn btn-success" target="_parent" href="{{ room.virtual.url }}">
             {% fa6_icon 'external-link-alt' 'fas' %} {% trans "Go to virtual room" %}
         </a>
     {% endif %}
diff --git a/AKSubmission/templates/AKSubmission/ak_detail.html b/AKSubmission/templates/AKSubmission/ak_detail.html
index 4cb54ebe..eba22297 100644
--- a/AKSubmission/templates/AKSubmission/ak_detail.html
+++ b/AKSubmission/templates/AKSubmission/ak_detail.html
@@ -144,8 +144,8 @@
                     {% endblocktrans %}
                 {% endif %}
 
-                {% if "AKOnline"|check_app_installed and featured_slot.room.virtualroom and featured_slot.room.virtualroom.url != '' %}
-                    <a class="btn btn-success" target="_parent" href="{{ featured_slot.room.virtualroom.url }}">
+                {% if "AKOnline"|check_app_installed and featured_slot.room.virtual and featured_slot.room.virtual.url != '' %}
+                    <a class="btn btn-success" target="_parent" href="{{ featured_slot.room.virtual.url }}">
                         {% fa6_icon 'external-link-alt' 'fas' %} {% trans "Go to virtual room" %}
                     </a>
                 {% endif %}
@@ -272,8 +272,8 @@
                                data-bs-toggle="tooltip" title="{% trans 'Delete' %}"
                                class="btn btn-danger">{% fa6_icon 'times' 'fas' %}</a>
                         {% else %}
-                            {% if "AKOnline"|check_app_installed and slot.room and slot.room.virtualroom and slot.room.virtualroom.url != '' %}
-                                <a class="btn btn-success" target="_parent" href="{{ slot.room.virtualroom.url }}">
+                            {% if "AKOnline"|check_app_installed and slot.room and slot.room.virtual and slot.room.virtual.url != '' %}
+                                <a class="btn btn-success" target="_parent" href="{{ slot.room.virtual.url }}">
                                     {% fa6_icon 'external-link-alt' 'fas' %} {% trans "Go to virtual room" %}
                                 </a>
                             {% endif %}
diff --git a/requirements.txt b/requirements.txt
index 43194a7d..64081f5f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -13,5 +13,6 @@ django-tex==1.1.10
 django-csp==3.7
 django-compressor==4.1
 django-libsass==0.9
+django-betterforms==2.0.0
 mysqlclient==2.1.1  # for production deployment
 tzdata==2022.7
-- 
GitLab