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 %}