Skip to content

Commit 9cecf04

Browse files
authored
Automation Rules: simplify ordering (#10896)
I'm trying to re-use this for redirects, and found a couple of things that we can improve. - New items would be inserted first instead of at the end, if a user is adding a new item, they may want to have it priority over existing ones. Inserting the item at the end was mostly done because it was "easier" to implement. - Instead of explicitly using the manager to add a new rule, just override the save() method. This allows us to: - Insert a new rule with any priority - Change the priority of a rule by just saving it with the new priority. With this, we no longer need to override forms, serializers, etc, saving the model will take care of putting the rule in the right place.
1 parent ea17d5f commit 9cecf04

File tree

8 files changed

+182
-132
lines changed

8 files changed

+182
-132
lines changed

docs/user/guides/automation-rules.rst

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,5 @@ You can change the order using the up and down arrow buttons.
8181

8282
.. note::
8383

84-
New rules are added at the end (lower priority).
84+
New rules are added at the start of the list
85+
(i.e. they have the highest priority).

readthedocs/builds/forms.py

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def save(self, commit=True):
9393

9494
class RegexAutomationRuleForm(forms.ModelForm):
9595

96+
project = forms.CharField(widget=forms.HiddenInput(), required=False)
9697
match_arg = forms.CharField(
9798
label='Custom match',
9899
help_text=_(textwrap.dedent(
@@ -109,11 +110,12 @@ class RegexAutomationRuleForm(forms.ModelForm):
109110
class Meta:
110111
model = RegexAutomationRule
111112
fields = [
112-
'description',
113-
'predefined_match_arg',
114-
'match_arg',
115-
'version_type',
116-
'action',
113+
"project",
114+
"description",
115+
"predefined_match_arg",
116+
"match_arg",
117+
"version_type",
118+
"action",
117119
]
118120
# Don't pollute the UI with help texts
119121
help_texts = {
@@ -174,19 +176,5 @@ def clean_match_arg(self):
174176
)
175177
return match_arg
176178

177-
def save(self, commit=True):
178-
if self.instance.pk:
179-
rule = super().save(commit=commit)
180-
else:
181-
rule = RegexAutomationRule.objects.add_rule(
182-
project=self.project,
183-
description=self.cleaned_data['description'],
184-
match_arg=self.cleaned_data['match_arg'],
185-
predefined_match_arg=self.cleaned_data['predefined_match_arg'],
186-
version_type=self.cleaned_data['version_type'],
187-
action=self.cleaned_data['action'],
188-
)
189-
if not rule.description:
190-
rule.description = rule.get_description()
191-
rule.save()
192-
return rule
179+
def clean_project(self):
180+
return self.project

readthedocs/builds/managers.py

Lines changed: 1 addition & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import structlog
44
from django.core.exceptions import ObjectDoesNotExist
55
from django.db import models
6-
from polymorphic.managers import PolymorphicManager
76

87
from readthedocs.builds.constants import (
98
BRANCH,
@@ -39,7 +38,7 @@ def from_queryset(cls, queryset_class, class_name=None):
3938
# no direct members.
4039
queryset_class = get_override_class(
4140
VersionQuerySet,
42-
VersionQuerySet._default_class, # pylint: disable=protected-access
41+
VersionQuerySet._default_class,
4342
)
4443
return super().from_queryset(queryset_class, class_name)
4544

@@ -135,52 +134,6 @@ def get_queryset(self):
135134
return super().get_queryset().filter(version__type=EXTERNAL)
136135

137136

138-
class VersionAutomationRuleManager(PolymorphicManager):
139-
140-
"""
141-
Manager for VersionAutomationRule.
142-
143-
.. note::
144-
145-
This manager needs to inherit from PolymorphicManager,
146-
since the model is a PolymorphicModel.
147-
See https://django-polymorphic.readthedocs.io/page/managers.html
148-
"""
149-
150-
def add_rule(
151-
self, *, project, description, match_arg, version_type,
152-
action, action_arg=None, predefined_match_arg=None,
153-
):
154-
"""
155-
Append an automation rule to `project`.
156-
157-
The rule is created with a priority lower than the last rule
158-
in `project`.
159-
"""
160-
last_priority = (
161-
project.automation_rules
162-
.values_list('priority', flat=True)
163-
.order_by('priority')
164-
.last()
165-
)
166-
if last_priority is None:
167-
priority = 0
168-
else:
169-
priority = last_priority + 1
170-
171-
rule = self.create(
172-
project=project,
173-
priority=priority,
174-
description=description,
175-
match_arg=match_arg,
176-
predefined_match_arg=predefined_match_arg,
177-
version_type=version_type,
178-
action=action,
179-
action_arg=action_arg,
180-
)
181-
return rule
182-
183-
184137
class AutomationRuleMatchManager(models.Manager):
185138

186139
def register_match(self, rule, version, max_registers=15):
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.5 on 2023-11-08 22:18
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("builds", "0054_add_builds_index_for_addons"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="versionautomationrule",
14+
name="priority",
15+
field=models.IntegerField(
16+
default=0,
17+
help_text="A lower number (0) means a higher priority",
18+
verbose_name="Rule priority",
19+
),
20+
),
21+
]
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Generated by Django 4.2.5 on 2023-11-22 16:50
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("builds", "0055_alter_versionautomationrule_priority"),
9+
]
10+
11+
operations = [
12+
migrations.AlterField(
13+
model_name="versionautomationrule",
14+
name="priority",
15+
field=models.PositiveIntegerField(
16+
default=0,
17+
help_text="A lower number (0) means a higher priority",
18+
verbose_name="Rule priority",
19+
),
20+
),
21+
]

readthedocs/builds/models.py

Lines changed: 86 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343
ExternalVersionManager,
4444
InternalBuildManager,
4545
InternalVersionManager,
46-
VersionAutomationRuleManager,
4746
VersionManager,
4847
)
4948
from readthedocs.builds.querysets import (
@@ -1236,9 +1235,10 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
12361235
related_name='automation_rules',
12371236
on_delete=models.CASCADE,
12381237
)
1239-
priority = models.IntegerField(
1238+
priority = models.PositiveIntegerField(
12401239
_('Rule priority'),
12411240
help_text=_('A lower number (0) means a higher priority'),
1241+
default=0,
12421242
)
12431243
description = models.CharField(
12441244
_('Description'),
@@ -1283,8 +1283,6 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
12831283
choices=VERSION_TYPES,
12841284
)
12851285

1286-
objects = VersionAutomationRuleManager()
1287-
12881286
class Meta:
12891287
unique_together = (('project', 'priority'),)
12901288
ordering = ('priority', '-modified', '-created')
@@ -1354,56 +1352,103 @@ def move(self, steps):
13541352
13551353
:param steps: Number of steps to be moved
13561354
(it can be negative)
1357-
:returns: True if the priority was changed
13581355
"""
13591356
total = self.project.automation_rules.count()
13601357
current_priority = self.priority
13611358
new_priority = (current_priority + steps) % total
1359+
self.priority = new_priority
1360+
self.save()
13621361

1363-
if current_priority == new_priority:
1364-
return False
1362+
def _change_priority(self):
1363+
"""
1364+
Re-order the priorities of the other rules when the priority of this rule changes.
1365+
1366+
If the rule is new, we just need to move all other rules down,
1367+
so there is space for the new rule.
1368+
1369+
If the rule already exists, we need to move the other rules up or down,
1370+
depending on the new priority, so we can insert the rule at the new priority.
1371+
1372+
The save() method needs to be called after this method.
1373+
"""
1374+
total = self.project.automation_rules.count()
1375+
1376+
# If the rule was just created, we just need to insert it at the given priority.
1377+
# We do this by moving the other rules down before saving.
1378+
if not self.pk:
1379+
# A new rule can be created at the end as max.
1380+
self.priority = min(self.priority, total)
1381+
1382+
# A new rule can't be created with a negative priority. All rules start at 0.
1383+
self.priority = max(self.priority, 0)
13651384

1366-
# Move other's priority
1367-
if new_priority > current_priority:
1368-
# It was moved down
1369-
rules = (
1370-
self.project.automation_rules
1371-
.filter(priority__gt=current_priority, priority__lte=new_priority)
1372-
# We sort the queryset in asc order
1373-
# to be updated in that order
1374-
# to avoid hitting the unique constraint (project, priority).
1375-
.order_by('priority')
1376-
)
1377-
expression = F('priority') - 1
1378-
else:
1379-
# It was moved up
13801385
rules = (
1381-
self.project.automation_rules
1382-
.filter(priority__lt=current_priority, priority__gte=new_priority)
1383-
.exclude(pk=self.pk)
1386+
self.project.automation_rules.filter(priority__gte=self.priority)
13841387
# We sort the queryset in desc order
13851388
# to be updated in that order
13861389
# to avoid hitting the unique constraint (project, priority).
13871390
.order_by('-priority')
13881391
)
13891392
expression = F('priority') + 1
1393+
else:
1394+
current_priority = self.project.automation_rules.values_list(
1395+
"priority",
1396+
flat=True,
1397+
).get(pk=self.pk)
1398+
1399+
# An existing rule can't be moved past the end.
1400+
self.priority = min(self.priority, total - 1)
1401+
1402+
# A new rule can't be created with a negative priority. all rules start at 0.
1403+
self.priority = max(self.priority, 0)
1404+
1405+
# The rule wasn't moved, so we don't need to do anything.
1406+
if self.priority == current_priority:
1407+
return
1408+
1409+
if self.priority > current_priority:
1410+
# It was moved down, so we need to move the other rules up.
1411+
rules = (
1412+
self.project.automation_rules.filter(
1413+
priority__gt=current_priority, priority__lte=self.priority
1414+
)
1415+
# We sort the queryset in asc order
1416+
# to be updated in that order
1417+
# to avoid hitting the unique constraint (project, priority).
1418+
.order_by("priority")
1419+
)
1420+
expression = F("priority") - 1
1421+
else:
1422+
# It was moved up, so we need to move the other rules down.
1423+
rules = (
1424+
self.project.automation_rules.filter(
1425+
priority__lt=current_priority, priority__gte=self.priority
1426+
)
1427+
# We sort the queryset in desc order
1428+
# to be updated in that order
1429+
# to avoid hitting the unique constraint (project, priority).
1430+
.order_by("-priority")
1431+
)
1432+
expression = F("priority") + 1
13901433

13911434
# Put an impossible priority to avoid
1392-
# the unique constraint (project, priority)
1393-
# while updating.
1394-
self.priority = total + 99
1395-
self.save()
1396-
1397-
# We update each object one by one to
1398-
# avoid hitting the unique constraint (project, priority).
1435+
# the unique constraint (project, priority) while updating.
1436+
# We use update() instead of save() to avoid calling the save() method again.
1437+
if self.pk:
1438+
self._meta.model.objects.filter(pk=self.pk).update(priority=total + 99)
1439+
1440+
# NOTE: we can't use rules.update(priority=expression), because SQLite is used
1441+
# in tests and hits a UNIQUE constraint error. PostgreSQL doesn't have this issue.
1442+
# We use update() instead of save() to avoid calling the save() method.
13991443
for rule in rules:
1400-
rule.priority = expression
1401-
rule.save()
1444+
self._meta.model.objects.filter(pk=rule.pk).update(priority=expression)
14021445

1403-
# Put back new priority
1404-
self.priority = new_priority
1405-
self.save()
1406-
return True
1446+
def save(self, *args, **kwargs):
1447+
"""Override method to update the other priorities before save."""
1448+
self._change_priority()
1449+
if not self.description:
1450+
self.description = self.get_description()
1451+
super().save(*args, **kwargs)
14071452

14081453
def delete(self, *args, **kwargs):
14091454
"""Override method to update the other priorities after delete."""
@@ -1421,9 +1466,11 @@ def delete(self, *args, **kwargs):
14211466
)
14221467
# We update each object one by one to
14231468
# avoid hitting the unique constraint (project, priority).
1469+
# We use update() instead of save() to avoid calling the save() method.
14241470
for rule in rules:
1425-
rule.priority = F('priority') - 1
1426-
rule.save()
1471+
self._meta.model.objects.filter(pk=rule.pk).update(
1472+
priority=F("priority") - 1,
1473+
)
14271474

14281475
def get_description(self):
14291476
if self.description:

0 commit comments

Comments
 (0)