diff --git a/docs/using-branches/syncing-merging.md b/docs/using-branches/syncing-merging.md index af3c8f9..3c7d0cd 100644 --- a/docs/using-branches/syncing-merging.md +++ b/docs/using-branches/syncing-merging.md @@ -6,6 +6,9 @@ Synchronizing a branch replicates all recent changes from main into the branch. To synchronize a branch, click the "Sync" button. (If this button is not visible, verify that the branch status shows "ready" and that you have permission to synchronize the branch.) +!!! warning + A branch must be synchronized frequently enough to avoid exceeding NetBox's configured [changelog retention period](https://netboxlabs.com/docs/netbox/en/stable/configuration/miscellaneous/#changelog_retention) (which defaults to 90 days). This is to protect against data loss when replicating changes from main. A branch whose `last_sync` time exceeds the configured retention window can no longer be synced. + While a branch is being synchronized, its status will show "synchronizing." !!! tip diff --git a/netbox_branching/models/branches.py b/netbox_branching/models/branches.py index 5cc2a2a..6ace090 100644 --- a/netbox_branching/models/branches.py +++ b/netbox_branching/models/branches.py @@ -1,6 +1,7 @@ import logging import random import string +from datetime import timedelta from functools import cached_property, partial from django.conf import settings @@ -15,6 +16,7 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectChange as ObjectChange_ +from netbox.config import get_config from netbox.context import current_request from netbox.context_managers import event_tracking from netbox.models import PrimaryModel @@ -121,7 +123,7 @@ def schema_name(self): def connection_name(self): return f'schema_{self.schema_name}' - @cached_property + @property def synced_time(self): return self.last_sync or self.created @@ -240,6 +242,16 @@ def get_event_history(self): last_time = event.time return history + @property + def is_stale(self): + """ + Indicates whether the branch is too far out of date to be synced. + """ + if not (changelog_retention := get_config().CHANGELOG_RETENTION): + # Changelog retention is disabled + return False + return self.synced_time < timezone.now() - timedelta(days=changelog_retention) + def sync(self, user, commit=True): """ Apply changes from the main schema onto the Branch's schema. @@ -249,6 +261,8 @@ def sync(self, user, commit=True): if not self.ready: raise Exception(f"Branch {self} is not ready to sync") + if self.is_stale: + raise Exception(f"Branch {self} is stale and can no longer be synced") # Emit pre-sync signal pre_sync.send(sender=self.__class__, branch=self, user=user) diff --git a/netbox_branching/tables/tables.py b/netbox_branching/tables/tables.py index b1165b3..8d90a59 100644 --- a/netbox_branching/tables/tables.py +++ b/netbox_branching/tables/tables.py @@ -56,8 +56,16 @@ class BranchTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) + is_active = columns.BooleanColumn( + verbose_name=_('Active') + ) status = columns.ChoiceFieldColumn( - verbose_name=_('Status'), + verbose_name=_('Status') + ) + is_stale = columns.BooleanColumn( + true_mark=mark_safe(''), + false_mark=None, + verbose_name=_('Stale') ) conflicts = ConflictsColumn( verbose_name=_('Conflicts') @@ -72,11 +80,11 @@ class BranchTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Branch fields = ( - 'pk', 'id', 'name', 'is_active', 'status', 'conflicts', 'schema_id', 'description', 'owner', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'name', 'is_active', 'status', 'is_stale', 'conflicts', 'schema_id', 'description', 'owner', + 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'is_active', 'status', 'owner', 'conflicts', 'schema_id', 'description', + 'pk', 'name', 'is_active', 'status', 'is_stale', 'owner', 'conflicts', 'schema_id', 'description', ) def render_is_active(self, value): diff --git a/netbox_branching/templates/netbox_branching/branch.html b/netbox_branching/templates/netbox_branching/branch.html index 75eff0c..08705d8 100644 --- a/netbox_branching/templates/netbox_branching/branch.html +++ b/netbox_branching/templates/netbox_branching/branch.html @@ -82,11 +82,24 @@
{% trans "Branch" %}
{% trans "Last synced" %} - {{ object.synced_time|isodatetime }} + + {{ object.synced_time|isodatetime }} + {% if object.is_stale %} + + + + {% endif %} +
{{ object.synced_time|timesince }} {% trans "ago" %}
+ {% trans "Last activity" %} - {{ latest_change.time|isodatetime|placeholder }} + + {{ latest_change.time|isodatetime|placeholder }} + {% if latest_change %} +
{{ latest_change.time|timesince }} {% trans "ago" %}
+ {% endif %} + {% trans "Conflicts" %} diff --git a/netbox_branching/templates/netbox_branching/buttons/branch_sync.html b/netbox_branching/templates/netbox_branching/buttons/branch_sync.html index fcf21bb..dc0e422 100644 --- a/netbox_branching/templates/netbox_branching/buttons/branch_sync.html +++ b/netbox_branching/templates/netbox_branching/buttons/branch_sync.html @@ -1,5 +1,5 @@ {% load i18n %} -{% if perms.netbox_branching.sync_branch %} +{% if perms.netbox_branching.sync_branch and not branch.is_stale %} {% trans "Sync" %} diff --git a/netbox_branching/tests/test_branches.py b/netbox_branching/tests/test_branches.py index 3bb263b..66a9a6b 100644 --- a/netbox_branching/tests/test_branches.py +++ b/netbox_branching/tests/test_branches.py @@ -1,8 +1,10 @@ import re +from datetime import timedelta from django.core.exceptions import ValidationError from django.db import connection from django.test import TransactionTestCase, override_settings +from django.utils import timezone from netbox_branching.choices import BranchStatusChoices from netbox_branching.constants import MAIN_SCHEMA @@ -125,3 +127,18 @@ def test_max_branches(self): branch = Branch(name='Branch 4') with self.assertRaises(ValidationError): branch.full_clean() + + @override_settings(CHANGELOG_RETENTION=10) + def test_is_stale(self): + branch = Branch(name='Branch 1') + branch.save(provision=False) + + # Set creation time to 9 days in the past + branch.last_sync = timezone.now() - timedelta(days=9) + branch.save() + self.assertFalse(branch.is_stale) + + # Set creation time to 11 days in the past + branch.last_sync = timezone.now() - timedelta(days=11) + branch.save() + self.assertTrue(branch.is_stale)