Skip to content

Commit

Permalink
Initial work on #2: Reverting a merged branch
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremystretch committed Aug 8, 2024
1 parent 6a585ea commit 3e43e51
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 3 deletions.
4 changes: 4 additions & 0 deletions netbox_branching/choices.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class BranchStatusChoices(ChoiceSet):
READY = 'ready'
SYNCING = 'syncing'
MERGING = 'merging'
REVERTING = 'reverting'
MERGED = 'merged'
FAILED = 'failed'

Expand All @@ -18,6 +19,7 @@ class BranchStatusChoices(ChoiceSet):
(READY, _('Ready'), 'green'),
(SYNCING, _('Syncing'), 'orange'),
(MERGING, _('Merging'), 'orange'),
(REVERTING, _('Reverting'), 'orange'),
(MERGED, _('Merged'), 'blue'),
(FAILED, _('Failed'), 'red'),
)
Expand All @@ -27,9 +29,11 @@ class BranchEventTypeChoices(ChoiceSet):
PROVISIONED = 'provisioned'
SYNCED = 'synced'
MERGED = 'merged'
REVERTED = 'reverted'

CHOICES = (
(PROVISIONED, _('Provisioned'), 'green'),
(SYNCED, _('Synced'), 'cyan'),
(MERGED, _('Merged'), 'blue'),
(REVERTED, _('Reverted'), 'blue'),
)
22 changes: 22 additions & 0 deletions netbox_branching/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
__all__ = (
'MergeBranchJob',
'ProvisionBranchJob',
'RevertBranchJob',
'SyncBranchJob',
)

Expand Down Expand Up @@ -109,3 +110,24 @@ def run(self, commit=True, *args, **kwargs):
branch.merge(user=self.job.user, commit=commit)
except AbortTransaction:
logger.info("Dry run completed; rolling back changes")


class RevertBranchJob(JobRunner):
"""
Revert changes from a merged Branch.
"""
class Meta:
name = 'Revert branch'

def run(self, commit=True, *args, **kwargs):
# Initialize logging
logger = logging.getLogger('netbox_branching.branch.revert')
logger.setLevel(logging.DEBUG)
logger.addHandler(ListHandler(queue=get_job_log(self.job)))

# Merge the Branch
try:
branch = self.job.object
branch.revert(user=self.job.user, commit=commit)
except AbortTransaction:
logger.info("Dry run completed; rolling back changes")
65 changes: 65 additions & 0 deletions netbox_branching/models/branches.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ def is_active(self):
def ready(self):
return self.status == BranchStatusChoices.READY

@property
def merged(self):
return self.status == BranchStatusChoices.MERGED

@cached_property
def schema_name(self):
schema_prefix = get_plugin_config('netbox_branching', 'schema_prefix')
Expand Down Expand Up @@ -299,6 +303,67 @@ def merge(self, user, commit=True):

merge.alters_data = True

def revert(self, user, commit=True):
"""
Undo all changes associated with a previously merged Branch in the main schema by replaying them in
reverse order and calling undo() on each.
"""
logger = logging.getLogger('netbox_branching.branch.revert')
logger.info(f'Reverting branch {self} ({self.schema_name})')

if not self.merged:
raise Exception(f"Only merged branches can be reverted.")

# Retrieve applied changes before we update the Branch's status
changes = self.get_changes().order_by('-time')

# Update Branch status
Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.REVERTING)

# Create a dummy request for the event_tracking() context manager
request = RequestFactory().get(reverse('home'))

# Prep & connect the signal receiver for recording AppliedChanges
handler = partial(record_applied_change, branch=self)
post_save.connect(handler, sender=ObjectChange_, weak=False)

try:
with transaction.atomic():
# Undo each change from the Branch
for change in changes:
with event_tracking(request):
logger.debug(f'Undoing change: {change}')
request.id = change.request_id
request.user = change.user
change.undo()
if not commit:
raise AbortTransaction()

except Exception as e:
logger.error(e)
# Disconnect signal receiver & restore original branch status
post_save.disconnect(handler, sender=ObjectChange_)
Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.READY)
raise e

# Update the Branch's status to "ready"
self.status = BranchStatusChoices.READY
self.merged_time = None
self.merged_by = None
self.save()
BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.REVERTED)

# TODO: Introduce branch_reverted signal
# Emit branch_merged signal
# branch_merged.send(sender=self.__class__, branch=self, user=user)

