]> git.ipfire.org Git - thirdparty/patchwork.git/commitdiff
models: Add State.slug field
authorStephen Finucane <stephen@that.guru>
Fri, 20 Dec 2019 10:49:21 +0000 (10:49 +0000)
committerStephen Finucane <stephen@that.guru>
Fri, 27 Dec 2019 12:53:37 +0000 (12:53 +0000)
This reduces a lot of the tech debt we had built up around this. This
field is populated from a slugified representation of State.name and has
a uniqueness constraint. As a result, we also need to a uniqueness
constraint to the State.name field. This shouldn't be an issue and
probably should have been used from day one.

Note that there is no REST API impact for this since we've been using
state slugs all along.

Signed-off-by: Stephen Finucane <stephen@that.guru>
patchwork/api/event.py
patchwork/api/patch.py
patchwork/fixtures/default_states.xml
patchwork/migrations/0038_state_slug.py [new file with mode: 0644]
patchwork/models.py
patchwork/tests/api/test_patch.py
patchwork/tests/utils.py

index e00ce051ad7aa50612adfa1a32c7605ab41b5046..a066faaec63bb0889ad4231b5c57609b8506645a 100644 (file)
@@ -8,6 +8,7 @@ from collections import OrderedDict
 from rest_framework.generics import ListAPIView
 from rest_framework.serializers import ModelSerializer
 from rest_framework.serializers import SerializerMethodField
+from rest_framework.serializers import SlugRelatedField
 
 from patchwork.api.embedded import CheckSerializer
 from patchwork.api.embedded import CoverLetterSerializer
@@ -16,7 +17,6 @@ from patchwork.api.embedded import ProjectSerializer
 from patchwork.api.embedded import SeriesSerializer
 from patchwork.api.embedded import UserSerializer
 from patchwork.api.filters import EventFilterSet
-from patchwork.api.patch import StateField
 from patchwork.models import Event
 
 
@@ -27,8 +27,8 @@ class EventSerializer(ModelSerializer):
     patch = PatchSerializer(read_only=True)
     series = SeriesSerializer(read_only=True)
     cover = CoverLetterSerializer(read_only=True)
-    previous_state = StateField()
-    current_state = StateField()
+    previous_state = SlugRelatedField(slug_field='slug', read_only=True)
+    current_state = SlugRelatedField(slug_field='slug', read_only=True)
     previous_delegate = UserSerializer()
     current_delegate = UserSerializer()
     created_check = SerializerMethodField()
index d1c9904d9c50fb6f9f5215a7d0cd1b16724ef44c..a29a1ab0eb71b7be8deb115f14eefe1b1828b538 100644 (file)
@@ -5,6 +5,7 @@
 
 import email.parser
 
+from django.utils.text import slugify
 from django.utils.translation import ugettext_lazy as _
 from rest_framework.generics import ListAPIView
 from rest_framework.generics import RetrieveUpdateAPIView
@@ -28,10 +29,7 @@ from patchwork.parser import clean_subject
 class StateField(RelatedField):
     """Avoid the need for a state endpoint.
 
-    NOTE(stephenfin): This field will only function for State names consisting
-    of alphanumeric characters, underscores and single spaces. In Patchwork
-    2.0+, we should consider adding a slug field to the State object and make
-    use of the SlugRelatedField in DRF.
+    TODO(stephenfin): Consider switching to SlugRelatedField for the v2.0 API.
     """
     default_error_messages = {
         'required': _('This field is required.'),
@@ -41,19 +39,13 @@ class StateField(RelatedField):
                             '{data_type}.'),
     }
 
-    @staticmethod
-    def format_state_name(state):
-        return ' '.join(state.split('-'))
-
     def to_internal_value(self, data):
+        data = slugify(data.lower())
         try:
-            data = self.format_state_name(data)
-            return self.get_queryset().get(name__iexact=data)
+            return self.get_queryset().get(slug=data)
         except State.DoesNotExist:
             self.fail('invalid_choice', name=data, choices=', '.join([
-                self.format_state_name(x.name) for x in self.get_queryset()]))
-        except (TypeError, ValueError):
-            self.fail('incorrect_type', data_type=type(data).__name__)
+                x.slug for x in self.get_queryset()]))
 
     def to_representation(self, obj):
         return obj.slug
index 86e1105ff15d50656b87c34612f41599553fd1f7..1bbd919d3e9315b90e40c7f3d1ee983c0d1a0b85 100644 (file)
@@ -4,51 +4,61 @@
   <!-- default states -->
   <object pk="1" model="patchwork.state">
     <field type="CharField" name="name">New</field>
+    <field type="SlugField" name="name">new</field>
     <field type="IntegerField" name="ordering">0</field>
     <field type="BooleanField" name="action_required">True</field>
   </object>
   <object pk="2" model="patchwork.state">
     <field type="CharField" name="name">Under Review</field>
+    <field type="SlugField" name="name">under-review</field>
     <field type="IntegerField" name="ordering">1</field>
     <field type="BooleanField" name="action_required">True</field>
   </object>
   <object pk="3" model="patchwork.state">
     <field type="CharField" name="name">Accepted</field>
+    <field type="SlugField" name="name">accepted</field>
     <field type="IntegerField" name="ordering">2</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="4" model="patchwork.state">
     <field type="CharField" name="name">Rejected</field>
+    <field type="SlugField" name="name">rejected</field>
     <field type="IntegerField" name="ordering">3</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="5" model="patchwork.state">
     <field type="CharField" name="name">RFC</field>
+    <field type="SlugField" name="name">rfc</field>
     <field type="IntegerField" name="ordering">4</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="6" model="patchwork.state">
     <field type="CharField" name="name">Not Applicable</field>
