diff --git a/README.md b/README.md index 350c238..5196850 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ PLUGINS = [ ] ``` -5. Create `local_settings.py` to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. +5. Create `local_settings.py` (in the same directory as `settings.py`) to override the `DATABASES` & `DATABASE_ROUTERS` settings. This enables dynamic schema support. ```python from netbox_branching.utilities import DynamicSchemaDict diff --git a/docs/changelog.md b/docs/changelog.md index a384c0a..4d79fed 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,23 +1,41 @@ # Change Log +## v0.5.1 + +### Enhancements + +* [#123](https://github.com/netboxlabs/netbox-branching/issues/123) - Introduce template tags for branch action buttons +* [#129](https://github.com/netboxlabs/netbox-branching/issues/129) - Implement pre-event signals for branch actions + +### Bug Fixes + +* [#98](https://github.com/netboxlabs/netbox-branching/issues/98) - Cable changes in branch should not impact main schema +* [#119](https://github.com/netboxlabs/netbox-branching/issues/119) - Fix the dynamic selection of related objects in forms while a branch is active +* [#120](https://github.com/netboxlabs/netbox-branching/issues/120) - `max_branches` config parameter should disregard archived branches +* [#138](https://github.com/netboxlabs/netbox-branching/issues/138) - Fix rendering the ID column of the change diffs table +* [#140](https://github.com/netboxlabs/netbox-branching/issues/140) - Fix representation of branch status in REST API +* [#142](https://github.com/netboxlabs/netbox-branching/issues/142) - Fix tab record counts for archived branches + +--- + ## 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 +* [#83](https://github.com/netboxlabs/netbox-branching/issues/83) - Add a "share" button under object views when a branch is active +* [#84](https://github.com/netboxlabs/netbox-branching/issues/84) - Introduce the `max_working_branches` configuration parameter +* [#88](https://github.com/netboxlabs/netbox-branching/issues/88) - Add branching support for NetBox's graphQL API +* [#90](https://github.com/netboxlabs/netbox-branching/issues/90) - Introduce the ability to archive & deprovision merged branches without deleting them +* [#97](https://github.com/netboxlabs/netbox-branching/issues/97) - Introduce the `exempt_models` config parameter to disable branching support for plugin models +* [#116](https://github.com/netboxlabs/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 +* [#81](https://github.com/netboxlabs/netbox-branching/issues/81) - Fix event rule triggering for the `branch_reverted` event +* [#91](https://github.com/netboxlabs/netbox-branching/issues/91) - Disregard the active branch (if any) when alerting on changes under object views +* [#94](https://github.com/netboxlabs/netbox-branching/issues/94) - Fix branch merging after modifying an object with custom field data +* [#101](https://github.com/netboxlabs/netbox-branching/issues/101) - Permit (but warn about) database queries issued before branching support has been initialized +* [#102](https://github.com/netboxlabs/netbox-branching/issues/102) - Record individual object actions in branch job logs --- @@ -25,18 +43,18 @@ ### Enhancements -* [#52](https://github.com/netboxlabs/nbl-netbox-branching/issues/52) - Introduce the `max_branches` config parameter -* [#71](https://github.com/netboxlabs/nbl-netbox-branching/issues/71) - Ensure the consistent application of logging messages -* [#76](https://github.com/netboxlabs/nbl-netbox-branching/issues/76) - Validate required configuration items on initialization +* [#52](https://github.com/netboxlabs/netbox-branching/issues/52) - Introduce the `max_branches` config parameter +* [#71](https://github.com/netboxlabs/netbox-branching/issues/71) - Ensure the consistent application of logging messages +* [#76](https://github.com/netboxlabs/netbox-branching/issues/76) - Validate required configuration items on initialization ### Bug Fixes -* [#57](https://github.com/netboxlabs/nbl-netbox-branching/issues/57) - Avoid recording ChangeDiff records for unsupported object types -* [#59](https://github.com/netboxlabs/nbl-netbox-branching/issues/59) - `BranchAwareRouter` should consider branching support for model when determining database connection to use -* [#61](https://github.com/netboxlabs/nbl-netbox-branching/issues/61) - Fix transaction rollback when performing a dry run sync -* [#66](https://github.com/netboxlabs/nbl-netbox-branching/issues/66) - Capture object representation on ChangeDiff when creating a new object within a branch -* [#69](https://github.com/netboxlabs/nbl-netbox-branching/issues/69) - Represent null values for ChangeDiff fields consistently in REST API -* [#73](https://github.com/netboxlabs/nbl-netbox-branching/issues/73) - Ensure all relevant branch diffs are updated when an object is modified in main +* [#57](https://github.com/netboxlabs/netbox-branching/issues/57) - Avoid recording ChangeDiff records for unsupported object types +* [#59](https://github.com/netboxlabs/netbox-branching/issues/59) - `BranchAwareRouter` should consider branching support for model when determining database connection to use +* [#61](https://github.com/netboxlabs/netbox-branching/issues/61) - Fix transaction rollback when performing a dry run sync +* [#66](https://github.com/netboxlabs/netbox-branching/issues/66) - Capture object representation on ChangeDiff when creating a new object within a branch +* [#69](https://github.com/netboxlabs/netbox-branching/issues/69) - Represent null values for ChangeDiff fields consistently in REST API +* [#73](https://github.com/netboxlabs/netbox-branching/issues/73) - Ensure all relevant branch diffs are updated when an object is modified in main --- @@ -44,10 +62,10 @@ ### Bug Fixes -* [#42](https://github.com/netboxlabs/nbl-netbox-branching/issues/42) - Fix exception raised when viewing custom scripts -* [#44](https://github.com/netboxlabs/nbl-netbox-branching/issues/44) - Handle truncated SQL sequence names to avoid exceptions during branch provisioning -* [#48](https://github.com/netboxlabs/nbl-netbox-branching/issues/48) - Ensure background job is terminated in the event branch provisioning errors -* [#50](https://github.com/netboxlabs/nbl-netbox-branching/issues/50) - Branch state should remain as "merged" after dry-run revert +* [#42](https://github.com/netboxlabs/netbox-branching/issues/42) - Fix exception raised when viewing custom scripts +* [#44](https://github.com/netboxlabs/netbox-branching/issues/44) - Handle truncated SQL sequence names to avoid exceptions during branch provisioning +* [#48](https://github.com/netboxlabs/netbox-branching/issues/48) - Ensure background job is terminated in the event branch provisioning errors +* [#50](https://github.com/netboxlabs/netbox-branching/issues/50) - Branch state should remain as "merged" after dry-run revert --- @@ -55,23 +73,23 @@ ### Enhancements -* [#2](https://github.com/netboxlabs/nbl-netbox-branching/issues/2) - Enable the ability to revert a previously merged branch -* [#3](https://github.com/netboxlabs/nbl-netbox-branching/issues/3) - Require review & acknowledgment of conflicts before syncing or merging a branch -* [#4](https://github.com/netboxlabs/nbl-netbox-branching/issues/4) - Include a three-way diff summary in the REST API representation of a modified object -* [#13](https://github.com/netboxlabs/nbl-netbox-branching/issues/13) - Add a link to the active branch in the branch selector dropdown -* [#15](https://github.com/netboxlabs/nbl-netbox-branching/issues/15) - Default to performing a "dry run" for branch sync & merge -* [#17](https://github.com/netboxlabs/nbl-netbox-branching/issues/17) - Utilize NetBox's `JobRunner` class for background jobs -* [#29](https://github.com/netboxlabs/nbl-netbox-branching/issues/29) - Register a branch column on NetBox's global changelog table -* [#36](https://github.com/netboxlabs/nbl-netbox-branching/issues/36) - Run the branch provisioning process within an isolated transaction +* [#2](https://github.com/netboxlabs/netbox-branching/issues/2) - Enable the ability to revert a previously merged branch +* [#3](https://github.com/netboxlabs/netbox-branching/issues/3) - Require review & acknowledgment of conflicts before syncing or merging a branch +* [#4](https://github.com/netboxlabs/netbox-branching/issues/4) - Include a three-way diff summary in the REST API representation of a modified object +* [#13](https://github.com/netboxlabs/netbox-branching/issues/13) - Add a link to the active branch in the branch selector dropdown +* [#15](https://github.com/netboxlabs/netbox-branching/issues/15) - Default to performing a "dry run" for branch sync & merge +* [#17](https://github.com/netboxlabs/netbox-branching/issues/17) - Utilize NetBox's `JobRunner` class for background jobs +* [#29](https://github.com/netboxlabs/netbox-branching/issues/29) - Register a branch column on NetBox's global changelog table +* [#36](https://github.com/netboxlabs/netbox-branching/issues/36) - Run the branch provisioning process within an isolated transaction ### Bug Fixes -* [#10](https://github.com/netboxlabs/nbl-netbox-branching/issues/10) - Fix branch merge failure when deleted object was modified in another branch -* [#11](https://github.com/netboxlabs/nbl-netbox-branching/issues/11) - Fix quick search functionality for branch diffs tab -* [#16](https://github.com/netboxlabs/nbl-netbox-branching/issues/16) - Fix support for many-to-many assignments -* [#24](https://github.com/netboxlabs/nbl-netbox-branching/issues/24) - Correct the REST API schema for the sync, merge, and revert branch endpoints -* [#30](https://github.com/netboxlabs/nbl-netbox-branching/issues/30) - Include only unmerged branches with relevant changes in object view notifications -* [#31](https://github.com/netboxlabs/nbl-netbox-branching/issues/31) - Prevent the deletion of a branch in a transitional state +* [#10](https://github.com/netboxlabs/netbox-branching/issues/10) - Fix branch merge failure when deleted object was modified in another branch +* [#11](https://github.com/netboxlabs/netbox-branching/issues/11) - Fix quick search functionality for branch diffs tab +* [#16](https://github.com/netboxlabs/netbox-branching/issues/16) - Fix support for many-to-many assignments +* [#24](https://github.com/netboxlabs/netbox-branching/issues/24) - Correct the REST API schema for the sync, merge, and revert branch endpoints +* [#30](https://github.com/netboxlabs/netbox-branching/issues/30) - Include only unmerged branches with relevant changes in object view notifications +* [#31](https://github.com/netboxlabs/netbox-branching/issues/31) - Prevent the deletion of a branch in a transitional state --- diff --git a/docs/index.md b/docs/index.md index 162404f..200c688 100644 --- a/docs/index.md +++ b/docs/index.md @@ -124,7 +124,7 @@ PLUGINS = [ This plugin employs dynamic schema resolution, which requires that we override two low-level Django settings. First, we'll wrap NetBox's configured `DATABASE` parameter with `DynamicSchemaDict` to support dynamic schemas. Second, we'll employ the plugin's custom database router. -Create a new file named `local_settings.py` in the same directory as `configuration.py`, and add the content below. +Create a new file named `local_settings.py` in the same directory as `settings.py`, and add the content below. ```python from netbox_branching.utilities import DynamicSchemaDict diff --git a/netbox_branching/__init__.py b/netbox_branching/__init__.py index de903be..2d3054c 100644 --- a/netbox_branching/__init__.py +++ b/netbox_branching/__init__.py @@ -9,7 +9,7 @@ class AppConfig(PluginConfig): name = 'netbox_branching' verbose_name = 'NetBox Branching' description = 'A git-like branching implementation for NetBox' - version = '0.5.0' + version = '0.5.1' base_url = 'branching' min_version = '4.1' middleware = [ diff --git a/netbox_branching/api/serializers.py b/netbox_branching/api/serializers.py index 2336f7c..d7b24c6 100644 --- a/netbox_branching/api/serializers.py +++ b/netbox_branching/api/serializers.py @@ -5,7 +5,7 @@ from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer -from netbox_branching.choices import BranchEventTypeChoices +from netbox_branching.choices import BranchEventTypeChoices, BranchStatusChoices from netbox_branching.models import ChangeDiff, Branch, BranchEvent from users.api.serializers import UserSerializer from utilities.api import get_serializer_for_model @@ -30,6 +30,9 @@ class BranchSerializer(NetBoxModelSerializer): nested=True, read_only=True ) + status = ChoiceField( + choices=BranchStatusChoices + ) class Meta: model = Branch diff --git a/netbox_branching/constants.py b/netbox_branching/constants.py index ea9e7fa..938963d 100644 --- a/netbox_branching/constants.py +++ b/netbox_branching/constants.py @@ -10,6 +10,12 @@ # URL query parameter name QUERY_PARAM = '_branch' +# Tables which must be replicated within a branch even though their +# models don't directly support branching. +REPLICATE_TABLES = ( + 'dcim_cablepath', +) + # Models for which branching support is explicitly disabled EXEMPT_MODELS = ( # Exempt applicable core NetBox models diff --git a/netbox_branching/database.py b/netbox_branching/database.py index 842f881..3c749fc 100644 --- a/netbox_branching/database.py +++ b/netbox_branching/database.py @@ -21,11 +21,6 @@ def _get_db(self, model, **hints): 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, []): - return - # Return the schema for the active branch (if any) if branch := active_branch.get(): return f'schema_{branch.schema_name}' diff --git a/netbox_branching/middleware.py b/netbox_branching/middleware.py index 79f9bbc..a79e994 100644 --- a/netbox_branching/middleware.py +++ b/netbox_branching/middleware.py @@ -8,7 +8,7 @@ from .choices import BranchStatusChoices from .constants import COOKIE_NAME, BRANCH_HEADER, QUERY_PARAM from .models import Branch -from .utilities import activate_branch +from .utilities import activate_branch, is_api_request __all__ = ( 'BranchMiddleware', @@ -45,13 +45,12 @@ def get_active_branch(request): """ Return the active Branch (if any). """ - # 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 + # The active Branch may be specified by HTTP header for REST & GraphQL API requests. + if is_api_request(request) and BRANCH_HEADER in request.headers: + branch = Branch.objects.get(schema_id=request.headers.get(BRANCH_HEADER)) + 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 23591d0..be5712d 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -129,12 +129,12 @@ def clean(self): # Enforce the maximum number of total branches if not self.pk and (max_branches := get_plugin_config('netbox_branching', 'max_branches')): - total_branch_count = Branch.objects.count() + total_branch_count = Branch.objects.exclude(status=BranchStatusChoices.ARCHIVED).count() if total_branch_count >= max_branches: raise ValidationError( _( - "The configured maximum number of branches ({max}) cannot be exceeded. One or more existing " - "branches must be deleted before a new branch may be created." + "The configured maximum number of non-archived branches ({max}) cannot be exceeded. One or " + "more existing branches must be deleted before a new branch may be created." ).format(max=max_branches) ) @@ -201,6 +201,8 @@ def get_unsynced_changes(self): """ Return a queryset of all ObjectChange records created in main since the Branch was last synced or created. """ + if self.status not in BranchStatusChoices.WORKING: + return ObjectChange.objects.none() return ObjectChange.objects.using(DEFAULT_DB_ALIAS).exclude( application__branch=self ).filter( @@ -212,7 +214,7 @@ def get_unmerged_changes(self): """ Return a queryset of all unmerged ObjectChange records within the Branch schema. """ - if self.status == BranchStatusChoices.MERGED: + if self.status not in BranchStatusChoices.WORKING: return ObjectChange.objects.none() return ObjectChange.objects.using(self.connection_name) @@ -220,7 +222,7 @@ def get_merged_changes(self): """ Return a queryset of all merged ObjectChange records for the Branch. """ - if self.status != BranchStatusChoices.MERGED: + if self.status not in (BranchStatusChoices.MERGED, BranchStatusChoices.ARCHIVED): return ObjectChange.objects.none() return ObjectChange.objects.using(DEFAULT_DB_ALIAS).filter( application__branch=self @@ -251,6 +253,9 @@ def sync(self, user, commit=True): if not self.ready: raise Exception(f"Branch {self} is not ready to sync") + # Emit pre-sync signal + pre_sync.send(sender=self.__class__, branch=self, user=user) + # Retrieve unsynced changes before we update the Branch's status if changes := self.get_unsynced_changes().order_by('time'): logger.info(f"Found {len(changes)} changes to sync") @@ -288,8 +293,8 @@ def sync(self, user, commit=True): logger.debug(f"Recording branch event: {BranchEventTypeChoices.SYNCED}") BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.SYNCED) - # Emit branch_synced signal - branch_synced.send(sender=self.__class__, branch=self, user=user) + # Emit post-sync signal + post_sync.send(sender=self.__class__, branch=self, user=user) logger.info('Syncing completed') @@ -306,6 +311,9 @@ def merge(self, user, commit=True): if not self.ready: raise Exception(f"Branch {self} is not ready to merge") + # Emit pre-merge signal + pre_merge.send(sender=self.__class__, branch=self, user=user) + # Retrieve staged changes before we update the Branch's status if changes := self.get_unmerged_changes().order_by('time'): logger.info(f"Found {len(changes)} changes to merge") @@ -354,8 +362,8 @@ def merge(self, user, commit=True): logger.debug(f"Recording branch event: {BranchEventTypeChoices.MERGED}") BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.MERGED) - # Emit branch_merged signal - branch_merged.send(sender=self.__class__, branch=self, user=user) + # Emit post-merge signal + post_merge.send(sender=self.__class__, branch=self, user=user) logger.info('Merging completed') @@ -375,6 +383,9 @@ def revert(self, user, commit=True): if not self.merged: raise Exception(f"Only merged branches can be reverted.") + # Emit pre-revert signal + pre_revert.send(sender=self.__class__, branch=self, user=user) + # Retrieve applied changes before we update the Branch's status if changes := self.get_changes().order_by('-time'): logger.info(f"Found {len(changes)} changes to revert") @@ -423,8 +434,8 @@ def revert(self, user, commit=True): logger.debug(f"Recording branch event: {BranchEventTypeChoices.REVERTED}") BranchEvent.objects.create(branch=self, user=user, type=BranchEventTypeChoices.REVERTED) - # Emit branch_reverted signal - branch_reverted.send(sender=self.__class__, branch=self, user=user) + # Emit post-revert signal + post_revert.send(sender=self.__class__, branch=self, user=user) logger.info('Reversion completed') @@ -440,6 +451,9 @@ def provision(self, user): logger = logging.getLogger('netbox_branching.branch.provision') logger.info(f'Provisioning branch {self} ({self.schema_name})') + # Emit pre-provision signal + pre_provision.send(sender=self.__class__, branch=self, user=user) + # Update Branch status Branch.objects.filter(pk=self.pk).update(status=BranchStatusChoices.PROVISIONING) @@ -516,8 +530,8 @@ def provision(self, user): raise e - # Emit branch_provisioned signal - branch_provisioned.send(sender=self.__class__, branch=self, user=user) + # Emit post-provision signal + post_provision.send(sender=self.__class__, branch=self, user=user) logger.info('Provisioning completed') @@ -545,6 +559,9 @@ def deprovision(self): logger = logging.getLogger('netbox_branching.branch.provision') logger.info(f'Deprovisioning branch {self} ({self.schema_name})') + # Emit pre-deprovision signal + pre_deprovision.send(sender=self.__class__, branch=self) + with connection.cursor() as cursor: # Delete the schema and all its tables logger.debug(f'Deleting schema {self.schema_name}') @@ -552,8 +569,8 @@ def deprovision(self): f"DROP SCHEMA IF EXISTS {self.schema_name} CASCADE" ) - # Emit branch_deprovisioned signal - branch_deprovisioned.send(sender=self.__class__, branch=self) + # Emit post-deprovision signal + post_deprovision.send(sender=self.__class__, branch=self) logger.info('Deprovisioning completed') diff --git a/netbox_branching/signal_receivers.py b/netbox_branching/signal_receivers.py index f3d79c6..026e6a6 100644 --- a/netbox_branching/signal_receivers.py +++ b/netbox_branching/signal_receivers.py @@ -124,11 +124,11 @@ 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)) +post_provision.connect(partial(handle_branch_event, event_type=BRANCH_PROVISIONED)) +post_deprovision.connect(partial(handle_branch_event, event_type=BRANCH_DEPROVISIONED)) +post_sync.connect(partial(handle_branch_event, event_type=BRANCH_SYNCED)) +post_merge.connect(partial(handle_branch_event, event_type=BRANCH_MERGED)) +post_revert.connect(partial(handle_branch_event, event_type=BRANCH_REVERTED)) @receiver(pre_delete, sender=Branch) diff --git a/netbox_branching/signals.py b/netbox_branching/signals.py index cbc99d6..063f6c4 100644 --- a/netbox_branching/signals.py +++ b/netbox_branching/signals.py @@ -1,26 +1,28 @@ from django.dispatch import Signal -from .events import * - __all__ = ( - 'branch_deprovisioned', - 'branch_merged', - 'branch_provisioned', - 'branch_reverted', - 'branch_synced', + 'post_deprovision', + 'post_merge', + 'post_provision', + 'post_revert', + 'post_sync', + 'pre_deprovision', + 'pre_merge', + 'pre_provision', + 'pre_revert', + 'pre_sync', ) +# Pre-event signals +pre_provision = Signal() +pre_deprovision = Signal() +pre_sync = Signal() +pre_merge = Signal() +pre_revert = Signal() -branch_provisioned = Signal() -branch_deprovisioned = Signal() -branch_synced = Signal() -branch_merged = Signal() -branch_reverted = Signal() - -branch_signals = { - branch_provisioned: BRANCH_PROVISIONED, - branch_deprovisioned: BRANCH_DEPROVISIONED, - branch_synced: BRANCH_SYNCED, - branch_merged: BRANCH_MERGED, - branch_reverted: BRANCH_REVERTED, -} +# Post-event signals +post_provision = Signal() +post_deprovision = Signal() +post_sync = Signal() +post_merge = Signal() +post_revert = Signal() diff --git a/netbox_branching/tables/tables.py b/netbox_branching/tables/tables.py index efdecd0..f1df9e6 100644 --- a/netbox_branching/tables/tables.py +++ b/netbox_branching/tables/tables.py @@ -83,9 +83,9 @@ def render_is_active(self, value): class ChangeDiffTable(NetBoxTable): - name = tables.Column( - verbose_name=_('Name'), - linkify=True + # TODO: Revert to the default "id" column when a detail view for ChangeDiff is implemented + id = tables.Column( + verbose_name=_('ID') ) branch = tables.Column( verbose_name=_('Branch'), @@ -122,8 +122,8 @@ class ChangeDiffTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ChangeDiff fields = ( - 'branch', 'object_type', 'object', 'action', 'conflicts', 'original_diff', 'modified_diff', 'current_diff', - 'last_updated', 'actions', + 'id', 'branch', 'object_type', 'object', 'action', 'conflicts', 'original_diff', 'modified_diff', + 'current_diff', 'last_updated', 'actions', ) default_columns = ('branch', 'object', 'action', 'conflicts', 'original_diff', 'modified_diff', 'current_diff') diff --git a/netbox_branching/templates/netbox_branching/branch.html b/netbox_branching/templates/netbox_branching/branch.html index 75edc00..75eff0c 100644 --- a/netbox_branching/templates/netbox_branching/branch.html +++ b/netbox_branching/templates/netbox_branching/branch.html @@ -4,6 +4,7 @@ {% load plugins %} {% load render_table from django_tables2 %} {% load i18n %} +{% load branch_buttons %} {% block extra_controls %} {% if not object.is_active %} @@ -22,44 +23,12 @@ {% endif %} {% if object.ready %} - {% if perms.netbox_branching.sync_branch %} - - {% trans "Sync" %} - - {% else %} - - {% endif %} - {% if perms.netbox_branching.merge_branch %} - - {% trans "Merge" %} - - {% else %} - - {% endif %} + {% branch_sync_button object %} + {% branch_merge_button object %} {% endif %} {% if object.merged %} - {% if perms.netbox_branching.revert_branch %} - - {% trans "Revert" %} - - {% else %} - - {% endif %} - {% if perms.netbox_branching.archive_branch %} - - {% trans "Archive" %} - - {% else %} - - {% endif %} + {% branch_revert_button object %} + {% branch_archive_button object %} {% endif %} {% endblock %} diff --git a/netbox_branching/templates/netbox_branching/buttons/branch_archive.html b/netbox_branching/templates/netbox_branching/buttons/branch_archive.html new file mode 100644 index 0000000..1ecbf34 --- /dev/null +++ b/netbox_branching/templates/netbox_branching/buttons/branch_archive.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% if perms.netbox_branching.archive_branch %} + + {% trans "Archive" %} + +{% else %} + +{% endif %} diff --git a/netbox_branching/templates/netbox_branching/buttons/branch_merge.html b/netbox_branching/templates/netbox_branching/buttons/branch_merge.html new file mode 100644 index 0000000..efbb5b0 --- /dev/null +++ b/netbox_branching/templates/netbox_branching/buttons/branch_merge.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% if perms.netbox_branching.merge_branch %} + + {% trans "Merge" %} + +{% else %} + +{% endif %} diff --git a/netbox_branching/templates/netbox_branching/buttons/branch_revert.html b/netbox_branching/templates/netbox_branching/buttons/branch_revert.html new file mode 100644 index 0000000..f004631 --- /dev/null +++ b/netbox_branching/templates/netbox_branching/buttons/branch_revert.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% if perms.netbox_branching.revert_branch %} + + {% trans "Revert" %} + +{% else %} + +{% endif %} diff --git a/netbox_branching/templates/netbox_branching/buttons/branch_sync.html b/netbox_branching/templates/netbox_branching/buttons/branch_sync.html new file mode 100644 index 0000000..fcf21bb --- /dev/null +++ b/netbox_branching/templates/netbox_branching/buttons/branch_sync.html @@ -0,0 +1,10 @@ +{% load i18n %} +{% if perms.netbox_branching.sync_branch %} + + {% trans "Sync" %} + +{% else %} + +{% endif %} diff --git a/netbox_branching/templatetags/__init__.py b/netbox_branching/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/netbox_branching/templatetags/branch_buttons.py b/netbox_branching/templatetags/branch_buttons.py new file mode 100644 index 0000000..2fa62f2 --- /dev/null +++ b/netbox_branching/templatetags/branch_buttons.py @@ -0,0 +1,42 @@ +from django import template + +__all__ = ( + 'branch_sync_button', + 'branch_merge_button', + 'branch_revert_button', + 'branch_archive_button', +) + +register = template.Library() + + +@register.inclusion_tag('netbox_branching/buttons/branch_sync.html', takes_context=True) +def branch_sync_button(context, branch): + return { + 'branch': branch, + 'perms': context.get('perms'), + } + + +@register.inclusion_tag('netbox_branching/buttons/branch_merge.html', takes_context=True) +def branch_merge_button(context, branch): + return { + 'branch': branch, + 'perms': context.get('perms'), + } + + +@register.inclusion_tag('netbox_branching/buttons/branch_revert.html', takes_context=True) +def branch_revert_button(context, branch): + return { + 'branch': branch, + 'perms': context.get('perms'), + } + + +@register.inclusion_tag('netbox_branching/buttons/branch_archive.html', takes_context=True) +def branch_archive_button(context, branch): + return { + 'branch': branch, + 'perms': context.get('perms'), + } diff --git a/netbox_branching/tests/test_api.py b/netbox_branching/tests/test_api.py index 3e63781..341dacd 100644 --- a/netbox_branching/tests/test_api.py +++ b/netbox_branching/tests/test_api.py @@ -9,6 +9,7 @@ from dcim.models import Site from users.models import Token +from netbox_branching.constants import COOKIE_NAME from netbox_branching.models import Branch @@ -58,21 +59,43 @@ def test_without_branch(self): self.assertEqual(len(results), 1) self.assertEqual(results[0]['name'], 'Site 1') - def test_with_branch(self): + def test_with_branch_header(self): + url = reverse('dcim-api:site-list') branch = Branch.objects.first() self.assertIsNotNone(branch, "Branch was not created") + + # Regular API query + response = self.client.get(url, **self.header) + results = self.get_results(response) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['name'], 'Site 1') + + # Branch-aware API query header = { **self.header, f'HTTP_X_NETBOX_BRANCH': branch.schema_id, } + response = self.client.get(url, **header) + results = self.get_results(response) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['name'], 'Site 2') - # Sanity checks - self.assertEqual(Site.objects.count(), 1) - self.assertEqual(Site.objects.using(branch.connection_name).count(), 1) - + def test_with_branch_cookie(self): url = reverse('dcim-api:site-list') - response = self.client.get(url, **header) + branch = Branch.objects.first() + self.assertIsNotNone(branch, "Branch was not created") + + # Regular API query + response = self.client.get(url, **self.header) results = self.get_results(response) + self.assertEqual(len(results), 1) + self.assertEqual(results[0]['name'], 'Site 1') + # Branch-aware API query + self.client.cookies.load({ + COOKIE_NAME: branch.schema_id, + }) + response = self.client.get(url, **self.header) + results = self.get_results(response) self.assertEqual(len(results), 1) self.assertEqual(results[0]['name'], 'Site 2') diff --git a/netbox_branching/tests/test_branches.py b/netbox_branching/tests/test_branches.py index 6cbd01d..3bb263b 100644 --- a/netbox_branching/tests/test_branches.py +++ b/netbox_branching/tests/test_branches.py @@ -112,10 +112,16 @@ def test_max_branches(self): Verify that the max_branches config parameter is enforced. """ Branch.objects.bulk_create(( - Branch(name='Branch 1'), - Branch(name='Branch 2'), + Branch(name='Branch 1', status=BranchStatusChoices.ARCHIVED), + Branch(name='Branch 2', status=BranchStatusChoices.READY), )) + # Creating a second non-archived Branch should succeed branch = Branch(name='Branch 3') + branch.full_clean() + branch.save(provision=False) + + # Creating a third non-archived Branch should fail + branch = Branch(name='Branch 4') with self.assertRaises(ValidationError): branch.full_clean() diff --git a/netbox_branching/utilities.py b/netbox_branching/utilities.py index 74431b2..ea38cea 100644 --- a/netbox_branching/utilities.py +++ b/netbox_branching/utilities.py @@ -4,7 +4,9 @@ from dataclasses import dataclass from django.db.models import ForeignKey, ManyToManyField +from django.urls import reverse +from .constants import REPLICATE_TABLES from .contextvars import active_branch __all__ = ( @@ -15,6 +17,7 @@ 'deactivate_branch', 'get_branchable_object_types', 'get_tables_to_replicate', + 'is_api_request', 'record_applied_change', 'update_object', ) @@ -79,9 +82,9 @@ def get_branchable_object_types(): def get_tables_to_replicate(): """ - Returned an ordered list of database tables to replicate when provisioning a new schema. + Return an ordered list of database tables to replicate when provisioning a new schema. """ - tables = set() + tables = set(REPLICATE_TABLES) branch_aware_models = [ ot.model_class() for ot in get_branchable_object_types() @@ -166,3 +169,10 @@ def record_applied_change(instance, branch, **kwargs): from .models import AppliedChange AppliedChange.objects.update_or_create(change=instance, defaults={'branch': branch}) + + +def is_api_request(request): + """ + Returns True if the given request is a REST or GraphQL API request. + """ + return request.path_info.startswith(reverse('api-root')) or request.path_info.startswith(reverse('graphql')) diff --git a/pyproject.toml b/pyproject.toml index f20d422..f1d8b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "netboxlabs-netbox-branching" -version = "0.5.0" +version = "0.5.1" description = "A git-like branching implementation for NetBox" readme = "README.md" requires-python = ">=3.10"