logger.info('Reversion completed')

# Disconnect the signal receiver
post_save.disconnect(handler, sender=ObjectChange_)

merge.alters_data = True

def provision(self, user):
"""
Create the schema & replicate main tables.
Expand Down
33 changes: 33 additions & 0 deletions netbox_branching/models/changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,39 @@ def apply(self, using=DEFAULT_DB_ALIAS):

apply.alters_data = True

def undo(self, using=DEFAULT_DB_ALIAS):
"""
Revert a previously applied change using the specified database connection.
"""
model = self.changed_object_type.model_class()

# Deleting a previously created object
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
try:
instance = model.objects.get(pk=self.changed_object_id)
print(f'Undoing creation of {model._meta.verbose_name} {instance}')
instance.delete(using=using)
except model.DoesNotExist:
print(f'{model._meta.verbose_name} ID {self.changed_object_id} does not exist; skipping')

# Reverting a modification to an object
elif self.action == ObjectChangeActionChoices.ACTION_UPDATE:
instance = model.objects.using(using).get(pk=self.changed_object_id)
update_object(instance, self.diff()['pre'], using=using)

# Restoring a deleted object
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
instance = deserialize_object(model, self.prechange_data, pk=self.changed_object_id)
print(f'Restoring {model._meta.verbose_name} {instance}')
instance.object.full_clean()
instance.save(using=using)

# Rebuild the MPTT tree where applicable
if issubclass(model, MPTTModel):
model.objects.rebuild()

undo.alters_data = True


class ChangeDiff(models.Model):
branch = models.ForeignKey(
Expand Down
4 changes: 4 additions & 0 deletions netbox_branching/templates/netbox_branching/branch.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@
<a href="{% url 'plugins:netbox_branching:branch_merge' pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-merge"></i> {% trans "Merge" %}
</a>
{% elif perms.netbox_branching.revert_branch and object.merged %}
<a href="{% url 'plugins:netbox_branching:branch_revert' pk=object.pk %}" class="btn btn-primary">
<i class="mdi mdi-arrow-u-left-top"></i> {% trans "Revert" %}
</a>
{% else %}
<button type="button" class="btn btn-primary disabled" aria-disabled="true">
<i class="mdi mdi-merge"></i> {% trans "Merge" %}
Expand Down
31 changes: 28 additions & 3 deletions netbox_branching/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
from netbox.views import generic
from utilities.views import ViewTab, register_model_view
from . import filtersets, forms, tables
from .jobs import MergeBranchJob, SyncBranchJob
from .choices import BranchStatusChoices
from .jobs import MergeBranchJob, RevertBranchJob, SyncBranchJob
from .models import ChangeDiff, Branch


Expand Down Expand Up @@ -151,6 +152,9 @@ class BaseBranchActionView(generic.ObjectView):
form = forms.BranchActionForm
template_name = 'netbox_branching/branch_action.html'
action = None
valid_states = (
BranchStatusChoices.READY,
)

def get_required_permission(self):
return f'netbox_branching.{self.action}_branch'
Expand Down Expand Up @@ -181,8 +185,10 @@ def post(self, request, **kwargs):
branch = self.get_object(**kwargs)
form = forms.BranchActionForm(branch, request.POST)

if not branch.ready:
messages.error(request, _("The branch must be in the ready state to perform this action."))
if branch.status not in self.valid_states:
messages.error(request, _(
"The branch must be in one of the following states to perform this action: {valid_states}"
).format(valid_states=', '.join(self.valid_states)))
elif form.is_valid():
return self.do_action(branch, request, form)

Expand Down Expand Up @@ -226,6 +232,25 @@ def do_action(self, branch, request, form):
return redirect(branch.get_absolute_url())


@register_model_view(Branch, 'revert')
class BranchRevertView(BaseBranchActionView):
action = 'revert'
valid_states = (
BranchStatusChoices.MERGED,
)

def do_action(self, branch, request, form):
# Enqueue a background job to revert the Branch
RevertBranchJob.enqueue(
instance=branch,
user=request.user,
commit=form.cleaned_data['commit']
)
messages.success(request, f"Reverting branch {branch}")

return redirect(branch.get_absolute_url())


class BranchBulkImportView(generic.BulkImportView):
queryset = Branch.objects.all()
model_form = forms.BranchImportForm
Expand Down

0 comments on commit 3e43e51

Please sign in to comment.