From 14283adbd53b0daa5212f1dd15b6cc58a04227bd Mon Sep 17 00:00:00 2001
From: ThomasBidot <77505438+ThomasBidot@users.noreply.github.com>
Date: Fri, 16 Apr 2021 18:37:32 +0200
Subject: [PATCH] Dev-> Master (ordering students by updated_date, school
 filter, export_csv for visits) (#34)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Password reset feature (#8)

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* test

* Added utf-8 support to exported csv and switched delimiter from , to ; in admin interface

* Disabled emails while in dev

* Added multi selection filter in admin

* Fixed mail settings

* Added year field to Tutor serializer

* Fixed year updated before registration form filled

* commit for automatic deploy

* Testing CI

* Added filtering in admin for registration validation

* Added filter to student admin

* Added school info

* Fixed typo in filters

* Sorties (#30)

* Custom participation_inline css

* Ordered student profiles by last modification date in admin

* Ajouté infos sorties

* Sortie thomas (#32)

* Updated student admin filters (#28)

* Password reset feature (#8)

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* test

* Added utf-8 support to exported csv and switched delimiter from , to ; in admin interface

* Disabled emails while in dev

* Added multi selection filter in admin

* Fixed mail settings

* Added year field to Tutor serializer

* Fixed year updated before registration form filled

* commit for automatic deploy

* Testing CI

* Added filtering in admin for registration validation

* Added filter to student admin

Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>

* Ajout filtre sur register

* Ajout filtre participation

* Ajout de 3 filtres par etablissements

* Ajout 3 filtres pour etablissement V2

Co-authored-by: Seon82 <46298009+Seon82@users.noreply.github.com>
Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>
Co-authored-by: Bidot-Naude Thomas <thomas.bidotnaude@student-cs.fr>
Co-authored-by: Witeden <58004019+Witeden@users.noreply.github.com>

* Sortie thomas (export CSV sorties) (#33)

* Updated student admin filters (#28)

* Password reset feature (#8)

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* Add Django Rest auth module

* Try to make the send reset password email work

* Modified template mail for reset

* test

* Added utf-8 support to exported csv and switched delimiter from , to ; in admin interface

* Disabled emails while in dev

* Added multi selection filter in admin

* Fixed mail settings

* Added year field to Tutor serializer

* Fixed year updated before registration form filled

* commit for automatic deploy

* Testing CI

* Added filtering in admin for registration validation

* Added filter to student admin

Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>

* Ajout filtre sur register

* Ajout filtre participation

* Ajout de 3 filtres par etablissements

* Ajout 3 filtres pour etablissement V2

* Ajout export CSV sorties

Co-authored-by: Seon82 <46298009+Seon82@users.noreply.github.com>
Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>
Co-authored-by: Bidot-Naude Thomas <thomas.bidotnaude@student-cs.fr>

Co-authored-by: Dylan Sechet <dylan.sechet82@gmail.com>
Co-authored-by: Seon82 <46298009+Seon82@users.noreply.github.com>
Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>
Co-authored-by: Bidot-Naude Thomas <thomas.bidotnaude@student-cs.fr>
Co-authored-by: Witeden <58004019+Witeden@users.noreply.github.com>

* Update runtime.txt

Changement version python

* Ordering students by updated_date

Co-authored-by: chiahetcho <44137047+chiahetcho@users.noreply.github.com>
Co-authored-by: florimondmanca <florimond.manca@gmail.com>
Co-authored-by: Dylan Sechet <dylan.sechet82@gmail.com>
Co-authored-by: Arthur Guédon <arthur.guedon@student-cs.fr>
Co-authored-by: Arthur Guédon <60623551+arthurgdn@users.noreply.github.com>
Co-authored-by: Seon82 <46298009+Seon82@users.noreply.github.com>
Co-authored-by: Bidot-Naude Thomas <thomas.bidotnaude@student-cs.fr>
Co-authored-by: Witeden <58004019+Witeden@users.noreply.github.com>
---
 oser_backend/settings/common.py               |  6 +-
 profiles/admin.py                             |  3 +-
 .../migrations/0015_student_updated_date.py   | 18 ++++
 profiles/models.py                            |  2 +
 projects/MultiSelectFieldListFilter.py        | 68 +++++++++++++++
 projects/admin.py                             | 30 ++++++-
 register/MultiSelectFieldListFilter.py        | 68 +++++++++++++++
 register/admin.py                             | 42 +++++++++-
 runtime.txt                                   |  2 +-
 visits/admin.py                               | 82 ++++++++++++++++---
 visits/templates/visits/visit_tabular.md      | 79 ++++++++++++++++++
 11 files changed, 379 insertions(+), 21 deletions(-)
 create mode 100644 profiles/migrations/0015_student_updated_date.py
 create mode 100644 projects/MultiSelectFieldListFilter.py
 create mode 100644 register/MultiSelectFieldListFilter.py
 create mode 100644 visits/templates/visits/visit_tabular.md

diff --git a/oser_backend/settings/common.py b/oser_backend/settings/common.py
index 9579af4..84ccf82 100644
--- a/oser_backend/settings/common.py
+++ b/oser_backend/settings/common.py
@@ -255,16 +255,16 @@ EMAIL_PORT = 587
 EMAIL_USE_TLS = True
 
 # Toggle sandbox mode (when running in DEBUG mode)
-SENDGRID_SANDBOX_MODE_IN_DEBUG=False
+SENDGRID_SANDBOX_MODE_IN_DEBUG = False
 
 # echo to stdout or any other file-like object that is passed to the backend via the stream kwarg.
-SENDGRID_ECHO_TO_STDOUT=True
+SENDGRID_ECHO_TO_STDOUT = True
 
 # Mails app config
 
 MAILS_ENABLED = True
 MAILS_NOTIFICATIONS_ADDRESS = 'notifications@oser-cs.fr'
-MAILS_RAISE_EXCEPTIONS = True
+MAILS_RAISE_EXCEPTIONS = False
 
 # Visits app config
 VISITS_TEAM_EMAIL = os.environ.get('VISITS_TEAM_EMAIL',
diff --git a/profiles/admin.py b/profiles/admin.py
index f7fc71a..9c8efa2 100644
--- a/profiles/admin.py
+++ b/profiles/admin.py
@@ -50,4 +50,5 @@ class StudentAdmin(ProfileAdminMixin, admin.ModelAdmin,ExportCsvMixin):
     list_filter = (('school',MultiSelectFieldListFilter), 'year', 'registration__validated')
     class Meta:  # noqa
         model = Student
-    actions = ["export_as_csv"]
\ No newline at end of file
+    ordering = ['-updated_date']
+    actions = ["export_as_csv"]
diff --git a/profiles/migrations/0015_student_updated_date.py b/profiles/migrations/0015_student_updated_date.py
new file mode 100644
index 0000000..4e672b5
--- /dev/null
+++ b/profiles/migrations/0015_student_updated_date.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.2 on 2021-01-15 17:06
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('profiles', '0014_auto_20201116_1118'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='student',
+            name='updated_date',
+            field=models.DateTimeField(auto_now=True),
+        ),
+    ]
diff --git a/profiles/models.py b/profiles/models.py
index 20952a3..4c9d912 100644
--- a/profiles/models.py
+++ b/profiles/models.py
@@ -38,6 +38,8 @@ class Student(ProfileMixin, models.Model):
 
     detail_view_name = 'api:student-detail'
 
+    updated_date = models.DateTimeField(auto_now=True)
+
     user = models.OneToOneField(
         'users.User',
         on_delete=models.CASCADE,
diff --git a/projects/MultiSelectFieldListFilter.py b/projects/MultiSelectFieldListFilter.py
new file mode 100644
index 0000000..28bbb89
--- /dev/null
+++ b/projects/MultiSelectFieldListFilter.py
@@ -0,0 +1,68 @@
+from django.contrib import admin
+from django.contrib.admin.utils import reverse_field_path
+from django.utils.translation import gettext_lazy as _
+
+
+class MultiSelectFieldListFilter(admin.FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        self.lookup_kwarg = field_path + '__in'
+        self.lookup_kwarg_isnull = field_path + '__isnull'
+
+        super().__init__(field, request, params, model, model_admin, field_path)
+
+        self.lookup_val = self.used_parameters.get(self.lookup_kwarg, [])
+        if len(self.lookup_val) == 1 and self.lookup_val[0] == '':
+            self.lookup_val = []
+        self.lookup_val_isnull = self.used_parameters.get(
+            self.lookup_kwarg_isnull)
+
+        self.empty_value_display = model_admin.get_empty_value_display()
+        parent_model, reverse_path = reverse_field_path(model, field_path)
+        # Obey parent ModelAdmin queryset when deciding which options to show
+        if model == parent_model:
+            queryset = model_admin.get_queryset(request)
+        else:
+            queryset = parent_model._default_manager.all()
+        self.lookup_choices = queryset.distinct().order_by(
+            field.name).values_list(field.name, flat=True)
+
+    def expected_parameters(self):
+        return [self.lookup_kwarg, self.lookup_kwarg_isnull]
+
+    def choices(self, changelist):
+        yield {
+            'selected': not self.lookup_val and self.lookup_val_isnull is None,
+            'query_string': changelist.get_query_string(remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]),
+            'display': _('All'),
+        }
+        include_none = False
+        for val in self.lookup_choices:
+            if val is None:
+                include_none = True
+                continue
+            val = str(val)
+
+            if val in self.lookup_val:
+                values = [v for v in self.lookup_val if v != val]
+            else:
+                values = self.lookup_val + [val]
+
+            if values:
+                yield {
+                    'selected': val in self.lookup_val,
+                    'query_string': changelist.get_query_string({self.lookup_kwarg: ','.join(values)}, [self.lookup_kwarg_isnull]),
+                    'display': val,
+                }
+            else:
+                yield {
+                    'selected': val in self.lookup_val,
+                    'query_string': changelist.get_query_string(remove=[self.lookup_kwarg]),
+                    'display': val,
+                }
+
+        if include_none:
+            yield {
+                'selected': bool(self.lookup_val_isnull),
+                'query_string': changelist.get_query_string({self.lookup_kwarg_isnull: 'True'}, [self.lookup_kwarg]),
+                'display': self.empty_value_display,
+            }
diff --git a/projects/admin.py b/projects/admin.py
index 5a65a46..e43c198 100644
--- a/projects/admin.py
+++ b/projects/admin.py
@@ -5,6 +5,8 @@ from django.contrib import admin
 from dynamicforms.views import download_multiple_forms_entries
 from dynamicforms.models import Form
 from .models import Edition, Participation, Project, EditionForm
+from django.contrib.admin import SimpleListFilter
+from profiles.models import Student
 
 
 @admin.register(Project)
@@ -37,7 +39,26 @@ class OrganizersInline(admin.TabularInline):
     extra = 0
 
 
-@admin.register(Edition)
+class SchoolFilter(admin.SimpleListFilter):
+    title = 'établissement'
+    parameter_name = 'profiles__school'
+
+    def lookups(self, request, model_admin):
+        list_of_school = []
+        query = Student.objects.values_list(
+            "school", flat=True).distinct()
+        for school in query:
+            list_of_school.append((school, school))
+        return list_of_school
+
+    def queryset(self, request, queryset):
+        if self.value():
+            emails = Student.objects.filter(
+                school=self.value()).values_list("user__email", flat=True)
+            return queryset.filter(user__email__in=emails)
+
+
+@ admin.register(Edition)
 class EditionAdmin(admin.ModelAdmin):
     """Admin panel for editions."""
 
@@ -73,7 +94,7 @@ class EditionAdmin(admin.ModelAdmin):
     num_cancelled.short_description = 'Annulés'
 
 
-@admin.register(EditionForm)
+@ admin.register(EditionForm)
 class EditionFormAdmin(admin.ModelAdmin):
     """Admin panel for edition forms."""
 
@@ -81,11 +102,12 @@ class EditionFormAdmin(admin.ModelAdmin):
     list_filter = ('edition', 'deadline',)
 
 
-@admin.register(Participation)
+@ admin.register(Participation)
 class ParticipationAdmin(admin.ModelAdmin):
     """Participation admin panel."""
 
     list_display = ('user', 'edition', 'submitted', 'state')
-    list_filter = ('edition', 'submitted', 'state',)
+    list_filter = (SchoolFilter,
+                   'edition', 'submitted', 'state',)
     readonly_fields = ('submitted',)
     search_fields = ('user__first_name', 'user__last_name', 'user__email',)
diff --git a/register/MultiSelectFieldListFilter.py b/register/MultiSelectFieldListFilter.py
new file mode 100644
index 0000000..28bbb89
--- /dev/null
+++ b/register/MultiSelectFieldListFilter.py
@@ -0,0 +1,68 @@
+from django.contrib import admin
+from django.contrib.admin.utils import reverse_field_path
+from django.utils.translation import gettext_lazy as _
+
+
+class MultiSelectFieldListFilter(admin.FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        self.lookup_kwarg = field_path + '__in'
+        self.lookup_kwarg_isnull = field_path + '__isnull'
+
+        super().__init__(field, request, params, model, model_admin, field_path)
+
+        self.lookup_val = self.used_parameters.get(self.lookup_kwarg, [])
+        if len(self.lookup_val) == 1 and self.lookup_val[0] == '':
+            self.lookup_val = []
+        self.lookup_val_isnull = self.used_parameters.get(
+            self.lookup_kwarg_isnull)
+
+        self.empty_value_display = model_admin.get_empty_value_display()
+        parent_model, reverse_path = reverse_field_path(model, field_path)
+        # Obey parent ModelAdmin queryset when deciding which options to show
+        if model == parent_model:
+            queryset = model_admin.get_queryset(request)
+        else:
+            queryset = parent_model._default_manager.all()
+        self.lookup_choices = queryset.distinct().order_by(
+            field.name).values_list(field.name, flat=True)
+
+    def expected_parameters(self):
+        return [self.lookup_kwarg, self.lookup_kwarg_isnull]
+
+    def choices(self, changelist):
+        yield {
+            'selected': not self.lookup_val and self.lookup_val_isnull is None,
+            'query_string': changelist.get_query_string(remove=[self.lookup_kwarg, self.lookup_kwarg_isnull]),
+            'display': _('All'),
+        }
+        include_none = False
+        for val in self.lookup_choices:
+            if val is None:
+                include_none = True
+                continue
+            val = str(val)
+
+            if val in self.lookup_val:
+                values = [v for v in self.lookup_val if v != val]
+            else:
+                values = self.lookup_val + [val]
+
+            if values:
+                yield {
+                    'selected': val in self.lookup_val,
+                    'query_string': changelist.get_query_string({self.lookup_kwarg: ','.join(values)}, [self.lookup_kwarg_isnull]),
+                    'display': val,
+                }
+            else:
+                yield {
+                    'selected': val in self.lookup_val,
+                    'query_string': changelist.get_query_string(remove=[self.lookup_kwarg]),
+                    'display': val,
+                }
+
+        if include_none:
+            yield {
+                'selected': bool(self.lookup_val_isnull),
+                'query_string': changelist.get_query_string({self.lookup_kwarg_isnull: 'True'}, [self.lookup_kwarg]),
+                'display': self.empty_value_display,
+            }
diff --git a/register/admin.py b/register/admin.py
index 8273b9e..482cba4 100644
--- a/register/admin.py
+++ b/register/admin.py
@@ -2,8 +2,45 @@
 
 from django.contrib import admin
 from .models import Registration
+from profiles.models import Student
 
-# Register your models here.
+
+class SchoolFilter(admin.SimpleListFilter):
+    title = 'établissement'
+    parameter_name = 'profiles__school'
+
+    def lookups(self, request, model_admin):
+        list_of_school = []
+        query = Student.objects.values_list(
+            "school", flat=True).distinct()
+        for school in query:
+            list_of_school.append((school, school))
+        return list_of_school
+
+    def queryset(self, request, queryset):
+        if self.value():
+            emails = Student.objects.filter(
+                school=self.value()).values_list("user__email", flat=True)
+            return queryset.filter(email__in=emails)
+
+
+class SchoolFilter(admin.SimpleListFilter):
+    title = 'établissement'
+    parameter_name = 'profiles__school'
+
+    def lookups(self, request, model_admin):
+        list_of_school = []
+        query = Student.objects.values_list(
+            "school", flat=True).distinct()
+        for school in query:
+            list_of_school.append((school, school))
+        return list_of_school
+
+    def queryset(self, request, queryset):
+        if self.value():
+            emails = Student.objects.filter(
+                school=self.value()).values_list("user__email", flat=True)
+            return queryset.filter(email__in=emails)
 
 
 @admin.register(Registration)
@@ -12,4 +49,5 @@ class RegistrationAdmin(admin.ModelAdmin):
 
     list_display = ('last_name', 'first_name', 'submitted')
     readonly_fields = ('submitted',)
-    list_filter = ('submitted', 'validated')
+    list_filter = (SchoolFilter,
+                   'submitted', 'validated')
diff --git a/runtime.txt b/runtime.txt
index 486fcce..0c89a2d 100644
--- a/runtime.txt
+++ b/runtime.txt
@@ -1 +1 @@
-python-3.6.5
+python-3.6.13
diff --git a/visits/admin.py b/visits/admin.py
index 0f1462e..7d49e11 100644
--- a/visits/admin.py
+++ b/visits/admin.py
@@ -8,10 +8,32 @@ from django.utils.safestring import mark_safe
 from django.http import HttpResponse
 import csv
 from .models import Participation, Place, Visit
+from profiles.models import Student
+from users.models import User
+import codecs
 
 # Register your models here.
 
 
+class SchoolFilter(admin.SimpleListFilter):
+    title = 'établissement'
+    parameter_name = 'profiles__school'
+
+    def lookups(self, request, model_admin):
+        list_of_school = []
+        query = Student.objects.values_list(
+            "school", flat=True).distinct()
+        for school in query:
+            list_of_school.append((school, school))
+        return list_of_school
+
+    def queryset(self, request, queryset):
+        if self.value():
+            emails = Student.objects.filter(
+                school=self.value()).values_list("user__email", flat=True)
+            return queryset.filter(user__email__in=emails)
+
+
 class RegistrationsOpenFilter(admin.SimpleListFilter):
     """Custom filter to filter visits by their registration openness.
 
@@ -41,6 +63,7 @@ class RegistrationsOpenFilter(admin.SimpleListFilter):
 class VisitForm(forms.ModelForm):
     """Custom admin form for Visit."""
 
+
     class Meta:  # noqa
         model = Visit
         fields = '__all__'
@@ -73,12 +96,29 @@ class VisitForm(forms.ModelForm):
                 self.add_error('end_time', error)
 
 
-class ParticipationInline(admin.StackedInline):
+class ParticipationInline(admin.TabularInline):
     """Inline for Participation."""
-
+    # template = "visits/visit_tabular.md"
+    actions = ["export_as_csv"]
     model = Visit.participants.through
     extra = 0
+    fields = ('name', 'school', 'user', 'submitted', 'present')
+    readonly_fields = ('name', 'school', 'user', 'submitted')
+
+    def school(self, participation: Participation):
+        """Return a link to the participation's user's school."""
+        school = Student.objects.get(user = participation.user).school
+        return school
+    school.short_description = "Établissement"
+
+    def name(self, participation: Participation):
+        """Returns the participation's user's name"""
+        return participation.user.first_name + " " + participation.user.last_name
+    name.short_description = "Nom"
+
 
+    class Media:
+        css = { "all" : ("css/hide_admin_original.css",) }
 
 def accept_selected_participations(modeladmin, request, queryset):
     """Accept selected participations in list view."""
@@ -115,7 +155,7 @@ class ParticipationAdmin(admin.ModelAdmin):
     """Admin panel for visit participations."""
 
     list_display = ('submitted', 'visit', 'user_link', 'accepted', 'present')
-    list_filter = ('submitted', 'accepted', 'present')
+    list_filter = (SchoolFilter, 'submitted', 'accepted', 'present')
     actions = [accept_selected_participations, reject_selected_participations]
 
     def user_link(self, participation: Participation):
@@ -128,20 +168,42 @@ class ParticipationAdmin(admin.ModelAdmin):
 
     actions = ["export_as_csv"]
 
+
+    def school(self, participation: Participation):
+        """Return a link to the participation's user's school."""
+        school = Student.objects.get(user = participation.user).school
+        return school
+    school.short_description = "Établissement"
+
+
     def export_as_csv(self, request, queryset):
         meta = self.model._meta
         field_names = [field.name for field in meta.fields]
-
         response = HttpResponse(content_type='text/csv')
         response['Content-Disposition'] = 'attachment; filename={}.csv'.format(
             meta)
-        writer = csv.writer(response)
-        writer.writerow(field_names)
+        response.write(codecs.BOM_UTF8)  # force response to be UTF-8
+        writer = csv.writer(response, delimiter=';')
+
+        writer.writerow(['first_name', 'last_name', 'school',
+                         'phone_number', 'scholarship'] + field_names)
+
+        list_email = queryset.values_list("user__email", flat=True)
+        nb_user = 0
         for obj in queryset:
-            row = writer.writerow([getattr(obj, field)
-                                   for field in field_names])
+
+            name = User.objects.filter(
+                email=str(list_email[nb_user])).values('first_name', 'last_name', 'phone_number')
+            school = Student.objects.filter(
+                user__email=str(list_email[nb_user])).values('school', 'scholarship')
+
+            row = writer.writerow([name[0]['first_name'], name[0]['last_name'], school[0]['school'], name[0]['phone_number'], school[0]['scholarship']] + [getattr(obj, field)
+                                                                                                                                                           for field in field_names])
+            nb_user += 1
         return response
-    export_as_csv.short_description = "Exporter au format CSV"
+
+    export_as_csv.short_description = "Exporter sélection (en .csv)"
+
 
 
 @admin.register(Visit.organizers.through)
@@ -177,7 +239,7 @@ class VisitAdmin(admin.ModelAdmin):
         return obj.participants.count()
     num_participants.short_description = 'Participants'
 
-
+    
 @admin.register(Place)
 class PlaceAdmin(admin.ModelAdmin):
     """Admin panel for places."""
diff --git a/visits/templates/visits/visit_tabular.md b/visits/templates/visits/visit_tabular.md
new file mode 100644
index 0000000..f96903b
--- /dev/null
+++ b/visits/templates/visits/visit_tabular.md
@@ -0,0 +1,79 @@
+{% load i18n admin_urls static admin_modify %}
+<div class="js-inline-admin-formset inline-group" id="{{ inline_admin_formset.formset.prefix }}-group"
+     data-inline-type="tabular"
+     data-inline-formset="{{ inline_admin_formset.inline_formset_data }}">
+  <div class="tabular inline-related {% if forloop.last %}last-related{% endif %}">
+{{ inline_admin_formset.formset.management_form }}
+<fieldset class="module {{ inline_admin_formset.classes }}">
+   {% if inline_admin_formset.formset.max_num == 1 %}
+     <h2>{{ inline_admin_formset.opts.verbose_name|capfirst }}</h2>
+   {% else %}
+     <h2>{{ inline_admin_formset.opts.verbose_name_plural|capfirst }}</h2>
+   {% endif %}
+   {{ inline_admin_formset.formset.non_form_errors }}
+   <table>
+     <thead><tr>
+       <th class="original"></th>
+     {% for field in inline_admin_formset.fields %}
+       {% if not field.widget.is_hidden %}
+         <th class="column-{{ field.name }}{% if field.required %} required{% endif %}">{{ field.label|capfirst }}
+         {% if field.help_text %}<img src="{% static "admin/img/icon-unknown.svg" %}" class="help help-tooltip" width="10" height="10" alt="({{ field.help_text|striptags }})" title="{{ field.help_text|striptags }}">{% endif %}
+         </th>
+       {% endif %}
+     {% endfor %}
+     {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission %}<th>{% translate "Delete?" %}</th>{% endif %}
+     </tr></thead>
+
+     <tbody>
+     {% for inline_admin_form in inline_admin_formset %}
+        {% if inline_admin_form.form.non_field_errors %}
+        <tr class="row-form-errors"><td colspan="{{ inline_admin_form|cell_count }}">{{ inline_admin_form.form.non_field_errors }}</td></tr>
+        {% endif %}
+        <tr class="form-row {% if inline_admin_form.original or inline_admin_form.show_url %}has_original{% endif %}{% if forloop.last and inline_admin_formset.has_add_permission %} empty-form{% endif %}"
+             id="{{ inline_admin_formset.formset.prefix }}-{% if not forloop.last %}{{ forloop.counter0 }}{% else %}empty{% endif %}">
+        <td class="original">
+          {% if inline_admin_form.original or inline_admin_form.show_url %}<p>
+          {% if inline_admin_form.original %}
+          {{ inline_admin_form.original }}
+          {% if inline_admin_form.model_admin.show_change_link and inline_admin_form.model_admin.has_registered_model %}<a href="{% url inline_admin_form.model_admin.opts|admin_urlname:'change' inline_admin_form.original.pk|admin_urlquote %}" class="{% if inline_admin_formset.has_change_permission %}inlinechangelink{% else %}inlineviewlink{% endif %}">{% if inline_admin_formset.has_change_permission %}{% translate "Change" %}{% else %}{% translate "View" %}{% endif %}</a>{% endif %}
+          {% endif %}
+          {% if inline_admin_form.show_url %}<a href="{{ inline_admin_form.absolute_url }}">{% translate "View on site" %}</a>{% endif %}
+            </p>{% endif %}
+          {% if inline_admin_form.needs_explicit_pk_field %}{{ inline_admin_form.pk_field.field }}{% endif %}
+          {% if inline_admin_form.fk_field %}{{ inline_admin_form.fk_field.field }}{% endif %}
+          {% spaceless %}
+          {% for fieldset in inline_admin_form %}
+            {% for line in fieldset %}
+              {% for field in line %}
+                {% if not field.is_readonly and field.field.is_hidden %}{{ field.field }}{% endif %}
+              {% endfor %}
+            {% endfor %}
+          {% endfor %}
+          {% endspaceless %}
+        </td>
+        {% for fieldset in inline_admin_form %}
+          {% for line in fieldset %}
+            {% for field in line %}
+              {% if field.is_readonly or not field.field.is_hidden %}
+              <td{% if field.field.name %} class="field-{{ field.field.name }}"{% endif %}>
+              {% if field.is_readonly %}
+                  <p>{{ field.contents }}</p>
+              {% else %}
+                  {{ field.field.errors.as_ul }}
+                  {{ field.field }}
+              {% endif %}
+              </td>
+              {% endif %}
+            {% endfor %}
+          {% endfor %}
+        {% endfor %}
+        {% if inline_admin_formset.formset.can_delete and inline_admin_formset.has_delete_permission %}
+          <td class="delete">{% if inline_admin_form.original %}{{ inline_admin_form.deletion_field.field }}{% endif %}</td>
+        {% endif %}
+        </tr>
+     {% endfor %}
+     </tbody>
+   </table>
+</fieldset>
+  </div>
+</div>
\ No newline at end of file
-- 
GitLab