diff --git a/netbox_branching/choices.py b/netbox_branching/choices.py
index 367b048..9d17349 100644
--- a/netbox_branching/choices.py
+++ b/netbox_branching/choices.py
@@ -9,6 +9,7 @@ class BranchStatusChoices(ChoiceSet):
READY = 'ready'
SYNCING = 'syncing'
MERGING = 'merging'
+ REVERTING = 'reverting'
MERGED = 'merged'
FAILED = 'failed'
@@ -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'),
)
@@ -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'),
)
diff --git a/netbox_branching/jobs.py b/netbox_branching/jobs.py
index 3032bc2..13f0199 100644
--- a/netbox_branching/jobs.py
+++ b/netbox_branching/jobs.py
@@ -10,6 +10,7 @@
__all__ = (
'MergeBranchJob',
'ProvisionBranchJob',
+ 'RevertBranchJob',
'SyncBranchJob',
)
@@ -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")
diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py
index 0b93f0e..b34936e 100644
--- a/netbox_branching/models/branches.py
+++ b/netbox_branching/models/branches.py
@@ -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')
@@ -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.
diff --git a/netbox_branching/models/changes.py b/netbox_branching/models/changes.py
index b9e22f3..b8c7a94 100644
--- a/netbox_branching/models/changes.py
+++ b/netbox_branching/models/changes.py
@@ -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(
diff --git a/netbox_branching/templates/netbox_branching/branch.html b/netbox_branching/templates/netbox_branching/branch.html
index 1637afa..3b2c31e 100644
--- a/netbox_branching/templates/netbox_branching/branch.html
+++ b/netbox_branching/templates/netbox_branching/branch.html
@@ -34,6 +34,10 @@
{% trans "Merge" %}
+ {% elif perms.netbox_branching.revert_branch and object.merged %}
+
+ {% trans "Revert" %}
+
{% else %}