diff --git a/gulpfile.js b/gulpfile.js index 68595bdede9..195165e0098 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -47,6 +47,7 @@ var sources = { projects: { 'js/tools.js': {}, 'js/import.js': {}, + 'js/automation-rules.js': {}, 'css/import.less': {}, 'css/admin.less': {}, }, diff --git a/media/css/core.css b/media/css/core.css index b5d631a3c6d..0375ed2b850 100644 --- a/media/css/core.css +++ b/media/css/core.css @@ -1307,6 +1307,13 @@ div.module.project-subprojects li.subproject a.subproject-edit:before { content: "\f044"; } +/* Automation Rules */ + +li.automation-rule input[type="submit"] { + font-family: FontAwesome; + font-weight: normal; +} + /* Pygments */ div.highlight pre .hll { background-color: #ffffcc } diff --git a/readthedocs/builds/constants.py b/readthedocs/builds/constants.py index bf873eea999..37cadf5e696 100644 --- a/readthedocs/builds/constants.py +++ b/readthedocs/builds/constants.py @@ -1,5 +1,3 @@ -# -*- coding: utf-8 -*- - """Constants for the builds app.""" from django.conf import settings @@ -37,16 +35,20 @@ # Manager name for External Versions or Builds. # ie: Only pull request/merge request Versions and Builds. EXTERNAL = 'external' +EXTERNAL_TEXT = _('External') BRANCH = 'branch' +BRANCH_TEXT = _('Branch') TAG = 'tag' +TAG_TEXT = _('Tag') UNKNOWN = 'unknown' +UNKNOWN_TEXT = _('Unknown') VERSION_TYPES = ( - (BRANCH, _('Branch')), - (TAG, _('Tag')), - (EXTERNAL, _('External')), - (UNKNOWN, _('Unknown')), + (BRANCH, BRANCH_TEXT), + (TAG, TAG_TEXT), + (EXTERNAL, EXTERNAL_TEXT), + (UNKNOWN, UNKNOWN_TEXT), ) LATEST = settings.RTD_LATEST @@ -101,3 +103,23 @@ GITHUB_EXTERNAL_VERSION_NAME = 'Pull Request' GITLAB_EXTERNAL_VERSION_NAME = 'Merge Request' GENERIC_EXTERNAL_VERSION_NAME = 'External Version' + + +# Automation rules + +ALL_VERSIONS = 'all-versions' +ALL_VERSIONS_REGEX = r'.*' +SEMVER_VERSIONS = 'semver-versions' +SEMVER_VERSIONS_REGEX = r'^v?(\d+\.)(\d+\.)(\d+)(-.+)?$' + + +PREDEFINED_MATCH_ARGS = ( + (ALL_VERSIONS, _('Any version')), + (SEMVER_VERSIONS, _('SemVer versions')), + (None, _('Custom match')), +) + +PREDEFINED_MATCH_ARGS_VALUES = { + ALL_VERSIONS: ALL_VERSIONS_REGEX, + SEMVER_VERSIONS: SEMVER_VERSIONS_REGEX, +} diff --git a/readthedocs/builds/forms.py b/readthedocs/builds/forms.py index 91aa3ba2d46..7edb70f12c6 100644 --- a/readthedocs/builds/forms.py +++ b/readthedocs/builds/forms.py @@ -1,11 +1,19 @@ -# -*- coding: utf-8 -*- - """Django forms for the builds app.""" +import re +import textwrap + from django import forms from django.utils.translation import ugettext_lazy as _ -from readthedocs.builds.models import Version +from readthedocs.builds.constants import ( + ALL_VERSIONS, + BRANCH, + BRANCH_TEXT, + TAG, + TAG_TEXT, +) +from readthedocs.builds.models import RegexAutomationRule, Version from readthedocs.core.mixins import HideProtectedLevelMixin from readthedocs.core.utils import trigger_build @@ -37,3 +45,91 @@ def save(self, commit=True): if obj.active and not obj.built and not obj.uploaded: trigger_build(project=obj.project, version=obj) return obj + + +class RegexAutomationRuleForm(forms.ModelForm): + + match_arg = forms.CharField( + label='Custom match', + help_text=_(textwrap.dedent( + """ + A regular expression to match the version. + + Check the documentation for valid patterns. + + """ + )), + required=False, + ) + + class Meta: + model = RegexAutomationRule + fields = [ + 'description', + 'predefined_match_arg', + 'match_arg', + 'version_type', + 'action', + ] + # Don't pollute the UI with help texts + help_texts = { + 'version_type': '', + 'action': '', + } + labels = { + 'predefined_match_arg': 'Match', + } + + def __init__(self, *args, **kwargs): + self.project = kwargs.pop('project', None) + super().__init__(*args, **kwargs) + + # Only list supported types + self.fields['version_type'].choices = [ + (None, '-' * 9), + (BRANCH, BRANCH_TEXT), + (TAG, TAG_TEXT), + ] + + if not self.instance.pk: + self.initial['predefined_match_arg'] = ALL_VERSIONS + # Allow users to start from the pattern of the predefined match + # if they want to use a custom one. + if self.instance.pk and self.instance.predefined_match_arg: + self.initial['match_arg'] = self.instance.get_match_arg() + + def clean_match_arg(self): + """Check that a custom match was given if a predefined match wasn't used.""" + match_arg = self.cleaned_data['match_arg'] + predefined_match = self.cleaned_data['predefined_match_arg'] + if predefined_match: + match_arg = '' + if not predefined_match and not match_arg: + raise forms.ValidationError( + _('Custom match should not be empty.'), + ) + + try: + re.compile(match_arg) + except Exception: + raise forms.ValidationError( + _('Invalid Python regular expression.'), + ) + return match_arg + + def save(self, commit=True): + if self.instance.pk: + rule = super().save(commit=commit) + else: + rule = RegexAutomationRule.objects.add_rule( + project=self.project, + description=self.cleaned_data['description'], + match_arg=self.cleaned_data['match_arg'], + predefined_match_arg=self.cleaned_data['predefined_match_arg'], + version_type=self.cleaned_data['version_type'], + action=self.cleaned_data['action'], + ) + if not rule.description: + rule.description = rule.get_description() + rule.save() + return rule diff --git a/readthedocs/builds/managers.py b/readthedocs/builds/managers.py index 0ce18634a10..2affd75a7da 100644 --- a/readthedocs/builds/managers.py +++ b/readthedocs/builds/managers.py @@ -195,7 +195,7 @@ class VersionAutomationRuleManager(PolymorphicManager): def add_rule( self, *, project, description, match_arg, version_type, - action, action_arg=None, + action, action_arg=None, predefined_match_arg=None, ): """ Append an automation rule to `project`. @@ -219,6 +219,7 @@ def add_rule( priority=priority, description=description, match_arg=match_arg, + predefined_match_arg=predefined_match_arg, version_type=version_type, action=action, action_arg=action_arg, diff --git a/readthedocs/builds/migrations/0012_add-predefined-match-arg-field.py b/readthedocs/builds/migrations/0012_add-predefined-match-arg-field.py new file mode 100644 index 00000000000..6b57b553ca0 --- /dev/null +++ b/readthedocs/builds/migrations/0012_add-predefined-match-arg-field.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.25 on 2019-11-05 23:54 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('builds', '0011_version-media-availability'), + ] + + operations = [ + migrations.AddField( + model_name='versionautomationrule', + name='predefined_match_arg', + field=models.CharField(blank=True, choices=[('all-versions', 'Any version'), ('semver-versions', 'SemVer versions'), (None, 'Custom match')], default=None, help_text='Match argument defined by us, it is used if is not None, otherwise match_arg will be used.', max_length=255, null=True, verbose_name='Predefined match argument'), + ), + migrations.AlterField( + model_name='versionautomationrule', + name='action', + field=models.CharField(choices=[('activate-version', 'Activate version'), ('set-default-version', 'Set version as default')], help_text='Action to apply to matching versions', max_length=32, verbose_name='Action'), + ), + migrations.AlterField( + model_name='versionautomationrule', + name='version_type', + field=models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('external', 'External'), ('unknown', 'Unknown')], help_text='Type of version the rule should be applied to', max_length=32, verbose_name='Version type'), + ), + ] diff --git a/readthedocs/builds/models.py b/readthedocs/builds/models.py index c1209c39e8b..3fbc9069cd8 100644 --- a/readthedocs/builds/models.py +++ b/readthedocs/builds/models.py @@ -32,6 +32,8 @@ INTERNAL, LATEST, NON_REPOSITORY_VERSIONS, + PREDEFINED_MATCH_ARGS, + PREDEFINED_MATCH_ARGS_VALUES, STABLE, TAG, VERSION_TYPES, @@ -954,8 +956,8 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel): ACTIVATE_VERSION_ACTION = 'activate-version' SET_DEFAULT_VERSION_ACTION = 'set-default-version' ACTIONS = ( - (ACTIVATE_VERSION_ACTION, _('Activate version on match')), - (SET_DEFAULT_VERSION_ACTION, _('Set as default version on match')), + (ACTIVATE_VERSION_ACTION, _('Activate version')), + (SET_DEFAULT_VERSION_ACTION, _('Set version as default')), ) project = models.ForeignKey( @@ -978,8 +980,21 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel): help_text=_('Value used for the rule to match the version'), max_length=255, ) + predefined_match_arg = models.CharField( + _('Predefined match argument'), + help_text=_( + 'Match argument defined by us, it is used if is not None, ' + 'otherwise match_arg will be used.' + ), + max_length=255, + choices=PREDEFINED_MATCH_ARGS, + null=True, + blank=True, + default=None, + ) action = models.CharField( _('Action'), + help_text=_('Action to apply to matching versions'), max_length=32, choices=ACTIONS, ) @@ -992,6 +1007,7 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel): ) version_type = models.CharField( _('Version type'), + help_text=_('Type of version the rule should be applied to'), max_length=32, choices=VERSION_TYPES, ) @@ -1002,6 +1018,13 @@ class Meta: unique_together = (('project', 'priority'),) ordering = ('priority', '-modified', '-created') + def get_match_arg(self): + """Get the match arg defined for `predefined_match_arg` or the match from user.""" + match_arg = PREDEFINED_MATCH_ARGS_VALUES.get( + self.predefined_match_arg, + ) + return match_arg or self.match_arg + def run(self, version, *args, **kwargs): """ Run an action if `version` matches the rule. @@ -1010,7 +1033,7 @@ def run(self, version, *args, **kwargs): :returns: True if the action was performed """ if version.type == self.version_type: - match, result = self.match(version, self.match_arg) + match, result = self.match(version, self.get_match_arg()) if match: self.apply_action(version, result) return True @@ -1127,6 +1150,9 @@ def get_description(self): return self.description return f'{self.get_action_display()}' + def get_edit_url(self): + raise NotImplementedError + def __str__(self): class_name = self.__class__.__name__ return ( @@ -1177,3 +1203,9 @@ def match(self, version, match_arg): except Exception as e: log.info('Error parsing regex: %s', e) return False, None + + def get_edit_url(self): + return reverse( + 'projects_automation_rule_regex_edit', + args=[self.project.slug, self.pk], + ) diff --git a/readthedocs/projects/static-src/projects/js/automation-rules.js b/readthedocs/projects/static-src/projects/js/automation-rules.js new file mode 100644 index 00000000000..1debaf8989a --- /dev/null +++ b/readthedocs/projects/static-src/projects/js/automation-rules.js @@ -0,0 +1,29 @@ +// TODO: use knockoutjs instead, and for new code as well. + +var $ = require('jquery'); + +function set_help_text(value) { + var help_texts = { + 'all-versions': 'All versions will be matched.', + 'semver-versions': 'Versions incremented based on semantic versioning rules will be matched.', + '': '' + }; + $('#id_predefined_match_arg').siblings('.helptext').text(help_texts[value]); +} + +$(function () { + var value = $('#id_predefined_match_arg').val(); + if (value !== '') { + $('#id_match_arg').parent().hide(); + } + set_help_text(value); + + $('#id_predefined_match_arg').bind('change', function (ev) { + if (this.value === '') { + $('#id_match_arg').parent().show(); + } else { + $('#id_match_arg').parent().hide(); + } + set_help_text(this.value); + }); +}); diff --git a/readthedocs/projects/static/projects/js/automation-rules.js b/readthedocs/projects/static/projects/js/automation-rules.js new file mode 100644 index 00000000000..8250f423e25 --- /dev/null +++ b/readthedocs/projects/static/projects/js/automation-rules.js @@ -0,0 +1 @@ +require=function a(o,u,s){function c(r,e){if(!u[r]){if(!o[r]){var n="function"==typeof require&&require;if(!e&&n)return n(r,!0);if(d)return d(r,!0);var i=new Error("Cannot find module '"+r+"'");throw i.code="MODULE_NOT_FOUND",i}var t=u[r]={exports:{}};o[r][0].call(t.exports,function(e){return c(o[r][1][e]||e)},t,t.exports,a,o,u,s)}return u[r].exports}for(var d="function"==typeof require&&require,e=0;e[-\w]+)/rules/$', + AutomationRuleList.as_view(), + name='projects_automation_rule_list', + ), + url( + r'^(?P[-\w]+)/rules/(?P[-\w]+)/move/(?P-?\d+)/$', + AutomationRuleMove.as_view(), + name='projects_automation_rule_move', + ), + url( + r'^(?P[-\w]+)/rules/(?P[-\w]+)/delete/$', + AutomationRuleDelete.as_view(), + name='projects_automation_rule_delete', + ), + url( + r'^(?P[-\w]+)/rules/regex/create/$', + RegexAutomationRuleCreate.as_view(), + name='projects_automation_rule_regex_create', + ), + url( + r'^(?P[-\w]+)/rules/regex/(?P[-\w]+)/$', + RegexAutomationRuleUpdate.as_view(), + name='projects_automation_rule_regex_edit', + ), +] + +urlpatterns += automation_rule_urls diff --git a/readthedocs/projects/views/private.py b/readthedocs/projects/views/private.py index d23bae49ce7..031f20fb735 100644 --- a/readthedocs/projects/views/private.py +++ b/readthedocs/projects/views/private.py @@ -33,9 +33,17 @@ UpdateView, ) -from readthedocs.builds.forms import VersionForm -from readthedocs.builds.models import Version -from readthedocs.core.mixins import ListViewWithForm, PrivateViewMixin +from readthedocs.builds.forms import RegexAutomationRuleForm, VersionForm +from readthedocs.builds.models import ( + RegexAutomationRule, + Version, + VersionAutomationRule, +) +from readthedocs.core.mixins import ( + ListViewWithForm, + LoginRequiredMixin, + PrivateViewMixin, +) from readthedocs.core.utils import broadcast, trigger_build from readthedocs.core.utils.extend import SettingsOverrideObject from readthedocs.integrations.models import HttpExchange, Integration @@ -923,6 +931,66 @@ class EnvironmentVariableDelete(EnvironmentVariableMixin, DeleteView): http_method_names = ['post'] +class AutomationRuleMixin(ProjectAdminMixin, PrivateViewMixin): + + model = VersionAutomationRule + lookup_url_kwarg = 'automation_rule_pk' + + def get_success_url(self): + return reverse( + 'projects_automation_rule_list', + args=[self.get_project().slug], + ) + + +class AutomationRuleList(AutomationRuleMixin, ListView): + pass + + +class AutomationRuleMove(AutomationRuleMixin, GenericModelView): + + http_method_names = ['post'] + + def post(self, request, *args, **kwargs): + rule = self.get_object() + steps = int(self.kwargs.get('steps', 0)) + rule.move(steps) + return HttpResponseRedirect( + reverse( + 'projects_automation_rule_list', + args=[self.get_project().slug], + ) + ) + + +class AutomationRuleDelete(AutomationRuleMixin, DeleteView): + + http_method_names = ['post'] + + +class RegexAutomationRuleMixin(AutomationRuleMixin): + + model = RegexAutomationRule + form_class = RegexAutomationRuleForm + + +class RegexAutomationRuleCreate(RegexAutomationRuleMixin, CreateView): + pass + + +class RegexAutomationRuleUpdate(RegexAutomationRuleMixin, UpdateView): + pass + + +@login_required +def search_analytics_view(request, project_slug): + """View for search analytics.""" + project = get_object_or_404( + Project.objects.for_admin_user(request.user), + slug=project_slug, + ) + + class SearchAnalytics(ProjectAdminMixin, PrivateViewMixin, TemplateView): template_name = 'projects/projects_search_analytics.html' diff --git a/readthedocs/rtd_tests/tests/test_automation_rule_views.py b/readthedocs/rtd_tests/tests/test_automation_rule_views.py new file mode 100644 index 00000000000..0b3669fbd75 --- /dev/null +++ b/readthedocs/rtd_tests/tests/test_automation_rule_views.py @@ -0,0 +1,373 @@ +import pytest +from django.urls import reverse +from django_dynamic_fixture import get + +from readthedocs.builds.constants import ( + ALL_VERSIONS, + ALL_VERSIONS_REGEX, + BRANCH, + SEMVER_VERSIONS, + SEMVER_VERSIONS_REGEX, + TAG, +) +from readthedocs.builds.forms import RegexAutomationRuleForm +from readthedocs.builds.models import VersionAutomationRule +from readthedocs.projects.models import Project + + +@pytest.mark.django_db +class TestAutomationRulesViews: + + @pytest.fixture(autouse=True) + def setup(self, client, django_user_model): + self.user = get(django_user_model) + self.client = client + self.client.force_login(self.user) + + self.project = get(Project, users=[self.user]) + + self.list_rules_url = reverse( + 'projects_automation_rule_list', args=[self.project.slug], + ) + + def test_create_and_update_regex_rule(self): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'One rule', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule = self.project.automation_rules.get(description='One rule') + assert rule.priority == 0 + + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'Another rule', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule = self.project.automation_rules.get(description='Another rule') + assert rule.priority == 1 + + r = self.client.post( + reverse( + 'projects_automation_rule_regex_edit', + args=[self.project.slug, rule.pk], + ), + { + 'description': 'Edit rule', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule.refresh_from_db() + assert rule.description == 'Edit rule' + assert rule.priority == 1 + + def test_create_regex_rule_default_description(self): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + assert ( + self.project.automation_rules + .filter(description='Activate version') + .exists() + ) + + def test_create_regex_rule_custom_match(self): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'One rule', + 'match_arg': r'^master$', + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule = self.project.automation_rules.get(description='One rule') + assert rule.match_arg == r'^master$' + + @pytest.mark.parametrize( + 'predefined_match_arg,expected_regex', + [ + (ALL_VERSIONS, ALL_VERSIONS_REGEX), + (SEMVER_VERSIONS, SEMVER_VERSIONS_REGEX), + ], + ) + def test_create_regex_rule_predefined_match(self, predefined_match_arg, expected_regex): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule', + 'predefined_match_arg': predefined_match_arg, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule = self.project.automation_rules.get(description='rule') + assert rule.get_match_arg() == expected_regex + + def test_empty_custom_match(self): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'One rule', + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + form = r.context['form'] + assert ( + 'Custom match should not be empty.' in form.errors['match_arg'] + ) + + def test_invalid_regex(self): + r = self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'One rule', + 'match_arg': r'?$', + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + form = r.context['form'] + assert ( + 'Invalid Python regular expression.' in form.errors['match_arg'] + ) + + def test_delete_rule(self): + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-0', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-1', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-2', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + + rule_0 = self.project.automation_rules.get(description='rule-0') + rule_1 = self.project.automation_rules.get(description='rule-1') + rule_2 = self.project.automation_rules.get(description='rule-2') + + self.project.automation_rules.all().count() == 3 + + assert rule_0.priority == 0 + assert rule_1.priority == 1 + assert rule_2.priority == 2 + + r = self.client.post( + reverse( + 'projects_automation_rule_delete', + args=[self.project.slug, rule_0.pk], + ), + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + self.project.automation_rules.all().count() == 2 + + rule_1.refresh_from_db() + rule_2.refresh_from_db() + + assert rule_1.priority == 0 + assert rule_2.priority == 1 + + def test_move_rule_up(self): + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-0', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-1', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-2', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + + rule_0 = self.project.automation_rules.get(description='rule-0') + rule_1 = self.project.automation_rules.get(description='rule-1') + rule_2 = self.project.automation_rules.get(description='rule-2') + + r = self.client.post( + reverse( + 'projects_automation_rule_move', + args=[self.project.slug, rule_1.pk, -1], + ), + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule_0.refresh_from_db() + rule_1.refresh_from_db() + rule_2.refresh_from_db() + + assert rule_1.priority == 0 + assert rule_0.priority == 1 + assert rule_2.priority == 2 + + def test_move_rule_down(self): + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-0', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': TAG, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-1', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + self.client.post( + reverse( + 'projects_automation_rule_regex_create', + args=[self.project.slug], + ), + { + 'description': 'rule-2', + 'predefined_match_arg': ALL_VERSIONS, + 'version_type': BRANCH, + 'action': VersionAutomationRule.ACTIVATE_VERSION_ACTION, + }, + ) + + rule_0 = self.project.automation_rules.get(description='rule-0') + rule_1 = self.project.automation_rules.get(description='rule-1') + rule_2 = self.project.automation_rules.get(description='rule-2') + + r = self.client.post( + reverse( + 'projects_automation_rule_move', + args=[self.project.slug, rule_1.pk, 1], + ), + ) + assert r.status_code == 302 + assert r['Location'] == self.list_rules_url + + rule_0.refresh_from_db() + rule_1.refresh_from_db() + rule_2.refresh_from_db() + + assert rule_0.priority == 0 + assert rule_2.priority == 1 + assert rule_1.priority == 2 diff --git a/readthedocs/rtd_tests/tests/test_automation_rules.py b/readthedocs/rtd_tests/tests/test_automation_rules.py index 8dc77f3680b..274dbdf1f8d 100644 --- a/readthedocs/rtd_tests/tests/test_automation_rules.py +++ b/readthedocs/rtd_tests/tests/test_automation_rules.py @@ -2,7 +2,13 @@ import pytest from django_dynamic_fixture import get -from readthedocs.builds.constants import BRANCH, LATEST, TAG +from readthedocs.builds.constants import ( + ALL_VERSIONS, + BRANCH, + LATEST, + SEMVER_VERSIONS, + TAG, +) from readthedocs.builds.models import ( RegexAutomationRule, Version, @@ -85,6 +91,76 @@ def test_match( ) assert rule.run(version) is result + @pytest.mark.parametrize( + 'version_name,result', + [ + ('master', True), + ('latest', True), + ('master-something', True), + ('something-master', True), + ('1.3.2', True), + ('1.3.3.5', True), + ('1.3.3-rc', True), + ('12-a', True), + ('1-a', True), + ] + ) + @pytest.mark.parametrize('version_type', [BRANCH, TAG]) + def test_predefined_match_all_versions(self, version_name, result, version_type): + version = get( + Version, + verbose_name=version_name, + project=self.project, + active=False, + type=version_type, + built=False, + ) + rule = get( + RegexAutomationRule, + project=self.project, + priority=0, + predefined_match_arg=ALL_VERSIONS, + action=VersionAutomationRule.ACTIVATE_VERSION_ACTION, + version_type=version_type, + ) + assert rule.run(version) is result + + @pytest.mark.parametrize( + 'version_name,result', + [ + ('master', False), + ('latest', False), + ('master-something', False), + ('something-master', False), + ('1.3.3.5', False), + ('12-a', False), + ('1-a', False), + + ('1.3.2', True), + ('1.3.3-rc', True), + ('0.1.1', True), + ] + ) + @pytest.mark.parametrize('version_type', [BRANCH, TAG]) + def test_predefined_match_semver_versions(self, version_name, result, version_type): + version = get( + Version, + verbose_name=version_name, + project=self.project, + active=False, + type=version_type, + built=False, + ) + rule = get( + RegexAutomationRule, + project=self.project, + priority=0, + predefined_match_arg=SEMVER_VERSIONS, + action=VersionAutomationRule.ACTIVATE_VERSION_ACTION, + version_type=version_type, + ) + assert rule.run(version) is result + @mock.patch('readthedocs.builds.automation_actions.trigger_build') def test_action_activation(self, trigger_build): version = get( diff --git a/readthedocs/rtd_tests/tests/test_privacy_urls.py b/readthedocs/rtd_tests/tests/test_privacy_urls.py index df0506cd3b1..5e52c6cf46e 100644 --- a/readthedocs/rtd_tests/tests/test_privacy_urls.py +++ b/readthedocs/rtd_tests/tests/test_privacy_urls.py @@ -8,12 +8,14 @@ from django_dynamic_fixture import get from taggit.models import Tag +from readthedocs.builds.constants import BRANCH from readthedocs.builds.models import Build, BuildCommandResult from readthedocs.core.utils.tasks import TaskNoPermission from readthedocs.integrations.models import HttpExchange, Integration from readthedocs.oauth.models import RemoteOrganization, RemoteRepository from readthedocs.projects.models import Domain, EnvironmentVariable, Project from readthedocs.rtd_tests.utils import create_user +from readthedocs.builds.models import RegexAutomationRule, VersionAutomationRule class URLAccessMixin: @@ -157,6 +159,13 @@ def setUp(self): ) self.domain = get(Domain, url='http://docs.foobar.com', project=self.pip) self.environment_variable = get(EnvironmentVariable, project=self.pip) + self.automation_rule = RegexAutomationRule.objects.create( + project=self.pip, + priority=0, + match_arg='.*', + action=VersionAutomationRule.ACTIVATE_VERSION_ACTION, + version_type=BRANCH, + ) self.default_kwargs = { 'project_slug': self.pip.slug, 'subproject_slug': self.subproject.slug, @@ -170,6 +179,8 @@ def setUp(self): 'integration_pk': self.integration.pk, 'exchange_pk': self.webhook_exchange.pk, 'environmentvariable_pk': self.environment_variable.pk, + 'automation_rule_pk': self.automation_rule.pk, + 'steps': 1, 'invalid_project_slug': 'invalid_slug', } @@ -252,12 +263,16 @@ class PrivateProjectAdminAccessTest(PrivateProjectMixin, TestCase): '/dashboard/pip/environmentvariables/{environmentvariable_id}/delete/': {'status_code': 405}, '/dashboard/pip/translations/delete/sub/': {'status_code': 405}, '/dashboard/pip/version/latest/delete_html/': {'status_code': 405}, + '/dashboard/pip/rules/{automation_rule_id}/delete/': {'status_code': 405}, + '/dashboard/pip/rules/{automation_rule_id}/move/{steps}/': {'status_code': 405}, } def get_url_path_ctx(self): return { 'integration_id': self.integration.id, 'environmentvariable_id': self.environment_variable.id, + 'automation_rule_id': self.automation_rule.id, + 'steps': 1, } def login(self): @@ -290,6 +305,8 @@ class PrivateProjectUserAccessTest(PrivateProjectMixin, TestCase): '/dashboard/pip/environmentvariables/{environmentvariable_id}/delete/': {'status_code': 405}, '/dashboard/pip/translations/delete/sub/': {'status_code': 405}, '/dashboard/pip/version/latest/delete_html/': {'status_code': 405}, + '/dashboard/pip/rules/{automation_rule_id}/delete/': {'status_code': 405}, + '/dashboard/pip/rules/{automation_rule_id}/move/{steps}/': {'status_code': 405}, } # Filtered out by queryset on projects that we don't own. @@ -299,6 +316,8 @@ def get_url_path_ctx(self): return { 'integration_id': self.integration.id, 'environmentvariable_id': self.environment_variable.id, + 'automation_rule_id': self.automation_rule.id, + 'steps': 1, } def login(self): diff --git a/readthedocs/templates/builds/regexautomationrule_form.html b/readthedocs/templates/builds/regexautomationrule_form.html new file mode 100644 index 00000000000..4de0a46293a --- /dev/null +++ b/readthedocs/templates/builds/regexautomationrule_form.html @@ -0,0 +1,38 @@ +{% extends "projects/project_edit_base.html" %} + +{% load static %} +{% load i18n %} + +{% block title %}{% trans "Automation Rule" %}{% endblock %} + +{% block nav-dashboard %} class="active"{% endblock %} + +{% block project-automation-rules-active %}active{% endblock %} +{% block project_edit_content_header %}{% trans "Automation Rule" %}{% endblock %} + + +{% block extra_scripts %} + + +{% endblock %} + +{% block project_edit_content %} +
+ {% csrf_token %} + {{ form.as_p }} + +
+ + {% if object.pk %} +
+ {% csrf_token %} + +
+ {% endif %} + +{% endblock %} diff --git a/readthedocs/templates/builds/versionautomationrule_list.html b/readthedocs/templates/builds/versionautomationrule_list.html new file mode 100644 index 00000000000..c3d60089bfd --- /dev/null +++ b/readthedocs/templates/builds/versionautomationrule_list.html @@ -0,0 +1,74 @@ +{% extends "projects/project_edit_base.html" %} + +{% load i18n %} + +{% block title %}{% trans "Automation Rules" %}{% endblock %} + +{% block nav-dashboard %} class="active"{% endblock %} + +{% block project-automation-rules-active %}active{% endblock %} +{% block project_edit_content_header %}{% trans "Automation Rules" %}{% endblock %} + +{% block project_edit_content %} + +

+ {% blocktrans trimmed %} + Automate actions on new branches and tags. + Check the documentation on automation rules for more information. + {% endblocktrans %} +

+ + + + +
+
+
    + {% for rule in object_list %} +
  • + + {{ rule.get_description }} + + + ({{ rule.get_version_type_display }}) + +
      + {% if not forloop.first %} +
    • +
      + {% csrf_token %} + +
      +
    • + {% endif %} + + {% if not forloop.last %} +
    • +
      + {% csrf_token %} + +
      +
    • + {% endif %} +
    +
  • + {% empty %} +
  • +

    + {% trans 'No automation rules are currently configured.' %} +

    +
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/readthedocs/templates/projects/project_edit_base.html b/readthedocs/templates/projects/project_edit_base.html index e7fa71af179..3f419f07b25 100644 --- a/readthedocs/templates/projects/project_edit_base.html +++ b/readthedocs/templates/projects/project_edit_base.html @@ -23,6 +23,7 @@
  • {% trans "Subprojects" %}
  • {% trans "Integrations" %}
  • {% trans "Environment Variables" %}
  • +
  • {% trans "Automation Rules" %}
  • {% trans "Notifications" %}
  • {% trans "Search Analytics" %}
  • {% if USE_PROMOS %}