Skip to content

Commit 3777436

Browse files
committed
Automation Rules: keep history of recent matches
Ref #7653 Close #6393
1 parent abd3b60 commit 3777436

File tree

6 files changed

+185
-11
lines changed

6 files changed

+185
-11
lines changed

readthedocs/builds/managers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,3 +225,19 @@ def add_rule(
225225
action_arg=action_arg,
226226
)
227227
return rule
228+
229+
230+
class AutomationRuleMatchManager(models.Manager):
231+
232+
def register_match(self, rule, version, max_registers=15):
233+
created = self.create(
234+
rule=rule,
235+
match_arg=rule.get_match_arg(),
236+
action=rule.action,
237+
version_name=version.verbose_name,
238+
version_type=version.type,
239+
)
240+
241+
for match in self.filter(rule__project=rule.project)[max_registers:]:
242+
match.delete()
243+
return created
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Generated by Django 2.2.16 on 2020-11-10 22:42
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import django_extensions.db.fields
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('builds', '0027_add_privacy_level_automation_rules'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='AutomationRuleMatch',
17+
fields=[
18+
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
19+
('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')),
20+
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
21+
('version_name', models.CharField(max_length=255)),
22+
('match_arg', models.CharField(max_length=255)),
23+
('action', models.CharField(choices=[('activate-version', 'Activate version'), ('hide-version', 'Hide version'), ('make-version-public', 'Make version public'), ('make-version-private', 'Make version private'), ('set-default-version', 'Set version as default')], max_length=255)),
24+
('version_type', models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('external', 'External'), ('unknown', 'Unknown')], max_length=32)),
25+
('rule', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='matches', to='builds.VersionAutomationRule', verbose_name='Automation rule match')),
26+
],
27+
options={
28+
'ordering': ('-modified', '-created'),
29+
},
30+
),
31+
]

readthedocs/builds/models.py

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
VERSION_TYPES,
4242
)
4343
from readthedocs.builds.managers import (
44+
AutomationRuleMatchManager,
4445
BuildManager,
4546
ExternalBuildManager,
4647
ExternalVersionManager,
@@ -1025,18 +1026,27 @@ def get_match_arg(self):
10251026
)
10261027
return match_arg or self.match_arg
10271028

1028-
def run(self, version, *args, **kwargs):
1029+
def run(self, version, register=True, **kwargs):
10291030
"""
10301031
Run an action if `version` matches the rule.
10311032
10321033
:type version: readthedocs.builds.models.Version
1034+
:param register: If ``True`` a register of the match is created
10331035
:returns: True if the action was performed
10341036
"""
1035-
if version.type == self.version_type:
1036-
match, result = self.match(version, self.get_match_arg())
1037-
if match:
1038-
self.apply_action(version, result)
1039-
return True
1037+
if version.type != self.version_type:
1038+
return False
1039+
1040+
match, result = self.match(version, self.get_match_arg())
1041+
if match:
1042+
self.apply_action(version, result)
1043+
1044+
if register:
1045+
AutomationRuleMatch.objects.register_match(
1046+
rule=self,
1047+
version=version,
1048+
)
1049+
return True
10401050
return False
10411051

10421052
def match(self, version, match_arg):
@@ -1212,3 +1222,29 @@ def get_edit_url(self):
12121222
'projects_automation_rule_regex_edit',
12131223
args=[self.project.slug, self.pk],
12141224
)
1225+
1226+
1227+
class AutomationRuleMatch(TimeStampedModel):
1228+
rule = models.ForeignKey(
1229+
VersionAutomationRule,
1230+
verbose_name=_('Automation rule match'),
1231+
related_name='matches',
1232+
on_delete=models.CASCADE,
1233+
)
1234+
1235+
# Metadata from when the match happened.
1236+
version_name = models.CharField(max_length=255)
1237+
match_arg = models.CharField(max_length=255)
1238+
action = models.CharField(
1239+
max_length=255,
1240+
choices=VersionAutomationRule.ACTIONS,
1241+
)
1242+
version_type = models.CharField(
1243+
max_length=32,
1244+
choices=VERSION_TYPES,
1245+
)
1246+
1247+
objects = AutomationRuleMatchManager()
1248+
1249+
class Meta:
1250+
ordering = ('-modified', '-created')

