From c4707b2d906cad618c4e797aa7294f1f1e8608a3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 15 Apr 2021 14:17:07 +0200
Subject: [PATCH 1/3] agendas: add slug form absence reason model (#53147)

---
 chrono/agendas/migrations/0079_reason_slug.py | 20 ++++++++++
 chrono/agendas/migrations/0080_reason_slug.py | 40 +++++++++++++++++++
 chrono/agendas/migrations/0081_reason_slug.py | 16 ++++++++
 chrono/agendas/models.py                      | 11 +++++
 chrono/manager/forms.py                       | 14 +++++++
 chrono/manager/views.py                       |  3 +-
 tests/manager/test_absence_reason.py          | 22 ++++++++--
 tests/test_agendas.py                         | 20 ++++++++++
 8 files changed, 142 insertions(+), 4 deletions(-)
 create mode 100644 chrono/agendas/migrations/0079_reason_slug.py
 create mode 100644 chrono/agendas/migrations/0080_reason_slug.py
 create mode 100644 chrono/agendas/migrations/0081_reason_slug.py

diff --git a/chrono/agendas/migrations/0079_reason_slug.py b/chrono/agendas/migrations/0079_reason_slug.py
new file mode 100644
index 0000000..b1aabf9
--- /dev/null
+++ b/chrono/agendas/migrations/0079_reason_slug.py
@@ -0,0 +1,20 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('agendas', '0078_absence_reasons'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='absencereason',
+            name='slug',
+            field=models.SlugField(max_length=160, null=True, verbose_name='Identifier'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='absencereason',
+            unique_together=['group', 'slug'],
+        ),
+    ]
diff --git a/chrono/agendas/migrations/0080_reason_slug.py b/chrono/agendas/migrations/0080_reason_slug.py
new file mode 100644
index 0000000..f7712be
--- /dev/null
+++ b/chrono/agendas/migrations/0080_reason_slug.py
@@ -0,0 +1,40 @@
+from django.db import migrations
+from django.utils.text import slugify
+
+
+def generate_slug(instance, **query_filters):
+    base_slug = slugify(instance.label)
+    slug = base_slug
+    i = 1
+    while True:
+        queryset = instance._meta.model.objects.filter(slug=slug, **query_filters).exclude(pk=instance.pk)
+        if not queryset.exists():
+            break
+        slug = '%s-%s' % (base_slug, i)
+        i += 1
+    return slug
+
+
+def set_slug_on_absence_reasons(apps, schema_editor):
+    AbsenceReason = apps.get_model('agendas', 'AbsenceReason')
+    for reason in AbsenceReason.objects.all().order_by('-pk'):
+        if (
+            reason.slug
+            and not AbsenceReason.objects.filter(slug=reason.slug, group=reason.group)
+            .exclude(pk=reason.pk)
+            .exists()
+        ):
+            continue
+        reason.slug = generate_slug(reason, group=reason.group)
+        reason.save(update_fields=['slug'])
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('agendas', '0079_reason_slug'),
+    ]
+
+    operations = [
+        migrations.RunPython(set_slug_on_absence_reasons, migrations.RunPython.noop),
+    ]
diff --git a/chrono/agendas/migrations/0081_reason_slug.py b/chrono/agendas/migrations/0081_reason_slug.py
new file mode 100644
index 0000000..d2444ef
--- /dev/null
+++ b/chrono/agendas/migrations/0081_reason_slug.py
@@ -0,0 +1,16 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('agendas', '0080_reason_slug'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='absencereason',
+            name='slug',
+            field=models.SlugField(max_length=160, verbose_name='Identifier'),
+        ),
+    ]
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 0834bb2..8b3be82 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -2455,10 +2455,21 @@ class AbsenceReasonGroup(models.Model):
 
 class AbsenceReason(models.Model):
     group = models.ForeignKey(AbsenceReasonGroup, on_delete=models.CASCADE, related_name='absence_reasons')
+    slug = models.SlugField(_('Identifier'), max_length=160)
     label = models.CharField(_('Label'), max_length=150)
 
     class Meta:
         ordering = ['label']
+        unique_together = ['group', 'slug']
 
     def __str__(self):
         return self.label
+
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            self.slug = generate_slug(self, group=self.group)
+        super().save(*args, **kwargs)
+
+    @property
+    def base_slug(self):
+        return slugify(self.label)
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 85bd044..c790418 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -53,6 +53,20 @@ from . import widgets
 from .widgets import SplitDateTimeField
 
 
