diff --git a/AGENTS.md b/AGENTS.md index 6e8d9377..9171d3d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ Defer all version pins to `pyproject.toml` and `netbox_custom_objects/__init__.p │ ├── tables.py — django-tables2 tables for list views. │ ├── template_content.py — PluginTemplateExtension registrations. │ ├── urls.py — Web UI URL routing (80+ routes). -│ ├── utilities.py — AppsProxy, generate_model(), get_viewname(), is_in_branch(). +│ ├── utilities.py — AppsProxy, generate_model(), get_viewname(). │ ├── views.py — All UI views. │ ├── api/ │ │ ├── serializers.py — get_serializer_class() + static serializers. @@ -134,7 +134,7 @@ Multi-object fields create a separate through table (`custom_objects__ [!NOTE] -> If you are using NetBox Custom Objects with NetBox Branching, you need to insert the following into your `configuration.py`. See the docs for a full description of how NetBox Custom Objects currently works with NetBox Branching. - -``` -PLUGINS_CONFIG = { - 'netbox_branching': { - 'exempt_models': [ - 'netbox_custom_objects.customobjecttype', - 'netbox_custom_objects.customobjecttypefield', - ], - }, -} -``` - ## Known Limitations NetBox Custom Objects is now Generally Available which means you can use it in production and migrations to future versions will work. There are many upcoming features including GraphQL support - the best place to see what's on the way is the [issues](https://github.com/netboxlabs/netbox-custom-objects/issues) list on the GitHub repository. diff --git a/docs/branching.md b/docs/branching.md index eb8d51f5..a260abc0 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -1,5 +1,12 @@ # Using NetBox Custom Objects with NetBox Branching +When using Custom Objects together with NetBox Branching, the following minimum versions are required: + +- NetBox >= 4.6.2 +- netbox-branching >= 1.0.4 + +These requirements are only enforced when `netbox_branching` is present in `PLUGINS`. If you do not use branching, the standard compatibility matrix in `COMPATIBILITY.md` applies. A Django system check (`netbox_custom_objects.E001` / `E002`) will fail at startup if the combination is misconfigured. + As of version 0.4.0, Custom Objects is _compatible_ with [NetBox Branching](https://netboxlabs.com/docs/extensions/branching/), but not yet fully supported. Users can safely run both plugins together, but there are some caveats to be aware of. See below for how each Custom Objects model interacts with NetBox Branching. !!! note diff --git a/docs/field-attributes.md b/docs/field-attributes.md index 2050780b..075e0d3f 100644 --- a/docs/field-attributes.md +++ b/docs/field-attributes.md @@ -19,6 +19,7 @@ The following attributes are available when creating or editing a Custom Object | `multiselect` | Multiple selections from a choice set | | `object` | Reference to a single object (built-in NetBox object or Custom Object) | | `multiobject` | Reference to multiple objects of the same type | +| `coordinates` | A geographic latitude/longitude pair (with a map link) | ## Common Attributes @@ -85,3 +86,24 @@ Field types: `object`, `multiobject` !!! note To reference another Custom Object Type, choose `Custom Objects > ` in the **Related object type** dropdown. To create a polymorphic field that may reference objects of multiple types, enable **Polymorphic** and select the allowed types under **Related object types**. + +## Coordinates Fields + +Field type: `coordinates` + +A single `coordinates` field stores a geographic latitude/longitude pair, mirroring NetBox's +native Site/Device coordinates (plain decimal columns — PostGIS is not required). Adding one +`coordinates` field named `location` creates two backing columns and two form inputs: + +- `location_latitude` — decimal, range −90 to 90, up to 6 decimal places +- `location_longitude` — decimal, range −180 to 180, up to 6 decimal places + +Behaviour: + +- **Both-or-neither.** Latitude and longitude must either both be set or both be empty; setting + only one is rejected in forms and via the REST API. +- **REST API.** The pair is exposed as two flat fields, `_latitude` and `_longitude` + (matching NetBox core's serializers), not as a nested object. +- **Map link.** Detail views render the coordinates with a **Map** button that opens the location + using NetBox's `MAPS_URL` configuration parameter (Google Maps by default). +- `Must be unique` and `Default` are not supported for `coordinates` fields. diff --git a/docs/graphql.md b/docs/graphql.md new file mode 100644 index 00000000..84139cec --- /dev/null +++ b/docs/graphql.md @@ -0,0 +1,173 @@ +# GraphQL API + +The NetBox Custom Objects plugin exposes custom objects through NetBox's GraphQL +API at the standard `/graphql/` endpoint, alongside NetBox's built-in models. For +each Custom Object Type you have defined, two root query fields are generated: + +- `` — fetch a single custom object by `id`. +- `_list` — fetch a list of custom objects (paginated). + +The `` is `custom_objects_`, where `` is the Custom Object +Type's **slug** with any characters that are not valid in a GraphQL name replaced +by underscores (for example a type with slug `dhcp-scope` becomes +`custom_objects_dhcp_scope` and `custom_objects_dhcp_scope_list`). + +The `custom_objects_` prefix namespaces these fields so they can never collide +with NetBox's own (or another plugin's) root query fields — every plugin's +GraphQL query is merged into NetBox's single global `Query`, so a bare, +slug-derived name like `site` or `group` would otherwise be shadowed by a core +model's field. + +## Authentication + +GraphQL requests use the same authentication as the REST API. Pass a token in the +`Authorization` header: + +``` +Authorization: Token +``` + +Object-level permissions are enforced. The top-level `` / `_list` +queries only return custom objects the authenticated user has permission to view, +and objects reached through a relationship field are likewise filtered to those +the user may view. + +## Using the GraphiQL explorer + +NetBox's built-in GraphiQL explorer (at `/graphql/`) loads the GraphQL schema +**once when the page opens** and caches it for the life of the page. Because +custom object types and their fields can change at runtime, the explorer's +autocomplete suggestions and **Documentation** panel can go stale. + +If you **add, remove, or change a custom object type's fields**, or **add a new +custom object type**, while GraphiQL is open, those changes will not appear in the +field autocomplete or the Docs panel until GraphiQL re-reads the schema. (The +change is already live on the server — a query using the new field works +immediately — it is only GraphiQL's cached copy of the schema that lags.) + +To refresh it, click the **Re-fetch GraphQL schema** button — the circular-arrow +icon in the GraphiQL toolbar (lower left). This re-reads the schema *without* +reloading the page, so your current query is preserved: + +![The Re-fetch GraphQL schema button in the GraphiQL toolbar](media/graphql/graphiql-refresh-schema.png) + +Reloading the whole browser page has the same effect, but discards whatever you +have typed in the editor. + +!!! note + This only affects the interactive explorer. Programmatic API clients fetch the + schema via introspection whenever they choose, and always query against the + current server-side schema. + +## Querying a list + +The fields you can select depend on how the Custom Object Type is defined. Every +type exposes `id` and `display`; the examples below also assume the type defines +custom fields named `name` and `description`. + +```graphql +query { + custom_objects_dhcp_scope_list { + id + display + name + description + } +} +``` + +> **Tip:** `display` is the only human-readable field guaranteed to exist on every +> type — it is the value of the type's primary field. `name` exists only if the +> type defines a custom field literally named `name`. + +## Querying a single object + +```graphql +query { + custom_objects_dhcp_scope(id: 42) { + id + display + } +} +``` + +## Available fields + +Each generated type exposes: + +| Field | Description | +|-------|-------------| +| `id` | The object's primary key. | +| `display` | The object's display string (its primary field value). | +| `created` / `last_updated` | Change-logging timestamps. | +| `tags` | The object's tags. | +| One field per custom field | Named exactly as the field's `name`. | + +### Scalar field types + +Scalar custom fields map to their natural GraphQL types: + +| Custom field type | GraphQL type | +|-------------------|--------------| +| text, long text, URL, select | `String` | +| integer | `Int` | +| decimal | `Decimal` | +| boolean | `Boolean` | +| date | `Date` | +| datetime | `DateTime` | +| JSON | `JSON` | +| multi-select | `[String]` | + +### Relationship field types + +Object and multi-object fields resolve to the **native GraphQL type** of their +target, so a related object is fully traversable exactly as it would be when +queried directly. A field pointing at a Site resolves to NetBox's `SiteType`: + +```graphql +query { + custom_objects_server_list { + name + primary_site { # an object (single) field → SiteType + id + name + region { name } # traverse into the related object's own fields + } + interfaces { # a multi-object (list) field → [InterfaceType] + id + name + } + } +} +``` + +#### Polymorphic fields + +A polymorphic relationship field may point at several different model types. It +resolves to a GraphQL **union** of those types; select fields per type with inline +fragments: + +```graphql +query { + custom_objects_binding_list { + name + target { # polymorphic object field + ... on SiteType { id name } + ... on DeviceType { id name } + } + } +} +``` + +#### Fallback for targets without a GraphQL type + +A small number of NetBox models are not exposed in GraphQL. When a relationship +field's target has no native GraphQL type, that field falls back to a uniform +`CustomObjectRelatedObjectType` so it is never silently dropped: + +| Field | Description | +|-------|-------------| +| `id` | The referenced object's primary key. | +| `object_type` | The referenced object's type, as `.`. | +| `display` | The referenced object's display string. | +| `url` | The referenced object's absolute URL, if resolvable. | diff --git a/docs/index.md b/docs/index.md index febc7945..eb175fac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -41,6 +41,7 @@ This documentation covers: * Full-text search * Change logging * Bookmarks + * Contacts * Custom Links * Cloning * Import/Export @@ -126,6 +127,39 @@ As with core NetBox objects, Custom Objects have their own list views. To see al You will see a standard NetBox list view for your new Custom Objects with the standard options including `Configure Table`, `+ Add`, `Import`, `Export`, and others. +### Config Context + +A Custom Object Type can opt in to NetBox's [config context](https://netboxlabs.com/docs/netbox/models/extras/configcontext/) support by checking **Config context support** when the type is created. (This can only be set at creation time — it adds a `local_context_data` column to the type's table, so it cannot be toggled afterwards.) + +When enabled, every object of that type gains: + +- A **Local Context Data** field (arbitrary JSON), stored per object and exposed in the REST API — useful for building configurations (e.g. with Ansible). +- A **Config Context** tab on the object's detail view, showing the rendered context, the local context, and any source contexts. + +#### Aggregating source config contexts + +Custom objects can also inherit the ConfigContexts that apply to NetBox objects they reference, exactly as a Device or VM would. This works **by field-naming convention** — no extra configuration: + +Give the Custom Object Type a single (non-polymorphic) **object** field named one of the following, pointing at the matching NetBox model: + +| Field name | Points at | +|------------|-----------| +| `site` | Site | +| `tenant` | Tenant | +| `role` | Device Role | +| `platform` | Platform | +| `location` | Location | +| `device_type` | Device Type | +| `cluster` | Cluster | + +Any active ConfigContext assigned to the referenced object (or to its region, site group, tenant group, cluster type/group, or matching tags) is then aggregated into the object's rendered context, ordered by weight, with the object's own **Local Context Data** applied last (it always wins). Assigning tags to the custom object also matches tag-scoped ConfigContexts automatically. + +Notes: + +- Field **names are unique per type**, so there is never ambiguity about which field feeds a dimension. +- A field must be an object field with both the **name and target model** above; a mis-named or mis-pointed field is simply ignored. +- If a type defines **none** of these fields, aggregation is skipped entirely and its rendered context is just its Local Context Data. This is a deliberate difference from Devices/VMs: **global (unassigned) ConfigContexts are not applied** to such a type — enabling config context support alone never silently pulls in every global context. Add at least one convention field (e.g. `site`) to opt the type into source aggregation; global contexts then apply too (as they do for any object with a dimension). + ### Deletions #### Deleting Custom Object Types diff --git a/docs/media/graphql/graphiql-refresh-schema.png b/docs/media/graphql/graphiql-refresh-schema.png new file mode 100644 index 00000000..367cc382 Binary files /dev/null and b/docs/media/graphql/graphiql-refresh-schema.png differ diff --git a/docs/releases.md b/docs/releases.md index 09ad9f89..63b0dcd6 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -1,5 +1,37 @@ # Releases +## 0.6.0 + +### New Features + +**NetBox Branching Support** + +Custom Object Types and their instances now participate fully in NetBox branching workflows. COT schema changes (adding/removing fields, modifying field definitions) and instance creates/updates/deletes are all branch-aware. A version check ensures the minimum required NetBox and netbox-branching versions are present before enabling branching features. + +- [#404](https://github.com/netboxlabs/netbox-custom-objects/issues/404) - Full branching support for Custom Object Types and instances +- [#569](https://github.com/netboxlabs/netbox-custom-objects/issues/569) - Add version check to gate branching features on required NetBox / netbox-branching versions + +**GraphQL Support** + +Custom Object Types and their instances are now queryable and mutable via the NetBox GraphQL API. Queries, mutations, and filtering are supported for all field types. + +- [#30](https://github.com/netboxlabs/netbox-custom-objects/issues/30) - GraphQL support for Custom Objects + +### Enhancements + +- [#98](https://github.com/netboxlabs/netbox-custom-objects/issues/98) - Config context support for Custom Objects +- [#254](https://github.com/netboxlabs/netbox-custom-objects/issues/254) - Quick Add support for object/multiobject fields +- [#286](https://github.com/netboxlabs/netbox-custom-objects/issues/286) - Contacts support for Custom Objects +- [#376](https://github.com/netboxlabs/netbox-custom-objects/issues/376) - Ownership field for Custom Object instances +- [#532](https://github.com/netboxlabs/netbox-custom-objects/issues/532) - Integer fields now use 64-bit `BigIntegerField` to support values exceeding the 32-bit range +- [#551](https://github.com/netboxlabs/netbox-custom-objects/issues/551) - Add `location` field type for latitude/longitude coordinate pairs + +### Bug Fixes + +- [#572](https://github.com/netboxlabs/netbox-custom-objects/issues/572) - Crash on startup when netbox-branching is installed but not listed in `PLUGINS` + +--- + ## 0.5.3 ### Bug Fixes diff --git a/mkdocs.yml b/mkdocs.yml index 7e127c75..c9c1dd2b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,5 +42,6 @@ nav: - Field Attributes: 'field-attributes.md' - Branching Support: 'branching.md' - REST API: 'rest-api.md' + - GraphQL API: 'graphql.md' - Portable Schema: 'portable-schema.md' - Releases: 'releases.md' diff --git a/netbox_custom_objects/__init__.py b/netbox_custom_objects/__init__.py index 690a45b1..a65e667e 100644 --- a/netbox_custom_objects/__init__.py +++ b/netbox_custom_objects/__init__.py @@ -1,4 +1,5 @@ import contextvars +import logging import sys import warnings @@ -12,6 +13,8 @@ from .constants import APP_LABEL as APP_LABEL from .utilities import extract_cot_id_from_model_name, install_clear_cache_suppressor +logger = logging.getLogger(__name__) + # Context variable to track if we're currently running migrations _is_migrating = contextvars.ContextVar('is_migrating', default=False) @@ -27,6 +30,11 @@ # access recomputes with the full set of COT models. _app_ready = False +# Guards ``super().ready()`` against the duplicate-registration check in +# NetBox's ``register_serializer_resolver`` (triggered by ``serializer_resolver`` +# on this PluginConfig). +_super_ready_called = False + def _migration_started(sender, **kwargs): """Signal handler for pre_migrate - sets the migration flag.""" @@ -40,6 +48,78 @@ def _migration_finished(sender, **kwargs): _migrations_checked = None +# Guards the netbox-branching hook setup against duplicate registration if +# ``ready()`` runs more than once (e.g. test isolation paths that reset the +# app registry). Covers signal connects and the netbox-branching +# ``register_*`` functions, which do not all dedupe internally. +_branching_hooks_registered = False + + +def _reset_deferred_co_field_data(sender, **kwargs): + """Module-level receiver so Django's ``Signal.connect`` dedupes it across + repeat ``ready()`` invocations (a closure would have a fresh id each call). + """ + from netbox_custom_objects.models import _deferred_co_field_data + _deferred_co_field_data.set(None) + + +def _register_branching_hooks_once(): + """Register netbox-branching integration hooks at most once per process. + + Wraps the branching-resolver, objectchange-field-migrator, deferred-data + reset receivers, and squash-dependency-graph receiver. Connect both pre- + and post- merge/sync/revert so the deferred-data reset runs even when the + operation raises (post-signals only fire on success). ``weak=False`` keeps + the receivers alive past the end of ``ready()``. + """ + global _branching_hooks_registered + if _branching_hooks_registered: + return + + try: + from netbox_branching.signals import ( + pre_merge, post_merge, + pre_sync, post_sync, + pre_revert, post_revert, + ) + except ImportError: + return + + for sig in (pre_merge, post_merge, pre_sync, post_sync, pre_revert, post_revert): + sig.connect(_reset_deferred_co_field_data, weak=False) + + try: + from netbox_branching.utilities import ( + register_branching_resolver, + register_objectchange_field_migrator, + ) + from .branching import ( + objectchange_field_migrator, + supports_branching_resolver, + ) + register_branching_resolver(supports_branching_resolver) + register_objectchange_field_migrator(objectchange_field_migrator) + # Subscribe to the squash dependency-graph signal so CO-specific + # edges (M2M targets, polymorphic-M2M sidecar) get added before + # topological ordering. Skipped silently on older netbox-branching + # that doesn't expose the signal yet. + try: + from netbox_branching.signals import ( + squash_dependency_graph_built, + ) + from .branching import add_custom_object_dependencies + squash_dependency_graph_built.connect( + add_custom_object_dependencies, + weak=False, + ) + except ImportError: + pass + except ImportError: + pass + + _branching_hooks_registered = True + + # Module-level flag so the heal runs at most once per process invocation even # though post_migrate fires once per installed app. _heal_ran = False @@ -64,12 +144,6 @@ def _heal_mixin_columns(sender, **kwargs): if any(cmd in sys.argv for cmd in ("makemigrations", "collectstatic")): return - # Set the flag *before* running so that subsequent post_migrate firings - # (one per installed app) are no-ops even if the first attempt raises. - # A failure here will not be retried in the same process; operators can - # run 'manage.py upgrade_custom_objects' manually if needed. - _heal_ran = True - try: from netbox_custom_objects.mixin_migration import heal_all_cots # noqa: PLC0415 heal_all_cots(verbosity=kwargs.get("verbosity", 1)) @@ -78,6 +152,13 @@ def _heal_mixin_columns(sender, **kwargs): logging.getLogger(__name__).exception( "upgrade_custom_objects: unexpected error during mixin drift check" ) + # Leave _heal_ran False so a subsequent post_migrate firing (or a + # manual 'manage.py upgrade_custom_objects') gets another attempt. + return + + # Only mark complete on success so a transient failure can be retried by + # the next post_migrate firing in this process. + _heal_ran = True def _patch_object_selector_view(): @@ -115,12 +196,62 @@ def _patched_get_filterset_class(self, model): ObjectSelectorView._get_filterset_class = _patched_get_filterset_class +_graphql_view_patched = False + + +def _patch_graphql_view(): + """ + Patch NetBox's GraphQL view so custom object types appear without a restart. + + NetBox builds its GraphQL schema once at startup and binds it to the view via + ``as_view(schema=...)``. Custom object types are created/deleted at runtime + and the app runs across multiple worker processes, so that single bound + schema goes stale. This patch swaps in a per-request, per-process schema + that is rebuilt only when the database changes (see + :mod:`netbox_custom_objects.graphql.live`). + + Like the ObjectSelectorView patch above, this monkey-patches NetBox because + the schema binding leaves no other extension point. The wrapper is + re-decorated with ``csrf_exempt`` because Django copies that marker from + ``dispatch.__dict__`` onto the view at ``as_view()`` time (which runs after + this patch), and the GraphQL endpoint must accept token-authenticated POSTs. + """ + global _graphql_view_patched + if _graphql_view_patched: + return + + from django.views.decorators.csrf import csrf_exempt + from netbox.graphql.views import NetBoxGraphQLView + + _original_dispatch = NetBoxGraphQLView.dispatch + + @csrf_exempt + def _patched_dispatch(self, request, *args, **kwargs): + from netbox_custom_objects.graphql.live import get_live_schema + + # netbox-branching has already activated the request's branch (from the + # X-NetBox-Branch header, ?_branch=, or the active_branch cookie) — the plugin + # imposes no GraphQL-specific branch policy. get_live_schema builds the + # schema for whichever branch is active, so the schema and the data the query + # returns agree, and core models resolve exactly as they do without the plugin. + try: + live_schema = get_live_schema() + if live_schema is not None: + self.schema = live_schema + except Exception: # noqa: BLE001 - fall back to the static schema + logger.warning("Failed to load live GraphQL schema; using static schema", exc_info=True) + return _original_dispatch(self, request, *args, **kwargs) + + NetBoxGraphQLView.dispatch = _patched_dispatch + _graphql_view_patched = True + + # Plugin Configuration class CustomObjectsPluginConfig(PluginConfig): name = "netbox_custom_objects" verbose_name = "Custom Objects" description = "A plugin to manage custom objects in NetBox" - version = "0.5.3" + version = "0.6.0" author = 'Netbox Labs' author_email = 'support@netboxlabs.com' base_url = "custom-objects" @@ -133,6 +264,18 @@ class CustomObjectsPluginConfig(PluginConfig): } required_settings = [] template_extensions = "template_content.template_extensions" + # Resolves dynamic CO models (table{n}model) to on-the-fly serializers — + # they have no importable path at the conventional location. + serializer_resolver = "api.serializers.serializer_resolver" + # Registers the plugin's GraphQL schema contribution with NetBox. The export + # is intentionally an empty list: custom object types are created/deleted at + # runtime, so a query class built once at startup would be immediately stale. + # The live, per-request schema is served instead by the GraphQL view patch in + # ``_patch_graphql_view`` (see the graphql/schema.py and graphql/live.py module + # docstrings). The path resolves as module ``graphql.schema`` + attribute + # ``schema`` (NetBox loads resources via ``import_string``, which treats the + # final component as an attribute). + graphql_schema = "graphql.schema.schema" @staticmethod def should_skip_dynamic_model_creation(): @@ -216,11 +359,25 @@ def should_skip_dynamic_model_creation(): # Always clear the recursion flag _checking_migrations = False + def _call_super_ready_once(self): + """Call ``super().ready()`` once; subsequent calls are no-ops. + ``register_serializer_resolver`` rejects duplicates.""" + global _super_ready_called + if _super_ready_called: + return + super().ready() + _super_ready_called = True + def ready(self): # Install the thread-safe apps.clear_cache wrapper before any dynamic # model is registered (must happen exactly once, before get_model() runs). install_clear_cache_suppressor() + # Register Django system checks (import triggers @register). These + # enforce the conditional NetBox/netbox-branching version floors that + # PluginConfig's static min_version/max_version can't express. + from . import checks # noqa: F401 + from .models import CustomObjectType from netbox_custom_objects.api.serializers import get_serializer_class @@ -234,6 +391,23 @@ def ready(self): # Patch ObjectSelectorView to support dynamically-generated custom object models _patch_object_selector_view() + # Patch the GraphQL view so custom object types added/removed at runtime + # are reflected in the schema without a NetBox restart. + _patch_graphql_view() + + # Keep the live GraphQL schema's signature cache fresh across workers + # event-driven, so the per-request hot path reads the cache instead of + # polling the database. + from .graphql.live import connect_signature_invalidation + connect_signature_invalidation() + + # Register netbox-branching integration hooks (deferred-data reset + # receivers, branchable resolver, ObjectChange field-name migrator, + # squash dependency-graph receiver). Guarded so the plugin still + # works without netbox-branching, and so repeat ready() invocations + # don't accumulate duplicate handlers. + _register_branching_hooks_once() + # Suppress warnings about database calls during app initialization. # Filter by source module rather than message content — message-pattern # matching proved unreliable on Python 3.13 (the filter silently failed @@ -253,7 +427,7 @@ def ready(self): # Skip database calls if dynamic models can't be created yet if self.should_skip_dynamic_model_creation(): - super().ready() + self._call_super_ready_once() return try: @@ -290,7 +464,7 @@ def ready(self): except (ProgrammingError, OperationalError): # DB schema is incomplete (unapplied migrations). Skip dynamic # model registration — it will happen after migrations finish. - super().ready() + self._call_super_ready_once() return # Signal that ready() has fully completed. get_models() checks this flag @@ -304,7 +478,7 @@ def ready(self): from django.apps import apps as django_apps django_apps.clear_cache() - super().ready() + self._call_super_ready_once() def get_model(self, model_name, require_ready=True): self.apps.check_apps_ready() diff --git a/netbox_custom_objects/api/serializers.py b/netbox_custom_objects/api/serializers.py index be3249ed..3d117af3 100644 --- a/netbox_custom_objects/api/serializers.py +++ b/netbox_custom_objects/api/serializers.py @@ -5,17 +5,20 @@ from core.models import ObjectType from django.contrib.contenttypes.models import ContentType from django.db import transaction -from django.db.utils import OperationalError, ProgrammingError from django.urls import NoReverseMatch from django.utils.translation import gettext_lazy as _ from extras.choices import CustomFieldTypeChoices +from extras.models import ConfigContextModel from netbox.api.serializers import NetBoxModelSerializer from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.reverse import reverse from rest_framework.utils import model_meta +from users.api.serializers_.owners import OwnerSerializer + from netbox_custom_objects import constants, field_types +from netbox_custom_objects.choices import CustomObjectFieldTypeChoices from netbox_custom_objects.models import (CustomObject, CustomObjectType, CustomObjectTypeField) @@ -333,6 +336,7 @@ class Meta: "version", "group_name", "description", + "config_context_enabled", "tags", "created", "last_updated", @@ -344,6 +348,16 @@ class Meta: read_only_fields = ("schema_document",) brief_fields = ("id", "url", "name", "slug", "description") + def validate_config_context_enabled(self, value): + # Settable only at creation: the backing local_context_data column is + # created once when the type's table is built, so the flag is immutable + # afterwards (mirrors the disable-on-edit behaviour of the web form). + if self.instance is not None and value != self.instance.config_context_enabled: + raise serializers.ValidationError( + _("Config context support cannot be changed after creation.") + ) + return value + def get_table_model_name(self, obj): return obj.get_table_model_name(obj.id) @@ -458,8 +472,22 @@ def get_serializer_class(model, skip_object_fields=False): if isinstance(attr, field_types.PolymorphicM2MDescriptor) ) + # If a COT field is named 'owner', it shadows the OwnerMixin FK on the dynamic model + # (Django silently lets child attrs override abstract parent fields). The serializer + # must skip the FK owner field to avoid OwnerSerializer.to_representation() being + # called on a string value and crashing on .pk. + has_owner_field_conflict = any(f.name == 'owner' for f in model_fields) + # Create field list including all necessary fields base_fields = ["id", "url", "display", "created", "last_updated", "tags"] + if not has_owner_field_conflict: + base_fields.insert(3, "owner") + + # Expose local_context_data when the type opted in to config context support + # (the generated model mixes in ConfigContextModel via + # CustomObjectConfigContextMixin). + if issubclass(model, ConfigContextModel): + base_fields.append("local_context_data") # Include _context field when the model has designated context fields has_context_fields = bool(getattr(model, '_context_field_ids', [])) @@ -469,6 +497,11 @@ def get_serializer_class(model, skip_object_fields=False): # Only include custom field names that will actually be added to the serializer custom_field_names = [] for field in model_fields: + # Coordinates fields expand into two real columns; expose them flat, mirroring + # NetBox core's Site/Device latitude/longitude serializer fields. + if field.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + custom_field_names += [f"{field.name}_latitude", f"{field.name}_longitude"] + continue if field.name not in model_field_names: continue # excluded during model generation (e.g. broken FK) if skip_object_fields and field.type in [ @@ -524,6 +557,12 @@ def get_display(self, obj): f.name for f in model_fields if f.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT and f.is_polymorphic } + # (latitude_column, longitude_column) pairs for coordinates fields. + _coordinate_fields = [ + (f"{f.name}_latitude", f"{f.name}_longitude") + for f in model_fields + if f.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES + ] def get__context(self, obj): """Return context field values as a nested display object for APISelect secondary text.""" @@ -661,6 +700,31 @@ def validate(self, data): saved[field_name] = data.pop(field_name) data = NetBoxModelSerializer.validate(self, data) data.update(saved) + + # Coordinates: latitude and longitude must both be set or both be empty. + # On partial updates, only enforce when at least one of the pair is supplied. + for lat_field, lon_field in _coordinate_fields: + lat_in_data = lat_field in data + lon_in_data = lon_field in data + if not lat_in_data and not lon_in_data: + continue + # For a PATCH that touches only one column, the other is absent from + # data; fall back to the instance's current DB value so clearing just + # one half of an already-populated pair is still rejected. + latitude = data.get(lat_field) if lat_in_data else ( + getattr(self.instance, lat_field, None) if self.instance else None + ) + longitude = data.get(lon_field) if lon_in_data else ( + getattr(self.instance, lon_field, None) if self.instance else None + ) + if (latitude is None) != (longitude is None): + # Pin the error to the empty field rather than non_field_errors, + # mirroring the UI form's add_error() behaviour. + missing_field = lat_field if latitude is None else lon_field + raise serializers.ValidationError( + {missing_field: [_("Latitude and longitude must both be set or both be empty.")]} + ) + return data # Create basic attributes for the serializer @@ -676,6 +740,9 @@ def validate(self, data): "validate": validate, } + if not has_owner_field_conflict: + attrs["owner"] = OwnerSerializer(nested=True, required=False, allow_null=True) + if has_context_fields: attrs["_context"] = serializers.SerializerMethodField() attrs["get__context"] = get__context @@ -716,7 +783,8 @@ def validate(self, data): ) # Register the FULL serializer as a module attribute so NetBox's import_string() - # and the module-level __getattr__ fallback can find it. + # can find it (the serializer_resolver below generates on demand; this keeps + # any direct import-path lookups working too). # The partial variant (skip_object_fields=True) is used only as a nested field # descriptor inside another serializer class and must NOT be stored on the module # — doing so would silently replace the full serializer with an incomplete one @@ -729,35 +797,23 @@ def validate(self, data): return serializer -def __getattr__(name): - """ - Module-level lazy resolution for Table{N}ModelSerializer attributes (PEP 562). - - NetBox's get_serializer_for_model() resolves serializers via - import_string("netbox_custom_objects.api.serializers.TableNModelSerializer"), - which ultimately calls getattr(module, name). That lookup hits this hook - when the attribute has not yet been registered — for example in a worker - whose ready() ran against an empty database, or in any worker that started - before a given COT was created. - - Generating on demand here means SerializerNotFound is never raised just - because startup-time registration was skipped or missed (issue #370). - The generated serializer is stored via setattr inside get_serializer_class(), - so subsequent lookups return it directly from __dict__ without re-entering - this hook. +def serializer_resolver(model, prefix=''): + """Resolve dynamic CO models (``table{n}model``) to on-the-fly serializers. + + Called by ``utilities.api.get_serializer_for_model`` before its default + import-path lookup. Returns ``None`` for non-CO models so the default + lookup runs (including this plugin's static CustomObjectType serializer). + + This supersedes the import-path/``__getattr__`` fallback approach: because + the resolver runs before any import path is built for a CO model, the + serializer is always generated on demand and ``SerializerNotFound`` is never + raised just because startup-time registration was skipped or missed + (issue #370). """ - match = re.match(r'^Table(\d+)ModelSerializer$', name) - if match: - cot_id = int(match.group(1)) - try: - obj = CustomObjectType.objects.get(pk=cot_id) - model = obj.get_model() - return get_serializer_class(model) - except (CustomObjectType.DoesNotExist, ProgrammingError, OperationalError, LookupError): - pass - except Exception: - logger.warning( - "Unexpected error generating serializer for %r; serializer will not be available", - name, exc_info=True, - ) - raise AttributeError(f"module '{__name__}' has no attribute {name!r}") + if ( + getattr(model, '_meta', None) + and model._meta.app_label == 'netbox_custom_objects' + and _TABLE_MODEL_PATTERN.match(model.__name__) + ): + return get_serializer_class(model) + return None diff --git a/netbox_custom_objects/api/views.py b/netbox_custom_objects/api/views.py index 58b41153..31b53cf8 100644 --- a/netbox_custom_objects/api/views.py +++ b/netbox_custom_objects/api/views.py @@ -39,8 +39,6 @@ class ETagMixin: # pragma: no cover – NetBox < 4.6 shim UnknownFieldTypeError, UnknownObjectTypeError, ) -from netbox_custom_objects.utilities import is_in_branch - from . import serializers logger = logging.getLogger(__name__) @@ -102,10 +100,6 @@ def _serialize_diff(diff) -> dict: } -# Constants -BRANCH_ACTIVE_ERROR_MESSAGE = _("Please switch to the main branch to perform this operation.") - - class RootView(APIRootView): def get_view_name(self): return "CustomObjects" @@ -158,14 +152,9 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) def create(self, request, *args, **kwargs): - if is_in_branch(): - raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE) return super().create(request, *args, **kwargs) def update(self, request, *args, **kwargs): - if is_in_branch(): - raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE) - # Replicate DRF's UpdateModelMixin.update() so we can snapshot the instance # before the serializer is constructed. Calling super().update() would invoke # get_object() a second time and return a fresh, un-snapshotted instance. @@ -415,13 +404,14 @@ class SchemaApplyView(APIView): permission_classes = [IsAuthenticatedOrLoginNotRequired, TokenWritePermission] def post(self, request, *args, **kwargs): - # TODO: Schema apply is blocked while in a branch context because the executor - # performs direct DDL (ALTER/DROP TABLE) that is not branch-aware. When branching - # is extended to support schema operations, remove this guard and wire up the - # appropriate branch-scoped apply path. - if is_in_branch(): - raise ValidationError(BRANCH_ACTIVE_ERROR_MESSAGE) - + # Branch context: this endpoint no longer rejects requests with an active + # branch. Schema-editor calls inside ``apply_document`` route through + # ``_get_schema_connection()`` in models.py, which selects the active + # branch's connection when one is set. The resulting DDL therefore lands + # in the branch's PostgreSQL schema, and the CustomObjectType / + # CustomObjectTypeField writes flow through netbox-branching's router. + # See ``_schema_add_field`` / ``_schema_remove_field`` / ``_schema_alter_field`` + # and ``CustomObjectType.save`` for the routing details. if not ( request.user.has_perm('netbox_custom_objects.add_customobjecttype') and request.user.has_perm('netbox_custom_objects.change_customobjecttype') diff --git a/netbox_custom_objects/branching.py b/netbox_custom_objects/branching.py new file mode 100644 index 00000000..667dd1b1 --- /dev/null +++ b/netbox_custom_objects/branching.py @@ -0,0 +1,135 @@ +"""netbox-branching integration hooks. + +Registered from ``__init__.ready()`` only when netbox-branching is installed. +""" + + +def supports_branching_resolver(model): + """Mark CustomObject M2M through models as branchable. + + Through models are plain ``models.Model`` subclasses (no ChangeLoggingMixin), + so the default heuristic would route their queries to main even inside a + branch — and the FK to the parent CO would resolve against main's rows. + Returning ``True`` pulls them into the branch connection routing. + """ + meta = getattr(model, '_meta', None) + if meta is None or meta.app_label != 'netbox_custom_objects': + return None + name = meta.model_name or '' + if name.startswith('through_custom_objects_'): + return True + return None + + +def objectchange_field_migrator(model, data): + """Rewrite stale field-name keys in *data* for CustomObject models. + + Delegates to ``CustomObject.resolve_field_aliases`` (shared with + ``deserialize_object``). Returns ``None`` for non-CO models so other + plugins' migrators can run. + """ + meta = getattr(model, '_meta', None) + if meta is None or meta.app_label != 'netbox_custom_objects': + return None + resolve = getattr(model, 'resolve_field_aliases', None) + if resolve is None: + return None + return resolve(data) + + +def _collect_co_refs(model_class, data): + """Return ``(app.model, pk)`` refs from CO-specific shapes in *data*. + + Covers: + * Local M2M target lists (squash's default ``_get_fk_references`` only + walks FK / GFK fields, so M2M targets — including self-referential + ones — are invisible to it). + * Every ``CustomObjectTypeField`` on the CO's model. A CO INSERT needs + the field's column (scalar) or through table (M2M) to exist first; + without these edges squash may apply the CO CREATE before the field + CREATEs. Pulled from the model class's ``_field_objects`` plus the + polymorphic ``POLY_M2M_SIDECAR_KEY`` (which carries field PKs in the + ObjectChange payload even when ``_field_objects`` isn't available). + """ + from .constants import APP_LABEL + from .models import POLY_M2M_SIDECAR_KEY + + refs = set() + if not data: + return refs + + for field in model_class._meta.local_many_to_many: + values = data.get(field.name) + if not values: + continue + rel_meta = field.related_model._meta + label = f'{rel_meta.app_label}.{rel_meta.model_name}' + for pk in values: + if isinstance(pk, int): + refs.add((label, pk)) + + field_label = f'{APP_LABEL}.customobjecttypefield' + for fo in (getattr(model_class, '_field_objects', None) or {}).values(): + cotf = fo.get('field') if isinstance(fo, dict) else None + if cotf is not None and getattr(cotf, 'pk', None) is not None: + refs.add((field_label, cotf.pk)) + + entries = data.get(POLY_M2M_SIDECAR_KEY) or () + for entry in entries: + if isinstance(entry, dict) and entry.get('pk') is not None: + refs.add((field_label, entry['pk'])) + + return refs + + +def add_custom_object_dependencies(sender, collapsed_changes, **kwargs): + """Extend squash's dependency graph with CO-specific edges. + + Walks every collapsed change for a CO model and mirrors squash's four + edge-direction rules (UPDATE→DELETE, UPDATE→CREATE, CREATE→CREATE, + DELETE→DELETE) using ``_collect_co_refs`` instead of the FK/GFK walker. + + The signal's ``operation`` kwarg ('merge' or 'revert') is intentionally + ignored: these edges express physical "must exist before" relationships, + and revert reverses the topological order so the same edges produce the + correct undo sequence. + """ + from .constants import APP_LABEL + + deletes_map = {} + updates_map = {} + creates_map = {} + for key, cc in collapsed_changes.items(): + action = cc.final_action.value if cc.final_action else None + if action == 'create': + creates_map[key] = cc + elif action == 'update': + updates_map[key] = cc + elif action == 'delete': + deletes_map[key] = cc + + for cc in collapsed_changes.values(): + meta = getattr(cc.model_class, '_meta', None) + if meta is None or meta.app_label != APP_LABEL: + continue + action = cc.final_action.value if cc.final_action else None + + if action == 'update': + for ref in _collect_co_refs(cc.model_class, cc.prechange_data): + if ref in deletes_map: + deletes_map[ref].depends_on.add(cc.key) + cc.depended_by.add(ref) + for ref in _collect_co_refs(cc.model_class, cc.postchange_data): + if ref in creates_map: + cc.depends_on.add(ref) + creates_map[ref].depended_by.add(cc.key) + elif action == 'create': + for ref in _collect_co_refs(cc.model_class, cc.postchange_data): + if ref != cc.key and ref in creates_map: + cc.depends_on.add(ref) + creates_map[ref].depended_by.add(cc.key) + elif action == 'delete': + for ref in _collect_co_refs(cc.model_class, cc.prechange_data): + if ref != cc.key and ref in deletes_map: + deletes_map[ref].depends_on.add(cc.key) + cc.depended_by.add(ref) diff --git a/netbox_custom_objects/checks.py b/netbox_custom_objects/checks.py new file mode 100644 index 00000000..47585501 --- /dev/null +++ b/netbox_custom_objects/checks.py @@ -0,0 +1,75 @@ +"""Django system checks. + +Enforces conditional version floors that PluginConfig's static +``min_version``/``max_version`` can't express: when netbox-branching is +installed, NetBox and netbox-branching versions are pinned tighter because +the branching integration relies on APIs that only landed in those releases: + +- ``serializer_resolver`` / ``register_serializer_resolver`` — NetBox 4.6.2 +- ``register_branching_resolver``, ``register_objectchange_field_migrator``, + and the ``squash_dependency_graph_built`` signal — netbox-branching 1.0.4 + +Users who never install netbox-branching keep the broader compatibility +window declared by PluginConfig. +""" + +from importlib.metadata import PackageNotFoundError, version as _pkg_version + +from django.apps import apps +from django.conf import settings +from django.core.checks import Error, Warning, register +from packaging.version import InvalidVersion, Version + + +# Version floors enforced only when netbox-branching is installed. +REQUIRED_NETBOX_VERSION_FOR_BRANCHING = '4.6.2' +REQUIRED_BRANCHING_VERSION = '1.0.4' + + +@register() +def check_branching_compatibility(app_configs, **kwargs): + """Enforce branching-only version floors; no-op without netbox-branching.""" + if not apps.is_installed('netbox_branching'): + return [] + + errors = [] + + try: + netbox_version = Version(settings.RELEASE.version) + if netbox_version < Version(REQUIRED_NETBOX_VERSION_FOR_BRANCHING): + errors.append(Error( + f'netbox-custom-objects requires NetBox >= {REQUIRED_NETBOX_VERSION_FOR_BRANCHING} ' + f'when netbox-branching is installed (detected {netbox_version}).', + hint='Upgrade NetBox, or remove netbox-branching from PLUGINS ' + 'if you do not need branching support for custom objects.', + id='netbox_custom_objects.E001', + )) + except (AttributeError, InvalidVersion): + pass # settings.RELEASE missing/unparseable — other checks surface it + + try: + branching_version = Version(_pkg_version('netbox-branching')) + if branching_version < Version(REQUIRED_BRANCHING_VERSION): + errors.append(Error( + f'netbox-custom-objects requires netbox-branching >= ' + f'{REQUIRED_BRANCHING_VERSION} (detected {branching_version}).', + hint=f'Upgrade with: pip install "netbox-branching>={REQUIRED_BRANCHING_VERSION}"', + id='netbox_custom_objects.E002', + )) + except PackageNotFoundError: + # netbox-branching is an installed app but its distribution metadata + # isn't discoverable (e.g. an editable checkout without dist-info), so + # the version floor can't be enforced. Warn rather than silently pass + # so an incompatible checkout doesn't slip through unnoticed. + errors.append(Warning( + 'netbox-branching is installed but its version could not be ' + f'determined, so the >= {REQUIRED_BRANCHING_VERSION} requirement ' + 'cannot be verified.', + hint='If using an editable install, ensure its dist-info metadata ' + 'is present (reinstall with `pip install -e`).', + id='netbox_custom_objects.W001', + )) + except InvalidVersion: + pass # unparseable version string — skip rather than emit a confusing error + + return errors diff --git a/netbox_custom_objects/choices.py b/netbox_custom_objects/choices.py index 8f66351d..59a8f4f4 100644 --- a/netbox_custom_objects/choices.py +++ b/netbox_custom_objects/choices.py @@ -1,7 +1,21 @@ from django.utils.translation import gettext_lazy as _ +from extras.choices import CustomFieldTypeChoices from utilities.choices import ChoiceSet +class CustomObjectFieldTypeChoices(CustomFieldTypeChoices): + """ + Extends NetBox's CustomFieldTypeChoices with field types specific to custom + objects. All existing ``CustomFieldTypeChoices.TYPE_*`` members remain valid. + """ + + TYPE_COORDINATES = "coordinates" + + CHOICES = CustomFieldTypeChoices.CHOICES + ( + (TYPE_COORDINATES, _("Coordinates")), + ) + + class ObjectFieldOnDeleteChoices(ChoiceSet): """Controls what happens to a Custom Object when the referenced object is deleted.""" CASCADE = "cascade" diff --git a/netbox_custom_objects/constants.py b/netbox_custom_objects/constants.py index d7ac7fc3..15c72a42 100644 --- a/netbox_custom_objects/constants.py +++ b/netbox_custom_objects/constants.py @@ -13,6 +13,21 @@ APP_LABEL = "netbox_custom_objects" +# Convention for config-context aggregation (issue #98): a single, non-polymorphic +# OBJECT field named as the key and pointing at the given (app_label, model) feeds the +# matching ConfigContext assignment dimension read by +# ConfigContextQuerySet.get_for_object(). Region / site-group / tenant-group / +# cluster-type / cluster-group are derived from site/tenant/cluster and need no field. +CONFIG_CONTEXT_DIMENSION_FIELDS = { + "site": ("dcim", "site"), + "tenant": ("tenancy", "tenant"), + "role": ("dcim", "devicerole"), + "platform": ("dcim", "platform"), + "location": ("dcim", "location"), + "device_type": ("dcim", "devicetype"), + "cluster": ("virtualization", "cluster"), +} + # Field names that are reserved and cannot be used for custom object fields. # Keep in alphabetical order for ease of reading error message. RESERVED_FIELD_NAMES = [ @@ -36,8 +51,10 @@ "jobs", "journal_entries", "last_updated", + "local_context_data", "model", "objects", + "owner", "pk", "refresh_from_db", "save", diff --git a/netbox_custom_objects/dynamic_forms.py b/netbox_custom_objects/dynamic_forms.py index 153f6d86..6fb227a9 100644 --- a/netbox_custom_objects/dynamic_forms.py +++ b/netbox_custom_objects/dynamic_forms.py @@ -1,6 +1,7 @@ import logging from netbox.forms import NetBoxModelFilterSetForm +from netbox.forms.mixins import OwnerFilterMixin as OwnerFilterFormMixin from utilities.forms.fields import TagFilterField from . import field_types @@ -35,6 +36,6 @@ def build_filterset_form_class(model): return type( f"{model._meta.object_name}FilterForm", - (NetBoxModelFilterSetForm,), + (OwnerFilterFormMixin, NetBoxModelFilterSetForm,), attrs, ) diff --git a/netbox_custom_objects/field_types.py b/netbox_custom_objects/field_types.py index 0de2bf65..ff2acc55 100644 --- a/netbox_custom_objects/field_types.py +++ b/netbox_custom_objects/field_types.py @@ -1,19 +1,27 @@ +import datetime +import decimal import hashlib import json import logging +from decimal import Decimal +from typing import List import django_tables2 as tables +from strawberry.scalars import JSON from django import forms from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField -from django.core.exceptions import FieldDoesNotExist -from django.core.validators import RegexValidator -from django.db import connection, models +from django.core.exceptions import FieldDoesNotExist, ValidationError +from django.core.validators import (MaxValueValidator, MinValueValidator, + RegexValidator) +from django.db import models, router from django.db.utils import OperationalError, ProgrammingError from django.db.models.fields.related import ForeignKey, ManyToManyDescriptor from django.db.models.manager import Manager +from django.db.models.signals import m2m_changed +from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ @@ -35,7 +43,8 @@ from utilities.templatetags.builtins.filters import linkify, render_markdown from netbox.tables.columns import BooleanColumn -from netbox_custom_objects.choices import ObjectFieldOnDeleteChoices +from netbox_custom_objects.choices import (CustomObjectFieldTypeChoices, + ObjectFieldOnDeleteChoices) from netbox_custom_objects.constants import APP_LABEL from netbox_custom_objects.utilities import extract_cot_id_from_model_name, generate_model @@ -178,11 +187,15 @@ def _resolve_model(self, model): try: from netbox_custom_objects.models import CustomObjectType cot = CustomObjectType.objects.get(pk=int(cot_id_str)) + branch_id = CustomObjectType._active_branch_id() # Only seed the cache if nothing is cached yet; don't overwrite # a full model that was generated by a previous get_model() call. - if not CustomObjectType.is_model_cached(cot.id): + if not CustomObjectType.is_model_cached(cot.id, branch_id): with CustomObjectType._global_lock: - CustomObjectType._model_cache[cot.id] = (actual_model, cot.cache_timestamp) + CustomObjectType._model_cache[(cot.id, branch_id)] = ( + actual_model, + cot.cache_timestamp, + ) except (CustomObjectType.DoesNotExist, OperationalError, ProgrammingError): pass @@ -217,6 +230,18 @@ def _make_lazy_cot_fk(cot, field, on_delete, **field_kwargs): class FieldType: + # The Python annotation Strawberry should use when exposing this field type + # as a scalar GraphQL field. ``None`` means the type is not exposed as a + # plain scalar — relationship types (OBJECT / MULTIOBJECT) are handled by + # dedicated resolvers in ``graphql/types.py`` instead. Co-locating the + # GraphQL mapping here (next to the model/serializer/form mappings) keeps the + # GraphQL schema in sync automatically: a new field type that needs a scalar + # annotation sets it on its own subclass. + graphql_annotation = None + + def get_graphql_annotation(self): + return self.graphql_annotation + def get_display_value(self, instance, field_name): """ This value is used as the object title in the Custom Object detail view. @@ -288,10 +313,11 @@ def _get_related_content_type(self, field): def after_model_generation(self, instance, model, field_name): ... - def create_m2m_table(self, instance, model, field_name): ... + def create_m2m_table(self, instance, model, field_name, schema_conn=None): ... class TextFieldType(FieldType): + graphql_annotation = str def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) @@ -324,6 +350,8 @@ def get_filterform_field(self, field, **kwargs): class LongTextFieldType(FieldType): + graphql_annotation = str + def get_filterform_field(self, field, **kwargs): return forms.CharField( label=field, @@ -361,12 +389,13 @@ def render_table_column(self, value): class IntegerFieldType(FieldType): + graphql_annotation = int def get_model_field(self, field, **kwargs): # TODO: handle all args for IntegerField field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) - return models.IntegerField(null=True, blank=True, **field_kwargs) + return models.BigIntegerField(null=True, blank=True, **field_kwargs) def get_filterform_field(self, field, **kwargs): return forms.IntegerField( @@ -384,6 +413,8 @@ def get_form_field(self, field, **kwargs): class DecimalFieldType(FieldType): + graphql_annotation = decimal.Decimal + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -415,6 +446,8 @@ def get_filterform_field(self, field, **kwargs): class BooleanFieldType(FieldType): + graphql_annotation = bool + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -449,6 +482,8 @@ def get_table_column_field(self, field, **kwargs): class DateFieldType(FieldType): + graphql_annotation = datetime.date + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -468,6 +503,8 @@ def get_filterform_field(self, field, **kwargs): class DateTimeFieldType(FieldType): + graphql_annotation = datetime.datetime + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -487,6 +524,8 @@ def get_filterform_field(self, field, **kwargs): class URLFieldType(FieldType): + graphql_annotation = str + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -505,6 +544,8 @@ def get_filterform_field(self, field, **kwargs): class JSONFieldType(FieldType): + graphql_annotation = JSON + def get_model_field(self, field, **kwargs): field_kwargs = self._safe_kwargs(**kwargs) field_kwargs.update({"default": field.default, "unique": field.unique}) @@ -524,6 +565,8 @@ def get_filterform_field(self, field, **kwargs): class SelectFieldType(FieldType): + graphql_annotation = str + def get_display_value(self, instance, field_name): value = getattr(instance, field_name) if value is None: @@ -602,6 +645,8 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): class MultiSelectFieldType(FieldType): + graphql_annotation = List[str] + def get_filterform_field(self, field, **kwargs): choices = field.choice_set.choices return DynamicMultipleChoiceField( @@ -817,6 +862,17 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): ) if has_context: form_field.widget.attrs['ts-parent-field'] = '_context' + # Enable quick-add for custom object targets: set quick_add_context + # directly on the widget (bypassing DynamicModelChoiceMixin.get_bound_field + # which uses get_action_url, a tag that can't resolve COT URLs). + if content_type.app_label == APP_LABEL: + form_field.widget.quick_add_context = { + 'url': reverse( + 'plugins:netbox_custom_objects:customobject_add', + kwargs={'custom_object_type': custom_object_type.slug}, + ), + 'params': {}, + } return form_field def get_filterform_field(self, field, **kwargs): @@ -911,6 +967,11 @@ def add_polymorphic_object_columns(self, field_instance, model, schema_editor): on_delete=models.SET_NULL, related_name="+", db_column=f"{field_instance.name}_content_type_id", + # No DB-level FK to django_content_type — same reasoning as the + # polymorphic-MULTIOBJECT through (avoids the AccessExclusiveLock + # branching merge needs to release + lets test teardown TRUNCATE + # django_content_type without manual CASCADE). + db_constraint=False, ) ct_field.contribute_to_class(model, ct_field_name) schema_editor.add_field(model, ct_field) @@ -994,6 +1055,14 @@ def remove_polymorphic_object_columns(self, field_instance, model, schema_editor # fields, this is the first place to check. The Django source to compare against # is django/db/models/fields/related_managers.py :: ManyRelatedManager. class CustomManyToManyManager(Manager): + """ + Many-related manager for custom M2M fields. + + m2m_changed deviation: ``post_clear`` fires with the cleared PKs instead + of ``None`` so netbox-branching's change-capture can log the removal. + Receivers that branch on ``pk_set is None`` will misread it as removes. + """ + def __init__(self, instance=None, field_name=None): super().__init__() self.instance = instance @@ -1066,20 +1135,75 @@ def get_queryset(self): # Add default ordering by pk return qs.order_by("pk") + def _fire_m2m_changed(self, action, pk_set): + """Emit ``m2m_changed`` so change-logging / branching can observe the write. + + Our direct-SQL add/remove/set/clear bypass Django's ManyRelatedManager, + which would otherwise fire this signal — without it, merge/sync replays + zero through-table rows. ``using`` reflects the alias the write + actually happened on; for ``*_clear`` callers pass ``pk_set=None`` to + match Django's contract. + """ + if pk_set is not None and not pk_set: + return + db = router.db_for_write(self.through, instance=self.instance) + m2m_changed.send( + sender=self.through, + instance=self.instance, + action=action, + reverse=False, + model=getattr(self.through._meta.get_field('target').remote_field, 'model', None), + pk_set=pk_set, + using=db, + ) + + @staticmethod + def _resolve_pk(obj): + """Accept an instance or raw PK — mirrors Django's ManyRelatedManager.""" + return obj.pk if hasattr(obj, 'pk') else obj + def add(self, *objs): - for obj in objs: - self.through.objects.get_or_create( - source_id=self.instance.pk, target_id=obj.pk + candidate_pks = {self._resolve_pk(obj) for obj in objs} + if not candidate_pks: + return + self._fire_m2m_changed('pre_add', candidate_pks) + added = set() + for pk in candidate_pks: + _, created = self.through.objects.get_or_create( + source_id=self.instance.pk, target_id=pk ) + if created: + added.add(pk) + if added: + self._fire_m2m_changed('post_add', added) def remove(self, *objs): - for obj in objs: - self.through.objects.filter( - source_id=self.instance.pk, target_id=obj.pk + candidate_pks = {self._resolve_pk(obj) for obj in objs} + if not candidate_pks: + return + self._fire_m2m_changed('pre_remove', candidate_pks) + removed = set() + for pk in candidate_pks: + n, _ = self.through.objects.filter( + source_id=self.instance.pk, target_id=pk ).delete() + if n: + removed.add(pk) + if removed: + self._fire_m2m_changed('post_remove', removed) def clear(self): + existing = set( + self.through.objects.filter(source_id=self.instance.pk) + .values_list('target_id', flat=True) + ) + if not existing: + return + # pre_clear follows Django's contract; post_clear deliberately does + # not — see the class docstring. + self._fire_m2m_changed('pre_clear', None) self.through.objects.filter(source_id=self.instance.pk).delete() + self._fire_m2m_changed('post_clear', existing) def set(self, objs, clear=False): objs = tuple(objs) # force evaluation before any mutation @@ -1087,7 +1211,7 @@ def set(self, objs, clear=False): self.clear() self.add(*objs) else: - new_pks = {obj.pk for obj in objs} + new_pks = {self._resolve_pk(obj) for obj in objs} old_pks = set( self.through.objects.filter(source_id=self.instance.pk) .values_list('target_id', flat=True) @@ -1095,15 +1219,21 @@ def set(self, objs, clear=False): # Remove relationships no longer in the target set to_remove = old_pks - new_pks if to_remove: + self._fire_m2m_changed('pre_remove', to_remove) self.through.objects.filter( source_id=self.instance.pk, target_id__in=to_remove, ).delete() + self._fire_m2m_changed('post_remove', to_remove) # Add only genuinely new relationships - for pk in new_pks - old_pks: - self.through.objects.get_or_create( - source_id=self.instance.pk, target_id=pk - ) + to_add = new_pks - old_pks + if to_add: + self._fire_m2m_changed('pre_add', to_add) + for pk in to_add: + self.through.objects.get_or_create( + source_id=self.instance.pk, target_id=pk + ) + self._fire_m2m_changed('post_add', to_add) class CustomManyToManyDescriptor(ManyToManyDescriptor): @@ -1119,10 +1249,6 @@ def __get__(self, instance, cls=None): return CustomManyToManyManager(instance=instance, field_name=self.field.name) - def get_prefetch_queryset(self, instances, queryset=None): - manager = CustomManyToManyManager(instances[0], self.field.name) - return manager.get_prefetch_queryset(instances, queryset) - def is_cached(self, instance): """ Returns True if the field's value has been cached for the given instance. @@ -1332,6 +1458,14 @@ def get_form_field(self, field, for_csv_import=False, **kwargs): ) if has_context: form_field.widget.attrs['ts-parent-field'] = '_context' + if content_type.app_label == APP_LABEL: + form_field.widget.quick_add_context = { + 'url': reverse( + 'plugins:netbox_custom_objects:customobject_add', + kwargs={'custom_object_type': custom_object_type.slug}, + ), + 'params': {}, + } return form_field def get_display_value(self, instance, field_name): @@ -1422,10 +1556,16 @@ def after_model_generation(self, instance, model, field_name): field = model._meta.get_field(field_name) + # Django's JSON serializer (handle_m2m_field) skips non-auto-created + # through models; mark ours so M2M values appear in change-log output. + # Guarded by test_serialize_object_includes_m2m_values. + through_model = field.remote_field.through + if through_model is not None: + through_model._meta.auto_created = model + # Skip model resolution for self-referential fields if getattr(field, "_is_self_referential", False): field.remote_field.model = model - through_model = field.remote_field.through # Update both source and target fields to point to the same model source_field = through_model._meta.get_field("source") @@ -1484,11 +1624,12 @@ def after_model_generation(self, instance, model, field_name): target_field.remote_field.model = to_model target_field.related_model = to_model - def create_m2m_table(self, instance, model, field_name): + def create_m2m_table(self, instance, model, field_name, schema_conn=None): """ Creates the actual M2M table after models are fully generated """ - from django.db import connection + from django.db import connection as default_connection + connection = schema_conn if schema_conn is not None else default_connection # Get the field instance field = model._meta.get_field(field_name) @@ -1514,44 +1655,31 @@ def create_m2m_table(self, instance, model, field_name): else: to_model = content_type.model_class() - # Create the through model with actual model references + # Create a fresh through for THIS CO model. Django's metaclass will + # auto-register it in apps.all_models, but the M2M field itself keeps + # a direct reference — so even if a later branch generation overwrites + # the registry entry, this field's through is unaffected. through = self.get_through_model(instance, model) - # Update the through model's foreign key references source_field = through._meta.get_field("source") target_field = through._meta.get_field("target") - - # Source field should point to the current model source_field.remote_field.model = model source_field.remote_field.field_name = model._meta.pk.name source_field.related_model = model - - # Target field should point to the related model target_field.remote_field.model = to_model target_field.remote_field.field_name = to_model._meta.pk.name target_field.related_model = to_model - # Register the model with Django's app registry - apps = model._meta.apps - - try: - through_model = apps.get_model(APP_LABEL, instance.through_model_name) - except LookupError: - apps.register_model(APP_LABEL, through) - through_model = through - - # Update the M2M field's through model and target model - field.remote_field.through = through_model + field.remote_field.through = through field.remote_field.model = to_model field.remote_field.field_name = to_model._meta.pk.name - # Create the through table with connection.schema_editor() as schema_editor: - table_name = through_model._meta.db_table + table_name = through._meta.db_table with connection.cursor() as cursor: tables = connection.introspection.table_names(cursor) if table_name not in tables: - schema_editor.create_model(through_model) + schema_editor.create_model(through) def get_polymorphic_through_model(self, field_instance, source_model_string): """ @@ -1567,10 +1695,10 @@ def get_polymorphic_through_model(self, field_instance, source_model_string): "app_label": APP_LABEL, "apps": apps, "managed": True, - "unique_together": (("source", "content_type", "object_id"),), + "unique_together": (("source", "content_type_id", "object_id"),), "indexes": [ models.Index( - fields=["content_type", "object_id"], + fields=["content_type_id", "object_id"], name=_safe_index_name( f"co_{field_instance.custom_object_type_id}" f"_{field_instance.name}_pgfk" @@ -1580,6 +1708,13 @@ def get_polymorphic_through_model(self, field_instance, source_model_string): }, ) + # content_type_id is a plain integer, not a ContentType FK. An ORM FK + # would (a) install a PG trigger needing AccessExclusiveLock on + # django_content_type during CREATE TABLE → deadlocks netbox-branching + # merge (default-conn DDL vs branch-conn changed_object_type lookups), + # and (b) make Django's collector traverse the through when an + # ObjectType is deleted, querying a table revert has already dropped. + # The descriptor tolerates orphan content_type_ids in _get_objects. attrs = { "__module__": "netbox_custom_objects.models", "Meta": meta, @@ -1590,12 +1725,7 @@ def get_polymorphic_through_model(self, field_instance, source_model_string): related_name="+", db_column="source_id", ), - "content_type": models.ForeignKey( - "contenttypes.ContentType", - on_delete=models.CASCADE, - related_name="+", - db_column="content_type_id", - ), + "content_type_id": models.PositiveBigIntegerField(db_column="content_type_id"), "object_id": models.PositiveBigIntegerField(db_column="object_id"), } @@ -1614,39 +1744,41 @@ def create_polymorphic_m2m_table(self, field_instance, model, schema_editor): source_model_string = f"{APP_LABEL}.{model.__name__}" through = self.get_polymorphic_through_model(field_instance, source_model_string) - # Update source FK to point to the actual model source_field = through._meta.get_field("source") source_field.remote_field.model = model source_field.related_model = model - # Register with Django's app registry - _apps = model._meta.apps - try: - through_model = _apps.get_model(APP_LABEL, field_instance.through_model_name) - except LookupError: - _apps.register_model(APP_LABEL, through) - through_model = through - - table_name = through_model._meta.db_table - with connection.cursor() as cursor: - existing_tables = connection.introspection.table_names(cursor) + # Probe the same schema the DDL will target. schema_editor is branch-aware + # (opened via _get_schema_connection() by the caller), whereas the module-level + # ``connection`` always points at the main schema — using it here would let the + # idempotency guard diverge from where create_model() actually writes. + conn = schema_editor.connection + table_name = through._meta.db_table + with conn.cursor() as cursor: + existing_tables = conn.introspection.table_names(cursor) if table_name not in existing_tables: - schema_editor.create_model(through_model) + schema_editor.create_model(through) def drop_polymorphic_m2m_table(self, field_instance, model, schema_editor): - """ - Drops the DB table for a polymorphic MultiObject through model. + """Drops the DB table for a polymorphic MultiObject through. - ``schema_editor`` must be supplied by the caller for the same reason as - ``remove_polymorphic_object_columns``: all DDL in a single operation - should share one schema editor context. + Looks up the through on the CO model (per-context) rather than via + apps.get_model, so we drop the table this model knows about, not + whatever happens to be registered in the global app registry. + ``schema_editor`` is shared by the caller — opening a nested one would + flush deferred SQL prematurely on PostgreSQL. """ - _apps = model._meta.apps - try: - through_model = _apps.get_model(APP_LABEL, field_instance.through_model_name) - schema_editor.delete_model(through_model) - except LookupError: - pass # Already dropped or never created + through_model = None + for tm in getattr(model, '_through_models', None) or (): + if tm._meta.db_table == field_instance.through_table_name: + through_model = tm + break + if through_model is None: + try: + through_model = model._meta.apps.get_model(APP_LABEL, field_instance.through_model_name) + except LookupError: + return # already gone + schema_editor.delete_model(through_model) class PolymorphicResultList: @@ -1701,6 +1833,11 @@ class PolymorphicManyToManyManager: Manager for polymorphic many-to-many relationships. Handles objects from multiple model types via a through table with (source_id, content_type_id, object_id) columns. + + m2m_changed deviation: ``post_clear`` fires with the cleared object PKs + instead of ``None`` so netbox-branching's change-capture can log the + removal. Receivers that branch on ``pk_set is None`` will misread it as + removes. """ def __init__(self, instance, field_name, through_model_name): @@ -1728,7 +1865,12 @@ def _get_objects(self): # Build a lookup map: (ct_id, obj_id) → object, preserving row order below. obj_map: dict[tuple, object] = {} for ct_id, obj_ids in by_ct.items(): - ct = ContentType.objects.get_for_id(ct_id) + try: + ct = ContentType.objects.get_for_id(ct_id) + except ContentType.DoesNotExist: + # Orphan row — content_type_id has no DB FK (see + # get_polymorphic_through_model). Skip. + continue model_class = ct.model_class() if model_class is None: continue @@ -1752,58 +1894,117 @@ def count(self): def exists(self): return self._get_through_model().objects.filter(source_id=self.instance.pk).exists() + def _fire_m2m_changed(self, action, pk_set): + """Emit ``m2m_changed`` so change-logging records the polymorphic write. + + Polymorphic targets span multiple model classes, so pass the through + as ``model`` (NetBox's handler only checks action + pk_set truthiness). + Postchange data comes from ``CustomObject.serialize_object``. + """ + if pk_set is not None and not pk_set: + return + through = self._get_through_model() + db = router.db_for_write(through, instance=self.instance) + m2m_changed.send( + sender=through, + instance=self.instance, + action=action, + reverse=False, + model=through, + pk_set=pk_set, + using=db, + ) + def add(self, *objs): through = self._get_through_model() + candidate_keys = [] for obj in objs: ct = ContentType.objects.get_for_model(obj) - through.objects.get_or_create( + candidate_keys.append((ct.pk, obj.pk)) + if not candidate_keys: + return + self._fire_m2m_changed('pre_add', {pk for _, pk in candidate_keys}) + added = set() + for ct_pk, obj_pk in candidate_keys: + _, created = through.objects.get_or_create( source_id=self.instance.pk, - content_type_id=ct.pk, - object_id=obj.pk, + content_type_id=ct_pk, + object_id=obj_pk, ) + if created: + added.add(obj_pk) + if added: + self._fire_m2m_changed('post_add', added) def remove(self, *objs): through = self._get_through_model() + candidate_keys = [] for obj in objs: ct = ContentType.objects.get_for_model(obj) - through.objects.filter( + candidate_keys.append((ct.pk, obj.pk)) + if not candidate_keys: + return + self._fire_m2m_changed('pre_remove', {pk for _, pk in candidate_keys}) + removed = set() + for ct_pk, obj_pk in candidate_keys: + n, _ = through.objects.filter( source_id=self.instance.pk, - content_type_id=ct.pk, - object_id=obj.pk, + content_type_id=ct_pk, + object_id=obj_pk, ).delete() + if n: + removed.add(obj_pk) + if removed: + self._fire_m2m_changed('post_remove', removed) def clear(self): - self._get_through_model().objects.filter(source_id=self.instance.pk).delete() + through = self._get_through_model() + existing = set( + through.objects.filter(source_id=self.instance.pk) + .values_list('object_id', flat=True) + ) + if not existing: + return + # pre_clear follows Django's contract; post_clear deliberately does + # not — see the class docstring. + self._fire_m2m_changed('pre_clear', None) + through.objects.filter(source_id=self.instance.pk).delete() + self._fire_m2m_changed('post_clear', existing) def set(self, objs, clear=False): if clear: self.clear() self.add(*objs) - else: - # Diff-based replacement: add new, remove old. Matches Django's - # standard ManyRelatedManager.set(clear=False) behaviour. - objs = tuple(objs) - through = self._get_through_model() - existing = { - (ct_id, obj_id) - for ct_id, obj_id in through.objects.filter(source_id=self.instance.pk) - .values_list("content_type_id", "object_id") - } - # Pre-compute (ct_id, obj_pk) once per object to avoid duplicate CT lookups. - new_items = [ - (ContentType.objects.get_for_model(obj).pk, obj.pk, obj) for obj in objs - ] - new_keys = {(ct_id, obj_pk) for ct_id, obj_pk, _ in new_items} - to_add = [obj for ct_id, obj_pk, obj in new_items if (ct_id, obj_pk) not in existing] - to_remove = existing - new_keys - if to_add: - self.add(*to_add) + return + + # Diff-based replacement: add new, remove old. Matches Django's + # standard ManyRelatedManager.set(clear=False) behaviour. + objs = tuple(objs) + through = self._get_through_model() + existing = { + (ct_id, obj_id) + for ct_id, obj_id in through.objects.filter(source_id=self.instance.pk) + .values_list("content_type_id", "object_id") + } + # Pre-compute (ct_id, obj_pk) once per object to avoid duplicate CT lookups. + new_items = [ + (ContentType.objects.get_for_model(obj).pk, obj.pk, obj) for obj in objs + ] + new_keys = {(ct_id, obj_pk) for ct_id, obj_pk, _ in new_items} + to_add = [obj for ct_id, obj_pk, obj in new_items if (ct_id, obj_pk) not in existing] + to_remove = existing - new_keys + if to_add: + self.add(*to_add) + if to_remove: + remove_pks = {obj_id for _, obj_id in to_remove} + self._fire_m2m_changed('pre_remove', remove_pks) for ct_id, obj_id in to_remove: through.objects.filter( source_id=self.instance.pk, content_type_id=ct_id, object_id=obj_id, ).delete() + self._fire_m2m_changed('post_remove', remove_pks) def __iter__(self): return iter(self.all()) @@ -1980,6 +2181,147 @@ def __get__(self, instance, owner=None): return PolymorphicMultiObjectReverseManager(instance, self.cot_pk, self.through_model_name) +class CoordinatesColumn(tables.Column): + """ + Table column for a coordinates field. Renders the latitude/longitude pair stored + in the two backing columns as ``", "`` (or a placeholder when unset). + """ + + def __init__(self, latitude_accessor, longitude_accessor, *args, **kwargs): + kwargs.setdefault("accessor", latitude_accessor) + kwargs.setdefault("orderable", True) + super().__init__(*args, **kwargs) + self.latitude_accessor = latitude_accessor + self.longitude_accessor = longitude_accessor + + def render(self, record): + latitude = getattr(record, self.latitude_accessor, None) + longitude = getattr(record, self.longitude_accessor, None) + if latitude is None or longitude is None: + return self.default + return f"{latitude}, {longitude}" + + +class CoordinatesFieldType(FieldType): + """ + A geographic coordinates field. A single field of this type expands into two real + DB columns, ``_latitude`` and ``_longitude``, mirroring NetBox's native + Site/Device coordinate handling (plain ``DecimalField``s; no PostGIS dependency). + """ + + @staticmethod + def latitude_field_name(field): + return f"{field.name}_latitude" + + @staticmethod + def longitude_field_name(field): + return f"{field.name}_longitude" + + def get_model_field(self, field, **kwargs): + return { + self.latitude_field_name(field): models.DecimalField( + verbose_name=_("latitude"), + null=True, + blank=True, + max_digits=8, + decimal_places=6, + validators=[ + MinValueValidator(Decimal("-90.0")), + MaxValueValidator(Decimal("90.0")), + ], + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)"), + ), + self.longitude_field_name(field): models.DecimalField( + verbose_name=_("longitude"), + null=True, + blank=True, + max_digits=9, + decimal_places=6, + validators=[ + MinValueValidator(Decimal("-180.0")), + MaxValueValidator(Decimal("180.0")), + ], + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)"), + ), + } + + def get_coordinate_values(self, instance, field): + """Return the (latitude, longitude) tuple stored on an instance.""" + return ( + getattr(instance, self.latitude_field_name(field), None), + getattr(instance, self.longitude_field_name(field), None), + ) + + def get_display_value(self, instance, field_name): + latitude = getattr(instance, f"{field_name}_latitude", None) + longitude = getattr(instance, f"{field_name}_longitude", None) + if latitude is None or longitude is None: + return None + return f"{latitude}, {longitude}" + + def get_form_fields(self, field): + """ + Return the two annotated form fields (latitude, longitude) keyed by their + backing column names. The keys match real model columns, so the generated + ModelForm binds and persists them natively. + """ + base_label = field.label or field.name.replace("_", " ").title() + latitude = forms.DecimalField( + label=f"{base_label} ({_('latitude')})", + required=field.required, + max_digits=8, + decimal_places=6, + min_value=Decimal("-90.0"), + max_value=Decimal("90.0"), + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)"), + ) + longitude = forms.DecimalField( + label=f"{base_label} ({_('longitude')})", + required=field.required, + max_digits=9, + decimal_places=6, + min_value=Decimal("-180.0"), + max_value=Decimal("180.0"), + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)"), + ) + if field.ui_editable != CustomFieldUIEditableChoices.YES: + latitude.disabled = True + longitude.disabled = True + return { + self.latitude_field_name(field): latitude, + self.longitude_field_name(field): longitude, + } + + def get_filterform_field(self, field, **kwargs): + base_label = field.label or field.name.replace("_", " ").title() + return { + self.latitude_field_name(field): forms.DecimalField( + label=f"{base_label} ({_('latitude')})", required=False + ), + self.longitude_field_name(field): forms.DecimalField( + label=f"{base_label} ({_('longitude')})", required=False + ), + } + + def get_table_column_field(self, field, **kwargs): + return CoordinatesColumn( + self.latitude_field_name(field), + self.longitude_field_name(field), + verbose_name=str(field), + ) + + @staticmethod + def validate_pair(latitude, longitude): + """ + Enforce that latitude and longitude are either both set or both empty. + Raises ``django.core.exceptions.ValidationError`` on a half-populated pair. + """ + if (latitude is None) != (longitude is None): + raise ValidationError( + _("Latitude and longitude must both be set or both be empty.") + ) + + FIELD_TYPE_CLASS = { CustomFieldTypeChoices.TYPE_TEXT: TextFieldType, CustomFieldTypeChoices.TYPE_LONGTEXT: LongTextFieldType, @@ -1994,4 +2336,5 @@ def __get__(self, instance, owner=None): CustomFieldTypeChoices.TYPE_MULTISELECT: MultiSelectFieldType, CustomFieldTypeChoices.TYPE_OBJECT: ObjectFieldType, CustomFieldTypeChoices.TYPE_MULTIOBJECT: MultiObjectFieldType, + CustomObjectFieldTypeChoices.TYPE_COORDINATES: CoordinatesFieldType, } diff --git a/netbox_custom_objects/filtersets.py b/netbox_custom_objects/filtersets.py index 62b90173..ba319f80 100644 --- a/netbox_custom_objects/filtersets.py +++ b/netbox_custom_objects/filtersets.py @@ -7,10 +7,13 @@ from django.apps import apps as django_apps from django.db.models import QuerySet, Q from django.utils.dateparse import parse_date, parse_datetime +from django.utils.timezone import make_aware, is_aware from extras.choices import CustomFieldTypeChoices from netbox.filtersets import NetBoxModelFilterSet +from users.models import Owner, OwnerGroup +from .choices import CustomObjectFieldTypeChoices from .constants import APP_LABEL from .models import CustomObjectType @@ -339,6 +342,18 @@ def build_filter_for_field(field) -> dict: ): return _build_polymorphic_filters(field) + # Coordinates expand into two real columns; register a numeric filter for each. + if field.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + base_label = field.label or field.name + return { + f"{field.name}_latitude": django_filters.NumberFilter( + field_name=f"{field.name}_latitude", label=f"{base_label} (latitude)" + ), + f"{field.name}_longitude": django_filters.NumberFilter( + field_name=f"{field.name}_longitude", label=f"{base_label} (longitude)" + ), + } + spec = FIELD_TYPE_FILTERS.get(field.type) if not spec: return {} @@ -434,15 +449,33 @@ def search(self, queryset, name, value): elif field.type == CustomFieldTypeChoices.TYPE_DATETIME: parsed = parse_datetime(value) if parsed is not None: + if not is_aware(parsed): + parsed = make_aware(parsed) q |= Q(**{f"{field.name}__exact": parsed}) if not q: return queryset.none() return queryset.filter(q) + def filter_owner_group_id(self, queryset, name, value): + if not value: + return queryset + return queryset.filter(owner__group__in=value) + attrs = { "Meta": meta, "__module__": "netbox_custom_objects.filtersets", "search": search, + "filter_owner_group_id": filter_owner_group_id, + "owner_id": django_filters.ModelMultipleChoiceFilter( + field_name='owner', + queryset=Owner.objects.all(), + label='Owner (ID)', + ), + "owner_group_id": django_filters.ModelMultipleChoiceFilter( + queryset=OwnerGroup.objects.all(), + method='filter_owner_group_id', + label='Owner Group (ID)', + ), } # For each custom field, add a corresponding filter (dict of name → Filter). diff --git a/netbox_custom_objects/forms.py b/netbox_custom_objects/forms.py index 0e560fae..3602f442 100644 --- a/netbox_custom_objects/forms.py +++ b/netbox_custom_objects/forms.py @@ -63,7 +63,7 @@ class CustomObjectTypeForm(NetBoxModelForm): fieldsets = ( FieldSet( "name", "verbose_name", "verbose_name_plural", "slug", - "version", "description", "group_name", "tags", + "version", "description", "group_name", "config_context_enabled", "tags", ), ) comments = CommentField() @@ -72,9 +72,20 @@ class Meta: model = CustomObjectType fields = ( "name", "verbose_name", "verbose_name_plural", "slug", "version", "description", - "group_name", "comments", "tags", + "group_name", "config_context_enabled", "comments", "tags", ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # config_context_enabled controls whether the generated model carries a + # local_context_data column, which is created once at type creation. It + # cannot be toggled afterwards, so lock it when editing an existing type. + if self.instance.pk: + self.fields["config_context_enabled"].disabled = True + self.fields["config_context_enabled"].help_text = _( + "Config context support cannot be changed after creation." + ) + class CustomObjectTypeBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( @@ -217,7 +228,7 @@ class CustomObjectTypeFieldForm(CustomFieldForm): class Meta: model = CustomObjectTypeField - fields = "__all__" + fields = '__all__' def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox_custom_objects/graphql/__init__.py b/netbox_custom_objects/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/netbox_custom_objects/graphql/live.py b/netbox_custom_objects/graphql/live.py new file mode 100644 index 00000000..6f8ccbfe --- /dev/null +++ b/netbox_custom_objects/graphql/live.py @@ -0,0 +1,383 @@ +""" +Live (restart-free) GraphQL schema for custom objects. + +NetBox builds its GraphQL schema once at startup and binds it to the GraphQL +view. Custom object types, however, are created and deleted at runtime, and the +plugin runs across multiple worker processes — so a schema built once at boot +goes stale and a signal-based rebuild in one process can't reach the others. + +This module makes the schema reflect the current database on every request, in +every process, without a restart: + +- :func:`schema_signature` computes a cheap fingerprint of the custom object + types and their fields (counts + most-recent change timestamps). The result + is memoised in NetBox's shared cache and invalidated event-driven by the + post_save/post_delete receivers in :func:`connect_signature_invalidation`, so + the steady-state cost is one cache read rather than two DB queries per request. +- :func:`get_live_schema` caches the assembled schema per process and rebuilds + it only when the signature changes. Because each process checks the signature + independently, a type created by a request handled in one process becomes + visible to every process on its next request — no restart, no cross-process + messaging. + +GraphQL resolves against whatever branch netbox-branching activated for the +request — the ``X-NetBox-Branch`` header, the ``?_branch=`` query param, or the +``active_branch`` cookie — exactly like the REST API and the rest of the UI (the +plugin imposes no GraphQL-specific branch policy of its own); with no branch active +it is main. The schema cache is keyed per branch (see :func:`_active_branch_key`) +so each branch's custom object types are reflected independently, and +:func:`get_live_schema` builds the schema for whichever branch is active so the +schema and the data the query returns always agree. + +The view patch in ``__init__.py`` calls :func:`get_live_schema` per request and +assigns the result to the view before it executes the operation. +""" + +import logging +import threading + +from django.apps import apps as django_apps + +logger = logging.getLogger("netbox_custom_objects.graphql") + +# Single-flight rebuild lock. Two roles: +# 1. Correctness (required): build_query_classes() clears and repopulates the +# *process-global* type cache in graphql.types (clear_type_cache()). Two +# rebuilds running at once — e.g. for different branches — would race on that +# cache, one wiping the other's freshly built entries mid-build. This single +# lock (global, not per-branch) serialises ALL rebuilds so that can't happen. +# 2. Performance: concurrent requests that find a stale schema serve it instead +# of blocking on the (potentially expensive) rebuild a peer is already doing. +_rebuild_lock = threading.Lock() +# Per-branch cache of (signature, schema), keyed by branch identifier (None = main). +# GraphQL reflects whichever branch netbox-branching activated for the request, so +# each branch gets its own schema reflecting that branch's custom object types. Each +# value is an atomically swapped (signature, schema) tuple, so a lockless reader can +# never see a schema that doesn't match its signature. +_schema_cache = {} +# Signature cache keys this process has populated, so reset_cache (tests) can clear +# every per-branch entry. +_signature_keys_seen = set() + + +def _active_branch_key(): + """ + Identifier for the active branch (``None`` for main), used to key the per-branch + schema and signature caches. ``None`` when netbox-branching is not installed. + """ + try: + from netbox_branching.contextvars import active_branch + except ImportError: + return None + branch = active_branch.get() + return branch.pk if branch is not None else None + + +def schema_signature(): + """ + Return a cheap, comparable fingerprint of the custom object type schema. + + Captures both custom object types and their fields so that creating, + deleting, or editing either invalidates the cached GraphQL schema: + + - count detects additions and removals, + - max timestamp detects edits (``cache_timestamp`` / ``last_updated`` are + ``auto_now`` fields). + """ + from django.db.models import Count, Max + + from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField + + cot = CustomObjectType.objects.aggregate(n=Count("id"), t=Max("cache_timestamp")) + fields = CustomObjectTypeField.objects.aggregate(n=Count("id"), t=Max("last_updated")) + return (cot["n"], cot["t"], fields["n"], fields["t"]) + + +# Sentinel signature for a best-effort first build made when the signature query +# itself failed (see get_live_schema): it compares unequal to every real +# signature, so the next request with a working DB rebuilds. +_UNKNOWN_SIGNATURE = object() + +_SIGNATURE_CACHE_KEY = "netbox_custom_objects.graphql.schema_signature" +# Backstop TTL only — invalidation is event-driven (connect_signature_invalidation). +# It bounds staleness if an invalidation is ever lost (e.g. a cache blip during a +# write) without making the steady state poll the database. +_SIGNATURE_CACHE_TIMEOUT = 300 + + +def _signature_cache_key(branch_key): + """Per-branch cache key for the schema signature (``None`` = main).""" + if branch_key is None: + return _SIGNATURE_CACHE_KEY + return f"{_SIGNATURE_CACHE_KEY}:{branch_key}" + + +def cached_schema_signature(branch_key=None): + """ + Return the active branch's schema signature, memoised in NetBox's shared cache. + + :func:`schema_signature` is two aggregate queries; running them on every + GraphQL request is a needless per-request DB tax when the schema almost never + changes. The cached value is invalidated event-driven by the receivers in + :func:`connect_signature_invalidation`, so a change in any worker is reflected + everywhere on the next request — the same freshness guarantee as polling, at + one cache read instead of two DB round-trips. The key includes the branch + identifier so a branch's signature never shadows main's, and the aggregates run + under the caller's active branch context. Falls back to a direct DB read + whenever the cache is unavailable. + """ + from django.core.cache import cache + + key = _signature_cache_key(branch_key) + _signature_keys_seen.add(key) + try: + cached = cache.get(key) + except Exception: # noqa: BLE001 - cache down: fall back to the DB + cached = None + if cached is not None: + # Cache backends may round-trip the tuple as a list; normalise so the + # equality check against the stored signature stays type-stable. + return tuple(cached) + + signature = schema_signature() + try: + cache.set(key, signature, _SIGNATURE_CACHE_TIMEOUT) + except Exception: # noqa: BLE001 - cache down: just skip memoisation + pass + return signature + + +def _invalidate_signature_cache(**kwargs): + """ + Drop the memoised schema signature for the branch the change occurred in, so the + next request for that branch recomputes it. The receiver fires inside whatever + branch context performed the save/delete, so the active branch is the one to + invalidate. + """ + from django.core.cache import cache + + try: + cache.delete(_signature_cache_key(_active_branch_key())) + except Exception: # noqa: BLE001 - cache down: the TTL backstop still bounds staleness + logger.debug("Could not invalidate GraphQL schema signature cache", exc_info=True) + + +def _evict_branch_schema(sender, instance, **kwargs): + """ + Drop a deleted branch's cached schema and signature. + + A branch's schema is cached under its pk in :data:`_schema_cache`; once the + branch is gone that entry can never be served again (a request can't reference a + deleted branch), so evict it rather than leak it for the life of the process. + """ + branch_key = instance.pk + _schema_cache.pop(branch_key, None) # atomic dict op; no lock needed + + from django.core.cache import cache + + key = _signature_cache_key(branch_key) + _signature_keys_seen.discard(key) + try: + cache.delete(key) + except Exception: # noqa: BLE001 - cache down: the TTL backstop still bounds it + logger.debug("Could not evict deleted branch's schema signature", exc_info=True) + + +def connect_signature_invalidation(): + """ + Connect the receivers that invalidate the cached schema signature. + + Called once from ``CustomObjectsPluginConfig.ready()``. Creating, deleting, + or editing any custom object type or field changes the signature; deleting the + cache key on those events keeps :func:`cached_schema_signature` correct without + polling the database per request. Deleting a branch evicts that branch's cached + schema so it can't leak. ``dispatch_uid`` makes repeat ``ready()`` calls + idempotent. + """ + from django.db.models.signals import post_delete, post_save + + from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField + + for signal, label in ((post_save, "save"), (post_delete, "delete")): + for model in (CustomObjectType, CustomObjectTypeField): + signal.connect( + _invalidate_signature_cache, + sender=model, + dispatch_uid=f"nco_graphql_sig_{label}_{model.__name__}", + weak=False, + ) + + # Evict a branch's cached schema when the branch itself is deleted. No-op when + # netbox-branching is not installed or not in INSTALLED_APPS. + if not django_apps.is_installed('netbox_branching'): + return + from netbox_branching.models import Branch + post_delete.connect( + _evict_branch_schema, + sender=Branch, + dispatch_uid="nco_graphql_evict_branch", + weak=False, + ) + + +# NetBox assembles its GraphQL schema once at import and never changes it for the +# life of the process — only our custom-object slice does. Capture NetBox's +# static contribution (its per-app/plugin Query bases and its own +# ``StrawberryConfig``) once; every rebuild then regenerates only our slice and +# reassembles. Reusing NetBox's real config — rather than a hand-copied one — +# keeps the rebuilt schema in lock-step with NetBox's own (e.g. its stored +# ``auto_camel_case`` is ``None``, not the ``False`` a copy would assume) and +# removes a source of silent drift across supported NetBox versions. +_static_query_parts = None + + +def _get_static_query_parts(): + """Return ``(query_bases, config)`` captured once from NetBox's startup schema.""" + global _static_query_parts + if _static_query_parts is None: + import netbox.graphql.schema as ngs + + # These bases never include our own contribution (our startup + # ``graphql_schema`` export is empty and we never write back to + # ``ngs.Query``); the ``_nco_query`` guard is belt-and-braces. + bases = tuple(b for b in ngs.Query.__bases__ if not getattr(b, "_nco_query", False)) + _static_query_parts = (bases, ngs.schema.config) + return _static_query_parts + + +def build_full_schema(): + """ + Assemble a complete NetBox GraphQL schema with the current custom object types. + + A ``strawberry.Schema`` is immutable once compiled, so adding/removing a root + query field requires building a new schema — but only our custom-object slice + (:func:`build_query_classes`) is rebuilt here. NetBox's Query bases and config + are captured once (:func:`_get_static_query_parts`); the result is identical to + NetBox's startup schema except for the live custom-object query. + """ + import strawberry + + import netbox.graphql.schema as ngs + + from .schema import build_query_classes + + static_bases, config = _get_static_query_parts() + bases = static_bases + tuple(build_query_classes()) + + # Every rebuild creates fresh classes with names ("Query", "TableModelType", + # …) that were used by previous rebuilds' schemas. This relies on Strawberry + # building a *per-schema* type map at strawberry.Schema(...) time — same-named + # types in different Schema objects don't collide; only duplicates *within* one + # schema do (which build_query_classes avoids by uniquifying field/type names). + # That behaviour is version-dependent and Strawberry isn't pinned here (it's + # whatever NetBox ships); it's exercised by the multi-rebuild tests + # (test_live_schema_drops_deleted_type, test_get_live_schema_rebuilds_on_new_type). + # If a future Strawberry raised on cross-schema name reuse, get_live_schema would + # log the rebuild failure and keep serving the prior schema (not silently break). + query_cls = strawberry.type(type("Query", bases, {})) + return strawberry.Schema( + query=query_cls, + config=config, + # Built fresh per rebuild on purpose — unlike ``config`` (a read-only + # settings object), get_schema_extensions() returns extension *instances* + # that strawberry mutates per request (``extension.execution_context = …``). + # Each Schema must own its own set; sharing one set across our several live + # schemas (main + per branch, plus an old schema still serving in-flight + # requests during a rebuild) would let concurrent requests on different + # schemas clobber each other's execution context. This mirrors what NetBox + # gets for its own schema, and rebuilds are rare, so the cost is irrelevant. + extensions=ngs.get_schema_extensions(), + ) + + +def get_live_schema(): + """ + Return the schema for the current request's active branch, rebuilding it if the + database has changed since this process last built it for that branch. + + netbox-branching has already scoped the active branch for the request (from the + X-NetBox-Branch header, the ``?_branch=`` query param, or the active_branch + cookie; main if none). The signature check, the rebuild, and the query's data + resolution therefore all run against that same branch. + + Returns ``None`` when dynamic models are unavailable (migrations/tests) or if + the very first build fails — the caller then falls back to NetBox's static + schema. + """ + from netbox_custom_objects import CustomObjectsPluginConfig + + if CustomObjectsPluginConfig.should_skip_dynamic_model_creation(): + return None + + branch_key = _active_branch_key() + + try: + signature = cached_schema_signature(branch_key) + except Exception: # noqa: BLE001 - DB hiccup + logger.warning("Could not compute GraphQL schema signature", exc_info=True) + cached = _schema_cache.get(branch_key) + if cached is not None and cached[1] is not None: + # We already have a (possibly slightly stale) schema for this branch. + return cached[1] + # First build for this branch and even the signature query failed. Rather + # than fall back to NetBox's static schema (which has no custom_objects_* + # fields and would reject otherwise-valid queries), make a best-effort first + # build under a sentinel signature so the next request re-checks once the DB + # recovers. + signature = _UNKNOWN_SIGNATURE + + # Hot path: structure unchanged since this process last built for this branch — + # no lock, no rebuild, just return the cached schema. + entry = _schema_cache.get(branch_key) + sig, schema = entry if entry is not None else (None, None) + if schema is not None and sig == signature: + return schema + + # Structure changed (or first build). Single-flight: one thread rebuilds while + # concurrent requests keep serving the existing schema rather than blocking on + # the (potentially expensive) rebuild. On the *first* build there is no schema + # to serve, so a loser must block until the rebuild completes — otherwise it + # would fall back to the custom-object-less static schema and spuriously reject + # custom_objects_* queries that do resolve. + blocking = schema is None + if not _rebuild_lock.acquire(blocking=blocking): + # Another thread is already rebuilding and we have a valid (one signature + # behind) schema to serve in the meantime. + return schema + + try: + # Re-check: a prior holder may have just published a matching schema for + # this branch (always true for the first-build blocker that just waited). + entry = _schema_cache.get(branch_key) + sig, schema = entry if entry is not None else (None, None) + if schema is not None and sig == signature: + return schema + try: + new_schema = build_full_schema() + except Exception: # noqa: BLE001 - never break the endpoint + logger.exception("Failed to rebuild live GraphQL schema") + return schema + _schema_cache[branch_key] = (signature, new_schema) + return new_schema + finally: + _rebuild_lock.release() + + +def reset_cache(): + """Clear the cached schemas, signatures, per-type cache, and build state (tests).""" + global _schema_cache + _schema_cache = {} + + from django.core.cache import cache + + for key in list(_signature_keys_seen): + try: + cache.delete(key) + except Exception: # noqa: BLE001 - cache down: nothing to clear + pass + _signature_keys_seen.clear() + + from .types import clear_type_cache, reset_build_state + + clear_type_cache() + reset_build_state() diff --git a/netbox_custom_objects/graphql/schema.py b/netbox_custom_objects/graphql/schema.py new file mode 100644 index 00000000..b1b627ae --- /dev/null +++ b/netbox_custom_objects/graphql/schema.py @@ -0,0 +1,186 @@ +""" +GraphQL schema contribution for the custom-objects plugin. + +NetBox discovers this module via the plugin's ``graphql_schema`` resource path +and extends its global ``Query`` with every class in the exported ``schema`` +list. :func:`build_query_classes` builds a single ``Query`` class exposing two +fields per custom object type — ```` (single object by ``id``) and +``_list`` (filtered, paginated list) — mirroring NetBox's own per-model +GraphQL fields. + +The module-level ``schema`` export is intentionally empty: the live schema in +:mod:`netbox_custom_objects.graphql.live` (installed via the view patch in +``__init__.py``) rebuilds the custom-object query per request from the current +database, so runtime-created types appear without a restart. A statically built +query here would be immediately shadowed by that live rebuild. ``live`` reuses +:func:`build_query_classes`, so both paths share one implementation. +""" + +import logging +from typing import List + +import strawberry +import strawberry_django + +from .types import build_object_type, clear_type_cache, graphql_safe_name, reset_build_state, set_cot_map + +logger = logging.getLogger("netbox_custom_objects.graphql") + +# All custom-object root query fields are namespaced with this prefix so they +# cannot collide with NetBox's own (or another plugin's) root query fields — every +# plugin's query class is mixed into the single global ``Query`` type, so bare, +# slug-derived names like ``site``/``group`` would otherwise be shadowed by core. +# The prefix mirrors the ``custom_objects_`` table-naming convention. +QUERY_FIELD_PREFIX = "custom_objects_" + + +def _query_field_name(custom_object_type, used_names): + """ + Derive a GraphQL-safe, unique field name from a custom object type's slug. + + The name is namespaced with :data:`QUERY_FIELD_PREFIX` (see above) so it never + collides with core/plugin root query fields. GraphQL names must match + ``[_A-Za-z][_0-9A-Za-z]*``; slugs may contain hyphens. Collisions among custom + object types (after sanitisation) are disambiguated with the type id. + """ + slug = graphql_safe_name((custom_object_type.slug or "").lower()) + # The prefix guarantees a valid leading character, so no digit/empty guard is + # needed on the slug portion. + base = f"{QUERY_FIELD_PREFIX}{slug}" + + def _taken(candidate): + # Reserve the singular field name *and* its ``_list`` companion together. + # Checking both prevents one type's list field from silently colliding + # with another type's singular field — e.g. slug 'foo' yields foo/foo_list + # while slug 'foo-list' sanitises to foo_list/foo_list_list, and the bare + # 'foo_list' would otherwise clobber the first type's list field. + return candidate in used_names or f"{candidate}_list" in used_names + + name = base + if _taken(name): + # Disambiguate with the (unique) type id. The disambiguated name can + # itself collide with one already reserved — another type whose slug + # happens to end in this id — so keep extending until both the singular + # name and its ``_list`` companion are genuinely free. Without this loop + # the colliding field would silently overwrite the earlier type's field. + name = f"{base}_{custom_object_type.id}" + counter = 2 + while _taken(name): + name = f"{base}_{custom_object_type.id}_{counter}" + counter += 1 + used_names.add(name) + used_names.add(f"{name}_list") + return name + + +def build_query_classes(): + """ + Build the list of Strawberry query classes contributed to NetBox's schema. + + Returns an empty list when dynamic models are unavailable (during + migrations, tests, or before migrations have been applied) or when no custom + object types are defined — extending NetBox's Query with an empty/invalid + class is avoided. + + Called on every live rebuild (:mod:`netbox_custom_objects.graphql.live`); the + module-level ``schema`` export below deliberately does not call it (see the + module docstring). The returned class carries a ``_nco_query`` marker so live + rebuilds can identify and replace a previously contributed instance. + """ + # Import lazily to avoid import-time side effects and circular imports. + # The skip-check must run *before* importing models: during migrations and + # the test run the app registry may not be ready, and importing models then + # raises "model isn't in an application in INSTALLED_APPS". + from netbox_custom_objects import CustomObjectsPluginConfig + + if CustomObjectsPluginConfig.should_skip_dynamic_model_creation(): + return [] + + from django.db.models import Prefetch + + from netbox_custom_objects.models import CustomObjectType, CustomObjectTypeField + + # Start each rebuild from an empty per-type cache. The cache is keyed by + # (cot id, cache_timestamp), but a type that embeds another COT's type via a + # relationship field does NOT get its own cache_timestamp bumped when the + # referenced COT changes — so a cached entry could embed a stale child type. + # Clearing here makes every type fresh per rebuild (rebuilds only happen on an + # actual structural change), while the cache still memoizes within this single + # rebuild pass so shared and recursive references reuse one built type. + # NOTE: this clear+repopulate of the process-global cache is why rebuilds must + # be serialised — concurrent rebuilds (e.g. different branches) would race here. + # live.get_live_schema holds _rebuild_lock around the whole rebuild to enforce it. + clear_type_cache() + # Also drop any in-progress build state (stack / cycle taint) leaked by an + # exception during a previous rebuild on this pooled thread, so it can't + # suppress caching or corrupt cycle detection on this rebuild. + reset_build_state() + + try: + # Preload every type's fields once, with each field's related type(s) + # joined/prefetched, so the per-type build issues no per-field queries: + # without this the inner loop hits the DB for each type's fields and again + # for each field's related object type(s) — O(N + N·M) queries per rebuild. + fields_qs = ( + CustomObjectTypeField.objects + .select_related("related_object_type") + .prefetch_related("related_object_types") + ) + custom_object_types = list( + CustomObjectType.objects.prefetch_related(Prefetch("fields", queryset=fields_qs)) + ) + except Exception: # noqa: BLE001 - DB may be unavailable at import time + logger.debug("Could not load custom object types for GraphQL schema", exc_info=True) + return [] + + # Register the preloaded types by pk so a relationship field pointing at another + # custom object resolves its target from the prefetched instance instead of + # re-querying it (build_object_type then reuses the per-rebuild type cache). + set_cot_map({cot.pk: cot for cot in custom_object_types}) + + annotations = {} + attrs = {} + used_names = set() + + for cot in custom_object_types: + try: + gql_type = build_object_type(cot) + except Exception: # noqa: BLE001 - never break the whole schema for one type + logger.warning( + "Failed to build GraphQL type for custom object type %r (id=%s); skipping", + cot.name, + cot.id, + exc_info=True, + ) + continue + if gql_type is None: + continue + + field_name = _query_field_name(cot, used_names) + list_name = f"{field_name}_list" + + annotations[field_name] = gql_type + attrs[field_name] = strawberry_django.field() + annotations[list_name] = List[gql_type] + attrs[list_name] = strawberry_django.field() + + if not attrs: + return [] + + attrs["__annotations__"] = annotations + # The GraphQL type name must be "Query": strawberry-django only attaches the + # single-object ``id`` lookup argument to fields whose origin type is named + # "Query" (see strawberry_django.filters: ``is_root_query``). NetBox names + # every per-app query class "Query" for the same reason; our contributed class + # is mixed into NetBox's real Query as a base, so it must do likewise. + query_cls = strawberry.type(type("CustomObjectsQuery", (), attrs), name="Query") + # Marker so live schema rebuilds can find and replace a stale instance of + # this class among NetBox's Query bases. + query_cls._nco_query = True + return [query_cls] + + +# Empty by design — the live schema (graphql/live.py) owns per-request assembly so +# runtime-created types appear without a restart. Must remain a list: NetBox calls +# ``.extend`` on it when registering plugin GraphQL schemas. +schema = [] diff --git a/netbox_custom_objects/graphql/types.py b/netbox_custom_objects/graphql/types.py new file mode 100644 index 00000000..33e89cd0 --- /dev/null +++ b/netbox_custom_objects/graphql/types.py @@ -0,0 +1,565 @@ +""" +Dynamic GraphQL type generation for custom object models. + +Each ``CustomObjectType`` is backed by a real, runtime-generated Django model +(``TableModel``). This module builds a Strawberry GraphQL type for each of +those models so that custom objects can be queried through NetBox's GraphQL API +exactly like first-class NetBox models. + +Because the underlying models are generated at runtime, the GraphQL types are +also generated at runtime. They are rebuilt per-request by +:mod:`netbox_custom_objects.graphql.live` (installed via the view patch in +``__init__.py``) whenever the set of custom object types or their fields changes, +so a type created *after* startup appears without a NetBox restart. + +Scalar fields are mapped to their natural GraphQL scalar types (the mapping +lives on each ``FieldType`` in ``field_types.py``). Object and multi-object +(relationship) fields resolve to the *native* NetBox GraphQL type of their +target — a field pointing at a Site resolves to NetBox's ``SiteType`` and is +fully traversable. Polymorphic relationship fields, which may point at several +model types, resolve to a Strawberry union of those native types (mirroring how +NetBox exposes ``assigned_object``/cable terminations). When a target model has +no registered GraphQL type, the field falls back to a lightweight, uniform +``CustomObjectRelatedObjectType`` stub so the field is never silently dropped. +""" + +import logging +import re +import threading +from typing import Annotated, List, Optional, Union + +import strawberry +import strawberry_django +from core.graphql.mixins import ChangelogMixin +from extras.choices import CustomFieldTypeChoices +from extras.graphql.mixins import TagsMixin +from netbox.graphql.scalars import BigInt +from netbox.graphql.types import BaseObjectType +from strawberry.types import Info + +from netbox_custom_objects.constants import APP_LABEL +from netbox_custom_objects.utilities import extract_cot_id_from_model_name + +logger = logging.getLogger("netbox_custom_objects.graphql") + +__all__ = ( + "CustomObjectObjectType", + "CustomObjectRelatedObjectType", + "build_object_type", + "clear_type_cache", + "graphql_safe_name", + "reset_build_state", + "set_cot_map", +) + +# Per-rebuild memoization of built GraphQL types, keyed by (cot id, +# cache_timestamp). It lets a single schema rebuild reuse one built type across +# the many places that reference it (shared targets and recursive relationships) +# instead of re-running build_object_type for each. It is cleared at the start of +# every rebuild (see schema.build_query_classes): a type that embeds another COT's +# type does not get its own cache_timestamp bumped when that referenced COT +# changes, so persisting entries across rebuilds could serve a stale embedded +# type. clear_type_cache() also lets tests reset it explicitly. +_type_cache = {} +_type_cache_lock = threading.RLock() + +# Per-thread build state. ``cot_stack`` is the stack of COT ids whose GraphQL +# type is being built on the current thread, so that a relationship between two +# custom objects (A -> B -> A) does not recurse forever: a back-reference to a +# type still under construction falls back to the flat stub instead of rebuilding +# it. ``cycle_tainted`` records the COT ids whose build had to use that stub +# fallback for a cyclic edge — those types are intentionally not cached (see +# build_object_type) so the next top-level query rebuilds them and resolves the +# related type fully from that entry point. +_building = threading.local() + +# Lazily-built map of Django model class -> its registered NetBox strawberry +# GraphQL type. The app-defined types are static for the life of the process, so +# this is computed once. +_model_type_registry = None +_registry_lock = threading.RLock() + + +def clear_type_cache(): + """Drop all cached GraphQL types (used by tests).""" + with _type_cache_lock: + _type_cache.clear() + + +def reset_build_state(): + """ + Clear this thread's in-progress build stack and cycle-taint set. + + Called at the start of each schema rebuild (and by tests) so that a stack + frame or taint leaked by an exception during a previous rebuild on this + (pooled) thread cannot suppress caching or corrupt cycle detection on the + next one. ``build_object_type`` only clears a type's taint on the success + path, so a build that raises after a cyclic edge tainted an ancestor would + otherwise leave that taint set on the thread indefinitely. Also drops the + preloaded COT map (see :func:`set_cot_map`). + """ + _building.cot_stack = [] + _building.cycle_tainted = set() + _building.cot_map = None + + +def set_cot_map(cot_map): + """ + Register a ``{pk: CustomObjectType}`` map for the current rebuild. + + ``build_query_classes`` preloads every custom object type once with its fields + (and their related types) prefetched, then registers them here so a relationship + field pointing at another custom object resolves its target from the prefetched + instance — via :func:`_custom_object_graphql_type` — instead of issuing a fresh + query per reference. Cleared by :func:`reset_build_state`. + """ + _building.cot_map = cot_map + + +RELATIONSHIP_TYPES = ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, +) + + +@strawberry.type +class CustomObjectRelatedObjectType: + """ + Fallback representation of an object referenced by a relationship field whose + target model has no registered NetBox GraphQL type. + + Most relationship targets resolve to their native GraphQL type and are fully + traversable; this uniform stub is only used when no such type exists, so the + field still exposes the basics rather than disappearing from the schema. + """ + + # BigInt (not int/Int): NetBox primary keys are BigAutoField and can exceed + # the signed 32-bit range of GraphQL's Int. Native relationship types already + # expose their id as BigInt; the fallback stub must match. + id: BigInt + object_type: str + display: str + url: Optional[str] + + +@strawberry.type +class CustomObjectObjectType(ChangelogMixin, TagsMixin, BaseObjectType): + """ + Base GraphQL type for all custom object models. + + ``BaseObjectType`` provides ``display``/``class_type`` and, crucially, + ``get_queryset()`` which enforces NetBox object-level view permissions. + ``ChangelogMixin`` and ``TagsMixin`` add change-log and tag access — both are + supported by the ``CustomObject`` base model. Custom fields are added per + type by :func:`build_object_type`. + """ + + pass + + +def graphql_safe_name(value): + """Replace any character not valid in a GraphQL name with an underscore.""" + return re.sub(r"[^0-9a-zA-Z_]", "_", value or "") + + +def _in_progress_stack(): + stack = getattr(_building, "cot_stack", None) + if stack is None: + stack = [] + _building.cot_stack = stack + return stack + + +def _cycle_tainted_set(): + tainted = getattr(_building, "cycle_tainted", None) + if tainted is None: + tainted = set() + _building.cycle_tainted = tainted + return tainted + + +def _request_user(info): + """Best-effort extraction of the requesting user from the GraphQL info context.""" + request = getattr(getattr(info, "context", None), "request", None) + return getattr(request, "user", None) + + +def _filter_viewable(user, objects): + """ + Return the subset of ``objects`` the user may view, preserving order. + + The top-level query restricts the custom objects themselves, but the objects + reached through their relationship fields are *not* covered by that check, so + each one must be gated here or the field would leak objects the user cannot + see. Used for both single- and multi-object fields so the permission rule + lives in one place; batches the check to one query per distinct model rather + than one ``.exists()`` per object (an N+1 explosion on multi-object fields): + the related objects are grouped by model and each model's permission-restricted + queryset is evaluated once with ``pk__in``. + + The user (anonymous or ``None`` included) is passed straight to the model + manager's ``restrict(user, "view")``, mirroring NetBox's + ``BaseObjectType.get_queryset`` — superuser bypass, ``EXEMPT_VIEW_PERMISSIONS`` + and anonymous handling are all left to ``restrict``. + """ + objects = [obj for obj in objects if obj is not None] + if not objects: + return [] + if getattr(user, "is_superuser", False): + return objects + + # sentinel meaning "model isn't permission-aware → all allowed". + allowed_by_model = {} + by_model = {} + for obj in objects: + by_model.setdefault(type(obj), []).append(obj) + for model, model_objs in by_model.items(): + manager = getattr(model, "_default_manager", None) + if manager is None or not hasattr(manager, "restrict"): + allowed_by_model[model] = None + continue + pks = [obj.pk for obj in model_objs] + allowed_by_model[model] = set( + manager.restrict(user, "view").filter(pk__in=pks).values_list("pk", flat=True) + ) + + return [ + obj + for obj in objects + if (allowed_by_model[type(obj)] is None or obj.pk in allowed_by_model[type(obj)]) + ] + + +def _related_repr(obj): + """Convert a referenced model instance into a ``CustomObjectRelatedObjectType``.""" + if obj is None: + return None + try: + url = obj.get_absolute_url() + except Exception: # noqa: BLE001 - URL resolution is best-effort + url = None + try: + display = str(obj) + except Exception: # noqa: BLE001 - a broken __str__ must not fail the whole field + # Degrade to a stable identifier so one unrenderable object becomes a + # placeholder rather than erroring the entire (possibly multi-object) field; + # pk and _meta are safe even when __str__ raises. + display = f"{obj._meta.label_lower}:{obj.pk}" + return CustomObjectRelatedObjectType( + id=obj.pk, + object_type=obj._meta.label_lower, + display=display, + url=url, + ) + + +def _build_model_type_registry(): + """ + Map every Django model to its registered NetBox strawberry GraphQL type. + + NetBox (and other plugins) declare their per-model types in + ``.graphql.types``. Each such type carries a strawberry-django + definition naming its model, so importing those modules and indexing by model + gives a model -> type lookup that relationship fields use to resolve their + target to a native, traversable type. + """ + from importlib import import_module + + from django.apps import apps as django_apps + from strawberry_django.utils.typing import get_django_definition + + registry = {} + for app_config in django_apps.get_app_configs(): + if app_config.label == APP_LABEL: + # Custom-object types are resolved dynamically, not from a module. + continue + module_name = f"{app_config.name}.graphql.types" + try: + module = import_module(module_name) + except ModuleNotFoundError: + continue + except Exception: # noqa: BLE001 - a broken app module must not break the schema + logger.debug("Could not import %s for the GraphQL type registry", module_name, exc_info=True) + continue + for value in vars(module).values(): + if not isinstance(value, type): + continue + definition = get_django_definition(value) + if definition is None or definition.model is None: + continue + # Only index GraphQL *output* object types. Filters and inputs also + # carry a django definition (and a model), but using one as a field's + # type would break the schema. + sb_definition = getattr(value, "__strawberry_definition__", None) + if sb_definition is None or sb_definition.is_input or sb_definition.is_interface: + continue + registry.setdefault(definition.model, value) + return registry + + +def _get_model_type_registry(): + global _model_type_registry + if _model_type_registry is not None: + return _model_type_registry + with _registry_lock: + if _model_type_registry is None: + _model_type_registry = _build_model_type_registry() + return _model_type_registry + + +def _custom_object_graphql_type(model_name): + """Resolve a custom-object target (``tablemodel``) to its GraphQL type.""" + from netbox_custom_objects.models import CustomObjectType + + cot_id = extract_cot_id_from_model_name(model_name) + if cot_id is None: + return None + # extract_cot_id_from_model_name returns the id as a str; the in-progress + # stack holds ints, so coerce before the membership test or it never matches. + cot_id = int(cot_id) + stack = _in_progress_stack() + if cot_id in stack: + # Back-reference to a type still being built — fall back to the flat stub + # for this edge to avoid infinite recursion, and taint every type currently + # under construction so none of them is cached with this temporary stub + # frozen in (see build_object_type). Tainting only the immediate parent + # (stack[-1]) would still cache the outer types of a cycle longer than two + # (A -> B -> C -> A), permanently freezing the stub into their subtree. + _cycle_tainted_set().update(stack) + return None + # Resolve from the rebuild's preloaded (prefetched) map when available, so a + # cross-COT reference doesn't issue a query per edge; fall back to a lookup for + # direct callers (e.g. tests) that didn't register a map. + cot_map = getattr(_building, "cot_map", None) + cot = cot_map.get(cot_id) if cot_map else None + if cot is None: + cot = CustomObjectType.objects.filter(pk=cot_id).first() + if cot is None: + return None + try: + return build_object_type(cot) + except Exception: # noqa: BLE001 + logger.warning( + "Failed to build related custom-object GraphQL type for %r", model_name, exc_info=True + ) + return None + + +def _graphql_type_for_content_type(content_type): + """Return the native strawberry GraphQL type for ``content_type``, or ``None``.""" + model = content_type.model_class() + if model is None: + return None + if content_type.app_label == APP_LABEL: + return _custom_object_graphql_type(content_type.model) + return _get_model_type_registry().get(model) + + +def _field_target_content_types(field): + """Return the ContentType(s) a relationship field may point at.""" + if field.is_polymorphic: + return list(field.related_object_types.all()) + if field.related_object_type_id: + return [field.related_object_type] + return [] + + +def _resolve_relationship_members(field): + """ + Return ``(members, native_models)`` for a relationship field. + + ``members`` is the list of GraphQL types the field can resolve to — the + native type of each target that has one, plus the flat stub when at least one + target has no native type (or there are no targets at all). ``native_models`` + is the set of Django model classes that resolve to a native type, used at + resolve time to decide whether to return the raw instance or wrap it. + """ + members = [] + native_models = set() + needs_stub = False + for content_type in _field_target_content_types(field): + gql_type = _graphql_type_for_content_type(content_type) + model = content_type.model_class() + if gql_type is not None and model is not None: + if gql_type not in members: + members.append(gql_type) + native_models.add(model) + else: + needs_stub = True + if needs_stub or not members: + if CustomObjectRelatedObjectType not in members: + members.append(CustomObjectRelatedObjectType) + return members, native_models + + +def _relationship_union_name(field): + """A schema-unique, GraphQL-safe name for a polymorphic field's union type.""" + base = graphql_safe_name(field.name) + return f"CustomObject{field.custom_object_type_id}_{base}_Related" + + +def _relationship_annotation(members, is_list, union_name): + """Build the resolver return annotation from the resolved member types.""" + if len(members) == 1: + inner = members[0] + else: + inner = Annotated[Union[tuple(members)], strawberry.union(union_name)] + return List[inner] if is_list else Optional[inner] + + +def _coerce_related(obj, native_models): + """Return ``obj`` itself if its model resolves natively, else the flat stub.""" + if type(obj) in native_models or any(isinstance(obj, model) for model in native_models): + return obj + return _related_repr(obj) + + +def _make_relationship_resolver(field): + """ + Build a resolver for an OBJECT or MULTIOBJECT relationship field. + + The resolver returns the referenced object(s) as their native GraphQL + type(s) (or the flat stub for targets without one), filtered to those the + requesting user may view. + """ + field_name = field.name + is_list = field.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT + + members, native_models = _resolve_relationship_members(field) + if not members: + return None + annotation = _relationship_annotation(members, is_list, _relationship_union_name(field)) + + # Query-optimisation hints read by NetBox's DjangoOptimizerExtension. A + # non-polymorphic OBJECT field is a ForeignKey (select_related); a polymorphic + # one is a GenericForeignKey (prefetch_related only). A non-polymorphic + # MULTIOBJECT field is a real M2M (prefetch_related); a polymorphic one is a + # custom descriptor that can't be prefetched. + if is_list: + hint = {} if field.is_polymorphic else {"prefetch_related": field_name} + description = f"Related objects referenced by '{field_name}'" + else: + hint = {"prefetch_related": field_name} if field.is_polymorphic else {"select_related": field_name} + description = f"Related object referenced by '{field_name}'" + + def resolver(self, info: Info): + user = _request_user(info) + value = getattr(self, field_name, None) + if is_list: + if value is None: + return [] + try: + related = list(value.all()) + except Exception: # noqa: BLE001 - never let one field break the query + logger.warning( + "Failed to resolve multi-object GraphQL field %r", field_name, exc_info=True + ) + return [] + return [ + _coerce_related(obj, native_models) + for obj in _filter_viewable(user, related) + ] + if value is None: + return None + viewable = _filter_viewable(user, [value]) + if not viewable: + return None + return _coerce_related(viewable[0], native_models) + + resolver.__annotations__ = {"info": Info, "return": annotation} + return strawberry_django.field(description=description, **hint)(resolver) + + +def _scalar_annotation_for(field_type): + """Return the GraphQL scalar annotation for a field type, or ``None``.""" + from netbox_custom_objects.field_types import FIELD_TYPE_CLASS + + field_type_cls = FIELD_TYPE_CLASS.get(field_type) + if field_type_cls is None: + return None + return field_type_cls().get_graphql_annotation() + + +def build_object_type(custom_object_type): + """ + Build (or return ``None`` on failure) a Strawberry type for a single + ``CustomObjectType``. + + The returned class is a ``strawberry_django.type`` bound to the runtime + model, with one GraphQL field per custom field plus the inherited base + fields (id, display, tags, changelog, created, last_updated). + """ + model = custom_object_type.get_model() + if model is None: + return None + + # Reuse the already-built type when this COT's structure is unchanged (see + # _type_cache). cache_timestamp moves on every structural change, so this can + # never serve a type that no longer matches the model. + cache_key = (custom_object_type.id, custom_object_type.cache_timestamp) + with _type_cache_lock: + cached = _type_cache.get(cache_key) + if cached is not None: + return cached + + stack = _in_progress_stack() + stack.append(custom_object_type.id) + try: + gql_type = _build_object_type(custom_object_type, model) + finally: + stack.pop() + + # A type whose build had to break a relationship cycle with the flat stub (a + # related custom object was still under construction) must not be cached: the + # stub edge is an artefact of *this* build order, and caching it would freeze + # that degraded edge forever. Leaving it uncached lets a later top-level + # query rebuild it and resolve the related type fully from that entry point. + tainted = _cycle_tainted_set() + if custom_object_type.id in tainted: + tainted.discard(custom_object_type.id) + return gql_type + + with _type_cache_lock: + _type_cache[cache_key] = gql_type + # Bound growth: drop now-superseded entries for this COT. + for key in [k for k in _type_cache if k[0] == cache_key[0] and k != cache_key]: + del _type_cache[key] + return gql_type + + +def _build_object_type(custom_object_type, model): + """Construct a fresh Strawberry type for ``custom_object_type`` (uncached).""" + type_name = f"{model.__name__}Type" + + namespace = { + "__doc__": f"Custom object type '{custom_object_type.name}'.", + "__annotations__": {}, + } + + for field in custom_object_type.fields.all(): + field_name = field.name + if field.type in RELATIONSHIP_TYPES: + resolver = _make_relationship_resolver(field) + if resolver is not None: + namespace[field_name] = resolver + continue + + annotation = _scalar_annotation_for(field.type) + if annotation is None: + logger.debug( + "Skipping custom field %r of unsupported GraphQL type %r", + field_name, + field.type, + ) + continue + # Every custom field is nullable at the database level. + namespace["__annotations__"][field_name] = Optional[annotation] + + cls = type(type_name, (CustomObjectObjectType,), namespace) + + return strawberry_django.type( + model, + name=type_name, + fields=["id", "created", "last_updated"], + pagination=True, + )(cls) diff --git a/netbox_custom_objects/migrations/0015_customobjecttype_config_context_enabled.py b/netbox_custom_objects/migrations/0015_customobjecttype_config_context_enabled.py new file mode 100644 index 00000000..e3a3b15b --- /dev/null +++ b/netbox_custom_objects/migrations/0015_customobjecttype_config_context_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-30 22:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0014_fix_mixed_case_field_names"), + ] + + operations = [ + migrations.AddField( + model_name="customobjecttype", + name="config_context_enabled", + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox_custom_objects/migrations/0016_widen_integer_columns.py b/netbox_custom_objects/migrations/0016_widen_integer_columns.py new file mode 100644 index 00000000..9e208eb5 --- /dev/null +++ b/netbox_custom_objects/migrations/0016_widen_integer_columns.py @@ -0,0 +1,67 @@ +""" +Widen existing integer custom-object columns from 32-bit to 64-bit (bigint). + +Integer fields previously mapped to Django's IntegerField, i.e. a 32-bit signed +PostgreSQL ``integer`` column (max 2_147_483_647). They now map to BigIntegerField +(``bigint``). New columns are created as bigint automatically by the schema editor, +but columns on already-created custom_objects_* tables must be widened in place. + +``integer -> bigint`` is a lossless widening (every int32 value fits in int64), so +no USING clause or data transformation is needed. The conversion rewrites the table +under an ACCESS EXCLUSIVE lock; this is fine for typical custom-object table sizes. + +The reverse is intentionally a no-op: narrowing bigint back to integer could fail or +lose data for any value outside the 32-bit range. + +See issue #532. +""" + +from django.db import migrations + + +def widen_integer_columns(apps, schema_editor): + """ALTER every integer-typed custom-object column to bigint, in place.""" + CustomObjectTypeField = apps.get_model("netbox_custom_objects", "CustomObjectTypeField") + + # Drive off field metadata (not blind introspection of every custom_objects_* + # column) so we only touch user integer fields, never base-model columns + # inherited from NetBox mixins. After migration 0014 all field names are + # lowercase and the scalar column name equals the field name exactly. + integer_fields = CustomObjectTypeField.objects.filter(type="integer") + + with schema_editor.connection.cursor() as cursor: + for field in integer_fields: + table_name = f"custom_objects_{field.custom_object_type_id}" + column_name = field.name + + # Idempotent + safe: only act on a column that exists and is still a + # 32-bit integer. A no-op on fresh installs (already bigint) and on + # partial re-runs. + cursor.execute( + """ + SELECT 1 FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = %s + AND column_name = %s + AND data_type = 'integer' + """, + [table_name, column_name], + ) + if cursor.fetchone(): + # quote_name() both identifiers (consistent with the %s-parameterised + # check above) so a field name containing a quote can't break out. + cursor.execute( + f"ALTER TABLE {schema_editor.quote_name(table_name)} " + f"ALTER COLUMN {schema_editor.quote_name(column_name)} TYPE bigint" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("netbox_custom_objects", "0015_customobjecttype_config_context_enabled"), + ] + + operations = [ + migrations.RunPython(widen_integer_columns, migrations.RunPython.noop), + ] diff --git a/netbox_custom_objects/mixin_migration.py b/netbox_custom_objects/mixin_migration.py index 14e99a8f..fe46d336 100644 --- a/netbox_custom_objects/mixin_migration.py +++ b/netbox_custom_objects/mixin_migration.py @@ -157,6 +157,9 @@ def heal_cot(cot, verbosity=1, dry_run=False): try: with connection.schema_editor() as editor: + # Flush pending DEFERRABLE FK trigger events before ALTER TABLE; + # PostgreSQL rejects ADD COLUMN when deferred triggers are pending. + editor.execute('SET CONSTRAINTS ALL IMMEDIATE') editor.add_field(model, field) added.append(col_name) if verbosity >= 1: diff --git a/netbox_custom_objects/models.py b/netbox_custom_objects/models.py index a31bc4f8..847f14bf 100644 --- a/netbox_custom_objects/models.py +++ b/netbox_custom_objects/models.py @@ -1,3 +1,4 @@ +import contextvars import decimal import logging import re @@ -7,6 +8,7 @@ from packaging.version import Version, InvalidVersion import django_filters +from core.choices import ObjectChangeActionChoices from core.models import ObjectType, ObjectChange from core.models.object_types import ObjectTypeManager from django.apps import apps @@ -16,9 +18,10 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist from django.core.validators import RegexValidator, ValidationError -from django.db import connection, IntegrityError, models, transaction +from django.db import DEFAULT_DB_ALIAS, connection, connections, IntegrityError, models, transaction from django.db.utils import OperationalError, ProgrammingError from django.db.models import Q +from django.db.models.fields.related import ForeignKey, ManyToManyField from django.db.models.functions import Lower from django.db.models.signals import m2m_changed, pre_delete, post_save from django.dispatch import receiver @@ -31,15 +34,17 @@ CustomFieldUIEditableChoices, CustomFieldUIVisibleChoices, ) -from extras.models import CustomField +from extras.models import ConfigContext, ConfigContextModel, CustomField from extras.models.customfields import SEARCH_TYPES -from extras.utils import run_validators +from extras.utils import is_taggable, run_validators from netbox.config import get_config from netbox.models import ChangeLoggedModel, NetBoxModel +from netbox.models.mixins import OwnerMixin from netbox.models.features import ( BookmarksMixin, ChangeLoggingMixin, CloningMixin, + ContactsMixin, CustomLinksMixin, CustomValidationMixin, EventRulesMixin, @@ -53,21 +58,31 @@ from netbox.registry import registry from netbox.search import SearchIndex from utilities import filters -from utilities.data import get_config_value_ci +from utilities.data import deepmerge, get_config_value_ci from utilities.datetime import datetime_from_timestamp from utilities.object_types import object_type_name from utilities.querysets import RestrictedQuerySet +from utilities.serialization import deserialize_object as _deserialize_object from utilities.string import title from utilities.validators import validate_regex -from netbox_custom_objects.choices import ObjectFieldOnDeleteChoices -from netbox_custom_objects.constants import APP_LABEL, RESERVED_FIELD_NAMES +from netbox_custom_objects.choices import (CustomObjectFieldTypeChoices, + ObjectFieldOnDeleteChoices) +from netbox_custom_objects.constants import ( + APP_LABEL, + CONFIG_CONTEXT_DIMENSION_FIELDS, + RESERVED_FIELD_NAMES, +) from netbox_custom_objects.field_types import ( FIELD_TYPE_CLASS, LazyForeignKey, safe_table_name, PolymorphicObjectReverseDescriptor, PolymorphicMultiObjectReverseDescriptor, ) from netbox_custom_objects.jobs import ReindexCustomObjectTypeJob -from netbox_custom_objects.utilities import _suppress_clear_cache, extract_cot_id_from_model_name, generate_model +from netbox_custom_objects.utilities import ( + _suppress_clear_cache, + extract_cot_id_from_model_name, + generate_model, +) logger = logging.getLogger(__name__) @@ -78,18 +93,502 @@ class UniquenessConstraintTestError(Exception): pass -def _table_exists(table_name): - """Return True if *table_name* exists in the current database.""" - return table_name in connection.introspection.table_names() +def _table_exists(table_name, conn=None): + """Return True if *table_name* exists in the database reachable via *conn*. + + Defaults to the global ``connection`` (main schema). When the caller is + operating inside a branch context, pass the branch's connection so the + lookup runs against the active branch's PostgreSQL schema. + """ + if conn is None: + conn = connection + return table_name in conn.introspection.table_names() USER_TABLE_DATABASE_NAME_PREFIX = "custom_objects_" +# Per-context storage for CO field values deferred during squash merge. +# Using ContextVar instead of a class-level dict so that concurrent merges +# (different threads or coroutines) each get an isolated copy. +# Shape: {db_table: {co_pk: {'data': {field_name: value}}}} +_deferred_co_field_data: contextvars.ContextVar[dict | None] = contextvars.ContextVar( + '_deferred_co_field_data', default=None +) + +# Sidecar key listing polymorphic-M2M fields in a serialized CO dict, as +# ``[{'name': ..., 'pk': ...}, ...]``. ``pk`` lets the squash dependency +# resolver in ``branching.py`` produce a CO → field CREATE edge without +# looking the field up in main (it isn't there yet during a branch-only +# merge). ``name`` lets ``deserialize_object`` map rows to through tables. +POLY_M2M_SIDECAR_KEY = '__nco_poly_m2m_fields__' + +# Serializes the save/restore of TM.post_through_setup in get_model(). The +# patch needs to be in place during type() class creation, so the lock has to +# span the whole generate_model() — that serialises unrelated COT generations. +# Acceptable for now: the bottleneck is bounded to startup and squash merges. +_taggable_manager_patch_lock = threading.Lock() + + +def _apply_poly_m2m_rows(schema_conn, through_table, co_pk, rows): + """Insert polymorphic M2M *rows* into *through_table* on *schema_conn*, + set-style (clear existing for *co_pk* first). ContentType resolved by + natural key, also via *schema_conn*. + + *through_table* originates from ``CustomObjectTypeField.through_table_name``, + which is derived from validated identifiers (the COT id and a field name + matching ``^[a-z0-9]+(_[a-z0-9]+)*$``) — safe to interpolate directly + into SQL. + """ + alias = schema_conn.alias + inserted = 0 + dropped = 0 + with schema_conn.cursor() as cursor: + cursor.execute( + f'DELETE FROM "{through_table}" WHERE source_id = %s', [co_pk], + ) + for row in rows: + ct_label = row.get('content_type') + obj_id = row.get('object_id') + if not ct_label or obj_id is None: + dropped += 1 + continue + try: + app_label, model_name = ct_label.split('.', 1) + ct = ContentType.objects.using(alias).get( + app_label=app_label, model=model_name, + ) + except (ValueError, ContentType.DoesNotExist) as exc: + logger.warning( + 'poly M2M replay: ct %r unresolved (%s) for %s pk=%s', + ct_label, exc, through_table, co_pk, + ) + dropped += 1 + continue + cursor.execute( + f'INSERT INTO "{through_table}" ' + '(source_id, content_type_id, object_id) VALUES (%s, %s, %s)', + [co_pk, ct.pk, obj_id], + ) + inserted += 1 + # All rows dropped — flag it. A CO with poly-M2M data should land with at + # least one row; zero inserts means the replay silently lost data. + if rows and inserted == 0 and dropped > 0: + logger.warning( + 'poly M2M replay: all %d row(s) dropped for %s pk=%s — ' + 'CO will land with empty %s', dropped, through_table, co_pk, through_table, + ) + + +def _get_schema_connection(): + """Active branch's connection if any, else the default — so DDL targets the right schema.""" + try: + from netbox_branching.contextvars import active_branch + branch = active_branch.get() + if branch is not None: + return connections[branch.connection_name] + except ImportError: + pass + return connection + + +def _historical_names_for_field(field_pk): + """All names this field has ever held, from its ObjectChange UPDATE history. + + Used so a deferred CO entry recorded under the field's old name still + matches when the field is created in the target schema under its new name. + """ + names = set() + rows = ObjectChange.objects.filter( + changed_object_type__app_label='netbox_custom_objects', + changed_object_type__model='customobjecttypefield', + changed_object_id=field_pk, + action=ObjectChangeActionChoices.ACTION_UPDATE, + ).values_list('prechange_data', 'postchange_data') + for pre, post in rows: + for blob in (pre, post): + if not blob: + continue + n = blob.get('name') + if n: + names.add(n) + return names + + +def _apply_deferred_co_field(field_instance): + """Apply deferred CO field values via raw UPDATE after the column is added. + + Squash merge fix: when a CO CREATE replays before its field's CREATE, the + field values stash in ``_deferred_co_field_data`` (shape: + ``{db_table: {co_pk: {'data': {field_name: value}}}}``); when the field + finally lands we UPDATE those rows. TYPE_OBJECT data key is ``{name}`` + but the column is ``{name}_id``; TYPE_MULTIOBJECT has no parent-table + column so it's skipped. Historical names (rename history) are accepted + as matches so a renamed field still picks up its pre-rename data. + """ + # No deferred data at all — fast path. + deferred = _deferred_co_field_data.get() + if not deferred: + return + + cot = field_instance.custom_object_type + table_name = cot.get_database_table_name() + per_table = deferred.get(table_name) + if not per_table: + return + + # M2M has no column on the main table — nothing to UPDATE. + if field_instance.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + return + + # For TYPE_OBJECT the data key is the field name but the DB column ends with _id. + if field_instance.type == CustomFieldTypeChoices.TYPE_OBJECT: + col_name = f'{field_instance.name}_id' + else: + col_name = field_instance.name + + # Candidate data keys: current name + all historical names. pk may be None + # when called before the field row is persisted; in that case we skip the + # history lookup and only match by current name. + candidate_keys = {field_instance.name} + if field_instance.pk is not None: + candidate_keys.update(_historical_names_for_field(field_instance.pk)) + + schema_conn = _get_schema_connection() + + with schema_conn.cursor() as cursor: + for co_pk, entry in per_table.items(): + data = entry['data'] + # Distinguish "key absent" from "key present with NULL" — explicit + # None is a legitimate write and must reach the column. + matched = next((k for k in candidate_keys if k in data), None) + if matched is None: + continue + value = data[matched] + # table_name / col_name come from validated identifiers + # (^[a-z0-9_]+$ on field.name) — safe to interpolate. + cursor.execute( + f'UPDATE "{table_name}" SET "{col_name}" = %s WHERE id = %s', + [value, co_pk], + ) + # Pop consumed keys immediately so a mid-loop failure leaves + # un-applied rows intact for retry but doesn't re-apply + # rows that already succeeded. + for k in candidate_keys: + data.pop(k, None) + + exhausted = [pk for pk, entry in per_table.items() if not entry['data']] + for pk in exhausted: + del per_table[pk] + if not per_table: + del deferred[table_name] + if not deferred: + _deferred_co_field_data.set(None) + + +def _schema_add_field(fi, model, schema_editor, schema_conn): + """``add_field`` against *schema_conn*; idempotent (skips if column exists). + + Creates the through table for MULTIOBJECT. Deferred CO field data is NOT + applied here — call ``_apply_deferred_co_field`` separately after. + """ + ft = FIELD_TYPE_CLASS[fi.type]() + mf = ft.get_model_field(fi) + mf.contribute_to_class(model, fi.name) + + with schema_conn.cursor() as cursor: + existing_cols = { + col.name + for col in schema_conn.introspection.get_table_description(cursor, model._meta.db_table) + } + if mf.column in existing_cols: + logger.debug('_schema_add_field: %r already exists on %s, skipping', mf.column, model._meta.db_table) + return + + # LazyForeignKey starts with a string remote_field.model. Django's + # lazy_related_operation fires immediately when the target is in + # apps.all_models, but tearDown() cleanup between tests can remove + # the target model from the registry. Resolve it directly here — + # bypassing the app-config's skip guard — so that schema_editor + # .add_field() always sees a model class, not a string. + if isinstance(mf, LazyForeignKey) and isinstance(mf.remote_field.model, str): + _app_label, _model_name = mf._to_model_name.rsplit('.', 1) + _cot_id_str = extract_cot_id_from_model_name(_model_name.lower()) + if _cot_id_str is not None: + try: + _cot = CustomObjectType.objects.get(pk=int(_cot_id_str)) + _actual = _cot.get_model() + mf.remote_field.model = _actual + mf.to = _actual + except (CustomObjectType.DoesNotExist, OperationalError, ProgrammingError): + logger.warning( + "Could not resolve LazyForeignKey target %r before add_field; " + "schema_editor.add_field may fail", + mf._to_model_name, + ) + + # Flush any pending DEFERRABLE FK trigger events (e.g. owner_id from OwnerMixin) + # before ALTER TABLE; PostgreSQL rejects ADD COLUMN when deferred triggers are pending. + schema_editor.execute('SET CONSTRAINTS ALL IMMEDIATE') + schema_editor.add_field(model, mf) + if fi.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + ft.create_m2m_table(fi, model, fi.name, schema_conn=schema_conn) + + +def _schema_remove_field(fi, model, schema_editor, schema_conn=None, existing_tables=None): + """``remove_field`` against *schema_conn*; idempotent. + + For MULTIOBJECT, drops the through table (skipped if already gone). + For scalar fields, flushes DEFERRABLE FK triggers before ALTER TABLE so + PostgreSQL doesn't reject the call with "pending trigger events". + *existing_tables* optionally short-circuits the per-call introspection. + """ + ft = FIELD_TYPE_CLASS[fi.type]() + mf = ft.get_model_field(fi) + mf.contribute_to_class(model, fi.name) + + if fi.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + through_table = fi.through_table_name + if existing_tables is None: + conn = schema_conn if schema_conn is not None else connection + with conn.cursor() as cursor: + existing_tables = set(conn.introspection.table_names(cursor)) + if through_table in existing_tables: + through_meta = type( + 'Meta', (), + {'db_table': through_table, 'app_label': APP_LABEL, 'managed': True}, + ) + temp_name = f'_TempThrough_{through_table}' + through_model = type( + temp_name, + (models.Model,), + {'Meta': through_meta, '__module__': 'netbox_custom_objects.models'}, + ) + try: + schema_editor.delete_model(through_model) + finally: + # ModelBase.__new__ registered the temp class in apps.all_models; + # drop it so repeated remove/re-add cycles don't leak entries. + apps.all_models.get(APP_LABEL, {}).pop(temp_name.lower(), None) + # M2M has no column on the parent table — nothing further to remove. + return + + # Flush any pending DEFERRABLE FK trigger events before ALTER TABLE; + # otherwise PostgreSQL raises "pending trigger events" when removing a FK field. + schema_editor.execute('SET CONSTRAINTS ALL IMMEDIATE') + schema_editor.remove_field(model, mf) + + +def _schema_alter_field(old_fi, new_fi, model, schema_editor, schema_conn, existing_tables=None): + """``alter_field`` from *old_fi* to *new_fi*; idempotent across replays. + + M2M renames go through ``_rename_or_create_m2m_through`` first. When + neither old nor new column exists (rename conflict — branch A→X vs main + A→Y), looks up the live field record to find the actual current column. + MULTIOBJECT↔scalar type changes are unsupported — caller must remove+add. + """ + old_is_m2m = old_fi.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT + new_is_m2m = new_fi.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT + + if old_is_m2m != new_is_m2m: + logger.warning( + '_schema_alter_field: skipping unsupported type change %r→%r on %s ' + '(MULTIOBJECT ↔ scalar changes require remove+add, not alter)', + old_fi.type, new_fi.type, model._meta.db_table, + ) + return + + old_mf = FIELD_TYPE_CLASS[old_fi.type]().get_model_field(old_fi) + new_mf = FIELD_TYPE_CLASS[new_fi.type]().get_model_field(new_fi) + old_mf.contribute_to_class(model, old_fi.name) + new_mf.contribute_to_class(model, new_fi.name) + + # M2M has no parent-table column — schema work happens on the through table. + if new_is_m2m: + if old_fi.name != new_fi.name: + _rename_or_create_m2m_through( + old_fi, new_fi, model, schema_editor, schema_conn, existing_tables, + ) + return + + with schema_conn.cursor() as cursor: + existing_cols = { + col.name + for col in schema_conn.introspection.get_table_description(cursor, model._meta.db_table) + } + if old_mf.column not in existing_cols: + if new_mf.column in existing_cols: + logger.debug( + '_schema_alter_field: %r already renamed to %r on %s, skipping', + old_mf.column, new_mf.column, model._meta.db_table, + ) + return + # Both source and target columns absent → independent rename in this + # schema; look up the live column and converge on the merge target. + logger.warning( + '_schema_alter_field: rename conflict on %s — neither %r nor %r ' + 'exists; resolving via live field pk=%d', + model._meta.db_table, old_mf.column, new_mf.column, new_fi.pk, + ) + try: + live_fi = CustomObjectTypeField.objects.using(schema_conn.alias).get(pk=new_fi.pk) + except CustomObjectTypeField.DoesNotExist: + logger.debug( + '_schema_alter_field: field pk=%d not found in %s; skipping', + new_fi.pk, schema_conn.alias, + ) + return + live_mf = FIELD_TYPE_CLASS[live_fi.type]().get_model_field(live_fi) + live_mf.contribute_to_class(model, live_fi.name) + if live_mf.column not in existing_cols: + logger.debug( + '_schema_alter_field: live column %r also absent on %s; skipping', + live_mf.column, model._meta.db_table, + ) + return + schema_editor.alter_field(model, live_mf, new_mf) + return + + schema_editor.alter_field(model, old_mf, new_mf) + + +def _rename_or_create_m2m_through(old_fi, new_fi, model, schema_editor, schema_conn, existing_tables): + """Rename the through-table for a renamed M2M field, or create the new one + if the old table is absent (sync/merge against a schema that never had it). + """ + old_through = old_fi.through_table_name + new_through = new_fi.through_table_name + + tables = existing_tables + if tables is None: + with schema_conn.cursor() as cursor: + tables = schema_conn.introspection.table_names(cursor) + + if old_through in tables: + old_through_meta = type( + 'Meta', (), + {'db_table': old_through, 'app_label': APP_LABEL, 'managed': True}, + ) + temp_name = f'_TempOldThrough_{old_through}' + old_through_model = generate_model( + temp_name, + (models.Model,), + { + '__module__': 'netbox_custom_objects.models', + 'Meta': old_through_meta, + 'id': models.AutoField(primary_key=True), + 'source': models.ForeignKey( + model, on_delete=models.CASCADE, db_column='source_id', related_name='+', + ), + 'target': models.ForeignKey( + model, on_delete=models.CASCADE, db_column='target_id', related_name='+', + ), + }, + ) + try: + schema_editor.alter_db_table(old_through_model, old_through, new_through) + finally: + # generate_model() registered the temp class in apps.all_models; + # drop it so repeated renames don't leak entries. + apps.all_models.get(APP_LABEL, {}).pop(temp_name.lower(), None) + else: + # Old through table absent — create the new one from scratch + ft = FIELD_TYPE_CLASS[new_fi.type]() + ft.create_m2m_table(new_fi, model, new_fi.name, schema_conn=schema_conn) + + +def _translate_renamed_field_name(cot, attr, rename_map=None): + """Resolve *attr* to the current name of one of *cot*'s fields via its + ObjectChange rename history. Returns ``None`` on ambiguity (caller falls + back to raw key) so we never silently overwrite the wrong column. Pass + *rename_map* (built once via ``_build_rename_map``) to skip the DB query. + """ + if rename_map is not None: + return rename_map.get(attr) + candidate_pks = set( + ObjectChange.objects.filter( + changed_object_type__app_label='netbox_custom_objects', + changed_object_type__model='customobjecttypefield', + action=ObjectChangeActionChoices.ACTION_UPDATE, + ).filter( + Q(postchange_data__name=attr) | Q(prechange_data__name=attr) + ).values_list('changed_object_id', flat=True) + ) + if not candidate_pks: + return None + fields = list( + cot.fields.filter(pk__in=candidate_pks).values_list('name', flat=True) + ) + if len(fields) == 1: + return fields[0] + return None + + +def _build_rename_map(cot, attrs): + """ + Return ``{old_name: current_name}`` for those entries in *attrs* that + resolve to exactly one of *cot*'s fields via rename history. + + One ObjectChange query covers all candidates. Ambiguous mappings (same + historical name appearing in multiple fields' history) are omitted so the + caller falls back to preserving the raw key — matching the + abstain-on-ambiguity behaviour of ``_translate_renamed_field_name``. + """ + attrs = [a for a in attrs if a] + if not attrs: + return {} + rows = ObjectChange.objects.filter( + changed_object_type__app_label='netbox_custom_objects', + changed_object_type__model='customobjecttypefield', + action=ObjectChangeActionChoices.ACTION_UPDATE, + ).filter( + Q(postchange_data__name__in=attrs) | Q(prechange_data__name__in=attrs) + ).values_list('changed_object_id', 'prechange_data', 'postchange_data') + + # attr → {field_pks that have this name anywhere in their history} + attr_to_field_pks: dict[str, set[int]] = {} + for field_pk, pre, post in rows: + for blob in (pre, post): + if not blob: + continue + name = blob.get('name') + if name in attrs: + attr_to_field_pks.setdefault(name, set()).add(field_pk) + + if not attr_to_field_pks: + return {} + + # Resolve the field pks we collected to their current names in one query. + pk_to_name = dict( + cot.fields.filter( + pk__in={pk for pks in attr_to_field_pks.values() for pk in pks} + ).values_list('pk', 'name') + ) + result = {} + for attr, field_pks in attr_to_field_pks.items(): + matched = [pk_to_name[pk] for pk in field_pks if pk in pk_to_name] + if len(matched) == 1: + result[attr] = matched[0] + return result + + +def _set_with_collision_preference(result, key, value): + """Set ``result[key] = value``; on collision prefer the non-None side. + + Squash-merge can map both the old and new name of a renamed field to the + same canonical key, with the new-side often carrying a sentinel ``None`` + from ``deep_compare_dict``. Preferring non-None keeps the real write. + """ + if key in result and value is None and result[key] is not None: + return + result[key] = value + class CustomObject( + OwnerMixin, BookmarksMixin, ChangeLoggingMixin, CloningMixin, + ContactsMixin, CustomLinksMixin, CustomValidationMixin, ExportTemplatesMixin, @@ -128,6 +627,188 @@ class CustomObject( class Meta: abstract = True + @classmethod + def resolve_field_aliases(cls, data): + """Rewrite *data* keys to this model's current field names. + + Called by netbox-branching's ``update_object()`` and + ``ChangeDiff._update_conflicts()`` when a CustomObjectTypeField has + been renamed between an ObjectChange recording and its replay. Walks + each field's rename history via ObjectChange records. Unknown keys + are preserved as-is; on collision the non-None value wins (see + ``_set_with_collision_preference``). + + Only top-level keys are rewritten. All current field-name carriers + (scalar columns, FK ``_id`` keys, M2M target lists, polymorphic + ``{content_type, object_id}`` payloads) appear at the top level, so + this is sufficient today. A future field type that stores user-field + names as nested-dict keys would need a recursive walk. + """ + if not data: + return data + + cot_id_str = extract_cot_id_from_model_name(cls.__name__.lower()) + if cot_id_str is None: + return data + cot_id = int(cot_id_str) + try: + cot = CustomObjectType.objects.get(pk=cot_id) + except CustomObjectType.DoesNotExist: + return data + + field_names = {f.name for f in cls._meta.get_fields()} + + # Collect the keys that don't match a current field name so we can do + # one batched ObjectChange query instead of one per unknown key. + unknown_keys = [] + for raw_key in data: + key = 'custom_field_data' if raw_key == 'custom_fields' else raw_key + if key not in field_names: + unknown_keys.append(key) + rename_map = _build_rename_map(cot, unknown_keys) if unknown_keys else {} + + result = {} + for raw_key, value in data.items(): + # Honour custom_fields → custom_field_data the same way update_object + # used to, so this hook is a true superset of the previous behaviour. + key = 'custom_field_data' if raw_key == 'custom_fields' else raw_key + if key in field_names: + _set_with_collision_preference(result, key, value) + continue + translated = _translate_renamed_field_name(cot, key, rename_map=rename_map) + if translated and translated in field_names: + _set_with_collision_preference(result, translated, value) + else: + # Unknown key (e.g. removed field) — preserve raw key so callers + # that inspect the dict for non-field metadata can still see it. + _set_with_collision_preference(result, raw_key, value) + return result + + @classmethod + def deserialize_object(cls, data, pk=None): + """ObjectChange.apply() hook for CREATE actions. + + Builds against the context-aware ``fresh_model`` (not Django's default + ``apps.get_model`` lookup, which would return main's class with the + wrong column set inside a branch). Stashes ``data`` in + ``_deferred_co_field_data`` so squash-ordering — a CO CREATE replayed + before its field's CREATE — can apply the values via raw UPDATE once + each column is added. + """ + cot_id_str = extract_cot_id_from_model_name(cls.__name__.lower()) + if cot_id_str is None: + return _deserialize_object(cls, data, pk=pk) + cot_id = int(cot_id_str) + + # In the squash case the cache may still point to a zero-field model. + CustomObjectType.clear_model_cache(cot_id) + try: + cot = CustomObjectType.objects.get(pk=cot_id) + fresh_model = cot.get_model() + except CustomObjectType.DoesNotExist: + fresh_model = cls + + resolved = fresh_model.resolve_field_aliases(data) + + obj = fresh_model() + if pk is not None: + obj.pk = pk + m2m_data = {} + # Polymorphic M2M data: keys named by serialize_object's sidecar + # (POLY_M2M_SIDECAR_KEY) — explicit so we don't rely on _field_objects + # (empty when squash replays the CO CREATE before its field CREATE) + # or value-shape guessing. + poly_m2m_field_names = { + entry['name'] + for entry in (resolved.get(POLY_M2M_SIDECAR_KEY) or ()) + if isinstance(entry, dict) and entry.get('name') + } + poly_m2m_data = {} + field_names = {f.name for f in fresh_model._meta.get_fields()} + + for attr, value in resolved.items(): + if attr == POLY_M2M_SIDECAR_KEY: + continue + # Tags via the standard NetBox path (Tag rows are looked up by name). + if attr == 'tags' and is_taggable(fresh_model): + tag_model = apps.get_model('extras', 'Tag') + m2m_data['tags'] = list(tag_model.objects.filter(name__in=value or [])) + continue + if attr in poly_m2m_field_names: + poly_m2m_data[attr] = value or [] + continue + if attr not in field_names: + # Unknown attribute (likely a removed field) — preserve it as a + # Python attribute so downstream code (e.g. _deferred_co_field_data) + # can still see it. + setattr(obj, attr, value) + continue + try: + f = fresh_model._meta.get_field(attr) + except FieldDoesNotExist: + setattr(obj, attr, value) + continue + if isinstance(f, ManyToManyField): + m2m_data[attr] = value + elif isinstance(f, ForeignKey): + # FK values arrive as the related PK; assign via the _id column. + setattr(obj, f.attname, value) + else: + # Coerce via to_python() for datetimes etc; fall back to raw on parse failure. + try: + setattr(obj, attr, f.to_python(value)) + except (ValidationError, ValueError, TypeError): + setattr(obj, attr, value) + + table_name = fresh_model._meta.db_table + full_data = dict(data) + + class _Deserialized: + object = obj + + def save(self, using=None, **_kwargs): + _using = using or DEFAULT_DB_ALIAS + models.Model.save_base(obj, using=_using, raw=True) + obj_pk = obj.pk # captures auto-assigned PK + # Re-apply M2M relations. Skipped quietly when the through + # table or column isn't present yet (squash ordering — the + # field's own CREATE replays later). hasattr() narrows the + # except below to genuine DB-state mismatches. + for accessor, related_pks in m2m_data.items(): + if not hasattr(obj, accessor): + logger.debug( + 'deserialize_object: deferred M2M %r on %s pk=%s (descriptor unbound)', + accessor, table_name, obj_pk, + ) + continue + manager = getattr(obj, accessor) + try: + manager.set(related_pks) + except (ProgrammingError, OperationalError): + logger.debug( + 'deserialize_object: deferred M2M %r on %s pk=%s (table absent)', + accessor, table_name, obj_pk, exc_info=True, + ) + # Replay polymorphic M2M directly via the through, pinned to + # _using. Bypasses manager.add() → m2m_changed → + # handle_changed_object, which can route across DB aliases. + schema_conn_local = connections[_using] + for field_name, rows in poly_m2m_data.items(): + through_table = ( + f'{USER_TABLE_DATABASE_NAME_PREFIX}{cot_id}_{field_name}' + ) + _apply_poly_m2m_rows(schema_conn_local, through_table, obj_pk, rows) + # Stash full data for deferred column updates (squash ordering fix). + deferred = _deferred_co_field_data.get() + if deferred is None: + deferred = {} + _deferred_co_field_data.set(deferred) + if table_name not in deferred: + deferred[table_name] = {} + deferred[table_name][obj_pk] = {'data': full_data} + + return _Deserialized() + def __str__(self): # Find the field with primary=True and return that field's "name" as the name of the object primary_field = self._field_objects.get(self._primary_field_id, None) @@ -157,11 +838,103 @@ def clean(self): if validators: run_validators(self, validators) + def serialize_object(self, exclude=None): + """Standard serialization plus polymorphic-field metadata. + + For polymorphic MULTIOBJECT fields, also appends + ``[{content_type, object_id}, ...]`` per field (Django's serializer + skips them — the descriptor isn't on ``_meta``). For both polymorphic + OBJECT and MULTIOBJECT, emits a sidecar of ``[{name, pk}, ...]`` so + the squash dependency resolver in ``branching.py`` can order the + field's CREATE before the CO's CREATE — without it, squash would + apply the CO before the columns/through exist. + """ + data = super().serialize_object(exclude=exclude) + field_objects = getattr(type(self), '_field_objects', None) or {} + poly_entries = [] + for fo in field_objects.values(): + field = fo['field'] + if not field.is_polymorphic: + continue + if field.type not in ( + CustomFieldTypeChoices.TYPE_OBJECT, + CustomFieldTypeChoices.TYPE_MULTIOBJECT, + ): + continue + if exclude and field.name in exclude: + continue + poly_entries.append({'name': field.name, 'pk': field.pk}) + if field.type != CustomFieldTypeChoices.TYPE_MULTIOBJECT: + continue + # MULTIOBJECT-only: append the through-table rows. + try: + manager = getattr(self, field.name) + through = manager._get_through_model() + except (AttributeError, LookupError): + continue + rows = list( + through.objects.filter(source_id=self.pk) + .values('content_type_id', 'object_id') + ) + if not rows: + data[field.name] = [] + continue + ct_ids = {r['content_type_id'] for r in rows} + ct_map = { + ct.id: f'{ct.app_label}.{ct.model}' + for ct in ContentType.objects.filter(id__in=ct_ids) + } + data[field.name] = [ + {'content_type': ct_map.get(r['content_type_id']), 'object_id': r['object_id']} + for r in rows + if r['content_type_id'] in ct_map + ] + if poly_entries: + data[POLY_M2M_SIDECAR_KEY] = poly_entries + return data + @property def _generated_table_model(self): # An indication that the model is a generated table model. return True + def delete(self, *args, **kwargs): + # Two prep steps before super() so the deletion collector doesn't + # raise traversing reverse FKs from through models: + # 1. Realign each through's ``source`` FK to ``type(self)`` — + # isinstance(instance, fk.related_model) otherwise sees two + # Table*Model classes and fails. + # 2. Temporarily unregister throughs whose physical table is gone + # (squash revert drops field-CREATEs before CO-CREATEs, so the + # CO's collector hits ``relation does not exist`` otherwise). + cls = type(self) + prefix = f'through_{cls._meta.db_table}' + registry = apps.all_models.get(APP_LABEL, {}) + # Branch contexts may have through tables only in the branch schema, so + # introspect via the active schema's connection, not the main one. + existing_tables = _get_schema_connection().introspection.table_names() + hidden = {} + for name, through in list(registry.items()): + if not name.startswith(prefix): + continue + if through._meta.db_table not in existing_tables: + hidden[name] = registry.pop(name) + continue + for fk_name in ('source', 'target'): + try: + field = through._meta.get_field(fk_name) + except FieldDoesNotExist: + continue + remote_meta = getattr(field.remote_field.model, '_meta', None) + if remote_meta is None or remote_meta.label != cls._meta.label: + continue + field.remote_field.model = cls + field.__dict__.pop('related_model', None) + try: + return super().delete(*args, **kwargs) + finally: + registry.update(hidden) + @property def clone_fields(self): """ @@ -172,12 +945,20 @@ def clone_fields(self): if not hasattr(self, "custom_object_type_id"): return () - # Get all field names where is_cloneable=True for this custom object type + # Get all fields where is_cloneable=True for this custom object type cloneable_fields = self.custom_object_type.fields.filter( is_cloneable=True - ).values_list("name", flat=True) + ).values_list("name", "type") - return tuple(cloneable_fields) + names = [] + for name, field_type in cloneable_fields: + # Coordinates fields have no single column; clone the two backing columns. + if field_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + names += [f"{name}_latitude", f"{name}_longitude"] + else: + names.append(name) + + return tuple(names) def get_absolute_url(self): return reverse( @@ -208,6 +989,78 @@ def _get_action_url(cls, action=None, rest_api=False, kwargs=None): return reverse(cls._get_viewname(action, rest_api), kwargs=kwargs) +class CustomObjectConfigContextMixin(ConfigContextModel): + """ConfigContextModel variant for dynamically generated custom object models. + + Inherits ``local_context_data`` (a JSONField) and its ``clean()`` validator + from NetBox's ``ConfigContextModel``. Custom objects have none of the fixed + site/tenant/role attributes that ``ConfigContext.get_for_object()`` reads, so + we can't hand a custom object to it directly. Instead, by convention (issue + #98), a single non-polymorphic OBJECT field named ``site`` / ``tenant`` / + ``role`` / ``platform`` / ``location`` / ``device_type`` / ``cluster`` and + pointing at the matching NetBox model feeds that assignment dimension. We + build a proxy from those fields and reuse ``get_for_object`` so all of + NetBox's matching, hierarchy walking, and weight ordering apply. When no + such field exists we fall back to local-data-only. + """ + + class Meta: + abstract = True + + def _config_context_source(self): + """Proxy populated from convention-named OBJECT fields, or None if none match. + + Only honours a field whose *name* and *target model* both match the + convention, so a mistyped/mispointed field is silently ignored rather + than feeding the wrong dimension. + """ + from types import SimpleNamespace + + cot = self.custom_object_type + by_name = { + f.name: f + for f in cot.fields.filter( + type=CustomFieldTypeChoices.TYPE_OBJECT, + is_polymorphic=False, + name__in=CONFIG_CONTEXT_DIMENSION_FIELDS.keys(), + ) + } + dims = {name: None for name in CONFIG_CONTEXT_DIMENSION_FIELDS} + used = False + for name, (app_label, model_name) in CONFIG_CONTEXT_DIMENSION_FIELDS.items(): + f = by_name.get(name) + ct = getattr(f, "related_object_type", None) + if f and ct and (ct.app_label, ct.model) == (app_label, model_name): + dims[name] = getattr(self, name, None) + used = True + if not used: + return None + proxy = SimpleNamespace(**dims) + proxy.tags = self.tags # custom objects are taggable + return proxy + + def get_config_context(self): + """Merge ConfigContexts applicable to referenced dimension objects, then + overlay ``local_context_data`` (which takes precedence).""" + return self._render_config_context(self._config_context_source()) + + def _render_config_context(self, source): + """Merge the ConfigContexts for *source* (a proxy from + ``_config_context_source()``, or ``None``), then overlay + ``local_context_data``. Takes a pre-built *source* so a caller that also + needs the source-context list (the detail tab) can build the proxy once + instead of paying for the ``cot.fields`` lookup twice. + """ + data = {} + if source is not None: + contexts = ConfigContext.objects.get_for_object(source, aggregate_data=True) or [] + for context in contexts: + data = deepmerge(data, context) + if self.local_context_data: + data = deepmerge(data, self.local_context_data) + return data + + def validate_pep440(value): """Validate that *value* is a valid PEP 440 version string.""" if not value: @@ -223,12 +1076,16 @@ def validate_pep440(value): class CustomObjectType(NetBoxModel): # Class-level cache for generated models + # Branch-aware model cache keyed by (cot_id, branch_id_or_None). Only main's + # class (branch_id=None) is registered in apps.all_models so that + # content_type.model_class() resolves to a class with main's column set — + # branches may have renamed columns that don't exist in main. _model_cache = {} - _through_model_cache = ( - {} - ) # Now stores {custom_object_type_id: {through_model_name: through_model}} - _model_cache_locks = {} # Per-model locks to prevent race conditions - _global_lock = threading.RLock() # Global lock for managing per-model locks + # Per-(cot, branch) through-model registry: {(cot_id, branch_id): {name: through}}. + # Each context owns its through class so the source FK is set once at + # generation time and never mutated to follow another context's CO class. + _through_model_cache = {} + _global_lock = threading.RLock() _ON_DELETE_SQL = { ObjectFieldOnDeleteChoices.CASCADE: "CASCADE", ObjectFieldOnDeleteChoices.SET_NULL: "SET NULL", @@ -294,6 +1151,14 @@ class CustomObjectType(NetBoxModel): blank=True, editable=False ) + config_context_enabled = models.BooleanField( + default=False, + verbose_name=_("config context support"), + help_text=_( + "Enable local config context data on objects of this type. " + "Can only be set when the type is created." + ), + ) class Meta: verbose_name = "Custom Object Type" @@ -312,6 +1177,9 @@ def __str__(self): return self.display_name def clean(self): + # Guard against None (can arrive via update_object during branch revert) + if self.custom_field_data is None: + self.custom_field_data = {} super().clean() if not self.slug: @@ -319,6 +1187,24 @@ def clean(self): {"slug": _("Slug field cannot be empty.")} ) + # config_context_enabled is immutable after creation: the local_context_data + # column is created once when the type's table is built (managed=False, no + # migration), so flipping the flag would desync the generated model from the + # physical schema (False→True makes every query raise "column does not exist"). + # Enforce on validated paths (forms, full_clean()); raw QuerySet.update()/save() + # bypass clean() as with any Django model. (original is None only if the row was + # deleted concurrently — nothing to compare against, so skip.) + if self.pk: + original = CustomObjectType.objects.filter(pk=self.pk).values_list( + "config_context_enabled", flat=True + ).first() + if original is not None and self.config_context_enabled != original: + raise ValidationError({ + "config_context_enabled": _( + "Config context support cannot be changed after creation." + ) + }) + # Enforce max number of COTs that may be created (max_custom_object_types) if not self.pk: max_cots = get_plugin_config("netbox_custom_objects", "max_custom_object_types") @@ -328,88 +1214,95 @@ def clean(self): "exceeded; adjust max_custom_object_types to raise this limit" )) - @classmethod - def clear_model_cache(cls, custom_object_type_id=None): - """ - Clear the model cache for a specific CustomObjectType or all models. + @staticmethod + def _active_branch_id(): + """Active Branch id, or None for main — second component of the cache key.""" + try: + from netbox_branching.contextvars import active_branch + except ImportError: + return None + branch = active_branch.get() + return branch.id if branch is not None else None - :param custom_object_type_id: ID of the CustomObjectType to clear cache for, or None to clear all + @classmethod + def clear_model_cache(cls, custom_object_type_id=None, *, all_branches=False): + """Clear the cached generated model. + + Defaults to clearing only the current branch context's entry so the + other context's class (which is registered in ``apps.all_models``) + stays valid. ``all_branches=True`` wipes every (cot, branch) entry, + appropriate for COT deletion or full re-init. ``custom_object_type_id=None`` + clears everything. """ with cls._global_lock: if custom_object_type_id is not None: - cls._model_cache.pop(custom_object_type_id, None) - cls._through_model_cache.pop(custom_object_type_id, None) - cls._model_cache_locks.pop(custom_object_type_id, None) + if all_branches: + for key in list(cls._model_cache): + if key[0] == custom_object_type_id: + cls._model_cache.pop(key, None) + for key in list(cls._through_model_cache): + if key[0] == custom_object_type_id: + cls._through_model_cache.pop(key, None) + else: + branch_id = cls._active_branch_id() + cls._model_cache.pop((custom_object_type_id, branch_id), None) + cls._through_model_cache.pop((custom_object_type_id, branch_id), None) else: cls._model_cache.clear() cls._through_model_cache.clear() - cls._model_cache_locks.clear() # Clear Django apps registry cache to ensure newly created models are recognized apps.get_models.cache_clear() @classmethod - def get_cached_model(cls, custom_object_type_id): - """ - Get a cached model for a specific CustomObjectType if it exists. - - :param custom_object_type_id: ID of the CustomObjectType - :return: The cached model or None if not found + def _restore_main_through_registration(cls, cot_id, through_model_name): + """Restore main's through to ``apps.all_models`` after a branch + generation overwrote it (Django's metaclass auto-registers under the + same name). Keeps ``apps.get_model`` lookups returning main's class. """ - cache_entry = cls._model_cache.get(custom_object_type_id) - if cache_entry: - # Cache stores (model, timestamp) tuples - return cache_entry[0] - return None + main_throughs = cls._through_model_cache.get((cot_id, None)) + if not main_throughs: + return + main_through = main_throughs.get(through_model_name) + if main_through is None: + return + apps.all_models[APP_LABEL][through_model_name.lower()] = main_through @classmethod - def get_cached_timestamp(cls, custom_object_type_id): - """ - Get the timestamp of a cached model for a specific CustomObjectType. - - :param custom_object_type_id: ID of the CustomObjectType - :return: The cached timestamp or None if not found - """ - cache_entry = cls._model_cache.get(custom_object_type_id) - if cache_entry: - # Cache stores (model, timestamp) tuples - return cache_entry[1] - return None + def get_cached_model(cls, custom_object_type_id, branch_id=None): + """Cached model for (cot, branch), or None.""" + cache_entry = cls._model_cache.get((custom_object_type_id, branch_id)) + return cache_entry[0] if cache_entry else None @classmethod - def is_model_cached(cls, custom_object_type_id): - """ - Check if a model is cached for a specific CustomObjectType. - - :param custom_object_type_id: ID of the CustomObjectType - :return: True if the model is cached, False otherwise - """ - return custom_object_type_id in cls._model_cache + def get_cached_timestamp(cls, custom_object_type_id, branch_id=None): + """Cached timestamp for (cot, branch), or None.""" + cache_entry = cls._model_cache.get((custom_object_type_id, branch_id)) + return cache_entry[1] if cache_entry else None @classmethod - def get_cached_through_model(cls, custom_object_type_id, through_model_name): - """ - Get a specific cached through model for a CustomObjectType. + def is_model_cached(cls, custom_object_type_id, branch_id=None): + """True if a model is cached for (cot, branch).""" + return (custom_object_type_id, branch_id) in cls._model_cache - :param custom_object_type_id: ID of the CustomObjectType - :param through_model_name: Name of the through model to retrieve - :return: The cached through model or None if not found - """ - if custom_object_type_id in cls._through_model_cache: - return cls._through_model_cache[custom_object_type_id].get( - through_model_name - ) - return None + @classmethod + def get_cached_through_model(cls, custom_object_type_id, through_model_name, branch_id=None): + """Get a cached through model for a (cot, branch) context, or None.""" + return cls._through_model_cache.get((custom_object_type_id, branch_id), {}).get( + through_model_name + ) @classmethod - def get_cached_through_models(cls, custom_object_type_id): - """ - Get all cached through models for a CustomObjectType. + def get_cached_through_models(cls, custom_object_type_id, branch_id=None): + """Get all cached through models for a (cot, branch) context.""" + return cls._through_model_cache.get((custom_object_type_id, branch_id), {}) - :param custom_object_type_id: ID of the CustomObjectType - :return: Dict of through models or empty dict if not found - """ - return cls._through_model_cache.get(custom_object_type_id, {}) + def serialize_object(self, exclude=None): + # cache_timestamp is an internal cache-invalidation field; exclude it + # from ObjectChange records so it doesn't appear as a tracked change. + extra = ['cache_timestamp'] + combined = list(exclude or []) + extra + return super().serialize_object(exclude=combined) def get_absolute_url(self): return reverse("plugins:netbox_custom_objects:customobjecttype", args=[self.pk]) @@ -508,7 +1401,12 @@ def _after_model_generation(self, attrs, model): for f in list(model._meta.local_fields) + list(model._meta.local_many_to_many) } - # Collect through models during after_model_generation + # Per-(cot, branch) through models — fresh per context so their source FK + # is set once at generation time and never mutated to follow another + # context's CO class. Main's throughs stay canonical in apps.all_models; + # branch's throughs are kept private (we restore main's registration + # after Django's metaclass auto-registers ours). + branch_id = self._active_branch_id() through_models = [] for field_object in all_field_objects.values(): @@ -521,11 +1419,12 @@ def _after_model_generation(self, attrs, model): if field_instance.is_polymorphic: if field_instance.type == CustomFieldTypeChoices.TYPE_OBJECT: - # Polymorphic GFK: no through model, no after_model_generation needed. + # Polymorphic GFK: no through model. pass elif field_instance.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - # Ensure the polymorphic through model is in the app registry. - # On server restart the registry is cleared; re-register if needed. + # Reuse the apps-registered through if present; otherwise + # create one. Avoids parallel through instances diverging + # from apps.all_models (collector class-identity mismatch). _apps = model._meta.apps try: through_model = _apps.get_model(APP_LABEL, field_instance.through_model_name) @@ -554,6 +1453,7 @@ def _after_model_generation(self, attrs, model): source_field.__dict__.pop('path_infos', None) source_field.__dict__.pop('reverse_path_infos', None) _apps.register_model(APP_LABEL, through_model) + self._register_context_through(branch_id, through_model) if through_model and through_model not in through_models: through_models.append(through_model) _wire_polymorphic_reverse_descriptors(field_instance) @@ -569,14 +1469,15 @@ def _after_model_generation(self, attrs, model): field_object["field"], model, field_name ) - # Collect through models from M2M fields + # Collect through models from M2M fields — already fresh per CO from + # MultiObjectFieldType.get_model_field → get_through_model. if hasattr(field, 'remote_field') and hasattr(field.remote_field, 'through'): through_model = field.remote_field.through - # Only collect custom through models, not auto-created Django ones if (through_model and through_model not in through_models and hasattr(through_model._meta, 'app_label') and through_model._meta.app_label == APP_LABEL): through_models.append(through_model) + self._register_context_through(branch_id, through_model) # Store through models on the model for yielding in get_models() model._through_models = through_models @@ -593,6 +1494,17 @@ def _after_model_generation(self, attrs, model): ).exclude(related_name=''): _wire_polymorphic_reverse_descriptors(inbound_field) + def _register_context_through(self, branch_id, through_model): + """Cache *through_model* under (self.pk, branch_id) and, for branch + contexts, restore main's canonical registration in apps.all_models + (Django's metaclass auto-registered this branch-side through with the + same name, overwriting main's entry).""" + key = (self.pk, branch_id) + bucket = type(self)._through_model_cache.setdefault(key, {}) + bucket[through_model.__name__] = through_model + if branch_id is not None: + self._restore_main_through_registration(self.pk, through_model.__name__) + @staticmethod def _collect_base_columns(model, user_field_names): """ @@ -671,11 +1583,9 @@ def get_content_type_label(custom_object_type_id): return f"Custom Objects > {custom_object_type.display_name}" def register_custom_object_search_index(self, model): - # model must be an instance of this CustomObjectType's get_model() generated class - # Use local_fields / local_many_to_many — plain lists populated at class-creation - # time — instead of _meta.get_field(), which triggers Django's lazy _relation_tree - # computation. _relation_tree calls apps.get_models(), which re-enters our - # get_models() override, which calls get_model() for every COT → infinite recursion. + # Use local_fields / local_many_to_many directly — calling _meta.get_field() + # triggers Django's lazy _relation_tree which re-enters get_models() and + # recurses through get_model() for every COT. present = ( {f.name for f in model._meta.local_fields} | {f.name for f in model._meta.local_many_to_many} @@ -716,14 +1626,27 @@ def get_model( :rtype: Model """ + branch_id = self._active_branch_id() + + # Lock guards the cache check, not the miss → re-cache window. Two + # threads can regenerate the same (cot_id, branch_id) in parallel; + # both produce equivalent classes, so the duplication is wasteful but + # not incorrect. Worth it to avoid serialising all generation. with self._global_lock: - if self.is_model_cached(self.id) and not no_cache: - cached_timestamp = self.get_cached_timestamp(self.id) - # Only use cache if the timestamps are available and match + if self.is_model_cached(self.id, branch_id) and not no_cache: + cached_timestamp = self.get_cached_timestamp(self.id, branch_id) if cached_timestamp and self.cache_timestamp and cached_timestamp == self.cache_timestamp: - model = self.get_cached_model(self.id) + model = self.get_cached_model(self.id, branch_id) + # registry["search"] is global, not per-branch — re-bind so + # post_save's search-cache handler sees this context's fields. + self.register_custom_object_search_index(model) return model else: + # Only clear the current (cot_id, branch_id) entry. Lazy + # invalidation: each branch context detects its own stale + # timestamp on next access — main's COT save propagates the + # bumped cache_timestamp to branches via change-capture, so + # they'll re-evaluate against their own row independently. self.clear_model_cache(self.id) # Generate the model outside the lock to avoid holding it during expensive operations @@ -771,107 +1694,123 @@ def get_model( # Wrap the existing post_through_setup method to handle ValueError exceptions from taggit.managers import TaggableManager as TM - original_post_through_setup = TM.post_through_setup + # TM.post_through_setup is class-level state; serialize concurrent + # generations so save/restore can't interleave across threads. + with _taggable_manager_patch_lock: + original_post_through_setup = TM.post_through_setup - def wrapped_post_through_setup(self, cls): - try: - return original_post_through_setup(self, cls) - except ValueError: - pass + def wrapped_post_through_setup(self, cls): + try: + return original_post_through_setup(self, cls) + except ValueError: + pass - TM.post_through_setup = wrapped_post_through_setup + TM.post_through_setup = wrapped_post_through_setup - try: - model = generate_model( - str(model_name), - (CustomObject, models.Model), - attrs, - ) - finally: - TM.post_through_setup = original_post_through_setup - - # Register the main model with Django's app registry. - # _suppress_clear_cache() is used directly here (rather than going - # through generate_model()) because we are calling apps.register_model() - # explicitly, not type(). generate_model() wraps type() and suppresses - # clear_cache only for that call; the suppression window needs to extend - # through the _model_cache write that follows so the model is safely - # cached before any re-entrant get_model() call can observe it. - # Without suppression: register_model() → clear_cache() → get_models() → - # get_model() → generate_model() → register_model() recurses infinitely. - with _suppress_clear_cache(): - if model_name.lower() in apps.all_models[APP_LABEL]: - # Remove the existing model from all_models before registering the new one - del apps.all_models[APP_LABEL][model_name.lower()] - - apps.register_model(APP_LABEL, model) - - self._after_model_generation(attrs, model) - - # When this COT's model is regenerated (cache miss), non-polymorphic through - # models owned by OTHER COTs that point to this COT as their M2M target keep - # their target FK stale (pointing at the old model class). Django's deletion - # collector finds those through FKs in the new model's related_objects and - # raises ValueError: "Cannot query X: Must be OldModel instance." - # Fix: walk all inbound non-polymorphic multiobject fields and patch the - # through model's target FK to the freshly generated model class. - # (Same pattern as the existing fix for polymorphic source FKs above at - # _after_model_generation lines 526-531.) - for inbound_field in CustomObjectTypeField.objects.filter( - related_object_type=self.object_type, - type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, - is_polymorphic=False, - ).iterator(): - try: - through_model = apps.get_model(APP_LABEL, inbound_field.through_model_name) - target_field = through_model._meta.get_field('target') - except (LookupError, FieldDoesNotExist): - continue - target_field.remote_field.model = model - target_field.related_model = model - # path_infos is a @cached_property on ForeignKey (see Django's - # related.py). Clear it so the path is rebuilt using the updated - # remote_field.model; stale cached path_infos would make Django's - # deletion collector compare obj against the old model class and - # raise ValueError: "Cannot query X: Must be OldModel instance." - target_field.__dict__.pop('path_infos', None) - target_field.__dict__.pop('reverse_path_infos', None) - - # Same staleness problem exists for direct FK fields (TYPE_OBJECT): - # when this COT is regenerated, any cached model for another COT that - # holds a LazyForeignKey pointing here still references the old class. - # Walk inbound non-polymorphic object fields and patch them too. - for inbound_fk_field in CustomObjectTypeField.objects.filter( - related_object_type=self.object_type, - type=CustomFieldTypeChoices.TYPE_OBJECT, - is_polymorphic=False, - ).iterator(): - owner_model = CustomObjectType.get_cached_model(inbound_fk_field.custom_object_type_id) - if owner_model is None: - continue - # Use local_fields list — avoids _relation_tree → get_models() recursion. - fk_field = next( - (f for f in owner_model._meta.local_fields if f.name == inbound_fk_field.name), - None, + # Optionally mix in config context support (local_context_data) when + # the type opts in. The mixin contributes a concrete column, so the + # flag is only honoured at creation time (the table is built once via + # schema_editor.create_model() and is otherwise managed=False). + bases = (CustomObject, models.Model) + if self.config_context_enabled: + bases = (CustomObject, CustomObjectConfigContextMixin, models.Model) + + try: + model = generate_model( + str(model_name), + bases, + attrs, ) - if fk_field is None: - continue - fk_field.remote_field.model = model - fk_field.related_model = model - fk_field.to = model - fk_field.__dict__.pop('path_infos', None) - fk_field.__dict__.pop('reverse_path_infos', None) - - # Only cache fully-generated models. Models generated with - # skip_object_fields=True omit FK fields to other COTs; caching them - # would permanently hide those fields if a dependent COT triggers - # generation before this one in the startup loop (issue #408). + finally: + TM.post_through_setup = original_post_through_setup + + # Suppress clear_cache() through the _model_cache write so a re-entrant + # get_model() inside register_model → clear_cache → get_models() can hit + # the cache instead of recursing into another generation. + with _suppress_clear_cache(): + # Main's class is the canonical registration in apps.all_models; + # branch's class is cached only. Without this, content_type.model_class() + # would return a class with the wrong column set across contexts. + model_key = model_name.lower() + if branch_id is None: + if model_key in apps.all_models[APP_LABEL]: + del apps.all_models[APP_LABEL][model_key] + apps.register_model(APP_LABEL, model) + else: + main_class = self.get_cached_model(self.id, branch_id=None) + if main_class is not None: + apps.all_models[APP_LABEL][model_key] = main_class + # Else: branch class stays registered until main is generated — + # self-healing on the next main-context get_model() call. + + # _after_model_generation registers through models in + # _through_model_cache and mutates apps.all_models; hold the lock so + # concurrent get_model() calls for the same (cot_id, branch_id) can't + # interleave their through-model registrations. with self._global_lock: + self._after_model_generation(attrs, model) + + # When this COT's model is regenerated (cache miss), non-polymorphic through + # models owned by OTHER COTs that point to this COT as their M2M target keep + # their target FK stale (pointing at the old model class). Django's deletion + # collector finds those through FKs in the new model's related_objects and + # raises ValueError: "Cannot query X: Must be OldModel instance." + # Fix: walk all inbound non-polymorphic multiobject fields and patch the + # through model's target FK to the freshly generated model class. + # (Same pattern as the existing fix for polymorphic source FKs above at + # _after_model_generation lines 526-531.) + for inbound_field in CustomObjectTypeField.objects.filter( + related_object_type=self.object_type, + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + is_polymorphic=False, + ).iterator(): + try: + through_model = apps.get_model(APP_LABEL, inbound_field.through_model_name) + target_field = through_model._meta.get_field('target') + except (LookupError, FieldDoesNotExist): + continue + target_field.remote_field.model = model + target_field.related_model = model + # path_infos is a @cached_property on ForeignKey (see Django's + # related.py). Clear it so the path is rebuilt using the updated + # remote_field.model; stale cached path_infos would make Django's + # deletion collector compare obj against the old model class and + # raise ValueError: "Cannot query X: Must be OldModel instance." + target_field.__dict__.pop('path_infos', None) + target_field.__dict__.pop('reverse_path_infos', None) + + # Same staleness problem exists for direct FK fields (TYPE_OBJECT): + # when this COT is regenerated, any cached model for another COT that + # holds a LazyForeignKey pointing here still references the old class. + # Walk inbound non-polymorphic object fields and patch them too. + for inbound_fk_field in CustomObjectTypeField.objects.filter( + related_object_type=self.object_type, + type=CustomFieldTypeChoices.TYPE_OBJECT, + is_polymorphic=False, + ).iterator(): + owner_model = CustomObjectType.get_cached_model(inbound_fk_field.custom_object_type_id) + if owner_model is None: + continue + # Use local_fields list — avoids _relation_tree → get_models() recursion. + fk_field = next( + (f for f in owner_model._meta.local_fields if f.name == inbound_fk_field.name), + None, + ) + if fk_field is None: + continue + fk_field.remote_field.model = model + fk_field.related_model = model + fk_field.to = model + fk_field.__dict__.pop('path_infos', None) + fk_field.__dict__.pop('reverse_path_infos', None) + + # Only cache fully-generated models. Models generated with + # skip_object_fields=True omit FK fields to other COTs; caching them + # would permanently hide those fields if a dependent COT triggers + # generation before this one in the startup loop (issue #408). if not skip_object_fields: - self._model_cache[self.id] = (model, self.cache_timestamp) + self._model_cache[(self.id, branch_id)] = (model, self.cache_timestamp) - # Now that the model is in _model_cache, clear_cache() is safe: - # re-entrant get_model() calls for this COT hit the cache immediately. apps.clear_cache() ContentType.objects.clear_cache() @@ -888,15 +1827,11 @@ def get_model_with_serializer(self): return model def _ensure_field_fk_constraint(self, model, field_name, on_delete_behavior=None): - """ - Ensure that a foreign key constraint is properly created at the database level - for a specific OBJECT type field. This is necessary because models are created - with managed=False, which may not properly create FK constraints. - - :param model: The model containing the field - :param field_name: The name of the field to ensure FK constraint for - :param on_delete_behavior: Override the ON DELETE behavior (ObjectFieldOnDeleteChoices value). - If None, the value is read from the corresponding CustomObjectTypeField record. + """Create the FK constraint for an OBJECT-type field at the DB level. + + Required because dynamic models are ``managed=False``, so Django won't + emit the FK on its own. ``on_delete_behavior`` defaults to the field's + recorded value, falling back to SET_NULL. """ table_name = self.get_database_table_name() @@ -925,8 +1860,9 @@ def _ensure_field_fk_constraint(self, model, field_name, on_delete_behavior=None related_table = related_model._meta.db_table column_name = model_field.column - q = connection.ops.quote_name - with connection.cursor() as cursor: + schema_conn = _get_schema_connection() + q = schema_conn.ops.quote_name + with schema_conn.cursor() as cursor: # Drop existing FK constraint if it exists. # Join on key_column_usage so we match by actual column name, not constraint name — # RENAME COLUMN updates kcu but leaves the constraint name unchanged. @@ -947,9 +1883,9 @@ def _ensure_field_fk_constraint(self, model, field_name, on_delete_behavior=None constraint_name = row[0] cursor.execute(f'ALTER TABLE {q(table_name)} DROP CONSTRAINT IF EXISTS {q(constraint_name)}') - # PROTECT maps to RESTRICT in SQL (raises an error on delete attempt). - # SET NULL and CASCADE map directly. - # For SET NULL the column must be nullable, which it always is for Object fields. + # PROTECT → RESTRICT; SET NULL needs a nullable column (always true + # for Object fields). NOT DEFERRABLE — deferred constraints queue + # trigger events that block subsequent ALTER TABLE calls. constraint_name = f"{table_name}_{column_name}_fk" cursor.execute(f""" ALTER TABLE {q(table_name)} @@ -978,13 +1914,11 @@ def create_model(self): # Ensure the ContentType exists and is immediately available features = get_model_features(model) - if 'branching' in features: - features.remove('branching') self.object_type.features = features self.object_type.public = True self.object_type.save() - with connection.schema_editor() as schema_editor: + with _get_schema_connection().schema_editor() as schema_editor: schema_editor.create_model(model) self._store_base_column_snapshot(model) @@ -992,6 +1926,64 @@ def create_model(self): get_serializer_class(model) self.register_custom_object_search_index(model) + @classmethod + def deserialize_object(cls, data, pk=None): + """Branching merge/revert hook — replays through the real ``save()``. + + The default ``DeserializedObject.save()`` does ``save_base(raw=True)``, + which skips signals and our ``save()`` override, so the dynamic table + never gets created in the destination schema. This wrapper runs the + full lifecycle. ``object_type`` is nulled only when the FK doesn't + resolve (mirrors ``clean_fields``) so normal merges don't churn the + ContentType/ObjectType pair. + """ + inner = _deserialize_object(cls, data, pk=pk) + + class _SchemaAwareDeserialized: + def __init__(self, deserialized): + self._inner = deserialized + self.object = deserialized.object + + def save(self, using=None, **kwargs): + # Snapshot before modifying so that diff()['pre'] records the + # current state rather than showing all fields as None on revert. + self.object.snapshot() + # Only null the ObjectType FK when it doesn't resolve in the + # destination schema; custom_object_type_post_save_handler will + # then rebuild the link via get_or_create after INSERT. + if ( + self.object.object_type_id is not None + and not ObjectType.objects.filter(pk=self.object.object_type_id).exists() + ): + self.object.object_type = None + self.object.object_type_id = None + self.object.save() + # Re-apply any M2M data (tags, etc.) that was stripped during deserialization. + if self._inner.m2m_data: + for accessor_name, object_list in self._inner.m2m_data.items(): + getattr(self.object, accessor_name).set(object_list) + self._inner.m2m_data = None + + return _SchemaAwareDeserialized(inner) + + def clean_fields(self, exclude=None): + """Tolerate a stale ``object_type`` FK on revert (DELETE-undo path). + + ``delete()`` destroys the core_objecttype row to satisfy ChangeDiff's + PROTECT FK; revert then re-inserts the COT, but full_clean would fail + validating the dangling FK. We null it here so validation passes; + ``custom_object_type_post_save_handler`` rebuilds the ContentType/ + ObjectType pair via get_or_create on the resulting INSERT. The new + pk is intentional — the old one's audit refs were already invalidated. + """ + if ( + self.object_type_id is not None + and not ObjectType.objects.filter(pk=self.object_type_id).exists() + ): + self.object_type = None + self.object_type_id = None + super().clean_fields(exclude=exclude) + def save(self, *args, **kwargs): needs_db_create = self._state.adding @@ -1004,10 +1996,15 @@ def save(self, *args, **kwargs): self.clear_model_cache(self.id) def delete(self, *args, **kwargs): - # Clear the model cache for this CustomObjectType - self.clear_model_cache(self.id) + # COT is going away — every branch's cached class is stale. + self.clear_model_cache(self.id, all_branches=True) + # Regenerate against the current context so the model used for the + # DDL drop below reflects this branch's column set, not whatever + # stale class an earlier context cached. model = self.get_model() + schema_conn = _get_schema_connection() + in_branch = schema_conn is not connection # Delete all CustomObjectTypeFields that reference this CustomObjectType (non-polymorphic) for field in CustomObjectTypeField.objects.filter(related_object_type=self.object_type): @@ -1022,33 +2019,71 @@ def delete(self, *args, **kwargs): field.delete() object_type = ObjectType.objects.get_for_model(model) - ObjectChange.objects.filter(changed_object_type=object_type).delete() - # Delete any NetBox CustomField records (extras) with related_object_type pointing - # to this COT's ObjectType. CustomField.related_object_type uses on_delete=PROTECT, - # so these must be removed before object_type.delete() is called below. - CustomField.objects.filter(related_object_type=object_type).delete() + # ObjectChange and ObjectType records live in the main schema. Only clean + # them up when operating outside a branch; inside a branch they belong to + # main and must not be touched until the deletion is merged. + if not in_branch: + ObjectChange.objects.filter(changed_object_type=object_type).delete() + + # Delete any NetBox CustomField records (extras) with related_object_type pointing + # to this COT's ObjectType. CustomField.related_object_type uses on_delete=PROTECT, + # so these must be removed before object_type.delete() is called below. + CustomField.objects.filter(related_object_type=object_type).delete() super().delete(*args, **kwargs) - # Temporarily disconnect the pre_delete handler to skip the ObjectType deletion - # TODO: Remove this disconnect/reconnect after ObjectType has been exempted from handle_deleted_object - pre_delete.disconnect(handle_deleted_object) - object_type.delete() - with connection.schema_editor() as schema_editor: - # Drop polymorphic through tables first (they have FKs to django_content_type - # and to the main table, so they must be dropped before the main table). + if not in_branch: + # ChangeDiff has a PROTECT FK to ContentType/ObjectType — delete those + # records first so object_type.delete() is not blocked. + try: + from netbox_branching.models import ChangeDiff + ChangeDiff.objects.filter(object_type=object_type).delete() + except ImportError: + pass + # Temporarily disconnect the pre_delete handler to skip the ObjectType deletion + # TODO: Remove this disconnect/reconnect after ObjectType has been exempted from handle_deleted_object + pre_delete.disconnect(handle_deleted_object) + try: + object_type.delete() + finally: + pre_delete.connect(handle_deleted_object) + + with schema_conn.schema_editor() as schema_editor: + # Drop through tables before the main table (FKs). Existence checks + # use schema_conn so they target the active branch's schema. for through_model in getattr(model, '_through_models', []): - if _table_exists(through_model._meta.db_table): + if _table_exists(through_model._meta.db_table, conn=schema_conn): schema_editor.delete_model(through_model) - schema_editor.delete_model(model) - - # Unregister the model and its through-models from Django's app registry so - # that subsequent ORM operations (e.g. deleting a related device) do not try - # to query the now-dropped table and receive a - # "relation 'custom_objects_' does not exist" error. - # Use _global_lock to prevent a concurrent get_model() call from racing - # against this de-registration and re-adding the model mid-cleanup. + # Django's schema_editor.delete_model(parent) auto-recurses into + # M2M fields' through models when their _meta.auto_created is + # truthy — which we set deliberately in + # MultiObjectFieldType.after_model_generation so Django's JSON + # serializer includes M2M values. That recursion would attempt a + # DROP TABLE for through tables whose actual DB tables are absent + # (e.g. when this delete runs on a COT whose branch-side through + # was already dropped by a revert). Clear auto_created on those + # missing-table throughs for the duration of delete_model(parent) + # and restore it after. + cleared = [] + for field in model._meta.local_many_to_many: + through = field.remote_field.through + if through._meta.auto_created and not _table_exists( + through._meta.db_table, conn=schema_conn, + ): + cleared.append((through, through._meta.auto_created)) + through._meta.auto_created = False + try: + # Flush DEFERRABLE FK triggers so PG doesn't reject the DROP. + schema_editor.execute('SET CONSTRAINTS ALL IMMEDIATE') + schema_editor.delete_model(model) + finally: + for through, original in cleared: + through._meta.auto_created = original + + # Unregister from apps.all_models so cascade-delete doesn't query the + # dropped table. _global_lock guards against a concurrent get_model() + # racing and re-registering mid-cleanup. with self._global_lock: model_name = model.__name__.lower() if model_name in apps.all_models.get(APP_LABEL, {}): @@ -1059,15 +2094,10 @@ def delete(self, *args, **kwargs): if through_name in apps.all_models.get(APP_LABEL, {}): del apps.all_models[APP_LABEL][through_name] - # Clear Django's internal relation/field caches so the removed model is no - # longer discovered during cascade-delete collector traversal. apps.clear_cache() - # Re-clear the model cache to remove re-cached model from get_model. - self.clear_model_cache(self.id) - - # Reconnect the pre_delete handler after all cleanup is done. - pre_delete.connect(handle_deleted_object) + # Re-clear in case anything re-cached during cleanup. + self.clear_model_cache(self.id, all_branches=True) @receiver(post_save, sender=CustomObjectType) @@ -1079,10 +2109,85 @@ def custom_object_type_post_save_handler(sender, instance, created, **kwargs): app_label=APP_LABEL, model=content_type_name ) + # Snapshot for the second save below (the object_type assignment). + # Without this, its ObjectChange would mark every field as changed. + instance.snapshot() instance.object_type = ct instance.save() +def _rename_objectchange_field_key(fi, old_name, new_name): + """Rewrite *old_name* → *new_name* JSON keys in ObjectChange (and + ChangeDiff when netbox-branching is installed) for this field's COT. + + Runs inside ``CustomObjectTypeField.save()``'s atomic so it rolls back + cleanly. JSON column names are literals and field names are validated + against ``^[a-z0-9_]+$`` — safe to interpolate. + """ + cot = fi.custom_object_type + model = cot.get_model() + ct = ContentType.objects.get_for_model(model) + # core.ObjectChange is branched by netbox-branching (migrations are allowed + # on the branch schema, and read routing sends ObjectChange queries to the + # active branch — see netbox_branching.database.BranchAwareRouter). Using + # _get_schema_connection() therefore updates the branch's copy of + # core_objectchange, keeping branch-context history consistent with the + # rename. Main's copy is updated when the rename is later merged and this + # function runs again in main context. + conn = _get_schema_connection() + + oc_sql = ( + 'UPDATE core_objectchange ' + 'SET {col} = ({col} - %s) || jsonb_build_object(%s, {col}->%s) ' + 'WHERE changed_object_type_id = %s AND {col} ? %s' + ) + # Savepoint contains the failure cleanly, then we re-raise so the outer + # field save aborts — silently logging would leave audit data inconsistent. + try: + with transaction.atomic(using=conn.alias): + with conn.cursor() as cursor: + for json_col in ('prechange_data', 'postchange_data'): + cursor.execute( + oc_sql.format(col=json_col), + [old_name, new_name, old_name, ct.id, old_name], + ) + except ProgrammingError: + logger.error( + '_rename_objectchange_field_key: ObjectChange schema mismatch ' + 'rewriting %r -> %r; aborting rename', + old_name, new_name, exc_info=True, + ) + raise + + logger.debug('_rename_objectchange_field_key: %r -> %r for %s', old_name, new_name, ct) + + try: + from netbox_branching.models import ChangeDiff # noqa: F401 + except ImportError: + return + + cd_sql = ( + 'UPDATE netbox_branching_changediff ' + 'SET {col} = ({col} - %s) || jsonb_build_object(%s, {col}->%s) ' + 'WHERE object_type_id = %s AND {col} IS NOT NULL AND {col} ? %s' + ) + try: + with transaction.atomic(using=conn.alias): + with conn.cursor() as cursor: + for json_col in ('original', 'modified', 'current'): + cursor.execute( + cd_sql.format(col=json_col), + [old_name, new_name, old_name, ct.id, old_name], + ) + except ProgrammingError: + logger.error( + '_rename_objectchange_field_key: ChangeDiff schema mismatch ' + 'rewriting %r -> %r; aborting rename', + old_name, new_name, exc_info=True, + ) + raise + + class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): custom_object_type = models.ForeignKey( CustomObjectType, on_delete=models.CASCADE, related_name="fields" @@ -1090,7 +2195,7 @@ class CustomObjectTypeField(CloningMixin, ExportTemplatesMixin, ChangeLoggedMode type = models.CharField( verbose_name=_("type"), max_length=50, - choices=CustomFieldTypeChoices, + choices=CustomObjectFieldTypeChoices, default=CustomFieldTypeChoices.TYPE_TEXT, help_text=_("The type of data this custom object field holds"), ) @@ -1365,6 +2470,13 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # Two distinct "original" mechanisms exist on this model: + # * ``_original_name`` / ``_original_type`` / ``_original_*`` (here) are + # scalar snapshots set on every instance (including freshly constructed + # ones) for cheap before/after comparisons in save(). + # * ``_original`` (set in ``from_db``) is a full instance clone from the + # DB row, used when more than a single attribute is needed; only + # DB-loaded objects have it — see ``original`` property. self._name = self.__dict__.get("name") self._original_name = self.name self._original_type = self.type @@ -1529,12 +2641,31 @@ def clean(self): {"unique": _("Uniqueness cannot be enforced for boolean or multiobject fields")} ) - # Check if uniqueness constraint can be applied when changing from non-unique to unique + # Coordinates fields expand into two columns and have no single value, so a + # number of single-value options do not apply. + if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + if self.unique: + raise ValidationError( + {"unique": _("Uniqueness cannot be enforced for coordinates fields")} + ) + if self.default is not None: + raise ValidationError( + {"default": _("A default value cannot be set for coordinates fields")} + ) + # A coordinates field has no single column matching its name, so it can + # never be added to the search index. Force the weight to 0 rather than + # silently honouring a non-zero value that has no effect. + self.search_weight = 0 + + # Check if uniqueness constraint can be applied when changing from non-unique to unique. + # _original is set by from_db only; deserialized objects (branch merge/revert) + # never load from DB and won't have it — guard before touching self.original. if ( self.pk and self.unique - and not self.original.unique and not self._state.adding + and hasattr(self, '_original') + and not self.original.unique ): field_type = FIELD_TYPE_CLASS[self.type]() model_field = field_type.get_model_field(self) @@ -1544,9 +2675,14 @@ def clean(self): old_field = field_type.get_model_field(self.original) old_field.contribute_to_class(model, self._original_name) + # Route the probe through the branch's connection so the ALTER + # TABLE runs in the active schema. Using the default connection + # here would either probe main's table from a branch context or + # fail outright if the table only exists in the branch schema. + probe_conn = _get_schema_connection() try: - with transaction.atomic(): - with connection.schema_editor() as test_schema_editor: + with transaction.atomic(using=probe_conn.alias): + with probe_conn.schema_editor() as test_schema_editor: test_schema_editor.alter_field(model, old_field, model_field) # If we get here, the constraint was applied successfully # Now raise a custom exception to rollback the test transaction @@ -1675,6 +2811,60 @@ def clean(self): {"name": _("Cannot rename a polymorphic field after creation.")} ) + # Prevent converting an existing field to or from coordinates. + # + # A coordinates field occupies two concrete columns ("{name}_latitude" and + # "{name}_longitude") while every other field type occupies a single column + # named "{name}". The save() path has no logic to migrate between those two + # shapes, so allowing the conversion would either leave the original column + # orphaned (→ coordinates) or attempt to alter a column that doesn't exist + # (coordinates → other), raising an uncaught database error. Reject it here, + # mirroring the polymorphic-flag guard above. + is_coordinates = self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES + was_coordinates = self._original_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES + if self.pk and not self._state.adding and is_coordinates != was_coordinates: + raise ValidationError( + {"type": _("Cannot change a field's type to or from coordinates after creation.")} + ) + + # Guard against backing-column name collisions. + # + # A coordinates field expands into "{name}_latitude"/"{name}_longitude". If a + # sibling field already occupies one of those column names (or vice versa: a + # plain field named "_latitude"), the schema editor would issue a + # duplicate-column ALTER and PostgreSQL would raise a ProgrammingError instead + # of a clean validation error. Detect the overlap here. + if self.custom_object_type_id: + def _occupied_columns(name, field_type): + if field_type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + return {f"{name}_latitude", f"{name}_longitude"} + return {name} + + own_columns = _occupied_columns(self.name, self.type) + # A clash can only involve a coordinates field's expanded columns. If + # this field isn't coordinates, only sibling coordinates fields can + # collide with it — so skip the full sibling scan and query just those + # (usually zero) to avoid an extra queryset on every field's save. + if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + siblings = self.custom_object_type.fields.all() + else: + siblings = self.custom_object_type.fields.filter( + type=CustomObjectFieldTypeChoices.TYPE_COORDINATES + ) + if self.pk: + siblings = siblings.exclude(pk=self.pk) + for sibling in siblings: + clash = own_columns & _occupied_columns(sibling.name, sibling.type) + if clash: + raise ValidationError( + { + "name": _( + "Field name conflicts with column '{column}' already used by field " + "'{other}' on this custom object type." + ).format(column=sorted(clash)[0], other=sibling.name) + } + ) + # related_name can only be set for object-type fields if self.related_name and self.type not in ( CustomFieldTypeChoices.TYPE_OBJECT, @@ -2227,7 +3417,6 @@ def from_db(cls, db, field_names, values): @property def original(self): return self._original - # return self.__class__(**self._loaded_values) @property def through_table_name(self): @@ -2257,211 +3446,136 @@ def through_model_name(self): # the app registry and does not collide with user-visible model names. return f"Through_{self.through_table_name}" + @classmethod + def deserialize_object(cls, data, pk=None): + """Branching merge/revert hook — replays through the real ``save()``. + + Same shape as ``CustomObjectType.deserialize_object``: the default + ``DeserializedObject.save(raw=True)`` skips our ``save()``, so the + column never gets added. This wrapper runs the full lifecycle. + """ + inner = _deserialize_object(cls, data, pk=pk) + + class _SchemaAwareDeserialized: + def __init__(self, deserialized): + self._inner = deserialized + self.object = deserialized.object + + def save(self, using=None, **kwargs): + self.object.save() + if self._inner.m2m_data: + for accessor_name, object_list in self._inner.m2m_data.items(): + getattr(self.object, accessor_name).set(object_list) + self._inner.m2m_data = None + + return _SchemaAwareDeserialized(inner) + def save(self, *args, **kwargs): is_new = self._state.adding - # Auto-assign schema_id for new fields that don't have one yet. - # Increments the monotonic counter on the parent CustomObjectType so that IDs are - # never reused, even after a field is deleted. The UniqueConstraint on - # (schema_id, custom_object_type) is the safety net against races; a concurrent - # writer would get an IntegrityError and must retry. - # Note: bulk_create() bypasses save() entirely, so auto-assignment will NOT fire for - # fields created via CustomObjectTypeField.objects.bulk_create(...). Always set - # schema_id explicitly when using bulk_create. + schema_conn = _get_schema_connection() + + # Auto-assign schema_id from the parent's monotonic counter. IDs are + # never reused, even after a field is deleted; the + # UniqueConstraint(schema_id, custom_object_type) is the race safety net. + # bulk_create() bypasses save() — callers must set schema_id explicitly. if self._state.adding and self.schema_id is None: - with transaction.atomic(): - cot = CustomObjectType.objects.select_for_update().get( + # Atomic and queryset pinned to schema_conn — the branching router + # routes CustomObjectType writes to the branch connection. + with transaction.atomic(using=schema_conn.alias): + cot = CustomObjectType.objects.using(schema_conn.alias).select_for_update().get( pk=self.custom_object_type_id ) new_schema_id = cot.next_schema_id + 1 - # Use update() rather than save() to avoid dispatching post_save on - # CustomObjectType, which would clear the model cache prematurely. - # The model cache must remain valid until this field's own save() calls - # get_model() below (to contribute the new field and alter the DB table). - CustomObjectType.objects.filter(pk=self.custom_object_type_id).update( - next_schema_id=new_schema_id - ) + # update() avoids post_save → clear_model_cache; the cache must + # remain valid until this field's own get_model() below. + CustomObjectType.objects.using(schema_conn.alias).filter( + pk=self.custom_object_type_id + ).update(next_schema_id=new_schema_id) self.schema_id = new_schema_id field_type = FIELD_TYPE_CLASS[self.type]() model = self.custom_object_type.get_model() - with connection.schema_editor() as schema_editor: - if self._state.adding: - if self.is_polymorphic: - # Polymorphic Object: add content_type + object_id columns + index - # Polymorphic MultiObject: create through table with content_type + object_id - if self.type == CustomFieldTypeChoices.TYPE_OBJECT: - field_type.add_polymorphic_object_columns(self, model, schema_editor) - elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - field_type.create_polymorphic_m2m_table(self, model, schema_editor) - else: - model_field = field_type.get_model_field(self) - model_field.contribute_to_class(model, self.name) - # LazyForeignKey starts with a string remote_field.model. Django's - # lazy_related_operation fires immediately when the target is in - # apps.all_models, but tearDown() cleanup between tests can remove - # the target model from the registry. Resolve it directly here — - # bypassing the app-config's skip guard — so that schema_editor - # .add_field() always sees a model class, not a string. - if isinstance(model_field, LazyForeignKey) and isinstance(model_field.remote_field.model, str): - _app_label, _model_name = model_field._to_model_name.rsplit('.', 1) - _cot_id_str = extract_cot_id_from_model_name(_model_name.lower()) - if _cot_id_str is not None: - try: - _cot = CustomObjectType.objects.get(pk=int(_cot_id_str)) - _actual = _cot.get_model() - model_field.remote_field.model = _actual - model_field.to = _actual - except (CustomObjectType.DoesNotExist, OperationalError, ProgrammingError): - logger.warning( - "Could not resolve LazyForeignKey target %r before add_field; " - "schema_editor.add_field may fail", - model_field._to_model_name, - ) - schema_editor.add_field(model, model_field) - if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - field_type.create_m2m_table(self, model, self.name) - else: - # Polymorphic fields: renames and type changes are rejected by clean(). - # Non-schema attributes (label, description, …) may still change here. - # If clean() was bypassed and a rename slipped through, raise rather - # than silently leaving DB columns / through table out of sync. - if self.is_polymorphic or self._original_is_polymorphic: - if self.name != self._original_name: - raise ValidationError( - {"name": _("Cannot rename a polymorphic field after creation.")} - ) + # Schema mutation + audit-key rewrite + cache bump + parent save() share + # one atomic so a failure between DDL and row save can't leave audit + # data rewritten but the field record un-persisted (or vice versa). + with transaction.atomic(using=schema_conn.alias): + with schema_conn.schema_editor() as schema_editor: + if self._state.adding: + if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + # Coordinates expand into two concrete columns (latitude/longitude). + for column_name, model_field in field_type.get_model_field(self).items(): + model_field.contribute_to_class(model, column_name) + schema_editor.add_field(model, model_field) + elif self.is_polymorphic: + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: + field_type.add_polymorphic_object_columns(self, model, schema_editor) + elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + field_type.create_polymorphic_m2m_table(self, model, schema_editor) + else: + _schema_add_field(self, model, schema_editor, schema_conn) + _apply_deferred_co_field(self) else: - old_field = field_type.get_model_field(self.original) - old_field.contribute_to_class(model, self._original_name) - - # Special handling for MultiObject fields when the name changes - if ( - self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT - and self.name != self._original_name - ): - # For renamed MultiObject fields, we just need to rename the through table - old_through_table_name = self.original.through_table_name - new_through_table_name = self.through_table_name - - # Check if old through table exists - with connection.cursor() as cursor: - tables = connection.introspection.table_names(cursor) - old_table_exists = old_through_table_name in tables - - if old_table_exists: - # Create temporary models to represent the old and new through table states - old_through_meta = type( - "Meta", - (), - { - "db_table": old_through_table_name, - "app_label": APP_LABEL, - "managed": True, - }, - ) - _old_through_model = generate_model( - f"TempOld{self.original.through_model_name}", - (models.Model,), - { - "__module__": "netbox_custom_objects.models", - "Meta": old_through_meta, - "id": models.AutoField(primary_key=True), - "source": models.ForeignKey( - model, - on_delete=models.CASCADE, - db_column="source_id", - related_name="+", - ), - "target": models.ForeignKey( - model, - on_delete=models.CASCADE, - db_column="target_id", - related_name="+", - ), - }, - ) - - new_through_meta = type( - "Meta", - (), - { - "db_table": new_through_table_name, - "app_label": APP_LABEL, - "managed": True, - }, - ) - new_through_model = generate_model( - f"TempNew{self.through_model_name}", - (models.Model,), - { - "__module__": "netbox_custom_objects.models", - "Meta": new_through_meta, - "id": models.AutoField(primary_key=True), - "source": models.ForeignKey( - model, - on_delete=models.CASCADE, - db_column="source_id", - related_name="+", - ), - "target": models.ForeignKey( - model, - on_delete=models.CASCADE, - db_column="target_id", - related_name="+", - ), - }, - ) - # Rename the table using Django's schema editor. - # new_through_model is passed as the first argument so Django - # can rename associated sequences (e.g. on PostgreSQL). - schema_editor.alter_db_table( - new_through_model, - old_through_table_name, - new_through_table_name, + if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + # Only a rename touches the schema; other attribute changes + # (label, description, …) are persisted by super().save() below. + if self.name != self._original_name: + new_fields = list(field_type.get_model_field(self).items()) + old_fields = list(field_type.get_model_field(self.original).items()) + for (old_col, old_field), (new_col, new_field) in zip(old_fields, new_fields): + old_field.contribute_to_class(model, old_col) + new_field.contribute_to_class(model, new_col) + schema_editor.alter_field(model, old_field, new_field) + # Polymorphic renames/type changes are rejected in clean(); + # raise here if one slipped through to avoid silent column drift. + elif self.is_polymorphic or self._original_is_polymorphic: + if self.name != self._original_name: + raise ValidationError( + {"name": _("Cannot rename a polymorphic field after creation.")} ) - else: - # No old table exists, create the new through table - field_type.create_m2m_table(self, model, self.name) - - # Alter the field normally (this updates the field definition) - schema_editor.alter_field(model, old_field, model_field) else: - # Normal field alteration - model_field = field_type.get_model_field(self) - model_field.contribute_to_class(model, self.name) - schema_editor.alter_field(model, old_field, model_field) - - # Ensure FK constraints are properly created for OBJECT fields - should_ensure_fk = False - if self.type == CustomFieldTypeChoices.TYPE_OBJECT and not self.is_polymorphic: - if self._state.adding: - should_ensure_fk = True - else: - type_changed_to_object = ( - self._original_type != CustomFieldTypeChoices.TYPE_OBJECT - and self.type == CustomFieldTypeChoices.TYPE_OBJECT - ) - related_object_changed = ( - self._original_type == CustomFieldTypeChoices.TYPE_OBJECT - and self.related_object_type_id != self._original_related_object_type_id - ) - on_delete_changed = ( - self._original_type == CustomFieldTypeChoices.TYPE_OBJECT - and self.on_delete_behavior != self._original_on_delete_behavior - ) - should_ensure_fk = type_changed_to_object or related_object_changed or on_delete_changed + _schema_alter_field(self.original, self, model, schema_editor, schema_conn) + + # Rewrite historical audit-data keys so any future replay can + # resolve old or new name to the current field name. + if ( + not self._state.adding + and not self.is_polymorphic + and self._original_name != self.name + ): + _rename_objectchange_field_key(self, self._original_name, self.name) - # Clear and refresh the model cache for this CustomObjectType when a field is modified - self.custom_object_type.clear_model_cache(self.custom_object_type.id) + # FK-constraint decision inside the atomic so a rollback discards it too. + should_ensure_fk = False + if self.type == CustomFieldTypeChoices.TYPE_OBJECT and not self.is_polymorphic: + if self._state.adding: + should_ensure_fk = True + else: + type_changed_to_object = ( + self._original_type != CustomFieldTypeChoices.TYPE_OBJECT + and self.type == CustomFieldTypeChoices.TYPE_OBJECT + ) + related_object_changed = ( + self._original_type == CustomFieldTypeChoices.TYPE_OBJECT + and self.related_object_type_id != self._original_related_object_type_id + ) + on_delete_changed = ( + self._original_type == CustomFieldTypeChoices.TYPE_OBJECT + and self.on_delete_behavior != self._original_on_delete_behavior + ) + should_ensure_fk = type_changed_to_object or related_object_changed or on_delete_changed - # Update parent's cache_timestamp to invalidate cache across all workers - self.custom_object_type.save(update_fields=['cache_timestamp']) + self.custom_object_type.clear_model_cache(self.custom_object_type.id) - super().save(*args, **kwargs) + # Bump cache_timestamp to invalidate other workers. snapshot() first + # so change logging records a correct pre-state. + self.custom_object_type.snapshot() + self.custom_object_type.save(update_fields=['cache_timestamp']) - # Ensure FK constraints AFTER the transaction commits to avoid "pending trigger events" errors + super().save(*args, **kwargs) + + # FK constraint runs AFTER commit to avoid "pending trigger events". if should_ensure_fk: _on_delete = self.on_delete_behavior _field_name = self.name @@ -2479,8 +3593,19 @@ def ensure_constraint(): transaction.on_commit(ensure_constraint) - # Reregister SearchIndex with new set of searchable fields - self.custom_object_type.register_custom_object_search_index(model) + # On rename, _schema_alter_field calls contribute_to_class twice on the + # same class — force a no_cache regeneration so _meta is clean. Non- + # rename changes lean on cache_timestamp for lazy invalidation; we skip + # the apps.clear_cache() cascade so signal-driven cache evictions (e.g. + # clear_cache_on_field_save for OBJECT fields) survive. + renamed = ( + not self._state.adding + and not self.is_polymorphic + and self._original_name != self.name + ) + if renamed: + updated_model = self.custom_object_type.get_model(no_cache=True) + self.custom_object_type.register_custom_object_search_index(updated_model) # Clean up stale descriptor when related_name is renamed on an existing polymorphic field if not is_new and self.is_polymorphic and hasattr(self, '_original'): @@ -2488,7 +3613,10 @@ def ensure_constraint(): if old_name and old_name != self.related_name: _unwire_polymorphic_reverse_descriptors(self, related_name=old_name) - # Reindex all objects of this type if search indexing was affected + # Reindex all objects of this type if search indexing was affected. + # self.original (backed by _original) is only set by from_db; the + # `not is_new` branch implies _state.adding is False, which implies + # the row came from the DB, so _original is guaranteed to exist. if is_new: needs_reindex = self.search_weight > 0 else: @@ -2500,14 +3628,20 @@ def ensure_constraint(): def delete(self, *args, **kwargs): field_type = FIELD_TYPE_CLASS[self.type]() model = self.custom_object_type.get_model() + schema_conn = _get_schema_connection() _dropped_through_model = None # Remove reverse descriptors before the M2M rows disappear via super().delete(). if self.is_polymorphic: _unwire_polymorphic_reverse_descriptors(self) - with connection.schema_editor() as schema_editor: - if self.is_polymorphic: + with schema_conn.schema_editor() as schema_editor: + if self.type == CustomObjectFieldTypeChoices.TYPE_COORDINATES: + # Drop both backing columns (latitude/longitude). + for column_name, model_field in field_type.get_model_field(self).items(): + model_field.contribute_to_class(model, column_name) + schema_editor.remove_field(model, model_field) + elif self.is_polymorphic: if self.type == CustomFieldTypeChoices.TYPE_OBJECT: field_type.remove_polymorphic_object_columns(self, model, schema_editor) elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: @@ -2517,16 +3651,18 @@ def delete(self, *args, **kwargs): pass field_type.drop_polymorphic_m2m_table(self, model, schema_editor) else: - model_field = field_type.get_model_field(self) - model_field.contribute_to_class(model, self.name) - + # _schema_remove_field drops the through table (for MULTIOBJECT) but + # leaves the real through model in apps.all_models; capture it so the + # deregistration below removes it (issue #535). if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: - _apps = model._meta.apps - _dropped_through_model = _apps.get_model(APP_LABEL, self.through_model_name) - schema_editor.delete_model(_dropped_through_model) - schema_editor.remove_field(model, model_field) + try: + _dropped_through_model = model._meta.apps.get_model(APP_LABEL, self.through_model_name) + except LookupError: + pass + _schema_remove_field(self, model, schema_editor, schema_conn=schema_conn) - # Deregister the dropped through model so the cascade-delete collector no longer queries the missing table. + # Deregister the dropped through model (both polymorphic and plain MULTIOBJECT) + # so the cascade-delete collector no longer queries the now-missing table. if _dropped_through_model is not None: through_name = _dropped_through_model.__name__.lower() with CustomObjectType._global_lock: @@ -2537,15 +3673,19 @@ def delete(self, *args, **kwargs): # Clear the model cache for this CustomObjectType when a field is deleted self.custom_object_type.clear_model_cache(self.custom_object_type.id) - # Update parent's cache_timestamp to invalidate cache across all workers + # snapshot() first so change logging records a correct pre-state. + self.custom_object_type.snapshot() self.custom_object_type.save(update_fields=['cache_timestamp']) super().delete(*args, **kwargs) - # Reregister SearchIndex with new set of searchable fields - self.custom_object_type.register_custom_object_search_index(model) + # Regenerate so the apps registry no longer holds a class with the + # removed column — squash revert may SELECT via the model class between + # field-undo and CO-undo, and a stale class would emit ProgrammingError. + updated_model = self.custom_object_type.get_model() + + self.custom_object_type.register_custom_object_search_index(updated_model) - # Reindex all objects of this type since a searchable field was removed if self.search_weight > 0: _cot_id = self.custom_object_type_id transaction.on_commit(lambda: ReindexCustomObjectTypeJob.enqueue(cot_id=_cot_id)) @@ -2798,6 +3938,7 @@ def clear_cache_on_field_save(sender, instance, **kwargs): try: related_cot = CustomObjectType.objects.get(object_type_id=instance.related_object_type_id) CustomObjectType.clear_model_cache(related_cot.id) + related_cot.snapshot() related_cot.save(update_fields=['cache_timestamp']) except CustomObjectType.DoesNotExist: pass diff --git a/netbox_custom_objects/templates/netbox_custom_objects/custom_object_bulk_delete.html b/netbox_custom_objects/templates/netbox_custom_objects/custom_object_bulk_delete.html index ce1346dd..9a7607f7 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/custom_object_bulk_delete.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/custom_object_bulk_delete.html @@ -14,10 +14,6 @@ {# Confirmation form #}
- {% if branch_warning %} - {% include 'netbox_custom_objects/inc/branch_warning.html' %} - {% endif %} - diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html index 0be4c39c..051d8c0e 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject.html @@ -10,9 +10,6 @@ {% load i18n %} {% load custom_object_utils %} -{% block extra_controls %} -{% endblock %} - {% block breadcrumbs %} @@ -27,6 +24,10 @@ {% block subtitle %}
+ {% if object.owner %} + {{ object.owner|linkify }} + · + {% endif %} {% trans "Created" %} {{ object.created|isodatetime:"minutes" }} {% if object.last_updated %} · @@ -38,6 +39,7 @@ {% block controls %}
{% block control-buttons %} + {% block extra_controls %}{% endblock %} {# Default buttons #} {% if perms.extras.add_bookmark and object.bookmarks %} {% custom_object_bookmark_button object %} @@ -73,6 +75,14 @@ + {% if object.custom_object_type.config_context_enabled %} + + {% endif %} + @@ -123,7 +133,24 @@ {% endif %} - {% customfield_value field object|get_field_value:field %} + {% if field.type == 'coordinates' %} + {% with coords=object|get_field_value:field %} + {% if coords %} + {{ coords }} + {% with map_url=object|get_coordinate_map_url:field %} + {% if map_url %} + + {% trans "Map" %} + + {% endif %} + {% endwith %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} + {% else %} + {% customfield_value field object|get_field_value:field %} + {% endif %} {% endif %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html b/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html index b10e8fd9..8bf2a0b8 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobject_edit.html @@ -14,10 +14,6 @@ {% endblock title %} {% block form %} - {% if branch_warning %} - {% include 'netbox_custom_objects/inc/branch_warning.html' %} - {% endif %} - {# Render hidden fields #} {% for field in form.hidden_fields %} {{ field }} @@ -29,15 +25,15 @@ {% block buttons %} {% trans "Cancel" %} {% if object.pk %} - {% else %}
- -
diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype.html index a6724902..ed0f7b59 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype.html @@ -37,6 +37,10 @@
{% trans "Custom Object Type" %}
{% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Config context support" %} + {% checkmark object.config_context_enabled %} + {% trans "Last activity" %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_delete.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_delete.html index a70435c2..4de80c5d 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_delete.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_delete.html @@ -2,8 +2,5 @@ {% load i18n %} {% block content %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock content %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_edit.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_edit.html index e56b8554..80c7c0da 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_edit.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_edit.html @@ -2,8 +2,5 @@ {% load i18n %} {% block content %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock content %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_import.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_import.html index 94a651ec..dd2d2bc6 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_import.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_bulk_import.html @@ -2,8 +2,5 @@ {% load i18n %} {% block content %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock content %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_delete.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_delete.html index b548fe31..67038f43 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_delete.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_delete.html @@ -2,8 +2,5 @@ {% load i18n %} {% block content %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock content %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_edit.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_edit.html index d7493013..b2224c1f 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_edit.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttype_edit.html @@ -2,8 +2,5 @@ {% load i18n %} {% block form %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock form %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttypefield_edit.html b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttypefield_edit.html index d7493013..b2224c1f 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/customobjecttypefield_edit.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/customobjecttypefield_edit.html @@ -2,8 +2,5 @@ {% load i18n %} {% block form %} - {% if branch_bypass_warning %} - {% include 'netbox_custom_objects/inc/branch_bypass_warning.html' %} - {% endif %} {{ block.super }} {% endblock form %} diff --git a/netbox_custom_objects/templates/netbox_custom_objects/htmx/bulk_edit_fields.html b/netbox_custom_objects/templates/netbox_custom_objects/htmx/bulk_edit_fields.html index 44d15a7a..e16b3cec 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/htmx/bulk_edit_fields.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/htmx/bulk_edit_fields.html @@ -24,5 +24,5 @@
{% trans "Cancel" %} - +
diff --git a/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_delete_form.html b/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_delete_form.html index 317a2b81..c7e54c4f 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_delete_form.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_delete_form.html @@ -11,9 +11,6 @@
diff --git a/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_quick_add.html b/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_quick_add.html new file mode 100644 index 00000000..19fdd82a --- /dev/null +++ b/netbox_custom_objects/templates/netbox_custom_objects/htmx/co_quick_add.html @@ -0,0 +1,27 @@ +{% load form_helpers %} +{% load helpers %} +{% load i18n %} + + \ No newline at end of file diff --git a/netbox_custom_objects/templates/netbox_custom_objects/htmx/delete_form.html b/netbox_custom_objects/templates/netbox_custom_objects/htmx/delete_form.html index 9d5ad6c5..0588a3a2 100644 --- a/netbox_custom_objects/templates/netbox_custom_objects/htmx/delete_form.html +++ b/netbox_custom_objects/templates/netbox_custom_objects/htmx/delete_form.html @@ -7,9 +7,6 @@