+    <field type="SlugField" name="name">not-applicable</field>
     <field type="IntegerField" name="ordering">5</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="7" model="patchwork.state">
     <field type="CharField" name="name">Changes Requested</field>
+    <field type="SlugField" name="name">changes-requested</field>
     <field type="IntegerField" name="ordering">6</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="8" model="patchwork.state">
     <field type="CharField" name="name">Awaiting Upstream</field>
+    <field type="SlugField" name="name">awaiting-upstream</field>
     <field type="IntegerField" name="ordering">7</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="9" model="patchwork.state">
     <field type="CharField" name="name">Superseded</field>
+    <field type="SlugField" name="name">superseded</field>
     <field type="IntegerField" name="ordering">8</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
   <object pk="10" model="patchwork.state">
     <field type="CharField" name="name">Deferred</field>
+    <field type="SlugField" name="name">deferred</field>
     <field type="IntegerField" name="ordering">9</field>
     <field type="BooleanField" name="action_required">False</field>
   </object>
diff --git a/patchwork/migrations/0038_state_slug.py b/patchwork/migrations/0038_state_slug.py
new file mode 100644 (file)
index 0000000..500624d
--- /dev/null
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+
+from __future__ import unicode_literals
+
+from django.db import migrations, models, transaction
+from django.utils.text import slugify
+
+
+def validate_uniqueness(apps, schema_editor):
+    """Ensure all State.name entries are unique.
+
+    We need to do this before enforcing a uniqueness constraint.
+    """
+
+    State = apps.get_model('patchwork', 'State')
+
+    total_count = State.objects.count()
+    slugs_count = len(set([
+        slugify(state.name) for state in State.objects.all()]))
+
+    if slugs_count != total_count:
+        raise Exception(
+            'You have non-unique States entries that need to be combined '
+            'before you can run this migration. This migration must be done '
+            'by hand. If you need assistance, please contact '
+            'patchwork@ozlabs.org')
+
+
+def populate_slug_field(apps, schema_editor):
+
+    State = apps.get_model('patchwork', 'State')
+
+    with transaction.atomic():
+        for state in State.objects.all():
+            state.slug = slugify(state.name)
+            state.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('patchwork', '0037_event_actor'),
+    ]
+
+    operations = [
+        # Ensure all 'State.name' entries are unique
+        migrations.RunPython(validate_uniqueness, migrations.RunPython.noop),
+        # Apply the unique constraint to 'State.name'
+        migrations.AlterField(
+            model_name='state',
+            name='name',
+            field=models.CharField(max_length=100, unique=True),
+        ),
+        # Add a 'State.slug' field but allow it to be nullable
+        migrations.AddField(
+            model_name='state',
+            name='slug',
+            field=models.SlugField(blank=True, max_length=100, null=True, unique=True),
+        ),
+        # Populate the 'State.slug' field
+        migrations.RunPython(populate_slug_field, migrations.RunPython.noop),
+        # Make the 'State.slug' field non-nullable
+        migrations.AlterField(
+            model_name='state',
+            name='slug',
+            field=models.SlugField(max_length=100, unique=True),
+        ),
+    ]
index 7f0efd489ae3710ec8054f587dc9a02e0a5612cf..d95eb0d9682049d924cae2716c313c78c26fef71 100644 (file)
@@ -215,14 +215,12 @@ models.signals.post_save.connect(_user_saved_callback, sender=User)
 
 @python_2_unicode_compatible
 class State(models.Model):
-    name = models.CharField(max_length=100)
+    # Both of these fields should be unique
+    name = models.CharField(max_length=100, unique=True)
+    slug = models.SlugField(max_length=100, unique=True)
     ordering = models.IntegerField(unique=True)
     action_required = models.BooleanField(default=True)
 
-    @property
-    def slug(self):
-        return '-'.join(self.name.lower().split())
-
     def __str__(self):
         return self.name
 
index bf3ef9f8f6ec01905fdf752dd03d9a2791a2f5b3..ef418e2e1b4f02b383b38ef03dcacae916c9de39 100644 (file)
@@ -300,7 +300,7 @@ class TestPatchAPI(utils.APITestCase):
 
         self.client.force_authenticate(user=user)
         resp = self.client.patch(self.api_url(patch.id),
-                                 {'state': state.name, 'delegate': user.id})
+                                 {'state': state.slug, 'delegate': user.id})
         self.assertEqual(status.HTTP_200_OK, resp.status_code, resp)
         self.assertEqual(Patch.objects.get(id=patch.id).state, state)
         self.assertEqual(Patch.objects.get(id=patch.id).delegate, user)
@@ -326,7 +326,7 @@ class TestPatchAPI(utils.APITestCase):
         self.client.force_authenticate(user=user)
         resp = self.client.patch(self.api_url(patch.id), {'state': 'foobar'})
         self.assertEqual(status.HTTP_400_BAD_REQUEST, resp.status_code)
-        self.assertContains(resp, 'Expected one of: %s.' % state.name,
+        self.assertContains(resp, 'Expected one of: %s.' % state.slug,
                             status_code=status.HTTP_400_BAD_REQUEST)
 
     def test_update_legacy_delegate(self):
index 577183d0986cc8cd315012f2dfbd9c83a41863ae..91bcbe12aaf4fcf73e7eadd165bba22570cb5ed1 100644 (file)
@@ -131,6 +131,7 @@ def create_state(**kwargs):
 
     values = {
         'name': 'state_%d' % num,
+        'slug': 'state-%d' % num,
         'ordering': num,
         'action_required': True,
     }