Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add models for automation rules #5323

Merged
merged 38 commits into from
May 21, 2019
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2ae80d1
Add models for automation rules
stsewd Feb 20, 2019
d0e04e0
Merge branch 'master' into add-models-for-automationrules
stsewd Feb 28, 2019
d53bad2
Use django-polymorphic with proxy models
stsewd Feb 28, 2019
d287286
Add migration
stsewd Feb 28, 2019
c5e6010
Merge branch 'master' into add-models-for-automationrules
stsewd Mar 4, 2019
cb5e1b9
Refactor and suggestions from review
stsewd Mar 4, 2019
4fd56f6
Update migration
stsewd Mar 4, 2019
e570a58
Implement activation action
stsewd Mar 4, 2019
353bc8a
Clean model
stsewd Mar 4, 2019
7a60668
Tests!
stsewd Mar 4, 2019
409e960
Refactor name
stsewd Mar 4, 2019
8f58e77
Refactor models
stsewd Mar 4, 2019
8dfe290
Integrate in admin
stsewd Mar 4, 2019
0d4ab95
Merge branch 'master' into add-models-for-automationrules
stsewd Mar 6, 2019
74f1b9a
Don't search for project.name
stsewd Mar 6, 2019
7936f55
Merge branch 'master' into add-models-for-automationrules
stsewd Apr 1, 2019
4c77043
Merge branch 'master' into add-models-for-automationrules
stsewd Apr 25, 2019
11add5d
Return explicitly a boolean
stsewd Apr 25, 2019
5e90f62
Use action_arg from self
stsewd Apr 25, 2019
0903763
Add set default version action
stsewd Apr 25, 2019
26fc59b
Fix migrations
stsewd Apr 25, 2019
7660b86
Typo
stsewd Apr 26, 2019
a2c50ac
Execute automation rules on new versions
stsewd Apr 26, 2019
0f06958
Typo
stsewd Apr 26, 2019
bf2f954
Trigger build after activate
stsewd Apr 26, 2019
73030c0
Better admin page
stsewd Apr 29, 2019
6237ca4
More tests
stsewd Apr 29, 2019
25faa28
More tests
stsewd Apr 29, 2019
eda1cd2
Test from api call
stsewd Apr 29, 2019
39c8cc5
Tests for set_default_version
stsewd Apr 29, 2019
a6d959d
Fix test
stsewd Apr 29, 2019
2ae831d
Docs
stsewd Apr 29, 2019
78e6e62
Period
stsewd Apr 29, 2019
5d0c706
Merge branch 'master' into add-models-for-automationrules
stsewd Apr 29, 2019
b2a1339
Merge branch 'master' into add-models-for-automationrules
stsewd May 9, 2019
b07b660
Notes about the versions order
stsewd May 9, 2019
a86d427
Merge branch 'master' into add-models-for-automationrules
stsewd May 20, 2019
cbce26a
Fix mock
stsewd May 20, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions readthedocs/builds/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,13 +144,15 @@ def wipe_version(self, request, queryset):

@admin.register(RegexAutomationRule)
class RegexAutomationRuleAdmin(PolymorphicChildModelAdmin, admin.ModelAdmin):
raw_id_fields = ('project',)
base_model = RegexAutomationRule


