diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fe11654..c53b832 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -112,4 +112,3 @@ jobs: release_name: ${{ needs.get-next-version.outputs.release-version }} body: ${{ needs.get-release-notes.outputs.release-notes }} draft: false - prerelease: true diff --git a/README.md b/README.md index 1307b08..350c238 100644 --- a/README.md +++ b/README.md @@ -11,19 +11,25 @@ This [NetBox](http://netboxlabs.com/oss/netbox/) plugin introduces branching fun Brief installation instructions are provided below. For a complete installation guide, please refer to the included documentation. -1. Activate the NetBox virtual environment: +1. Grant PostgreSQL permission for the NetBox database user to create schemas: + +```postgresql +GRANT CREATE ON DATABASE $database TO $user; +``` + +2. Activate the NetBox virtual environment: ``` $ source /opt/netbox/venv/bin/activate ``` -2. Install the plugin from [PyPI](https://pypi.org/project/netboxlabs-netbox-branching/): +3. Install the plugin from [PyPI](https://pypi.org/project/netboxlabs-netbox-branching/): ``` $ pip install netboxlabs-netbox-branching ``` -3. Add `netbox_branching` to `PLUGINS` in `configuration.py`: +4. Add `netbox_branching` to the end of `PLUGINS` in `configuration.py`. Note that `netbox_branching` **MUST** be the last plugin listed. ```python PLUGINS = [ @@ -32,7 +38,7 @@ PLUGINS = [ ] ``` -4. Create `local_settings.py` to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. +5. Create `local_settings.py` to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. ```python from netbox_branching.utilities import DynamicSchemaDict @@ -49,7 +55,7 @@ DATABASE_ROUTERS = [ ] ``` -5. Run NetBox migrations: +6. Run NetBox migrations: ``` $ ./manage.py migrate diff --git a/docs/changelog.md b/docs/changelog.md index 79d43f9..a384c0a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,26 @@ # Change Log +## v0.5.0 + +### Enhancements + +* [#83](https://github.com/netboxlabs/nbl-netbox-branching/issues/83) - Add a "share" button under object views when a branch is active +* [#84](https://github.com/netboxlabs/nbl-netbox-branching/issues/84) - Introduce the `max_working_branches` configuration parameter +* [#88](https://github.com/netboxlabs/nbl-netbox-branching/issues/88) - Add branching support for NetBox's graphQL API +* [#90](https://github.com/netboxlabs/nbl-netbox-branching/issues/90) - Introduce the ability to archive & deprovision merged branches without deleting them +* [#97](https://github.com/netboxlabs/nbl-netbox-branching/issues/97) - Introduce the `exempt_models` config parameter to disable branching support for plugin models +* [#116](https://github.com/netboxlabs/nbl-netbox-branching/issues/116) - Disable branching support for applicable core models + +### Bug Fixes + +* [#81](https://github.com/netboxlabs/nbl-netbox-branching/issues/81) - Fix event rule triggering for the `branch_reverted` event +* [#91](https://github.com/netboxlabs/nbl-netbox-branching/issues/91) - Disregard the active branch (if any) when alerting on changes under object views +* [#94](https://github.com/netboxlabs/nbl-netbox-branching/issues/94) - Fix branch merging after modifying an object with custom field data +* [#101](https://github.com/netboxlabs/nbl-netbox-branching/issues/101) - Permit (but warn about) database queries issued before branching support has been initialized +* [#102](https://github.com/netboxlabs/nbl-netbox-branching/issues/102) - Record individual object actions in branch job logs + +--- + ## v0.4.0 ### Enhancements diff --git a/docs/configuration.md b/docs/configuration.md index 6e141aa..bc13a04 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,10 +1,46 @@ # Configuration Parameters +## `exempt_models` + +Default: `[]` (empty list) + +A list of models provided by other plugins which should be exempt from branching support. (Only models which support change logging need be listed; all other models are ineligible for branching support.) + +!!! warning + A model may not be exempted from branching support if it has one or more relationships to models for which branching is supported. Branching **must** be supported consistently for all inter-related models; otherwise, data corruption can occur. Configure this setting only if you have a specific need to disable branching for certain models provided by plugins. + +Models must be specified by app label and model name, as such: + +```python +exempt_models = ( + 'my_plugin.foo', + 'my_plugin.bar', +) +``` + +It is also possible to exclude _all_ models from within a plugin by substituting an asterisk (`*`) for the model name: + +```python +exempt_models = ( + 'my_plugin.*', +) +``` + +--- + +## `max_working_branches` + +Default: None + +The maximum number of operational branches that can exist simultaneously. This count excludes branches which have been merged or archived. + +--- + ## `max_branches` Default: None -The maximum number of branches that can exist simultaneously, including merged branches that have not been deleted. It may be desirable to limit the total number of provisioned branches to safeguard against excessive database size. +The maximum total number of branches that can exist simultaneously, including merged branches that have not been deleted. It may be desirable to limit the total number of provisioned branches to safeguard against excessive database size. --- diff --git a/docs/index.md b/docs/index.md index a3611f0..162404f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -74,6 +74,14 @@ sequenceDiagram ## Getting Started +### Database Preparation + +Before installing this plugin, ensure that the PostgreSQL user as which NetBox authenticates has permission to create new schemas in the database. This can be achieved by issuing the following command in the PostgreSQL shell (substituting `$database` and `$user` with their respective values): + +```postgresql +GRANT CREATE ON DATABASE $database TO $user; +``` + ### Plugin Installation #### 1. Virtual Environment @@ -97,7 +105,7 @@ pip install netboxlabs-netbox-branching #### 3. Enable Plugin -Add `netbox_branching` to the list `PLUGINS` list in `configuration.py`. +Add `netbox_branching` to **the end** of the `PLUGINS` list in `configuration.py`. ```python PLUGINS = [ @@ -106,6 +114,9 @@ PLUGINS = [ ] ``` +!!! warning + `netbox_branching` must be the **last** (or only) plugin in the list. Branching support will not be registered for models provided by any plugin appearing later in the list. + !!! note If there are no plugins already installed, you might need to create this parameter. If so, be sure to define `PLUGINS` as a list _containing_ the plugin name as above, rather than just the name. diff --git a/docs/models/branch.md b/docs/models/branch.md index 560c87e..1ecbf59 100644 --- a/docs/models/branch.md +++ b/docs/models/branch.md @@ -29,6 +29,7 @@ The current status of the branch. This must be one of the following values. | Merging | A job is running to merge changes from the branch into main | | Reverting | A job is running to revert previously merged changes in main | | Merged | Changes from this branch have been successfully merged into main | +| Archived | A merged branch which has been deprovisioned in the database | | Failed | Provisioning the schema for this branch has failed | ### Last Sync diff --git a/docs/using-branches/reverting-a-branch.md b/docs/using-branches/reverting-a-branch.md index 5de33b5..68be470 100644 --- a/docs/using-branches/reverting-a-branch.md +++ b/docs/using-branches/reverting-a-branch.md @@ -2,8 +2,8 @@ Once a branch has been merged, it is generally no longer needed, and can no longer be activated. However, occasionally you may find it necessary to undo the changes from a branch (due to an error or an otherwise undesired state). This can be done by _reverting_ the branch. Only merged branches can be reverted. -!!! note - Only branches which have not yet been deleted can be reverted. Once a branch is deleted, reversion is no longer possible. +!!! warning + Only branches which have not yet been archived or deleted can be reverted. Once a branch's schema has been deprovisioned, it can no longer be reverted. Before reverting a branch, review the changes listed under its "Merged Changes" tab. NetBox will attempt to undo these specific changes when reverting the branch. diff --git a/docs/using-branches/syncing-merging.md b/docs/using-branches/syncing-merging.md index 27d5a55..af3c8f9 100644 --- a/docs/using-branches/syncing-merging.md +++ b/docs/using-branches/syncing-merging.md @@ -22,6 +22,8 @@ While a branch is being merged, its status will show "merging." !!! tip You can check on the status of the merging job under the "Jobs" tab of the branch view. +Once a branch has been merged, it can be [reverted](./reverting-a-branch.md), archived, or deleted. Archiving a branch removes its associated schema from the PostgreSQL database to deallocate space. An archived branch cannot be restored, however the branch record is retained for future reference. + ## Dealing with Conflicts In the event an object has been modified in both your branch _and_ in main in a diverging manner, this will be flagged as a conflict. For example, if both you and another user have modified the description of an interface to two different values in main and in the branch, this represents a conflict. diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index 5174856..de903be 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -1,7 +1,7 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from netbox.plugins import PluginConfig +from netbox.plugins import PluginConfig, get_plugin_config from netbox.registry import registry @@ -9,16 +9,22 @@ class AppConfig(PluginConfig): name = 'netbox_branching' verbose_name = 'NetBox Branching' description = 'A git-like branching implementation for NetBox' - version = '0.4.0' + version = '0.5.0' base_url = 'branching' min_version = '4.1' middleware = [ 'netbox_branching.middleware.BranchMiddleware' ] default_settings = { + # The maximum number of working branches (excludes merged & archived branches) + 'max_working_branches': None, + # The maximum number of branches which can be provisioned simultaneously 'max_branches': None, + # Models from other plugins which should be excluded from branching support + 'exempt_models': [], + # This string is prefixed to the name of each new branch schema during provisioning 'schema_prefix': 'branch_', } @@ -39,11 +45,22 @@ def ready(self): ) # Record all object types which support branching in the NetBox registry - if 'branching' not in registry['model_features']: - registry['model_features']['branching'] = { - k: v for k, v in registry['model_features']['change_logging'].items() - if k not in constants.EXCLUDED_APPS - } + exempt_models = ( + *constants.EXEMPT_MODELS, + *get_plugin_config('netbox_branching', 'exempt_models'), + ) + branching_models = {} + for app_label, models in registry['model_features']['change_logging'].items(): + # Wildcard exclusion for all models in this app + if f'{app_label}.*' in exempt_models: + continue + models = [ + model for model in models + if f'{app_label}.{model}' not in exempt_models + ] + if models: + branching_models[app_label] = models + registry['model_features']['branching'] = branching_models config = AppConfig diff --git a/netbox_branching/choices.py b/netbox_branching/choices.py index db8c9c8..90c6f46 100644 --- a/netbox_branching/choices.py +++ b/netbox_branching/choices.py @@ -11,6 +11,7 @@ class BranchStatusChoices(ChoiceSet): MERGING = 'merging' REVERTING = 'reverting' MERGED = 'merged' + ARCHIVED = 'archived' FAILED = 'failed' CHOICES = ( @@ -21,6 +22,7 @@ class BranchStatusChoices(ChoiceSet): (MERGING, _('Merging'), 'orange'), (REVERTING, _('Reverting'), 'orange'), (MERGED, _('Merged'), 'blue'), + (ARCHIVED, _('Archived'), 'gray'), (FAILED, _('Failed'), 'red'), ) @@ -28,7 +30,13 @@ class BranchStatusChoices(ChoiceSet): PROVISIONING, SYNCING, MERGING, - REVERTING + REVERTING, + ) + + WORKING = ( + NEW, + READY, + *TRANSITIONAL, ) @@ -37,10 +45,12 @@ class BranchEventTypeChoices(ChoiceSet): SYNCED = 'synced' MERGED = 'merged' REVERTED = 'reverted' + ARCHIVED = 'archived' CHOICES = ( (PROVISIONED, _('Provisioned'), 'green'), (SYNCED, _('Synced'), 'cyan'), (MERGED, _('Merged'), 'blue'), (REVERTED, _('Reverted'), 'orange'), + (ARCHIVED, _('Archived'), 'gray'), ) diff --git a/netbox_branching/constants.py b/netbox_branching/constants.py index c7743b5..ea9e7fa 100644 --- a/netbox_branching/constants.py +++ b/netbox_branching/constants.py @@ -10,8 +10,21 @@ # URL query parameter name QUERY_PARAM = '_branch' -# Apps which are explicitly excluded from branching -EXCLUDED_APPS = ( - 'netbox_branching', - 'netbox_changes', +# Models for which branching support is explicitly disabled +EXEMPT_MODELS = ( + # Exempt applicable core NetBox models + 'core.*', + 'extras.branch', + 'extras.customfield', + 'extras.customfieldchoiceset', + 'extras.customlink', + 'extras.eventrule', + 'extras.exporttemplate', + 'extras.notificationgroup', + 'extras.savedfilter', + 'extras.webhook', + + # Exempt all models from this plugin and from netbox-changes + 'netbox_branching.*', + 'netbox_changes.*', ) diff --git a/netbox_branching/database.py b/netbox_branching/database.py index 161c6e7..842f881 100644 --- a/netbox_branching/database.py +++ b/netbox_branching/database.py @@ -1,3 +1,5 @@ +import warnings + from netbox.registry import registry from .contextvars import active_branch @@ -14,6 +16,11 @@ class BranchAwareRouter: the active branch (if any). """ def _get_db(self, model, **hints): + # Warn & exit if branching support has not yet been initialized + if 'branching' not in registry['model_features']: + warnings.warn(f"Routing database query for {model} before branching support is initialized.") + return + # Bail if the model does not support branching app_label, model_name = model._meta.label.lower().split('.') if model_name not in registry['model_features']['branching'].get(app_label, []): diff --git a/netbox_branching/forms/misc.py b/netbox_branching/forms/misc.py index 2177f2f..ad895cb 100644 --- a/netbox_branching/forms/misc.py +++ b/netbox_branching/forms/misc.py @@ -5,6 +5,7 @@ __all__ = ( 'BranchActionForm', + 'ConfirmationForm', ) @@ -36,3 +37,10 @@ def clean(self): raise forms.ValidationError(_("All conflicts must be acknowledged in order to merge the branch.")) return self.cleaned_data + + +class ConfirmationForm(forms.Form): + confirm = forms.BooleanField( + required=True, + label=_('Confirm') + ) diff --git a/netbox_branching/middleware.py b/netbox_branching/middleware.py index 73de3dd..79f9bbc 100644 --- a/netbox_branching/middleware.py +++ b/netbox_branching/middleware.py @@ -45,12 +45,13 @@ def get_active_branch(request): """ Return the active Branch (if any). """ - # The active Branch is specified by HTTP header for REST API requests. - if request.path_info.startswith(reverse('api-root')) and (schema_id := request.headers.get(BRANCH_HEADER)): - branch = Branch.objects.get(schema_id=schema_id) - if not branch.ready: - return HttpResponseBadRequest(f"Branch {branch} is not ready for use (status: {branch.status})") - return branch + # The active Branch is specified by HTTP header for REST & GraphQL API requests. + if request.path_info.startswith(reverse('api-root')) or request.path_info.startswith(reverse('graphql')): + if schema_id := request.headers.get(BRANCH_HEADER): + branch = Branch.objects.get(schema_id=schema_id) + if not branch.ready: + return HttpResponseBadRequest(f"Branch {branch} is not ready for use (status: {branch.status})") + return branch # Branch activated/deactivated by URL query parameter elif QUERY_PARAM in request.GET: diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py index b337ab1..23591d0 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -127,10 +127,10 @@ def synced_time(self): def clean(self): - # Check whether we're exceeding the maximum number of Branches + # Enforce the maximum number of total branches if not self.pk and (max_branches := get_plugin_config('netbox_branching', 'max_branches')): - branch_count = Branch.objects.count() - if branch_count >= max_branches: + total_branch_count = Branch.objects.count() + if total_branch_count >= max_branches: raise ValidationError( _( "The configured maximum number of branches ({max}) cannot be exceeded. One or more existing " @@ -138,6 +138,17 @@ def clean(self): ).format(max=max_branches) ) + # Enforce the maximum number of active branches + if not self.pk and (max_working_branches := get_plugin_config('netbox_branching', 'max_working_branches')): + working_branch_count = Branch.objects.filter(status__in=BranchStatusChoices.WORKING).count() + if working_branch_count >= max_working_branches: + raise ValidationError( + _( + "The configured maximum number of working branches ({max}) cannot be exceeded. One or more " + "working branches must be merged or archived before a new branch may be created." + ).format(max=max_working_branches) + ) + def save(self, provision=True, *args, **kwargs): """ Args: @@ -242,7 +253,7 @@ def sync(self, user, commit=True): # Retrieve unsynced changes before we update the Branch's status if changes := self.get_unsynced_changes().order_by('time'): - logger.debug(f"Found {len(changes)} changes to sync") + logger.info(f"Found {len(changes)} changes to sync") else: logger.info(f"No changes found; aborting.") return @@ -256,12 +267,13 @@ def sync(self, user, commit=True): with transaction.atomic(using=self.connection_name): # Apply each change from the main schema for change in changes: - change.apply(using=self.connection_name) + change.apply(using=self.connection_name, logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Restore original branch status Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.READY) raise e @@ -296,7 +308,7 @@ def merge(self, user, commit=True): # Retrieve staged changes before we update the Branch's status if changes := self.get_unmerged_changes().order_by('time'): - logger.debug(f"Found {len(changes)} changes to merge") + logger.info(f"Found {len(changes)} changes to merge") else: logger.info(f"No changes found; aborting.") return @@ -319,12 +331,13 @@ def merge(self, user, commit=True): with event_tracking(request): request.id = change.request_id request.user = change.user - change.apply(using=DEFAULT_DB_ALIAS) + change.apply(using=DEFAULT_DB_ALIAS, logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Disconnect signal receiver & restore original branch status post_save.disconnect(handler, sender=ObjectChange_) Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.READY) @@ -364,7 +377,7 @@ def revert(self, user, commit=True): # Retrieve applied changes before we update the Branch's status if changes := self.get_changes().order_by('-time'): - logger.debug(f"Found {len(changes)} changes to revert") + logger.info(f"Found {len(changes)} changes to revert") else: logger.info(f"No changes found; aborting.") return @@ -387,12 +400,13 @@ def revert(self, user, commit=True): with event_tracking(request): request.id = change.request_id request.user = change.user - change.undo() + change.undo(logger=logger) if not commit: raise AbortTransaction() except Exception as e: - logger.error(e) + if err_message := str(e): + logger.error(err_message) # Disconnect signal receiver & restore original branch status post_save.disconnect(handler, sender=ObjectChange_) Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.MERGED) @@ -417,7 +431,7 @@ def revert(self, user, commit=True): # Disconnect the signal receiver post_save.disconnect(handler, sender=ObjectChange_) - merge.alters_data = True + revert.alters_data = True def provision(self, user): """ @@ -512,6 +526,18 @@ def provision(self, user): provision.alters_data = True + def archive(self, user): + """ + Deprovision the Branch and set its status to "archived." + """ + self.deprovision() + + # Update the branch's status to "archived" + Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.ARCHIVED) + BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.ARCHIVED) + + archive.alters_data = True + def deprovision(self): """ Delete the Branch's schema and all its tables from the database. diff --git a/netbox_branching/models/changes.py b/netbox_branching/models/changes.py index 8c894db..1890823 100644 --- a/netbox_branching/models/changes.py +++ b/netbox_branching/models/changes.py @@ -27,13 +27,13 @@ class ObjectChange(ObjectChange_): class Meta: proxy = True - def apply(self, using=DEFAULT_DB_ALIAS): + def apply(self, using=DEFAULT_DB_ALIAS, logger=None): """ Apply the change using the specified database connection. """ - logger = logging.getLogger('netbox_branching.models.ObjectChange.apply') + logger = logger or logging.getLogger('netbox_branching.models.ObjectChange.apply') model = self.changed_object_type.model_class() - logger.debug(f'Applying change {self} using {using}') + logger.info(f'Applying change {self} using {using}') # Creating a new object if self.action == ObjectChangeActionChoices.ACTION_CREATE: @@ -62,12 +62,13 @@ def apply(self, using=DEFAULT_DB_ALIAS): apply.alters_data = True - def undo(self, using=DEFAULT_DB_ALIAS): + def undo(self, using=DEFAULT_DB_ALIAS, logger=None): """ Revert a previously applied change using the specified database connection. """ - logger = logging.getLogger('netbox_branching.models.ObjectChange.undo') + logger = logger or logging.getLogger('netbox_branching.models.ObjectChange.undo') model = self.changed_object_type.model_class() + logger.info(f'Undoing change {self} using {using}') # Deleting a previously created object if self.action == ObjectChangeActionChoices.ACTION_CREATE: diff --git a/netbox_branching/signal_receivers.py b/netbox_branching/signal_receivers.py index da59578..f3d79c6 100644 --- a/netbox_branching/signal_receivers.py +++ b/netbox_branching/signal_receivers.py @@ -127,6 +127,7 @@ def handle_branch_event(event_type, branch, user=None, **kwargs): branch_provisioned.connect(partial(handle_branch_event, event_type=BRANCH_PROVISIONED)) branch_synced.connect(partial(handle_branch_event, event_type=BRANCH_SYNCED)) branch_merged.connect(partial(handle_branch_event, event_type=BRANCH_MERGED)) +branch_reverted.connect(partial(handle_branch_event, event_type=BRANCH_REVERTED)) branch_deprovisioned.connect(partial(handle_branch_event, event_type=BRANCH_DEPROVISIONED)) diff --git a/netbox_branching/template_content.py b/netbox_branching/template_content.py index 2d2c154..063a9df 100644 --- a/netbox_branching/template_content.py +++ b/netbox_branching/template_content.py @@ -17,7 +17,15 @@ class BranchSelector(PluginTemplateExtension): def navbar(self): return self.render('netbox_branching/inc/branch_selector.html', extra_context={ 'active_branch': active_branch.get(), - 'branches': Branch.objects.exclude(status=BranchStatusChoices.MERGED), + 'branches': Branch.objects.filter(status__in=BranchStatusChoices.WORKING), + }) + + +class ShareButton(PluginTemplateExtension): + + def buttons(self): + return self.render('netbox_branching/inc/share_button.html', extra_context={ + 'active_branch': active_branch.get(), }) @@ -32,6 +40,8 @@ def alerts(self): object_id=instance.pk ).exclude( branch__status=BranchStatusChoices.MERGED + ).exclude( + branch=active_branch.get() ) branches = [ diff.branch for diff in relevant_changes.only('branch') @@ -41,4 +51,4 @@ def alerts(self): }) -template_extensions = [BranchSelector, BranchNotification] +template_extensions = [BranchSelector, ShareButton, BranchNotification] diff --git a/netbox_branching/templates/netbox_branching/branch.html b/netbox_branching/templates/netbox_branching/branch.html index 5d8d6b1..75edc00 100644 --- a/netbox_branching/templates/netbox_branching/branch.html +++ b/netbox_branching/templates/netbox_branching/branch.html @@ -51,6 +51,15 @@ {% trans "Revert" %} {% endif %} + {% if perms.netbox_branching.archive_branch %} + + {% trans "Archive" %} + + {% else %} + + {% endif %} {% endif %} {% endblock %} diff --git a/netbox_branching/templates/netbox_branching/branch_archive.html b/netbox_branching/templates/netbox_branching/branch_archive.html new file mode 100644 index 0000000..8849acc --- /dev/null +++ b/netbox_branching/templates/netbox_branching/branch_archive.html @@ -0,0 +1,43 @@ +{% extends 'generic/_base.html' %} +{% load form_helpers %} +{% load i18n %} + +{% block title %}{% trans "Archive" %} {{ branch }}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} + {# Form tab #} +
+
+ {% csrf_token %} +
+
+
+ + {% blocktrans %} + Are you sure you want to archive the branch {{ branch }}? This will permanently deprovision + its database schema, and it will no longer be possible to automatically rever the branch. + {% endblocktrans %} +
+ {% render_field form.confirm %} +
+ {% trans "Cancel" %} + +
+
+
+
+
+ {# /Form tab #} +{% endblock content %} diff --git a/netbox_branching/templates/netbox_branching/inc/share_button.html b/netbox_branching/templates/netbox_branching/inc/share_button.html new file mode 100644 index 0000000..79fe1d5 --- /dev/null +++ b/netbox_branching/templates/netbox_branching/inc/share_button.html @@ -0,0 +1,6 @@ +{% if active_branch %} + {% load i18n %} + + {% trans "Share" %} + +{% endif %} diff --git a/netbox_branching/tests/test_branches.py b/netbox_branching/tests/test_branches.py index efcfe28..6cbd01d 100644 --- a/netbox_branching/tests/test_branches.py +++ b/netbox_branching/tests/test_branches.py @@ -4,6 +4,7 @@ from django.db import connection from django.test import TransactionTestCase, override_settings +from netbox_branching.choices import BranchStatusChoices from netbox_branching.constants import MAIN_SCHEMA from netbox_branching.models import Branch from netbox_branching.utilities import get_tables_to_replicate @@ -77,12 +78,36 @@ def test_branch_schema_id(self): branch.refresh_from_db() self.assertEqual(branch.schema_id, schema_id, msg="Schema ID was changed during save()") + @override_settings(PLUGINS_CONFIG={ + 'netbox_branching': { + 'max_working_branches': 2, + } + }) + def test_max_working_branches(self): + """ + Verify that the max_working_branches config parameter is enforced. + """ + Branch.objects.bulk_create(( + Branch(name='Branch 1', status=BranchStatusChoices.MERGED), + Branch(name='Branch 2', status=BranchStatusChoices.READY), + )) + + # Second active branch should be permitted (merged branches don't count) + branch = Branch(name='Branch 3') + branch.full_clean() + branch.save() + + # Attempting to create a third active branch should fail + branch = Branch(name='Branch 4') + with self.assertRaises(ValidationError): + branch.full_clean() + @override_settings(PLUGINS_CONFIG={ 'netbox_branching': { 'max_branches': 2, } }) - def text_max_branches(self): + def test_max_branches(self): """ Verify that the max_branches config parameter is enforced. """ diff --git a/netbox_branching/utilities.py b/netbox_branching/utilities.py index 539573b..74431b2 100644 --- a/netbox_branching/utilities.py +++ b/netbox_branching/utilities.py @@ -136,6 +136,10 @@ def update_object(instance, data, using): m2m_assignments = {} for attr, value in data.items(): + # Account for custom field data + if attr == 'custom_fields': + attr = 'custom_field_data' + model_field = instance._meta.get_field(attr) field_cls = model_field.__class__ diff --git a/netbox_branching/views.py b/netbox_branching/views.py index 6346210..38fb44c 100644 --- a/netbox_branching/views.py +++ b/netbox_branching/views.py @@ -12,7 +12,7 @@ from . import filtersets, forms, tables from .choices import BranchStatusChoices from .jobs import MergeBranchJob, RevertBranchJob, SyncBranchJob -from .models import ChangeDiff, Branch +from .models import Branch, ChangeDiff # @@ -141,7 +141,7 @@ def _get_change_count(obj): @register_model_view(Branch, 'changes-merged') -class BranchChangesAheadView(generic.ObjectChildrenView): +class BranchChangesMergedView(generic.ObjectChildrenView): queryset = Branch.objects.all() child_model = ObjectChange filterset = ObjectChangeFilterSet @@ -186,7 +186,7 @@ def do_action(self, branch, request, form): def get(self, request, **kwargs): branch = self.get_object(**kwargs) - form = forms.BranchActionForm(branch) + form = self.form(branch) return render(request, self.template_name, { 'branch': branch, @@ -197,7 +197,7 @@ def get(self, request, **kwargs): def post(self, request, **kwargs): branch = self.get_object(**kwargs) - form = forms.BranchActionForm(branch, request.POST) + form = self.form(branch, request.POST) if branch.status not in self.valid_states: messages.error(request, _( @@ -225,7 +225,7 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Syncing of branch {branch} in progress") + messages.success(request, _("Syncing of branch {branch} in progress").format(branch=branch)) return redirect(branch.get_absolute_url()) @@ -241,7 +241,7 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Merging of branch {branch} in progress") + messages.success(request, _("Merging of branch {branch} in progress").format(branch=branch)) return redirect(branch.get_absolute_url()) @@ -260,11 +260,55 @@ def do_action(self, branch, request, form): user=request.user, commit=form.cleaned_data['commit'] ) - messages.success(request, f"Reverting branch {branch}") + messages.success(request, _("Reverting branch {branch}").format(branch=branch)) return redirect(branch.get_absolute_url()) +@register_model_view(Branch, 'archive') +class BranchArchiveView(generic.ObjectView): + """ + Archive a merged Branch, deleting its database schema but retaining the Branch object. + """ + queryset = Branch.objects.all() + template_name = 'netbox_branching/branch_archive.html' + + def get_required_permission(self): + return f'netbox_branching.archive_branch' + + @staticmethod + def _enforce_status(request, branch): + if branch.status != BranchStatusChoices.MERGED: + messages.error(request, _("Only merged branches can be archived.")) + return redirect(branch.get_absolute_url()) + + def get(self, request, **kwargs): + branch = self.get_object(**kwargs) + self._enforce_status(request, branch) + form = forms.ConfirmationForm() + + return render(request, self.template_name, { + 'branch': branch, + 'form': form, + }) + + def post(self, request, **kwargs): + branch = self.get_object(**kwargs) + self._enforce_status(request, branch) + form = forms.ConfirmationForm(request.POST) + + if form.is_valid(): + branch.archive(user=request.user) + + messages.success(request, _("Branch {branch} has been archived.").format(branch=branch)) + return redirect(branch.get_absolute_url()) + + return render(request, self.template_name, { + 'branch': branch, + 'form': form, + }) + + class BranchBulkImportView(generic.BulkImportView): queryset = Branch.objects.all() model_form = forms.BranchImportForm diff --git a/pyproject.toml b/pyproject.toml index af09f1c..f20d422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netboxlabs-netbox-branching" -version = "0.4.0" +version = "0.5.0" description = "A git-like branching implementation for NetBox" readme = "README.md" requires-python = ">=3.10"