[-\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 %}
+
+
+ {% if object.pk %}
+
+ {% 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 %}
+
+
+
+
+
+
+{% 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 %}