readthedocs/projects/views/private.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from readthedocs.analytics.models import PageView
3636
from readthedocs.builds.forms import RegexAutomationRuleForm, VersionForm
3737
from readthedocs.builds.models import (
38+
AutomationRuleMatch,
3839
RegexAutomationRule,
3940
Version,
4041
VersionAutomationRule,
@@ -938,7 +939,14 @@ def get_success_url(self):
938939

939940

940941
class AutomationRuleList(AutomationRuleMixin, ListView):
941-
pass
942+
943+
def get_context_data(self, **kwargs):
944+
context = super().get_context_data(**kwargs)
945+
context['matches'] = (
946+
AutomationRuleMatch.objects
947+
.filter(rule__project=self.get_project())
948+
)
949+
return context
942950

943951

944952
class AutomationRuleMove(AutomationRuleMixin, GenericModelView):

readthedocs/rtd_tests/tests/test_automation_rules.py

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,9 @@ def setup_method(self):
7272
]
7373
)
7474
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
75+
@mock.patch('readthedocs.builds.automation_actions.trigger_build')
7576
def test_match(
76-
self, version_name, regex, result, version_type):
77+
self, trigger_build, version_name, regex, result, version_type):
7778
version = get(
7879
Version,
7980
verbose_name=version_name,
@@ -91,6 +92,7 @@ def test_match(
9192
version_type=version_type,
9293
)
9394
assert rule.run(version) is result
95+
assert rule.matches.all().count() == (1 if result else 0)
9496

9597
@pytest.mark.parametrize(
9698
'version_name,result',
@@ -107,7 +109,8 @@ def test_match(
107109
]
108110
)
109111
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
110-
def test_predefined_match_all_versions(self, version_name, result, version_type):
112+
@mock.patch('readthedocs.builds.automation_actions.trigger_build')
113+
def test_predefined_match_all_versions(self, trigger_build, version_name, result, version_type):
111114
version = get(
112115
Version,
113116
verbose_name=version_name,
@@ -143,7 +146,8 @@ def test_predefined_match_all_versions(self, version_name, result, version_type)
143146
]
144147
)
145148
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
146-
def test_predefined_match_semver_versions(self, version_name, result, version_type):
149+
@mock.patch('readthedocs.builds.automation_actions.trigger_build')
150+
def test_predefined_match_semver_versions(self, trigger_build, version_name, result, version_type):
147151
version = get(
148152
Version,
149153
verbose_name=version_name,
@@ -183,7 +187,8 @@ def test_action_activation(self, trigger_build):
183187
assert version.active is True
184188
trigger_build.assert_called_once()
185189

186-
def test_action_set_default_version(self):
190+
@mock.patch('readthedocs.builds.automation_actions.trigger_build')
191+
def test_action_set_default_version(self, trigger_build):
187192
version = get(
188193
Version,
189194
verbose_name='v2',
@@ -272,6 +277,54 @@ def test_version_make_private_action(self, trigger_build):
272277
assert version.privacy_level == PRIVATE
273278
trigger_build.assert_not_called()
274279

280+
@mock.patch('readthedocs.builds.automation_actions.trigger_build')
281+
def test_matches_history(self, trigger_build):
282+
version = get(
283+
Version,
284+
verbose_name='test',
285+
project=self.project,
286+
active=False,
287+
type=TAG,
288+
built=False,
289+
)
290+
291+
rule = get(
292+
RegexAutomationRule,
293+
project=self.project,
294+
priority=0,
295+
match_arg='^test',
296+
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
297+
version_type=TAG,
298+
)
299+
300+
assert rule.run(version) is True
301+
assert rule.matches.all().count() == 1
302+
303+
match = rule.matches.first()
304+
assert match.version_name == 'test'
305+
assert match.version_type == TAG
306+
assert match.action == VersionAutomationRule.ACTIVATE_VERSION_ACTION
307+
assert match.match_arg == '^test'
308+
309+
for i in range(1, 31):
310+
version.verbose_name = f'test {i}'
311+
version.save()
312+
assert rule.run(version) is True
313+
314+
assert rule.matches.all().count() == 15
315+
316+
match = rule.matches.first()
317+
assert match.version_name == 'test 30'
318+
assert match.version_type == TAG
319+
assert match.action == VersionAutomationRule.ACTIVATE_VERSION_ACTION
320+
assert match.match_arg == '^test'
321+
322+
match = rule.matches.last()
323+
assert match.version_name == 'test 16'
324+
assert match.version_type == TAG
325+
assert match.action == VersionAutomationRule.ACTIVATE_VERSION_ACTION
326+
assert match.match_arg == '^test'
327+
275328

276329
@pytest.mark.django_db
277330
class TestAutomationRuleManager:

readthedocs/templates/builds/versionautomationrule_list.html

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,4 +80,34 @@
8080
</ul>
8181
</div>
8282
</div>
83+
84+
<p>
85+
<h3>{% trans "Recent Activity" %}</h3>
86+
</p>
87+
<div class="module-list-wrapper">
88+
<ul>
89+
{% for match in matches %}
90+
<li class="module-item">
91+
{% blocktrans trimmed with version=match.version_name pattern=match.match_arg type=match.get_version_type_display action=match.get_action_display %}
92+
{{ type }} "{{ version }}"
93+
matched the pattern <span>"{{ pattern }}"</span>,
94+
and the action <span>"{{ action }}"</span> was performed
95+
{% endblocktrans %}
96+
<a href="{{ match.rule.get_edit_url }}">
97+
{% blocktrans trimmed with date=match.created|timesince %}
98+
{{ date }} ago
99+
{% endblocktrans %}
100+
</a>
101+
.
102+
</li>
103+
{% empty %}
104+
<li class="module-item">
105+
<span class="quiet">
106+
{% trans 'There is no recent activity' %}
107+
</span>
108+
</li>
109+
{% endfor %}
110+
</ul>
111+
</div>
112+
83113
{% endblock %}

0 commit comments

Comments
 (0)