@admin.register(VersionAutomationRule)
class VersionAutomationRuleAdmin(PolymorphicParentModelAdmin, admin.ModelAdmin):
base_model = VersionAutomationRule
list_display = (
'id',
'project',
'priority',
'match_arg',
Expand Down
24 changes: 24 additions & 0 deletions readthedocs/builds/automation_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,31 @@
- action_arg: An additional argument to apply the action
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I said in another comment, I'd remove everything that it's not needed at this point: match_result and action_arg here.

If we want to include them in this PR, I think it would be good to have the use case in this PR also otherwise we are adding code that it's not useful and only complicate things.

"""

from readthedocs.core.utils import trigger_build


def activate_version(version, match_result, action_arg, *args, **kwargs):
"""
Sets version as active.

It triggers a build if the version isn't built.
"""
version.active = True
version.save()
if not version.built:
trigger_build(
project=version.project,
version=version
)


def set_default_version(version, match_result, action_arg, *args, **kwargs):
"""
Sets version as the project's default version.

The version is activated first.
"""
activate_version(version, match_result, action_arg)
project = version.project
project.default_version = version.slug
project.save()
6 changes: 3 additions & 3 deletions readthedocs/builds/migrations/0007_add-automation-rules.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.20 on 2019-03-04 22:15
# Generated by Django 1.11.20 on 2019-04-25 23:04
from __future__ import unicode_literals

from django.db import migrations, models
Expand All @@ -10,7 +10,7 @@
class Migration(migrations.Migration):

dependencies = [
('projects', '0040_increase_path_max_length'),
('projects', '0042_increase_env_variable_value_max_length'),
('contenttypes', '0002_remove_content_type_name'),
('builds', '0006_add_config_field'),
]
Expand All @@ -24,7 +24,7 @@ class Migration(migrations.Migration):
('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')),
('priority', models.IntegerField(help_text='A lower number (0) means a higher priority', verbose_name='Rule priority')),
('match_arg', models.CharField(help_text='Value used for the rule to match the version', max_length=255, verbose_name='Match argument')),
('action', models.CharField(choices=[('activate-version', 'Activate version on match')], max_length=32, verbose_name='Action')),
('action', models.CharField(choices=[('activate-version', 'Activate version on match'), ('set-default-version', 'Set as default version on match')], max_length=32, verbose_name='Action')),
('action_arg', models.CharField(blank=True, help_text='Value used for the action to perfom an operation', max_length=255, null=True, verbose_name='Action argument')),
('version_type', models.CharField(choices=[('branch', 'Branch'), ('tag', 'Tag'), ('unknown', 'Unknown')], max_length=32, verbose_name='Version type')),
('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_builds.versionautomationrule_set+', to='contenttypes.ContentType')),
Expand Down
24 changes: 13 additions & 11 deletions readthedocs/builds/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -711,8 +711,10 @@ class VersionAutomationRule(PolymorphicModel, TimeStampedModel):
"""Versions automation rules for projects."""

ACTIVATE_VERSION_ACTION = 'activate-version'
SET_DEFAULT_VERSION_ACTION = 'set-default-version'
ACTIONS = (
(ACTIVATE_VERSION_ACTION, _('Activate version on match')),
stsewd marked this conversation as resolved.
Show resolved Hide resolved
(SET_DEFAULT_VERSION_ACTION, _('Set as default version on match')),
)

project = models.ForeignKey(
Expand Down Expand Up @@ -759,37 +761,36 @@ 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)
if match_result is not None:
self.apply_action(version, match_result, self.action_arg)
match, result = self.match(version, self.match_arg)
if match:
self.apply_action(version, result)
return True
return False

def match(self, version, match_arg):
"""
Returns an object different from None if the version matches the rule.
Returns True and the match result if the version matches the rule.

:type version: readthedocs.builds.models.Version
:param str match_arg: Additional argument to perform the match
:returns: An object different from `None` if the match is successful.
:returns: A tuple of (boolean, match_resul).
The result will be passed to `apply_action`.
"""
return None
return False, None

def apply_action(self, version, match_result, action_arg):
def apply_action(self, version, match_result):
"""
Apply the action from allowed_actions.

:type version: readthedocs.builds.models.Version
:param any match_result: Additional context from the match operation
:param str action_arg: Additional argument to perform the action
:raises: NotImplementedError if the action
isn't implemented or supported for this rule.
"""
action = self.allowed_actions.get(self.action)
if action is None:
raise NotImplementedError
action(version, match_result, action_arg)
action(version, match_result, self.action_arg)

def __str__(self):
class_name = self.__class__.__name__
Expand All @@ -804,6 +805,7 @@ class RegexAutomationRule(VersionAutomationRule):

allowed_actions = {
VersionAutomationRule.ACTIVATE_VERSION_ACTION: actions.activate_version,
VersionAutomationRule.SET_DEFAULT_VERSION_ACTION: actions.set_default_version,
}

class Meta:
Expand All @@ -814,7 +816,7 @@ def match(self, version, match_arg):
match = re.search(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say that what we want here is fullmatch; otherwise we are not really matching the user's regex.

.search produce unexpected results here:

In [2]: re.search('[0-9]', 'my-release-1')                                                                                                                                                    
Out[2]: <re.Match object; span=(11, 12), match='1'>

In [3]: re.fullmatch('[0-9]', 'my-release-1')                                                                                                                                                 

In [4]: re.fullmatch('[0-9]', '1')                                                                                                                                                            
Out[4]: <re.Match object; span=(0, 1), match='1'>

In [5]: re.fullmatch('[0-9]', '1a')                                                                                                                                                           

In [6]:  

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me the result of fullmatch is unexpected. In regex I always need to start with ^ if I want to match from the beginning

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think fullmatch does exactly what we want: matches the full string. Using this approach will avoid false positives when matching things that are not complete, as I showed in the examples above.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In regex I always need to start with ^ if I want to match from the beginning

You are an advanced user :)

I'm sure that many people will end up matching versions that they don't want to match.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The thing is that this should not be exposed to general users, so we need all the power of regex internally more than exposing this to users.

match_arg, version.verbose_name
)
return match
return bool(match), match
except Exception as e:
log.info('Error parsing regex: %s', e)
return None
return False, None
2 changes: 1 addition & 1 deletion readthedocs/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,7 +911,7 @@ def update_stable_version(self):
"""
Returns the version that was promoted to be the new stable version.

Return ``None`` if no update was mode or if there is no version on the
Return ``None`` if no update was made or if there is no version on the
project that can be considered stable.
"""
versions = self.versions.all()
Expand Down
13 changes: 13 additions & 0 deletions readthedocs/restapi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

"""Utility functions that are used by both views and celery tasks."""

import itertools
import logging

from rest_framework.pagination import PageNumberPagination
Expand Down Expand Up @@ -160,6 +161,18 @@ def delete_versions(project, version_data):
return set()


def run_automation_rules(project, versions_slug):
"""
Runs the automation rules on each version.

The rules are sorted by priority.
"""
versions = project.versions.filter(slug__in=versions_slug)
rules = project.automation_rules.all()
for version, rule in itertools.product(versions, rules):
rule.run(version)


class RemoteOrganizationPagination(PageNumberPagination):
page_size = 25

Expand Down
13 changes: 12 additions & 1 deletion readthedocs/restapi/views/model_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from rest_framework import decorators, permissions, status, viewsets
from rest_framework.parsers import MultiPartParser, JSONParser, FormParser
from rest_framework.parsers import JSONParser, MultiPartParser
from rest_framework.renderers import BaseRenderer, JSONRenderer
from rest_framework.response import Response

Expand Down Expand Up @@ -215,6 +215,17 @@ def sync_versions(self, request, **kwargs): # noqa: D205
status=status.HTTP_400_BAD_REQUEST,
)

try:
api_utils.run_automation_rules(project, added_versions)
stsewd marked this conversation as resolved.
Show resolved Hide resolved
except Exception:
# Don't interrupt the request if something goes wrong
# in the automation rules.
log.exception(
'Failed to execute automation rules for [%s]: %s',
project.slug, added_versions
)

# TODO: move this to an automation rule
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to move this to the run_automation_rules function, but it depends on the previous state of one version. It can be refactored for later

promoted_version = project.update_stable_version()
new_stable = project.get_stable_version()
if promoted_version and new_stable and new_stable.active:
Expand Down
48 changes: 45 additions & 3 deletions readthedocs/rtd_tests/tests/test_automation_rules.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import mock
import pytest
from django_dynamic_fixture import get

from readthedocs.builds.constants import BRANCH, TAG
from readthedocs.builds.constants import BRANCH, LATEST, TAG
from readthedocs.builds.models import (
RegexAutomationRule,
Version,
Expand Down Expand Up @@ -64,14 +65,15 @@ def setup_method(self):
]
)
@pytest.mark.parametrize('version_type', [BRANCH, TAG])
def test_action_activation_match(
def test_match(
self, version_name, regex, result, version_type):
version = get(
Version,
verbose_name=version_name,
project=self.project,
active=False,
type=version_type,
built=False,
)
rule = get(
RegexAutomationRule,
Expand All @@ -82,4 +84,44 @@ def test_action_activation_match(
version_type=version_type,
)
assert rule.run(version) is result
assert version.active is result

@mock.patch('readthedocs.builds.automation_actions.trigger_build')
def test_action_activation(self, trigger_build):
version = get(
Version,
verbose_name='v2',
project=self.project,
active=False,
type=TAG,
)
rule = get(
RegexAutomationRule,
project=self.project,
priority=0,
match_arg='.*',
action=VersionAutomationRule.ACTIVATE_VERSION_ACTION,
version_type=TAG,
)
assert rule.run(version) is True
assert version.active is True
trigger_build.assert_called_once()

def test_action_set_default_version(self):
version = get(
Version,
verbose_name='v2',
project=self.project,
active=True,
type=TAG,
)
rule = get(
RegexAutomationRule,
project=self.project,
priority=0,
match_arg='.*',
action=VersionAutomationRule.SET_DEFAULT_VERSION_ACTION,
version_type=TAG,
)
assert self.project.get_default_version() == LATEST
assert rule.run(version) is True
assert self.project.get_default_version() == version.slug
Loading