+class AbsenceReasonForm(forms.ModelForm):
+    class Meta:
+        model = AbsenceReason
+        fields = ['label', 'slug']
+
+    def clean_slug(self):
+        slug = self.cleaned_data['slug']
+
+        if self.instance.group.absence_reasons.filter(slug=slug).exclude(pk=self.instance.pk).exists():
+            raise ValidationError(_('Another absence reason exists with the same identifier.'))
+
+        return slug
+
+
 class AgendaAddForm(forms.ModelForm):
     edit_role = forms.ModelChoiceField(
         label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 0275b09..44d060f 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -79,6 +79,7 @@ from chrono.agendas.models import (
 )
 
 from .forms import (
+    AbsenceReasonForm,
     AgendaAddForm,
     AgendaEditForm,
     AgendaBookingDelaysForm,
@@ -703,7 +704,7 @@ absence_reason_add = AbsenceReasonAddView.as_view()
 class AbsenceReasonEditView(UpdateView):
     template_name = 'chrono/manager_absence_reason_form.html'
     model = AbsenceReason
-    fields = ['label']
+    form_class = AbsenceReasonForm
 
     def dispatch(self, request, *args, **kwargs):
         self.group_pk = kwargs.pop('group_pk')
diff --git a/tests/manager/test_absence_reason.py b/tests/manager/test_absence_reason.py
index d4ffc29..db21f68 100644
--- a/tests/manager/test_absence_reason.py
+++ b/tests/manager/test_absence_reason.py
@@ -68,17 +68,24 @@ def test_add_group_as_manager(app, manager_user, agenda_with_restrictions):
 
 def test_edit_group(app, admin_user):
     group = AbsenceReasonGroup.objects.create(label='Foo bar')
+    group2 = AbsenceReasonGroup.objects.create(label='baz')
 
     app = login(app)
     resp = app.get('/manage/absence-reasons/')
     resp = resp.click(href='/manage/absence-reason/group/%s/edit/' % group.pk)
     resp.form['label'] = 'Foo bar baz'
-    resp.form['slug'] = 'baz'
+    resp.form['slug'] = group2.slug
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['slug'] == [
+        'Absence reason group with this Identifier already exists.'
+    ]
+
+    resp.form['slug'] = 'baz2'
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/absence-reasons/')
     group.refresh_from_db()
     assert group.label == 'Foo bar baz'
-    assert group.slug == 'baz'
+    assert group.slug == 'baz2'
 
 
 def test_edit_group_as_manager(app, manager_user, agenda_with_restrictions):
@@ -121,6 +128,7 @@ def test_add_reason(app, admin_user):
     assert resp.location.endswith('/manage/absence-reasons/')
     assert reason.label == 'Foo reason'
     assert reason.group == group
+    assert reason.slug == 'foo-reason'
 
 
 def test_add_reason_as_manager(app, manager_user, agenda_with_restrictions):
@@ -133,17 +141,25 @@ def test_add_reason_as_manager(app, manager_user, agenda_with_restrictions):
 def test_edit_reason(app, admin_user):
     group = AbsenceReasonGroup.objects.create(label='Foo bar')
     reason = AbsenceReason.objects.create(label='Foo reason', group=group)
+    reason2 = AbsenceReason.objects.create(label='Baz', group=group)
+    group2 = AbsenceReasonGroup.objects.create(label='Foo bar')
+    reason3 = AbsenceReason.objects.create(label='Foo bar reason', group=group2)
 
     app = login(app)
     resp = app.get('/manage/absence-reasons/')
     resp = resp.click(href='/manage/absence-reason/group/%s/%s/edit/' % (group.pk, reason.pk))
     resp.form['label'] = 'Foo bar reason'
+    resp.form['slug'] = reason2.slug
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['slug'] == ['Another absence reason exists with the same identifier.']
+
+    resp.form['slug'] = reason3.slug
     resp = resp.form.submit()
     assert resp.location.endswith('/manage/absence-reasons/')
     reason.refresh_from_db()
     assert reason.label == 'Foo bar reason'
+    assert reason.slug == 'foo-bar-reason'
 
-    group2 = AbsenceReasonGroup.objects.create(label='Foo bar baz')
     app.get('/manage/absence-reason/group/%s/%s/edit/' % (group2.pk, reason.pk), status=404)
 
 
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 667a84f..c9b7974 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -14,6 +14,7 @@ from django.test import override_settings
 from django.utils.timezone import localtime, make_aware, now
 
 from chrono.agendas.models import (
+    AbsenceReasonGroup,
     Agenda,
     Booking,
     Category,
@@ -177,6 +178,25 @@ def test_category_duplicate_slugs():
     assert category.slug == 'foo-baz-2'
 
 
+def test_absence_reason_group_slug():
+    group = AbsenceReasonGroup.objects.create(label=u'Foo bar')
+    assert group.slug == 'foo-bar'
+
+
+def test_absence_reason_group_existing_slug():
+    group = AbsenceReasonGroup.objects.create(label=u'Foo bar', slug='bar')
+    assert group.slug == 'bar'
+
+
+def test_absence_reason_group_duplicate_slugs():
+    group = AbsenceReasonGroup.objects.create(label=u'Foo baz')
+    assert group.slug == 'foo-baz'
+    group = AbsenceReasonGroup.objects.create(label=u'Foo baz')
+    assert group.slug == 'foo-baz-1'
+    group = AbsenceReasonGroup.objects.create(label=u'Foo baz')
+    assert group.slug == 'foo-baz-2'
+
+
 @pytest.mark.parametrize('with_prefetch', [True, False])
 def test_agenda_is_available_for_simple_management(settings, with_prefetch):
     settings.EXCEPTIONS_SOURCES = {
-- 
2.30.2

