diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 612d01d89..44c797241 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -1,7 +1,7 @@ --- name: 🐛 Bug Report description: Report a reproducible bug in the current release of NetBox -labels: ["type: bug"] +labels: ["type: bug", "needs triage"] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml index cb097d579..0f80f1716 100644 --- a/.github/ISSUE_TEMPLATE/documentation_change.yaml +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -1,7 +1,7 @@ --- name: 📖 Documentation Change description: Suggest an addition or modification to the NetBox documentation -labels: ["type: documentation"] +labels: ["type: documentation", "needs triage"] body: - type: dropdown attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 8eb47180d..a550bff57 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -1,7 +1,7 @@ --- name: ✨ Feature Request description: Propose a new NetBox feature or enhancement -labels: ["type: feature"] +labels: ["type: feature", "needs triage"] body: - type: markdown attributes: diff --git a/.github/workflows/auto-assign-issue.yml b/.github/workflows/auto-assign-issue.yml new file mode 100644 index 000000000..20e054806 --- /dev/null +++ b/.github/workflows/auto-assign-issue.yml @@ -0,0 +1,20 @@ +# auto-assign-issue (https://github.com/marketplace/actions/auto-assign-issue) +name: Issue assignment + +on: + issues: + types: [opened] + +permissions: + issues: write + +jobs: + auto-assign: + runs-on: ubuntu-latest + steps: + - uses: pozil/auto-assign-issue@v1 + if: "contains(github.event.issue.labels.*.name, 'type: bug') || contains(github.event.issue.labels.*.name, 'type: feature')" + with: + assignees: abhi1693,arthanson,DanSheps,jeffgdotorg,jeremystretch + numOfAssignee: 1 + abortIfPreviousAssignees: true diff --git a/.github/workflows/stale.yml b/.github/workflows/close-stale-issues.yml similarity index 96% rename from .github/workflows/stale.yml rename to .github/workflows/close-stale-issues.yml index 22de146a2..a1bbb0b7e 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,5 +1,5 @@ # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues) -name: 'Close stale issues/PRs' +name: Close stale issues/PRs on: schedule: @@ -12,10 +12,9 @@ permissions: jobs: stale: - runs-on: ubuntu-latest steps: - - uses: actions/stale@v8 + - uses: actions/stale@v9 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an diff --git a/.github/workflows/lock.yml b/.github/workflows/lock-threads.yml similarity index 95% rename from .github/workflows/lock.yml rename to .github/workflows/lock-threads.yml index ad3bf5d75..0f3636784 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock-threads.yml @@ -1,5 +1,5 @@ # lock-threads (https://github.com/marketplace/actions/lock-threads) -name: 'Lock threads' +name: Lock threads on: schedule: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bb42ca19f..c69c51748 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,8 +1,8 @@ version: 2 build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: - python: "3.9" + python: "3.12" mkdocs: configuration: mkdocs.yml python: diff --git a/README.md b/README.md index a81e41854..8d2efed23 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ NetBox automatically logs the creation, modification, and deletion of all manage
- Looking for an enterprise solution? Check out NetBox Cloud!
+ Looking for a managed solution? Check out NetBox Cloud or NetBox Enterprise!
```
-
-````
-```
-var s = "Code block";
-alert(s);
-```
-````
-
-```
-var s = "Code block";
-alert(s);
-```
-
-
-
-## Tables
-
-```no-highlight
-Colons can be used to align columns.
-
-| Tables | Are | Cool |
-| ------------- |:-------------:| -----:|
-| col 3 is | right-aligned | $1600 |
-| col 2 is | centered | $12 |
-| zebra stripes | are neat | $1 |
-
-There must be at least 3 dashes separating each header cell.
-The outer pipes (|) are optional, and you don't need to make the
-raw Markdown line up prettily. You can also use inline Markdown.
-
-Markdown | Less | Pretty
---- | --- | ---
-*Still* | `renders` | **nicely**
-1 | 2 | 3
-```
-
-Colons can be used to align columns.
-
-| Tables | Are | Cool |
-| ------------- |:-------------:| -----:|
-| col 3 is | right-aligned | $1600 |
-| col 2 is | centered | $12 |
-| zebra stripes | are neat | $1 |
-
-There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
-
-Markdown | Less | Pretty
---- | --- | ---
-*Still* | `renders` | **nicely**
-1 | 2 | 3
-
-
-
-## Blockquotes
-
-```no-highlight
-> Blockquotes are very handy in email to emulate reply text.
-> This line is part of the same quote.
-
-Quote break.
-
-> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
-```
-
-> Blockquotes are very handy in email to emulate reply text.
-> This line is part of the same quote.
-
-Quote break.
-
-> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
-
-
-
-## Inline HTML
-
-You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
-
-```no-highlight
-` and `
` tags. +``` + +Paragraphs are rendered in HTML using `` and `
` tags. + +```` +``` +def my_func(foo, bar): + # Do something + return foo * bar +``` +```` + +```no-highlight +def my_func(foo, bar): + # Do something + return foo * bar +``` + +## Tables + +Simple tables can be constructed using the pipe character (`|`) to denote columns, and hyphens (`-`) to denote the heading. Inline Markdown can be used to style text within columns. + +```no-highlight +| Heading 1 | Heading 2 | Heading 3 | +|-----------|-----------|-----------| +| Row 1 | Alpha | Red | +| Row 2 | **Bravo** | Green | +| Row 3 | Charlie | ~~Blue~~ | +``` + +| Heading 1 | Heading 2 | Heading 3 | +|-----------|-----------|-----------| +| _Row 1_ | Alpha | Red | +| Row 2 | **Bravo** | Green | +| Row 3 | Charlie | ~~Blue~~ | + +Colons can be used to align text to the left or right side of a column. + +```no-highlight +| Left-aligned | Centered | Right-aligned | +|:-------------|:--------:|--------------:| +| Text | Text | Text | +| Text | Text | Text | +| Text | Text | Text | +``` + +| Left-aligned | Centered | Right-aligned | +|:-------------|:--------:|--------------:| +| Text | Text | Text | +| Text | Text | Text | +| Text | Text | Text | + +## Blockquotes + +Text can be wrapped in a blockquote by prepending a right angle bracket (`>`) before each line. + +```no-highlight +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. +``` + +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. + +Markdown removes line breaks by default. To preserve line breaks, append two spaces to each line (represented below with the `⋅` character). + +```no-highlight +> I think that I shall never see⋅⋅ +> a graph more lovely than a tree.⋅⋅ +> A tree whose crucial property⋅⋅ +> is loop-free connectivity. +``` + +> I think that I shall never see +> a graph more lovely than a tree. +> A tree whose crucial property +> is loop-free connectivity. + +## Horizontal Rule + +A horizontal rule is a single line rendered across the width of the page using a series of three or more hyphens or asterisks. It can be useful for separating sections of content. + +```no-highlight +Content + +--- + +More content + +*** + +Final content +``` + +Content + +--- + +More content + +*** + +Final content diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index f01d3160f..b1f23ae55 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,14 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 4.0](./version-4.0.md) (April 2024) + +* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128)) +* Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087)) +* Strawberry GraphQL Engine ([#9856](https://github.com/netbox-community/netbox/issues/9856)) +* Advanced Form Rendering Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) +* Legacy Admin UI Disabled ([#12325](https://github.com/netbox-community/netbox/issues/12325)) + #### [Version 3.7](./version-3.7.md) (December 2023) * VPN Tunnels ([#9816](https://github.com/netbox-community/netbox/issues/9816)) diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md index 9724c4488..0e4bcfc5b 100644 --- a/docs/release-notes/version-3.7.md +++ b/docs/release-notes/version-3.7.md @@ -2,6 +2,11 @@ ## v3.7.5 (FUTURE) +### Bug Fixes + +* [#14799](https://github.com/netbox-community/netbox/issues/14799) - Avoid caching modified reports & scripts +* [#15502](https://github.com/netbox-community/netbox/issues/15502) - Fix KeyError exception when modifying an IP address assigned to a virtual machine + --- ## v3.7.4 (2024-03-13) diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index b5889f8cd..ad5304331 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -1,46 +1,104 @@ # NetBox v4.0 -## v4.0.0 (FUTURE) +## v4.0-beta2 (FUTURE) + +**WARNING:** This is a beta release of NetBox intended for testing and evaluation. **Do not use this software in production.** Also be aware that no upgrade path is provided to future releases. + +!!! tip "Plugin Maintainers" + Please see the dedicated [plugin migration guide](../plugins/development/migration-v4.md) for a checklist of changes that may be needed to ensure compatibility with NetBox v4.0. ### Breaking Changes +* Support for Python 3.8 and 3.9 has been removed. +* The format for GraphQL query filters has changed. Please see the GraphQL documentation for details and examples. * The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.) +* The obsolete `device_role` field has been removed from the REST API serializer for devices. (Use `role` instead.) * The legacy reports functionality has been dropped. Reports will be automatically converted to custom scripts on upgrade. * The `parent` and `parent_id` filters for locations now return only immediate children of the specified location. (Use `ancestor` and `ancestor_id` to return _all_ descendants.) +* The `object_type` field on the CustomField model has been renamed to `related_object_type`. +* The `utilities.utils` module has been removed and its resources reorganized into separate modules organized by function. +* The obsolete `NullableCharField` class has been removed. (Use Django's stock `CharField` class with `null=True` instead.) ### New Features #### Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128)) -The NetBox user interface has been completely refreshed and updated. +The NetBox user interface has been completely refreshed and updated. This massive effort entailed: + +* Refactoring the base HTML templates +* Moving from Boostrap 5.0 to Bootstrap 5.3 +* Adopting the [Tabler](https://tabler.io/) UI theme +* Replacing slim-select with [Tom-Select](https://tom-select.js.org/) +* Displaying additional object attributes in dropdown form fields +* Enabling opt-in HTMX-powered navigation (see [#14736](https://github.com/netbox-community/netbox/issues/14736)) +* Widespread cleanup & standardization of UI components #### Dynamic REST API Fields ([#15087](https://github.com/netbox-community/netbox/issues/15087)) -The REST API now supports specifying which fields to include in the response data. +The REST API now supports specifying which fields to include in the response data. For example, the response to a request for -#### Advanced FieldSet Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) +``` +GET /api/dcim/sites/?fields=name,status,region,tenant +``` -New resources have been introduced to enable advanced form rendering without a need for custom HTML templates. +will include only the four specified fields in the representation of each site. Additionally, the underlying database queries effected by such requests have been optimized to omit fields which are not included in the response, resulting in a substantial performance improvement. + +#### Strawberry GraphQL Engine ([#9856](https://github.com/netbox-community/netbox/issues/9856)) + +The GraphQL engine has been changed from using Graphene-Django to Strawberry-Django. Changes include: + +* Queryset Optimizer - reduces the number of database queries when querying related tables +* Updated GraphiQL Browser +* The format for GraphQL query filters and lookups has changed. Please see the GraphQL documentation for details and examples. + +#### Advanced Form Rendering Functionality ([#14739](https://github.com/netbox-community/netbox/issues/14739)) + +New resources have been introduced to enable advanced form rendering without a need for custom HTML templates. These include: + +* FieldSet - Represents a grouping of form fields (replaces the use of lists/tuples) +* InlineFields - Multiple fields rendered on a single row +* TabbedGroups - Fieldsets rendered under navigable tabs within a form +* ObjectAttribute - Renders a read-only representation of a particular object attribute (for reference) + +#### Legacy Admin UI Disabled ([#12325](https://github.com/netbox-community/netbox/issues/12325)) + +The legacy admin user interface is now disabled by default, and the few remaining views it provided have been relocated to the primary UI. NetBox deployments which still depend on the legacy admin functionality for plugins can enable it by setting the `DJANGO_ADMIN_ENABLED` configuration parameter to true. ### Enhancements -* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace bleach HTML sanitization library with nh3 -* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown fields +* [#12776](https://github.com/netbox-community/netbox/issues/12776) - Introduce the `htmx_talble` template tag to simplify the rendering of embedded tables +* [#12851](https://github.com/netbox-community/netbox/issues/12851) - Replace the deprecated Bleach HTML sanitization library with nh3 +* [#13283](https://github.com/netbox-community/netbox/issues/13283) - Display additional context on API-backed dropdown form fields (e.g. object descriptions) * [#13918](https://github.com/netbox-community/netbox/issues/13918) - Add `facility` field to Location model -* [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection fields when modifying a parent selection -* [#14454](https://github.com/netbox-community/netbox/issues/14454) - Include member devices for virtual chassis in REST API +* [#14237](https://github.com/netbox-community/netbox/issues/14237) - Automatically clear dependent selection form fields when modifying a parent selection +* [#14279](https://github.com/netbox-community/netbox/issues/14279) - Make the current request available as context when running custom validators +* [#14454](https://github.com/netbox-community/netbox/issues/14454) - Include member devices in the REST API representation of virtual chassis * [#14637](https://github.com/netbox-community/netbox/issues/14637) - Upgrade to Django 5.0 * [#14672](https://github.com/netbox-community/netbox/issues/14672) - Add support for Python 3.12 * [#14728](https://github.com/netbox-community/netbox/issues/14728) - The plugins list view has been moved from the legacy admin UI to the main NetBox UI * [#14729](https://github.com/netbox-community/netbox/issues/14729) - All background task views have been moved from the legacy admin UI to the main NetBox UI +* [#14736](https://github.com/netbox-community/netbox/issues/14736) - Introduce a user preference to enable HTMX-powered navigation * [#14438](https://github.com/netbox-community/netbox/issues/14438) - Track individual custom scripts as database objects * [#15131](https://github.com/netbox-community/netbox/issues/15131) - Automatically annotate related object counts on REST API querysets -* [#15237](https://github.com/netbox-community/netbox/issues/15237) - Ensure consistent filtering ability for all model fields +* [#15237](https://github.com/netbox-community/netbox/issues/15237) - Ensure consistent filtering ability for all model fields by testing for missing/incorrect filters * [#15238](https://github.com/netbox-community/netbox/issues/15238) - Include the `description` field in "brief" REST API serializations +* [#15278](https://github.com/netbox-community/netbox/issues/15278) - BaseModelSerializer now takes a `nested` keyword argument allowing it to represent a related object * [#15383](https://github.com/netbox-community/netbox/issues/15383) - Standardize filtering logic for the parents of recursively-nested models (parent & ancestor filters) +* [#15413](https://github.com/netbox-community/netbox/issues/15413) - The global search engine now supports caching of non-field object attributes +* [#15490](https://github.com/netbox-community/netbox/issues/15490) - Custom validators can now reference related object attributes via dotted paths + +### Bug Fixes (from Beta1) + +* [#15605](https://github.com/netbox-community/netbox/issues/15605) - Fix `ProgrammingError` exception when applying migrations to older databases +* [#15616](https://github.com/netbox-community/netbox/issues/15616) - Fix button style for invalid custom links +* [#15617](https://github.com/netbox-community/netbox/issues/15617) - Fix rack elevation styling under dark mode +* [#15619](https://github.com/netbox-community/netbox/issues/15619) - Enforce a minimum width for progress bars +* [#15637](https://github.com/netbox-community/netbox/issues/15637) - Correct nonfunctional links within embedded tables when HTMX enabled +* [#15638](https://github.com/netbox-community/netbox/issues/15638) - Correct parameter used to retrieve saved filters for a model ### Other Changes +* [#10587](https://github.com/netbox-community/netbox/issues/10587) - Enable pagination and filtering for custom script logs * [#12325](https://github.com/netbox-community/netbox/issues/12325) - The Django admin UI is now disabled by default (set `DJANGO_ADMIN_ENABLED` to True to enable it) * [#12510](https://github.com/netbox-community/netbox/issues/12510) - Dropped support for legacy reports * [#12795](https://github.com/netbox-community/netbox/issues/12795) - NetBox now uses custom User and Group models rather than the stock models provided by Django @@ -48,43 +106,59 @@ New resources have been introduced to enable advanced form rendering without a n * [#14092](https://github.com/netbox-community/netbox/issues/14092) - Remove backward compatibility for importing plugin resources from `extras.plugins` (now `netbox.plugins`) * [#14638](https://github.com/netbox-community/netbox/issues/14638) - Drop support for Python 3.8 and 3.9 * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin` -* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`) +* [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` from `extras.webhooks_worker` (now `extras.webhooks.send_webhook()`) * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class -* [#15042](https://github.com/netbox-community/netbox/issues/15042) - Rearchitect the logic for registering models & model features +* [#15042](https://github.com/netbox-community/netbox/issues/15042) - The logic for registering models & model features now executes under the `ready()` method of individual app configs, rather than relying on the `class_prepared` signal * [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices * [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class +* [#15154](https://github.com/netbox-community/netbox/issues/15154) - The installation documentation been extended to include instructions and an example configuration file for uWSGI as an alternative to gunicorn * [#15193](https://github.com/netbox-community/netbox/issues/15193) - Switch to compiled distribution of the `psycopg` library * [#15277](https://github.com/netbox-community/netbox/issues/15277) - Replace references to ContentType without ObjectType proxy model & standardize field names * [#15292](https://github.com/netbox-community/netbox/issues/15292) - Remove obsolete `device_role` attribute from Device model (this field was renamed to `role` in v3.6) +* [#15357](https://github.com/netbox-community/netbox/issues/15357) - The `object_type` field on the CustomField model has been renamed to `related_object_type` to avoid confusion with its `object_types` field +* [#15401](https://github.com/netbox-community/netbox/issues/15401) - PostgreSQL indexes and sequence tables for the relocated L2VPN models (see [#14311](https://github.com/netbox-community/netbox/issues/14311)) have been renamed +* [#15462](https://github.com/netbox-community/netbox/issues/15462) - Relocate resources from the `utilities.utils` module +* [#15464](https://github.com/netbox-community/netbox/issues/15464) - The many-to-many relationships for ObjectPermission are now defined on the custom User and Group models ### REST API Changes * The `/api/extras/content-types/` endpoint has moved to `/api/extras/object-types/` +* The `/api/extras/reports/` endpoint has been removed +* The `description` field is now included by default when using "brief mode" for all relevant models * dcim.Device - * The obsolete read-only attribute `device_role` has been removed (replaced by `role` in v3.6) + * The obsolete read-only attribute `device_role` has been removed (replaced by `role` in v3.6) +* dcim.Location + * Added the optional `location` field +* dcim.VirtualChassis + * Added `members` field to list the member devices * extras.CustomField - * `content_types` has been renamed to `object_types` - * The `content_types` filter is now `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_types` has been renamed to `object_types` + * `object_type` has been renamed to `related_object_type` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` * extras.CustomLink - * `content_types` has been renamed to `object_types` - * The `content_types` filter is now `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` * extras.EventRule - * `content_types` has been renamed to `object_types` - * The `content_types` filter is now `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` * extras.ExportTemplate - * `content_types` has been renamed to `object_types` - * The `content_types` filter is now `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` * extras.ImageAttachment - * `content_type` has been renamed to `object_type` - * The `content_type` filter is now `object_type` + * `content_type` has been renamed to `object_type` + * The `content_type` filter is now `object_type` * extras.SavedFilter - * `content_types` has been renamed to `object_types` - * The `content_types` filter is now `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_types` has been renamed to `object_types` + * The `content_types` filter is now `object_type` + * The `content_type_id` filter is now `object_type_id` * tenancy.ContactAssignment - * `content_type` has been renamed to `object_type` - * The `content_type_id` filter is now `object_type_id` + * `content_type` has been renamed to `object_type` + * The `content_type_id` filter is now `object_type_id` +* users.Group + * Added the `permissions` field +* users.User + * Added the `permissions` field diff --git a/mkdocs.yml b/mkdocs.yml index 354c10608..3db4734a4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,7 +94,8 @@ nav: - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. Redis: 'installation/2-redis.md' - 3. NetBox: 'installation/3-netbox.md' - - 4. Gunicorn: 'installation/4-gunicorn.md' + - 4a. Gunicorn: 'installation/4a-gunicorn.md' + - 4b. uWSGI: 'installation/4b-uwsgi.md' - 5. HTTP Server: 'installation/5-http-server.md' - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' @@ -146,6 +147,7 @@ nav: - Dashboard Widgets: 'plugins/development/dashboard-widgets.md' - Staged Changes: 'plugins/development/staged-changes.md' - Exceptions: 'plugins/development/exceptions.md' + - Migrating to v4.0: 'plugins/development/migration-v4.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' diff --git a/netbox/circuits/graphql/filters.py b/netbox/circuits/graphql/filters.py new file mode 100644 index 000000000..10887ce3f --- /dev/null +++ b/netbox/circuits/graphql/filters.py @@ -0,0 +1,50 @@ +import strawberry +import strawberry_django +from circuits import filtersets, models + +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'CircuitTerminationFilter', + 'CircuitFilter', + 'CircuitTypeFilter', + 'ProviderFilter', + 'ProviderAccountFilter', + 'ProviderNetworkFilter', +) + + +@strawberry_django.filter(models.CircuitTermination, lookups=True) +@autotype_decorator(filtersets.CircuitTerminationFilterSet) +class CircuitTerminationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Circuit, lookups=True) +@autotype_decorator(filtersets.CircuitFilterSet) +class CircuitFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CircuitType, lookups=True) +@autotype_decorator(filtersets.CircuitTypeFilterSet) +class CircuitTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Provider, lookups=True) +@autotype_decorator(filtersets.ProviderFilterSet) +class ProviderFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ProviderAccount, lookups=True) +@autotype_decorator(filtersets.ProviderAccountFilterSet) +class ProviderAccountFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ProviderNetwork, lookups=True) +@autotype_decorator(filtersets.ProviderNetworkFilterSet) +class ProviderNetworkFilter(BaseFilterMixin): + pass diff --git a/netbox/circuits/graphql/schema.py b/netbox/circuits/graphql/schema.py index 3d85f2512..ac8626cc5 100644 --- a/netbox/circuits/graphql/schema.py +++ b/netbox/circuits/graphql/schema.py @@ -1,41 +1,40 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from circuits import models -from netbox.graphql.fields import ObjectField, ObjectListField from .types import * -from utilities.graphql_optimizer import gql_query_optimizer -class CircuitsQuery(graphene.ObjectType): - circuit = ObjectField(CircuitType) - circuit_list = ObjectListField(CircuitType) +@strawberry.type +class CircuitsQuery: + @strawberry.field + def circuit(self, id: int) -> CircuitType: + return models.Circuit.objects.get(pk=id) + circuit_list: List[CircuitType] = strawberry_django.field() - def resolve_circuit_list(root, info, **kwargs): - return gql_query_optimizer(models.Circuit.objects.all(), info) + @strawberry.field + def circuit_termination(self, id: int) -> CircuitTerminationType: + return models.CircuitTermination.objects.get(pk=id) + circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field() - circuit_termination = ObjectField(CircuitTerminationType) - circuit_termination_list = ObjectListField(CircuitTerminationType) + @strawberry.field + def circuit_type(self, id: int) -> CircuitTypeType: + return models.CircuitType.objects.get(pk=id) + circuit_type_list: List[CircuitTypeType] = strawberry_django.field() - def resolve_circuit_termination_list(root, info, **kwargs): - return gql_query_optimizer(models.CircuitTermination.objects.all(), info) + @strawberry.field + def provider(self, id: int) -> ProviderType: + return models.Provider.objects.get(pk=id) + provider_list: List[ProviderType] = strawberry_django.field() - circuit_type = ObjectField(CircuitTypeType) - circuit_type_list = ObjectListField(CircuitTypeType) + @strawberry.field + def provider_account(self, id: int) -> ProviderAccountType: + return models.ProviderAccount.objects.get(pk=id) + provider_account_list: List[ProviderAccountType] = strawberry_django.field() - def resolve_circuit_type_list(root, info, **kwargs): - return gql_query_optimizer(models.CircuitType.objects.all(), info) - - provider = ObjectField(ProviderType) - provider_list = ObjectListField(ProviderType) - - def resolve_provider_list(root, info, **kwargs): - return gql_query_optimizer(models.Provider.objects.all(), info) - - provider_account = ObjectField(ProviderAccountType) - provider_account_list = ObjectListField(ProviderAccountType) - - provider_network = ObjectField(ProviderNetworkType) - provider_network_list = ObjectListField(ProviderNetworkType) - - def resolve_provider_network_list(root, info, **kwargs): - return gql_query_optimizer(models.ProviderNetwork.objects.all(), info) + @strawberry.field + def provider_network(self, id: int) -> ProviderNetworkType: + return models.ProviderNetwork.objects.get(pk=id) + provider_network_list: List[ProviderNetworkType] = strawberry_django.field() diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index baa135e00..bae91e6b0 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,9 +1,14 @@ -import graphene +from typing import Annotated, List -from circuits import filtersets, models +import strawberry +import strawberry_django + +from circuits import models from dcim.graphql.mixins import CabledObjectMixin -from extras.graphql.mixins import CustomFieldsMixin, TagsMixin, ContactsMixin -from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType +from extras.graphql.mixins import ContactsMixin, CustomFieldsMixin, TagsMixin +from netbox.graphql.types import NetBoxObjectType, ObjectType, OrganizationalObjectType +from tenancy.graphql.types import TenantType +from .filters import * __all__ = ( 'CircuitTerminationType', @@ -15,48 +20,74 @@ __all__ = ( ) -class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): - - class Meta: - model = models.CircuitTermination - fields = '__all__' - filterset_class = filtersets.CircuitTerminationFilterSet - - -class CircuitType(NetBoxObjectType, ContactsMixin): - class Meta: - model = models.Circuit - fields = '__all__' - filterset_class = filtersets.CircuitFilterSet - - -class CircuitTypeType(OrganizationalObjectType): - - class Meta: - model = models.CircuitType - fields = '__all__' - filterset_class = filtersets.CircuitTypeFilterSet - - +@strawberry_django.type( + models.Provider, + fields='__all__', + filters=ProviderFilter +) class ProviderType(NetBoxObjectType, ContactsMixin): - class Meta: - model = models.Provider - fields = '__all__' - filterset_class = filtersets.ProviderFilterSet + networks: List[Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')]] + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] + asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + accounts: List[Annotated["ProviderAccountType", strawberry.lazy('circuits.graphql.types')]] +@strawberry_django.type( + models.ProviderAccount, + fields='__all__', + filters=ProviderAccountFilter +) class ProviderAccountType(NetBoxObjectType): + provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] - class Meta: - model = models.ProviderAccount - fields = '__all__' - filterset_class = filtersets.ProviderAccountFilterSet + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] +@strawberry_django.type( + models.ProviderNetwork, + fields='__all__', + filters=ProviderNetworkFilter +) class ProviderNetworkType(NetBoxObjectType): + provider: Annotated["ProviderType", strawberry.lazy('circuits.graphql.types')] - class Meta: - model = models.ProviderNetwork - fields = '__all__' - filterset_class = filtersets.ProviderNetworkFilterSet + circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] + + +@strawberry_django.type( + models.CircuitTermination, + fields='__all__', + filters=CircuitTerminationFilter +) +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): + circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')] + provider_network: Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')] | None + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None + + +@strawberry_django.type( + models.CircuitType, + fields='__all__', + filters=CircuitTypeFilter +) +class CircuitTypeType(OrganizationalObjectType): + color: str + + circuits: List[Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]] + + +@strawberry_django.type( + models.Circuit, + fields='__all__', + filters=CircuitFilter +) +class CircuitType(NetBoxObjectType, ContactsMixin): + provider: ProviderType + provider_account: ProviderAccountType | None + termination_a: CircuitTerminationType | None + termination_z: CircuitTerminationType | None + type: CircuitTypeType + tenant: TenantType | None + + terminations: List[CircuitTerminationType] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 0c01d6eb9..54f875975 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -6,7 +6,7 @@ from dcim.views import PathTraceView from netbox.views import generic from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm -from utilities.utils import count_related +from utilities.query import count_related from utilities.views import register_model_view from . import filtersets, forms, tables from .models import * diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index 8eecfa8b9..5f64dcc53 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -156,8 +156,6 @@ class NetBoxAutoSchema(AutoSchema): remove_fields.append(child_name) if isinstance(child, (ChoiceField, WritableNestedSerializer)): properties[child_name] = None - elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField): - properties[child_name] = None if not properties: return None diff --git a/netbox/core/graphql/filters.py b/netbox/core/graphql/filters.py new file mode 100644 index 000000000..64b4d0de2 --- /dev/null +++ b/netbox/core/graphql/filters.py @@ -0,0 +1,21 @@ +import strawberry_django + +from core import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'DataFileFilter', + 'DataSourceFilter', +) + + +@strawberry_django.filter(models.DataFile, lookups=True) +@autotype_decorator(filtersets.DataFileFilterSet) +class DataFileFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DataSource, lookups=True) +@autotype_decorator(filtersets.DataSourceFilterSet) +class DataSourceFilter(BaseFilterMixin): + pass diff --git a/netbox/core/graphql/schema.py b/netbox/core/graphql/schema.py index 876faa442..34135cd47 100644 --- a/netbox/core/graphql/schema.py +++ b/netbox/core/graphql/schema.py @@ -1,20 +1,20 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from core import models -from netbox.graphql.fields import ObjectField, ObjectListField from .types import * -from utilities.graphql_optimizer import gql_query_optimizer -class CoreQuery(graphene.ObjectType): - data_file = ObjectField(DataFileType) - data_file_list = ObjectListField(DataFileType) +@strawberry.type +class CoreQuery: + @strawberry.field + def data_file(self, id: int) -> DataFileType: + return models.DataFile.objects.get(pk=id) + data_file_list: List[DataFileType] = strawberry_django.field() - def resolve_data_file_list(root, info, **kwargs): - return gql_query_optimizer(models.DataFile.objects.all(), info) - - data_source = ObjectField(DataSourceType) - data_source_list = ObjectListField(DataSourceType) - - def resolve_data_source_list(root, info, **kwargs): - return gql_query_optimizer(models.DataSource.objects.all(), info) + @strawberry.field + def data_source(self, id: int) -> DataSourceType: + return models.DataSource.objects.get(pk=id) + data_source_list: List[DataSourceType] = strawberry_django.field() diff --git a/netbox/core/graphql/types.py b/netbox/core/graphql/types.py index 402e36345..8287bfa31 100644 --- a/netbox/core/graphql/types.py +++ b/netbox/core/graphql/types.py @@ -1,5 +1,11 @@ -from core import filtersets, models +from typing import Annotated, List + +import strawberry +import strawberry_django + +from core import models from netbox.graphql.types import BaseObjectType, NetBoxObjectType +from .filters import * __all__ = ( 'DataFileType', @@ -7,15 +13,20 @@ __all__ = ( ) +@strawberry_django.type( + models.DataFile, + exclude=['data',], + filters=DataFileFilter +) class DataFileType(BaseObjectType): - class Meta: - model = models.DataFile - exclude = ('data',) - filterset_class = filtersets.DataFileFilterSet + source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] +@strawberry_django.type( + models.DataSource, + fields='__all__', + filters=DataSourceFilter +) class DataSourceType(NetBoxObjectType): - class Meta: - model = models.DataSource - fields = '__all__' - filterset_class = filtersets.DataSourceFilterSet + + datafiles: List[Annotated["DataFileType", strawberry.lazy('core.graphql.types')]] diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index 4ceb22ba9..48fa2ff71 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -1,3 +1,4 @@ +import hashlib import logging import os import yaml @@ -18,7 +19,6 @@ from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED from netbox.models import PrimaryModel from netbox.models.features import JobsMixin from netbox.registry import registry -from utilities.files import sha256_hash from utilities.querysets import RestrictedQuerySet from ..choices import * from ..exceptions import SyncError @@ -357,7 +357,8 @@ class DataFile(models.Model): has changed. """ file_path = os.path.join(source_root, self.path) - file_hash = sha256_hash(file_path).hexdigest() + with open(file_path, 'rb') as f: + file_hash = hashlib.sha256(f.read()).hexdigest() # Update instance file attributes & data if is_modified := file_hash != self.hash: diff --git a/netbox/core/models/files.py b/netbox/core/models/files.py index 5a321bdc3..7b626a441 100644 --- a/netbox/core/models/files.py +++ b/netbox/core/models/files.py @@ -89,6 +89,9 @@ class ManagedFile(SyncedDataMixin, models.Model): def clean(self): super().clean() + if self.data_file and not self.file_path: + self.file_path = os.path.basename(self.data_path) + # Ensure that the file root and path make a unique pair if self._meta.model.objects.filter(file_root=self.file_root, file_path=self.file_path).exclude(pk=self.pk).exists(): raise ValidationError( diff --git a/netbox/core/views.py b/netbox/core/views.py index 0379900e1..b19ab207b 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -25,7 +25,8 @@ from netbox.views import generic from netbox.views.generic.base import BaseObjectView from netbox.views.generic.mixins import TableMixin from utilities.forms import ConfirmationForm -from utilities.utils import count_related +from utilities.htmx import htmx_partial +from utilities.query import count_related from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from . import filtersets, forms, tables from .models import * @@ -320,7 +321,7 @@ class BackgroundTaskListView(TableMixin, BaseRQView): table = self.get_table(data, request, False) # If this is an HTMX request, return only the rendered table HTML - if request.htmx: + if htmx_partial(request): return render(request, 'htmx/table.html', { 'table': table, }) @@ -489,8 +490,8 @@ class WorkerListView(TableMixin, BaseRQView): table = self.get_table(data, request, False) # If this is an HTMX request, return only the rendered table HTML - if request.htmx: - if request.htmx.target != 'object_list': + if htmx_partial(request): + if not request.htmx.target: table.embedded = True # Hide selection checkboxes if 'pk' in table.base_columns: diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index 0bd8ba824..a5830fa90 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -1,3 +1,5 @@ +import decimal + from django.utils.translation import gettext as _ from rest_framework import serializers @@ -22,7 +24,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer): max_digits=4, decimal_places=1, label=_('Position (U)'), - min_value=0, + min_value=decimal.Decimal(0), default=1.0 ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 2ff9f49ae..ad1e29f26 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -10,12 +10,12 @@ from extras.filtersets import LocalConfigContextFilterSet from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from ipam.models import ASN, IPAddress, VRF +from netbox.choices import ColorChoices from netbox.filtersets import ( BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * -from utilities.choices import ColorChoices from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py new file mode 100644 index 000000000..2ae5e7771 --- /dev/null +++ b/netbox/dcim/graphql/filters.py @@ -0,0 +1,294 @@ +import strawberry_django + +from dcim import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'CableFilter', + 'CableTerminationFilter', + 'ConsolePortFilter', + 'ConsolePortTemplateFilter', + 'ConsoleServerPortFilter', + 'ConsoleServerPortTemplateFilter', + 'DeviceFilter', + 'DeviceBayFilter', + 'DeviceBayTemplateFilter', + 'InventoryItemTemplateFilter', + 'DeviceRoleFilter', + 'DeviceTypeFilter', + 'FrontPortFilter', + 'FrontPortTemplateFilter', + 'InterfaceFilter', + 'InterfaceTemplateFilter', + 'InventoryItemFilter', + 'InventoryItemRoleFilter', + 'LocationFilter', + 'ManufacturerFilter', + 'ModuleFilter', + 'ModuleBayFilter', + 'ModuleBayTemplateFilter', + 'ModuleTypeFilter', + 'PlatformFilter', + 'PowerFeedFilter', + 'PowerOutletFilter', + 'PowerOutletTemplateFilter', + 'PowerPanelFilter', + 'PowerPortFilter', + 'PowerPortTemplateFilter', + 'RackFilter', + 'RackReservationFilter', + 'RackRoleFilter', + 'RearPortFilter', + 'RearPortTemplateFilter', + 'RegionFilter', + 'SiteFilter', + 'SiteGroupFilter', + 'VirtualChassisFilter', + 'VirtualDeviceContextFilter', +) + + +@strawberry_django.filter(models.Cable, lookups=True) +@autotype_decorator(filtersets.CableFilterSet) +class CableFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CableTermination, lookups=True) +@autotype_decorator(filtersets.CableTerminationFilterSet) +class CableTerminationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsolePort, lookups=True) +@autotype_decorator(filtersets.ConsolePortFilterSet) +class ConsolePortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsolePortTemplate, lookups=True) +@autotype_decorator(filtersets.ConsolePortTemplateFilterSet) +class ConsolePortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsoleServerPort, lookups=True) +@autotype_decorator(filtersets.ConsoleServerPortFilterSet) +class ConsoleServerPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConsoleServerPortTemplate, lookups=True) +@autotype_decorator(filtersets.ConsoleServerPortTemplateFilterSet) +class ConsoleServerPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Device, lookups=True) +@autotype_decorator(filtersets.DeviceFilterSet) +class DeviceFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceBay, lookups=True) +@autotype_decorator(filtersets.DeviceBayFilterSet) +class DeviceBayFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceBayTemplate, lookups=True) +@autotype_decorator(filtersets.DeviceBayTemplateFilterSet) +class DeviceBayTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItemTemplate, lookups=True) +@autotype_decorator(filtersets.InventoryItemTemplateFilterSet) +class InventoryItemTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceRole, lookups=True) +@autotype_decorator(filtersets.DeviceRoleFilterSet) +class DeviceRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.DeviceType, lookups=True) +@autotype_decorator(filtersets.DeviceTypeFilterSet) +class DeviceTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FrontPort, lookups=True) +@autotype_decorator(filtersets.FrontPortFilterSet) +class FrontPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FrontPortTemplate, lookups=True) +@autotype_decorator(filtersets.FrontPortTemplateFilterSet) +class FrontPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Interface, lookups=True) +@autotype_decorator(filtersets.InterfaceFilterSet) +class InterfaceFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InterfaceTemplate, lookups=True) +@autotype_decorator(filtersets.InterfaceTemplateFilterSet) +class InterfaceTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItem, lookups=True) +@autotype_decorator(filtersets.InventoryItemFilterSet) +class InventoryItemFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.InventoryItemRole, lookups=True) +@autotype_decorator(filtersets.InventoryItemRoleFilterSet) +class InventoryItemRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Location, lookups=True) +@autotype_decorator(filtersets.LocationFilterSet) +class LocationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Manufacturer, lookups=True) +@autotype_decorator(filtersets.ManufacturerFilterSet) +class ManufacturerFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Module, lookups=True) +@autotype_decorator(filtersets.ModuleFilterSet) +class ModuleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleBay, lookups=True) +@autotype_decorator(filtersets.ModuleBayFilterSet) +class ModuleBayFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleBayTemplate, lookups=True) +@autotype_decorator(filtersets.ModuleBayTemplateFilterSet) +class ModuleBayTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ModuleType, lookups=True) +@autotype_decorator(filtersets.ModuleTypeFilterSet) +class ModuleTypeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Platform, lookups=True) +@autotype_decorator(filtersets.PlatformFilterSet) +class PlatformFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerFeed, lookups=True) +@autotype_decorator(filtersets.PowerFeedFilterSet) +class PowerFeedFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerOutlet, lookups=True) +@autotype_decorator(filtersets.PowerOutletFilterSet) +class PowerOutletFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerOutletTemplate, lookups=True) +@autotype_decorator(filtersets.PowerOutletTemplateFilterSet) +class PowerOutletTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPanel, lookups=True) +@autotype_decorator(filtersets.PowerPanelFilterSet) +class PowerPanelFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPort, lookups=True) +@autotype_decorator(filtersets.PowerPortFilterSet) +class PowerPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.PowerPortTemplate, lookups=True) +@autotype_decorator(filtersets.PowerPortTemplateFilterSet) +class PowerPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Rack, lookups=True) +@autotype_decorator(filtersets.RackFilterSet) +class RackFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RackReservation, lookups=True) +@autotype_decorator(filtersets.RackReservationFilterSet) +class RackReservationFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RackRole, lookups=True) +@autotype_decorator(filtersets.RackRoleFilterSet) +class RackRoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RearPort, lookups=True) +@autotype_decorator(filtersets.RearPortFilterSet) +class RearPortFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RearPortTemplate, lookups=True) +@autotype_decorator(filtersets.RearPortTemplateFilterSet) +class RearPortTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Region, lookups=True) +@autotype_decorator(filtersets.RegionFilterSet) +class RegionFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Site, lookups=True) +@autotype_decorator(filtersets.SiteFilterSet) +class SiteFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.SiteGroup, lookups=True) +@autotype_decorator(filtersets.SiteGroupFilterSet) +class SiteGroupFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VirtualChassis, lookups=True) +@autotype_decorator(filtersets.VirtualChassisFilterSet) +class VirtualChassisFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VirtualDeviceContext, lookups=True) +@autotype_decorator(filtersets.VirtualDeviceContextFilterSet) +class VirtualDeviceContextFilter(BaseFilterMixin): + pass diff --git a/netbox/dcim/graphql/gfk_mixins.py b/netbox/dcim/graphql/gfk_mixins.py index 2f669fb87..86ca88774 100644 --- a/netbox/dcim/graphql/gfk_mixins.py +++ b/netbox/dcim/graphql/gfk_mixins.py @@ -1,4 +1,3 @@ -import graphene from circuits.graphql.types import CircuitTerminationType, ProviderNetworkType from circuits.models import CircuitTermination, ProviderNetwork from dcim.graphql.types import ( @@ -37,79 +36,7 @@ from dcim.models import ( ) -class LinkPeerType(graphene.Union): - class Meta: - types = ( - CircuitTerminationType, - ConsolePortType, - ConsoleServerPortType, - FrontPortType, - InterfaceType, - PowerFeedType, - PowerOutletType, - PowerPortType, - RearPortType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is CircuitTermination: - return CircuitTerminationType - if type(instance) is ConsolePortType: - return ConsolePortType - if type(instance) is ConsoleServerPort: - return ConsoleServerPortType - if type(instance) is FrontPort: - return FrontPortType - if type(instance) is Interface: - return InterfaceType - if type(instance) is PowerFeed: - return PowerFeedType - if type(instance) is PowerOutlet: - return PowerOutletType - if type(instance) is PowerPort: - return PowerPortType - if type(instance) is RearPort: - return RearPortType - - -class CableTerminationTerminationType(graphene.Union): - class Meta: - types = ( - CircuitTerminationType, - ConsolePortType, - ConsoleServerPortType, - FrontPortType, - InterfaceType, - PowerFeedType, - PowerOutletType, - PowerPortType, - RearPortType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is CircuitTermination: - return CircuitTerminationType - if type(instance) is ConsolePortType: - return ConsolePortType - if type(instance) is ConsoleServerPort: - return ConsoleServerPortType - if type(instance) is FrontPort: - return FrontPortType - if type(instance) is Interface: - return InterfaceType - if type(instance) is PowerFeed: - return PowerFeedType - if type(instance) is PowerOutlet: - return PowerOutletType - if type(instance) is PowerPort: - return PowerPortType - if type(instance) is RearPort: - return RearPortType - - -class InventoryItemTemplateComponentType(graphene.Union): +class InventoryItemTemplateComponentType: class Meta: types = ( ConsolePortTemplateType, @@ -139,7 +66,7 @@ class InventoryItemTemplateComponentType(graphene.Union): return RearPortTemplateType -class InventoryItemComponentType(graphene.Union): +class InventoryItemComponentType: class Meta: types = ( ConsolePortType, @@ -169,7 +96,7 @@ class InventoryItemComponentType(graphene.Union): return RearPortType -class ConnectedEndpointType(graphene.Union): +class ConnectedEndpointType: class Meta: types = ( CircuitTerminationType, diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py index 8241b7de5..589af50c8 100644 --- a/netbox/dcim/graphql/mixins.py +++ b/netbox/dcim/graphql/mixins.py @@ -1,20 +1,43 @@ -import graphene +from typing import Annotated, List, Union + +import strawberry +import strawberry_django + +__all__ = ( + 'CabledObjectMixin', + 'PathEndpointMixin', +) +@strawberry.type class CabledObjectMixin: - link_peers = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType') + cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None - def resolve_cable_end(self, info): - # Handle empty values - return self.cable_end or None - - def resolve_link_peers(self, info): - return self.link_peers + link_peers: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("LinkPeerType")]] +@strawberry.type class PathEndpointMixin: - connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.ConnectedEndpointType') - def resolve_connected_endpoints(self, info): - # Handle empty values - return self.connected_endpoints or None + connected_endpoints: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ProviderNetworkType", strawberry.lazy('circuits.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("ConnectedEndpointType")]] diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 6d689ac2d..c3962a87a 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -1,249 +1,210 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django -from netbox.graphql.fields import ObjectField, ObjectListField -from .types import * from dcim import models -from .types import VirtualDeviceContextType -from utilities.graphql_optimizer import gql_query_optimizer - - -class DCIMQuery(graphene.ObjectType): - cable = ObjectField(CableType) - cable_list = ObjectListField(CableType) - - def resolve_cable_list(root, info, **kwargs): - return gql_query_optimizer(models.Cable.objects.all(), info) - - console_port = ObjectField(ConsolePortType) - console_port_list = ObjectListField(ConsolePortType) - - def resolve_console_port_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsolePort.objects.all(), info) - - console_port_template = ObjectField(ConsolePortTemplateType) - console_port_template_list = ObjectListField(ConsolePortTemplateType) - - def resolve_console_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsolePortTemplate.objects.all(), info) - - console_server_port = ObjectField(ConsoleServerPortType) - console_server_port_list = ObjectListField(ConsoleServerPortType) - - def resolve_console_server_port_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsoleServerPort.objects.all(), info) - - console_server_port_template = ObjectField(ConsoleServerPortTemplateType) - console_server_port_template_list = ObjectListField(ConsoleServerPortTemplateType) - - def resolve_console_server_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ConsoleServerPortTemplate.objects.all(), info) - - device = ObjectField(DeviceType) - device_list = ObjectListField(DeviceType) - - def resolve_device_list(root, info, **kwargs): - return gql_query_optimizer(models.Device.objects.all(), info) - - device_bay = ObjectField(DeviceBayType) - device_bay_list = ObjectListField(DeviceBayType) - - def resolve_device_bay_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceBay.objects.all(), info) - - device_bay_template = ObjectField(DeviceBayTemplateType) - device_bay_template_list = ObjectListField(DeviceBayTemplateType) - - def resolve_device_bay_template_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceBayTemplate.objects.all(), info) - - device_role = ObjectField(DeviceRoleType) - device_role_list = ObjectListField(DeviceRoleType) - - def resolve_device_role_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceRole.objects.all(), info) - - device_type = ObjectField(DeviceTypeType) - device_type_list = ObjectListField(DeviceTypeType) - - def resolve_device_type_list(root, info, **kwargs): - return gql_query_optimizer(models.DeviceType.objects.all(), info) - - front_port = ObjectField(FrontPortType) - front_port_list = ObjectListField(FrontPortType) - - def resolve_front_port_list(root, info, **kwargs): - return gql_query_optimizer(models.FrontPort.objects.all(), info) - - front_port_template = ObjectField(FrontPortTemplateType) - front_port_template_list = ObjectListField(FrontPortTemplateType) - - def resolve_front_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.FrontPortTemplate.objects.all(), info) - - interface = ObjectField(InterfaceType) - interface_list = ObjectListField(InterfaceType) - - def resolve_interface_list(root, info, **kwargs): - return gql_query_optimizer(models.Interface.objects.all(), info) - - interface_template = ObjectField(InterfaceTemplateType) - interface_template_list = ObjectListField(InterfaceTemplateType) - - def resolve_interface_template_list(root, info, **kwargs): - return gql_query_optimizer(models.InterfaceTemplate.objects.all(), info) - - inventory_item = ObjectField(InventoryItemType) - inventory_item_list = ObjectListField(InventoryItemType) - - def resolve_inventory_item_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItem.objects.all(), info) - - inventory_item_role = ObjectField(InventoryItemRoleType) - inventory_item_role_list = ObjectListField(InventoryItemRoleType) - - def resolve_inventory_item_role_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItemRole.objects.all(), info) - - inventory_item_template = ObjectField(InventoryItemTemplateType) - inventory_item_template_list = ObjectListField(InventoryItemTemplateType) - - def resolve_inventory_item_template_list(root, info, **kwargs): - return gql_query_optimizer(models.InventoryItemTemplate.objects.all(), info) - - location = ObjectField(LocationType) - location_list = ObjectListField(LocationType) - - def resolve_location_list(root, info, **kwargs): - return gql_query_optimizer(models.Location.objects.all(), info) - - manufacturer = ObjectField(ManufacturerType) - manufacturer_list = ObjectListField(ManufacturerType) - - def resolve_manufacturer_list(root, info, **kwargs): - return gql_query_optimizer(models.Manufacturer.objects.all(), info) - - module = ObjectField(ModuleType) - module_list = ObjectListField(ModuleType) - - def resolve_module_list(root, info, **kwargs): - return gql_query_optimizer(models.Module.objects.all(), info) - - module_bay = ObjectField(ModuleBayType) - module_bay_list = ObjectListField(ModuleBayType) - - def resolve_module_bay_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleBay.objects.all(), info) - - module_bay_template = ObjectField(ModuleBayTemplateType) - module_bay_template_list = ObjectListField(ModuleBayTemplateType) - - def resolve_module_bay_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleBayTemplate.objects.all(), info) - - module_type = ObjectField(ModuleTypeType) - module_type_list = ObjectListField(ModuleTypeType) - - def resolve_module_type_list(root, info, **kwargs): - return gql_query_optimizer(models.ModuleType.objects.all(), info) - - platform = ObjectField(PlatformType) - platform_list = ObjectListField(PlatformType) - - def resolve_platform_list(root, info, **kwargs): - return gql_query_optimizer(models.Platform.objects.all(), info) - - power_feed = ObjectField(PowerFeedType) - power_feed_list = ObjectListField(PowerFeedType) - - def resolve_power_feed_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerFeed.objects.all(), info) - - power_outlet = ObjectField(PowerOutletType) - power_outlet_list = ObjectListField(PowerOutletType) - - def resolve_power_outlet_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerOutlet.objects.all(), info) - - power_outlet_template = ObjectField(PowerOutletTemplateType) - power_outlet_template_list = ObjectListField(PowerOutletTemplateType) - - def resolve_power_outlet_template_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerOutletTemplate.objects.all(), info) - - power_panel = ObjectField(PowerPanelType) - power_panel_list = ObjectListField(PowerPanelType) - - def resolve_power_panel_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPanel.objects.all(), info) - - power_port = ObjectField(PowerPortType) - power_port_list = ObjectListField(PowerPortType) - - def resolve_power_port_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPort.objects.all(), info) - - power_port_template = ObjectField(PowerPortTemplateType) - power_port_template_list = ObjectListField(PowerPortTemplateType) - - def resolve_power_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.PowerPortTemplate.objects.all(), info) - - rack = ObjectField(RackType) - rack_list = ObjectListField(RackType) - - def resolve_rack_list(root, info, **kwargs): - return gql_query_optimizer(models.Rack.objects.all(), info) - - rack_reservation = ObjectField(RackReservationType) - rack_reservation_list = ObjectListField(RackReservationType) - - def resolve_rack_reservation_list(root, info, **kwargs): - return gql_query_optimizer(models.RackReservation.objects.all(), info) - - rack_role = ObjectField(RackRoleType) - rack_role_list = ObjectListField(RackRoleType) - - def resolve_rack_role_list(root, info, **kwargs): - return gql_query_optimizer(models.RackRole.objects.all(), info) - - rear_port = ObjectField(RearPortType) - rear_port_list = ObjectListField(RearPortType) - - def resolve_rear_port_list(root, info, **kwargs): - return gql_query_optimizer(models.RearPort.objects.all(), info) - - rear_port_template = ObjectField(RearPortTemplateType) - rear_port_template_list = ObjectListField(RearPortTemplateType) - - def resolve_rear_port_template_list(root, info, **kwargs): - return gql_query_optimizer(models.RearPortTemplate.objects.all(), info) - - region = ObjectField(RegionType) - region_list = ObjectListField(RegionType) - - def resolve_region_list(root, info, **kwargs): - return gql_query_optimizer(models.Region.objects.all(), info) - - site = ObjectField(SiteType) - site_list = ObjectListField(SiteType) - - def resolve_site_list(root, info, **kwargs): - return gql_query_optimizer(models.Site.objects.all(), info) - - site_group = ObjectField(SiteGroupType) - site_group_list = ObjectListField(SiteGroupType) - - def resolve_site_group_list(root, info, **kwargs): - return gql_query_optimizer(models.SiteGroup.objects.all(), info) - - virtual_chassis = ObjectField(VirtualChassisType) - virtual_chassis_list = ObjectListField(VirtualChassisType) - - def resolve_virtual_chassis_list(root, info, **kwargs): - return gql_query_optimizer(models.VirtualChassis.objects.all(), info) - - virtual_device_context = ObjectField(VirtualDeviceContextType) - virtual_device_context_list = ObjectListField(VirtualDeviceContextType) - - def resolve_virtual_device_context_list(root, info, **kwargs): - return gql_query_optimizer(models.VirtualDeviceContext.objects.all(), info) +from .types import * + + +@strawberry.type +class DCIMQuery: + @strawberry.field + def cable(self, id: int) -> CableType: + return models.Cable.objects.get(pk=id) + cable_list: List[CableType] = strawberry_django.field() + + @strawberry.field + def console_port(self, id: int) -> ConsolePortType: + return models.ConsolePort.objects.get(pk=id) + console_port_list: List[ConsolePortType] = strawberry_django.field() + + @strawberry.field + def console_port_template(self, id: int) -> ConsolePortTemplateType: + return models.ConsolePortTemplate.objects.get(pk=id) + console_port_template_list: List[ConsolePortTemplateType] = strawberry_django.field() + + @strawberry.field + def console_server_port(self, id: int) -> ConsoleServerPortType: + return models.ConsoleServerPort.objects.get(pk=id) + console_server_port_list: List[ConsoleServerPortType] = strawberry_django.field() + + @strawberry.field + def console_server_port_template(self, id: int) -> ConsoleServerPortTemplateType: + return models.ConsoleServerPortTemplate.objects.get(pk=id) + console_server_port_template_list: List[ConsoleServerPortTemplateType] = strawberry_django.field() + + @strawberry.field + def device(self, id: int) -> DeviceType: + return models.Device.objects.get(pk=id) + device_list: List[DeviceType] = strawberry_django.field() + + @strawberry.field + def device_bay(self, id: int) -> DeviceBayType: + return models.DeviceBay.objects.get(pk=id) + device_bay_list: List[DeviceBayType] = strawberry_django.field() + + @strawberry.field + def device_bay_template(self, id: int) -> DeviceBayTemplateType: + return models.DeviceBayTemplate.objects.get(pk=id) + device_bay_template_list: List[DeviceBayTemplateType] = strawberry_django.field() + + @strawberry.field + def device_role(self, id: int) -> DeviceRoleType: + return models.DeviceRole.objects.get(pk=id) + device_role_list: List[DeviceRoleType] = strawberry_django.field() + + @strawberry.field + def device_type(self, id: int) -> DeviceTypeType: + return models.DeviceType.objects.get(pk=id) + device_type_list: List[DeviceTypeType] = strawberry_django.field() + + @strawberry.field + def front_port(self, id: int) -> FrontPortType: + return models.FrontPort.objects.get(pk=id) + front_port_list: List[FrontPortType] = strawberry_django.field() + + @strawberry.field + def front_port_template(self, id: int) -> FrontPortTemplateType: + return models.FrontPortTemplate.objects.get(pk=id) + front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field() + + @strawberry.field + def interface(self, id: int) -> InterfaceType: + return models.Interface.objects.get(pk=id) + interface_list: List[InterfaceType] = strawberry_django.field() + + @strawberry.field + def interface_template(self, id: int) -> InterfaceTemplateType: + return models.InterfaceTemplate.objects.get(pk=id) + interface_template_list: List[InterfaceTemplateType] = strawberry_django.field() + + @strawberry.field + def inventory_item(self, id: int) -> InventoryItemType: + return models.InventoryItem.objects.get(pk=id) + inventory_item_list: List[InventoryItemType] = strawberry_django.field() + + @strawberry.field + def inventory_item_role(self, id: int) -> InventoryItemRoleType: + return models.InventoryItemRole.objects.get(pk=id) + inventory_item_role_list: List[InventoryItemRoleType] = strawberry_django.field() + + @strawberry.field + def inventory_item_template(self, id: int) -> InventoryItemTemplateType: + return models.InventoryItemTemplate.objects.get(pk=id) + inventory_item_template_list: List[InventoryItemTemplateType] = strawberry_django.field() + + @strawberry.field + def location(self, id: int) -> LocationType: + return models.Location.objects.get(pk=id) + location_list: List[LocationType] = strawberry_django.field() + + @strawberry.field + def manufacturer(self, id: int) -> ManufacturerType: + return models.Manufacturer.objects.get(pk=id) + manufacturer_list: List[ManufacturerType] = strawberry_django.field() + + @strawberry.field + def module(self, id: int) -> ModuleType: + return models.Module.objects.get(pk=id) + module_list: List[ModuleType] = strawberry_django.field() + + @strawberry.field + def module_bay(self, id: int) -> ModuleBayType: + return models.ModuleBay.objects.get(pk=id) + module_bay_list: List[ModuleBayType] = strawberry_django.field() + + @strawberry.field + def module_bay_template(self, id: int) -> ModuleBayTemplateType: + return models.ModuleBayTemplate.objects.get(pk=id) + module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field() + + @strawberry.field + def module_type(self, id: int) -> ModuleTypeType: + return models.ModuleType.objects.get(pk=id) + module_type_list: List[ModuleTypeType] = strawberry_django.field() + + @strawberry.field + def platform(self, id: int) -> PlatformType: + return models.Platform.objects.get(pk=id) + platform_list: List[PlatformType] = strawberry_django.field() + + @strawberry.field + def power_feed(self, id: int) -> PowerFeedType: + return models.PowerFeed.objects.get(pk=id) + power_feed_list: List[PowerFeedType] = strawberry_django.field() + + @strawberry.field + def power_outlet(self, id: int) -> PowerOutletType: + return models.PowerOutlet.objects.get(pk=id) + power_outlet_list: List[PowerOutletType] = strawberry_django.field() + + @strawberry.field + def power_outlet_template(self, id: int) -> PowerOutletTemplateType: + return models.PowerOutletTemplate.objects.get(pk=id) + power_outlet_template_list: List[PowerOutletTemplateType] = strawberry_django.field() + + @strawberry.field + def power_panel(self, id: int) -> PowerPanelType: + return models.PowerPanel.objects.get(id=id) + power_panel_list: List[PowerPanelType] = strawberry_django.field() + + @strawberry.field + def power_port(self, id: int) -> PowerPortType: + return models.PowerPort.objects.get(id=id) + power_port_list: List[PowerPortType] = strawberry_django.field() + + @strawberry.field + def power_port_template(self, id: int) -> PowerPortTemplateType: + return models.PowerPortTemplate.objects.get(id=id) + power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field() + + @strawberry.field + def rack(self, id: int) -> RackType: + return models.Rack.objects.get(id=id) + rack_list: List[RackType] = strawberry_django.field() + + @strawberry.field + def rack_reservation(self, id: int) -> RackReservationType: + return models.RackReservation.objects.get(id=id) + rack_reservation_list: List[RackReservationType] = strawberry_django.field() + + @strawberry.field + def rack_role(self, id: int) -> RackRoleType: + return models.RackRole.objects.get(id=id) + rack_role_list: List[RackRoleType] = strawberry_django.field() + + @strawberry.field + def rear_port(self, id: int) -> RearPortType: + return models.RearPort.objects.get(id=id) + rear_port_list: List[RearPortType] = strawberry_django.field() + + @strawberry.field + def rear_port_template(self, id: int) -> RearPortTemplateType: + return models.RearPortTemplate.objects.get(id=id) + rear_port_template_list: List[RearPortTemplateType] = strawberry_django.field() + + @strawberry.field + def region(self, id: int) -> RegionType: + return models.Region.objects.get(id=id) + region_list: List[RegionType] = strawberry_django.field() + + @strawberry.field + def site(self, id: int) -> SiteType: + return models.Site.objects.get(id=id) + site_list: List[SiteType] = strawberry_django.field() + + @strawberry.field + def site_group(self, id: int) -> SiteGroupType: + return models.SiteGroup.objects.get(id=id) + site_group_list: List[SiteGroupType] = strawberry_django.field() + + @strawberry.field + def virtual_chassis(self, id: int) -> VirtualChassisType: + return models.VirtualChassis.objects.get(id=id) + virtual_chassis_list: List[VirtualChassisType] = strawberry_django.field() + + @strawberry.field + def virtual_device_context(self, id: int) -> VirtualDeviceContextType: + return models.VirtualDeviceContext.objects.get(id=id) + virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 7d7434587..023f72be3 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -1,17 +1,26 @@ -import graphene +from typing import Annotated, List, Union -from dcim import filtersets, models +import strawberry +import strawberry_django + +from dcim import models from extras.graphql.mixins import ( - ChangelogMixin, ConfigContextMixin, ContactsMixin, CustomFieldsMixin, ImageAttachmentsMixin, TagsMixin, + ChangelogMixin, + ConfigContextMixin, + ContactsMixin, + CustomFieldsMixin, + ImageAttachmentsMixin, + TagsMixin, ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType +from .filters import * from .mixins import CabledObjectMixin, PathEndpointMixin __all__ = ( 'CableType', - 'ComponentObjectType', + 'ComponentType', 'ConsolePortType', 'ConsolePortTemplateType', 'ConsoleServerPortType', @@ -30,6 +39,7 @@ __all__ = ( 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', + 'ModularComponentType', 'ModuleType', 'ModuleBayType', 'ModuleBayTemplateType', @@ -50,6 +60,7 @@ __all__ = ( 'SiteType', 'SiteGroupType', 'VirtualChassisType', + 'VirtualDeviceContextType', ) @@ -58,7 +69,8 @@ __all__ = ( # -class ComponentObjectType( +@strawberry.type +class ComponentType( ChangelogMixin, CustomFieldsMixin, TagsMixin, @@ -67,447 +79,679 @@ class ComponentObjectType( """ Base type for device/VM components """ - class Meta: - abstract = True + _name: str + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] -class ComponentTemplateObjectType( +@strawberry.type +class ModularComponentType(ComponentType): + module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None + + +@strawberry.type +class ComponentTemplateType( ChangelogMixin, BaseObjectType ): """ Base type for device/VM components """ - class Meta: - abstract = True + _name: str + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] +@strawberry.type +class ModularComponentTemplateType(ComponentTemplateType): + """ + Base type for ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] | None + module_type: Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')] | None + # # Model types # -class CableType(NetBoxObjectType): - a_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - b_terminations = graphene.List('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - - class Meta: - model = models.Cable - fields = '__all__' - filterset_class = filtersets.CableFilterSet - - def resolve_type(self, info): - return self.type or None - - def resolve_length_unit(self, info): - return self.length_unit or None - - def resolve_a_terminations(self, info): - return self.a_terminations - - def resolve_b_terminations(self, info): - return self.b_terminations - +@strawberry_django.type( + models.CableTermination, + exclude=('termination_type', 'termination_id'), + filters=CableTerminationFilter +) class CableTerminationType(NetBoxObjectType): - termination = graphene.Field('dcim.graphql.gfk_mixins.CableTerminationTerminationType') - class Meta: - model = models.CableTermination - exclude = ('termination_type', 'termination_id') - filterset_class = filtersets.CableTerminationFilterSet + termination: Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")] -class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.Cable, + fields='__all__', + filters=CableFilter +) +class CableType(NetBoxObjectType): + color: str + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.ConsolePort - exclude = ('_path',) - filterset_class = filtersets.ConsolePortFilterSet + terminations: List[CableTerminationType] - def resolve_type(self, info): - return self.type or None + a_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")]] + + b_terminations: List[Annotated[Union[ + Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')], + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("CableTerminationTerminationType")]] -class ConsolePortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ConsolePortTemplate - fields = '__all__' - filterset_class = filtersets.ConsolePortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsolePort, + exclude=('_path',), + filters=ConsolePortFilter +) +class ConsolePortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + pass -class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.ConsoleServerPort - exclude = ('_path',) - filterset_class = filtersets.ConsoleServerPortFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsolePortTemplate, + fields='__all__', + filters=ConsolePortTemplateFilter +) +class ConsolePortTemplateType(ModularComponentTemplateType): + _name: str -class ConsoleServerPortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ConsoleServerPortTemplate - fields = '__all__' - filterset_class = filtersets.ConsoleServerPortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.ConsoleServerPort, + exclude=('_path',), + filters=ConsoleServerPortFilter +) +class ConsoleServerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + pass +@strawberry_django.type( + models.ConsoleServerPortTemplate, + fields='__all__', + filters=ConsoleServerPortTemplateFilter +) +class ConsoleServerPortTemplateType(ModularComponentTemplateType): + _name: str + + +@strawberry_django.type( + models.Device, + fields='__all__', + filters=DeviceFilter +) class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): + _name: str + console_port_count: BigInt + console_server_port_count: BigInt + power_port_count: BigInt + power_outlet_count: BigInt + interface_count: BigInt + front_port_count: BigInt + rear_port_count: BigInt + device_bay_count: BigInt + module_bay_count: BigInt + inventory_item_count: BigInt + config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None + device_type: Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')] + role: Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + platform: Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')] | None + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] | None + primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + oob_ip: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + cluster: Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')] | None + virtual_chassis: Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Device - fields = '__all__' - filterset_class = filtersets.DeviceFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + modules: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + rearports: List[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]] + consoleports: List[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]] + powerports: List[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] + services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] + inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] - def resolve_face(self, info): - return self.face or None + @strawberry_django.field + def vc_master_for(self) -> Annotated["VirtualChassisType", strawberry.lazy('dcim.graphql.types')] | None: + return self.vc_master_for if hasattr(self, 'vc_master_for') else None - def resolve_airflow(self, info): - return self.airflow or None + @strawberry_django.field + def parent_bay(self) -> Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent_bay if hasattr(self, 'parent_bay') else None -class DeviceBayType(ComponentObjectType): - - class Meta: - model = models.DeviceBay - fields = '__all__' - filterset_class = filtersets.DeviceBayFilterSet +@strawberry_django.type( + models.DeviceBay, + fields='__all__', + filters=DeviceBayFilter +) +class DeviceBayType(ComponentType): + installed_device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None -class DeviceBayTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.DeviceBayTemplate - fields = '__all__' - filterset_class = filtersets.DeviceBayTemplateFilterSet +@strawberry_django.type( + models.DeviceBayTemplate, + fields='__all__', + filters=DeviceBayTemplateFilter +) +class DeviceBayTemplateType(ComponentTemplateType): + _name: str -class InventoryItemTemplateType(ComponentTemplateObjectType): - component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemTemplateComponentType') +@strawberry_django.type( + models.InventoryItemTemplate, + exclude=('component_type', 'component_id', 'parent'), + filters=InventoryItemTemplateFilter +) +class InventoryItemTemplateType(ComponentTemplateType): + _name: str + role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.InventoryItemTemplate - exclude = ('component_type', 'component_id') - filterset_class = filtersets.InventoryItemTemplateFilterSet + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent + + child_items: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + + component: Annotated[Union[ + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("InventoryItemTemplateComponentType")] +@strawberry_django.type( + models.DeviceRole, + fields='__all__', + filters=DeviceRoleFilter +) class DeviceRoleType(OrganizationalObjectType): + color: str + config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None - class Meta: - model = models.DeviceRole - fields = '__all__' - filterset_class = filtersets.DeviceRoleFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.DeviceType, + fields='__all__', + filters=DeviceTypeFilter +) class DeviceTypeType(NetBoxObjectType): + console_port_template_count: BigInt + console_server_port_template_count: BigInt + power_port_template_count: BigInt + power_outlet_template_count: BigInt + interface_template_count: BigInt + front_port_template_count: BigInt + rear_port_template_count: BigInt + device_bay_template_count: BigInt + module_bay_template_count: BigInt + inventory_item_template_count: BigInt + front_image: strawberry_django.fields.types.DjangoImageType | None + rear_image: strawberry_django.fields.types.DjangoImageType | None + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] + default_platform: Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.DeviceType - fields = '__all__' - filterset_class = filtersets.DeviceTypeFilterSet - - def resolve_subdevice_role(self, info): - return self.subdevice_role or None - - def resolve_airflow(self, info): - return self.airflow or None - - def resolve_weight_unit(self, info): - return self.weight_unit or None + frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + modulebaytemplates: List[Annotated["ModuleBayTemplateType", strawberry.lazy('dcim.graphql.types')]] + instances: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + powerporttemplates: List[Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + inventoryitemtemplates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] + devicebaytemplates: List[Annotated["DeviceBayTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleporttemplates: List[Annotated["ConsolePortTemplateType", strawberry.lazy('dcim.graphql.types')]] -class FrontPortType(ComponentObjectType, CabledObjectMixin): - - class Meta: - model = models.FrontPort - fields = '__all__' - filterset_class = filtersets.FrontPortFilterSet +@strawberry_django.type( + models.FrontPort, + fields='__all__', + filters=FrontPortFilter +) +class FrontPortType(ModularComponentType, CabledObjectMixin): + color: str + rear_port: Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')] -class FrontPortTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.FrontPortTemplate - fields = '__all__' - filterset_class = filtersets.FrontPortTemplateFilterSet +@strawberry_django.type( + models.FrontPortTemplate, + fields='__all__', + filters=FrontPortTemplateFilter +) +class FrontPortTemplateType(ModularComponentTemplateType): + _name: str + color: str + rear_port: Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')] -class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.Interface, + exclude=('_path',), + filters=InterfaceFilter +) +class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, PathEndpointMixin): + mac_address: str | None + wwn: str | None + parent: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + bridge: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + lag: Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')] | None + wireless_link: Annotated["WirelessLinkType", strawberry.lazy('wireless.graphql.types')] | None + untagged_vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None + vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None - class Meta: - model = models.Interface - exclude = ('_path',) - filterset_class = filtersets.InterfaceFilterSet - - def resolve_poe_mode(self, info): - return self.poe_mode or None - - def resolve_poe_type(self, info): - return self.poe_type or None - - def resolve_mode(self, info): - return self.mode or None - - def resolve_rf_role(self, info): - return self.rf_role or None - - def resolve_rf_channel(self, info): - return self.rf_channel or None + vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] + tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] + bridge_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] + member_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] -class InterfaceTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.InterfaceTemplate, + fields='__all__', + filters=InterfaceTemplateFilter +) +class InterfaceTemplateType(ModularComponentTemplateType): + _name: str + bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.InterfaceTemplate - fields = '__all__' - filterset_class = filtersets.InterfaceTemplateFilterSet - - def resolve_poe_mode(self, info): - return self.poe_mode or None - - def resolve_poe_type(self, info): - return self.poe_type or None - - def resolve_rf_role(self, info): - return self.rf_role or None + bridge_interfaces: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] -class InventoryItemType(ComponentObjectType): - component = graphene.Field('dcim.graphql.gfk_mixins.InventoryItemComponentType') +@strawberry_django.type( + models.InventoryItem, + exclude=('component_type', 'component_id', 'parent'), + filters=InventoryItemFilter +) +class InventoryItemType(ComponentType): + role: Annotated["InventoryItemRoleType", strawberry.lazy('dcim.graphql.types')] | None + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.InventoryItem - exclude = ('component_type', 'component_id') - filterset_class = filtersets.InventoryItemFilterSet + child_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent + + component: Annotated[Union[ + Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')], + Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')], + Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')], + Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("InventoryItemComponentType")] +@strawberry_django.type( + models.InventoryItemRole, + fields='__all__', + filters=InventoryItemRoleFilter +) class InventoryItemRoleType(OrganizationalObjectType): + color: str - class Meta: - model = models.InventoryItemRole - fields = '__all__' - filterset_class = filtersets.InventoryItemRoleFilterSet + inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Location, + # fields='__all__', + exclude=('parent',), # bug - temp + filters=LocationFilter +) class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, OrganizationalObjectType): + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + parent: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Location - fields = '__all__' - filterset_class = filtersets.LocationFilterSet + powerpanel_set: List[Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Manufacturer, + fields='__all__', + filters=ManufacturerFilter +) class ManufacturerType(OrganizationalObjectType, ContactsMixin): - class Meta: - model = models.Manufacturer - fields = '__all__' - filterset_class = filtersets.ManufacturerFilterSet + platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] + device_types: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + inventory_item_templates: List[Annotated["InventoryItemTemplateType", strawberry.lazy('dcim.graphql.types')]] + inventory_items: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] + module_types: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] -class ModuleType(ComponentObjectType): +@strawberry_django.type( + models.Module, + fields='__all__', + filters=ModuleFilter +) +class ModuleType(NetBoxObjectType): + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] + module_bay: Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] + module_type: Annotated["ModuleTypeType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.Module - fields = '__all__' - filterset_class = filtersets.ModuleFilterSet + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + powerports: List[Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')]] + consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] + consoleports: List[Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')]] + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] + rearports: List[Annotated["RearPortType", strawberry.lazy('dcim.graphql.types')]] + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] -class ModuleBayType(ComponentObjectType): +@strawberry_django.type( + models.ModuleBay, + fields='__all__', + filters=ModuleBayFilter +) +class ModuleBayType(ComponentType): - class Meta: - model = models.ModuleBay - fields = '__all__' - filterset_class = filtersets.ModuleBayFilterSet + installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None -class ModuleBayTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.ModuleBayTemplate - fields = '__all__' - filterset_class = filtersets.ModuleBayTemplateFilterSet +@strawberry_django.type( + models.ModuleBayTemplate, + fields='__all__', + filters=ModuleBayTemplateFilter +) +class ModuleBayTemplateType(ComponentTemplateType): + _name: str +@strawberry_django.type( + models.ModuleType, + fields='__all__', + filters=ModuleTypeFilter +) class ModuleTypeType(NetBoxObjectType): + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] - class Meta: - model = models.ModuleType - fields = '__all__' - filterset_class = filtersets.ModuleTypeFilterSet - - def resolve_weight_unit(self, info): - return self.weight_unit or None + frontporttemplates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + consoleserverporttemplates: List[Annotated["ConsoleServerPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + interfacetemplates: List[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]] + powerporttemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + poweroutlettemplates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] + rearporttemplates: List[Annotated["RearPortTemplateType", strawberry.lazy('dcim.graphql.types')]] + instances: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + consoleporttemplates: List[Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Platform, + fields='__all__', + filters=PlatformFilter +) class PlatformType(OrganizationalObjectType): + manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] | None + config_template: Annotated["ConfigTemplateType", strawberry.lazy('extras.graphql.types')] | None - class Meta: - model = models.Platform - fields = '__all__' - filterset_class = filtersets.PlatformFilterSet + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.PowerFeed, + exclude=('_path',), + filters=PowerFeedFilter +) class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.PowerFeed - exclude = ('_path',) - filterset_class = filtersets.PowerFeedFilterSet + power_panel: Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')] + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None -class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): - - class Meta: - model = models.PowerOutlet - exclude = ('_path',) - filterset_class = filtersets.PowerOutletFilterSet - - def resolve_feed_leg(self, info): - return self.feed_leg or None - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.PowerOutlet, + exclude=('_path',), + filters=PowerOutletFilter +) +class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): + power_port: Annotated["PowerPortType", strawberry.lazy('dcim.graphql.types')] | None -class PowerOutletTemplateType(ComponentTemplateObjectType): - - class Meta: - model = models.PowerOutletTemplate - fields = '__all__' - filterset_class = filtersets.PowerOutletTemplateFilterSet - - def resolve_feed_leg(self, info): - return self.feed_leg or None - - def resolve_type(self, info): - return self.type or None +@strawberry_django.type( + models.PowerOutletTemplate, + fields='__all__', + filters=PowerOutletTemplateFilter +) +class PowerOutletTemplateType(ModularComponentTemplateType): + _name: str + power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None +@strawberry_django.type( + models.PowerPanel, + fields='__all__', + filters=PowerPanelFilter +) class PowerPanelType(NetBoxObjectType, ContactsMixin): + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.PowerPanel - fields = '__all__' - filterset_class = filtersets.PowerPanelFilterSet + powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] -class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin): +@strawberry_django.type( + models.PowerPort, + exclude=('_path',), + filters=PowerPortFilter +) +class PowerPortType(ModularComponentType, CabledObjectMixin, PathEndpointMixin): - class Meta: - model = models.PowerPort - exclude = ('_path',) - filterset_class = filtersets.PowerPortFilterSet - - def resolve_type(self, info): - return self.type or None + poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] -class PowerPortTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.PowerPortTemplate, + fields='__all__', + filters=PowerPortTemplateFilter +) +class PowerPortTemplateType(ModularComponentTemplateType): + _name: str - class Meta: - model = models.PowerPortTemplate - fields = '__all__' - filterset_class = filtersets.PowerPortTemplateFilterSet - - def resolve_type(self, info): - return self.type or None + poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Rack, + fields='__all__', + filters=RackFilter +) class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): + _name: str + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] + location: Annotated["LocationType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.Rack - fields = '__all__' - filterset_class = filtersets.RackFilterSet - - def resolve_type(self, info): - return self.type or None - - def resolve_outer_unit(self, info): - return self.outer_unit or None - - def resolve_weight_unit(self, info): - return self.weight_unit or None + reservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.RackReservation, + fields='__all__', + filters=RackReservationFilter +) class RackReservationType(NetBoxObjectType): - - class Meta: - model = models.RackReservation - fields = '__all__' - filterset_class = filtersets.RackReservationFilterSet + units: List[int] + rack: Annotated["RackType", strawberry.lazy('dcim.graphql.types')] + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + user: Annotated["UserType", strawberry.lazy('users.graphql.types')] +@strawberry_django.type( + models.RackRole, + fields='__all__', + filters=RackRoleFilter +) class RackRoleType(OrganizationalObjectType): + color: str - class Meta: - model = models.RackRole - fields = '__all__' - filterset_class = filtersets.RackRoleFilterSet + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] -class RearPortType(ComponentObjectType, CabledObjectMixin): +@strawberry_django.type( + models.RearPort, + fields='__all__', + filters=RearPortFilter +) +class RearPortType(ModularComponentType, CabledObjectMixin): + color: str - class Meta: - model = models.RearPort - fields = '__all__' - filterset_class = filtersets.RearPortFilterSet + frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] -class RearPortTemplateType(ComponentTemplateObjectType): +@strawberry_django.type( + models.RearPortTemplate, + fields='__all__', + filters=RearPortTemplateFilter +) +class RearPortTemplateType(ModularComponentTemplateType): + _name: str + color: str - class Meta: - model = models.RearPortTemplate - fields = '__all__' - filterset_class = filtersets.RearPortTemplateFilterSet + frontport_templates: List[Annotated["FrontPortTemplateType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.Region, + exclude=('parent',), + # fields='__all__', + filters=RegionFilter +) class RegionType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): - class Meta: - model = models.Region - fields = '__all__' - filterset_class = filtersets.RegionFilterSet + sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent +@strawberry_django.type( + models.Site, + fields='__all__', + filters=SiteFilter +) class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObjectType): - asn = graphene.Field(BigInt) + _name: str + time_zone: str | None + region: Annotated["RegionType", strawberry.lazy('dcim.graphql.types')] | None + group: Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.Site - fields = '__all__' - filterset_class = filtersets.SiteFilterSet + prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] + virtual_machines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + racks: List[Annotated["RackType", strawberry.lazy('dcim.graphql.types')]] + cabletermination_set: List[Annotated["CableTerminationType", strawberry.lazy('dcim.graphql.types')]] + powerpanel_set: List[Annotated["PowerPanelType", strawberry.lazy('dcim.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] + asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + circuit_terminations: List[Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')]] + clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] + vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.SiteGroup, + # fields='__all__', + exclude=('parent',), # bug - temp + filters=SiteGroupFilter +) class SiteGroupType(VLANGroupsMixin, ContactsMixin, OrganizationalObjectType): - class Meta: - model = models.SiteGroup - fields = '__all__' - filterset_class = filtersets.SiteGroupFilterSet + sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] + children: List[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent +@strawberry_django.type( + models.VirtualChassis, + fields='__all__', + filters=VirtualChassisFilter +) class VirtualChassisType(NetBoxObjectType): + member_count: BigInt + master: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None - class Meta: - model = models.VirtualChassis - fields = '__all__' - filterset_class = filtersets.VirtualChassisFilterSet + members: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.VirtualDeviceContext, + fields='__all__', + filters=VirtualDeviceContextFilter +) class VirtualDeviceContextType(NetBoxObjectType): + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None + primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.VirtualDeviceContext - fields = '__all__' - filterset_class = filtersets.VirtualDeviceContextFilterSet + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index f8a61a794..64f0b8560 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -15,9 +15,9 @@ from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node from netbox.models import ChangeLoggedModel, PrimaryModel +from utilities.conversion import to_meters from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet -from utilities.utils import to_meters from wireless.models import WirelessLink from .device_components import FrontPort, RearPort, PathEndpoint diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5b2564b32..9438b741f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -12,8 +12,8 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, NetBoxModel -from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 4f221fe16..10792e387 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -18,10 +18,10 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet +from netbox.choices import ColorChoices from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices from utilities.fields import ColorField, CounterCacheField, NaturalOrderingField from utilities.tracking import TrackingModelMixin from .device_components import * diff --git a/netbox/dcim/models/mixins.py b/netbox/dcim/models/mixins.py index 9be8dc0a3..d4a05699c 100644 --- a/netbox/dcim/models/mixins.py +++ b/netbox/dcim/models/mixins.py @@ -2,7 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import gettext_lazy as _ from dcim.choices import * -from utilities.utils import to_grams +from utilities.conversion import to_grams __all__ = ( 'RenderConfigMixin', diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3cb4e0225..289c38133 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -14,11 +14,12 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG +from netbox.choices import ColorChoices from netbox.models import OrganizationalModel, PrimaryModel from netbox.models.features import ContactsMixin, ImageAttachmentsMixin -from utilities.choices import ColorChoices +from utilities.conversion import to_grams +from utilities.data import array_to_string, drange from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string, drange, to_grams from .device_components import PowerPort from .devices import Device, Module from .mixins import WeightMixin diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index d7365161e..aaa9e24ed 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -6,7 +6,7 @@ from svgwrite.text import Text from django.conf import settings from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH -from utilities.utils import foreground_color +from utilities.html import foreground_color __all__ = ( diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 07ea55a33..0f73095b5 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -14,7 +14,8 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color, array_to_ranges +from utilities.data import array_to_ranges +from utilities.html import foreground_color from dcim.constants import RACK_ELEVATION_BORDER_WIDTH diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fffa82a10..96ea020b3 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -6,13 +6,12 @@ from dcim.choices import * from dcim.filtersets import * from dcim.models import * from ipam.models import ASN, IPAddress, RIR, VRF +from netbox.choices import ColorChoices from tenancy.models import Tenant, TenantGroup -from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices - User = get_user_model() diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 1a5cc8435..cab1760ed 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,7 +7,7 @@ from dcim.choices import * from dcim.models import * from extras.models import CustomField from tenancy.models import Tenant -from utilities.utils import drange +from utilities.data import drange class LocationTestCase(TestCase): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e3437cefc..ec85fc1d5 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,12 +11,11 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from tenancy.models import Tenant -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN - User = get_user_model() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 49bbe9be1..120bbcb59 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -25,8 +25,8 @@ from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.query import count_related from utilities.query_functions import CollateAsChar -from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view from virtualization.models import VirtualMachine from . import filtersets, forms, tables diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3439f6f3f..0a5303741 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -20,7 +20,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet from utilities.exceptions import RQWorkerNotRunningException -from utilities.utils import copy_safe_request +from utilities.request import copy_safe_request from . import serializers from .mixins import ConfigTemplateRenderMixin diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index d4b41aba9..2c9d5836a 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -2,7 +2,8 @@ import logging from django.utils.translation import gettext_lazy as _ -from utilities.choices import ButtonColorChoices, ChoiceSet +from netbox.choices import ButtonColorChoices +from utilities.choices import ChoiceSet # diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 69bef0d8f..23f082ce2 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -14,10 +14,12 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.choices import BookmarkOrderingChoices -from utilities.choices import ButtonColorChoices +from netbox.choices import ButtonColorChoices +from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model +from utilities.querydict import dict_to_querydict from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, dict_to_querydict, get_viewname +from utilities.views import get_viewname from .utils import register_widget __all__ = ( @@ -33,15 +35,15 @@ __all__ = ( def get_object_type_choices(): return [ - (content_type_identifier(ct), content_type_name(ct)) - for ct in ObjectType.objects.public().order_by('app_label', 'model') + (object_type_identifier(ot), object_type_name(ot)) + for ot in ObjectType.objects.public().order_by('app_label', 'model') ] def get_bookmarks_object_type_choices(): return [ - (content_type_identifier(ct), content_type_name(ct)) - for ct in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') + (object_type_identifier(ot), object_type_name(ot)) + for ot in ObjectType.objects.with_feature('bookmarks').order_by('app_label', 'model') ] diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 0ee4cffa8..a33ac213c 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -1,9 +1,6 @@ -import logging - from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone from django.utils.module_loading import import_string from django.utils.translation import gettext as _ @@ -15,9 +12,9 @@ from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry -from utilities.utils import serialize_object +from utilities.serialization import serialize_object from .choices import * -from .models import EventRule, ScriptModule +from .models import EventRule logger = logging.getLogger('netbox.events_processor') diff --git a/netbox/extras/forms/reports.py b/netbox/extras/forms/reports.py index ad37eb744..358ee90e3 100644 --- a/netbox/extras/forms/reports.py +++ b/netbox/extras/forms/reports.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import DurationChoices from utilities.forms.widgets import DateTimePicker, NumberWithOptions -from utilities.utils import local_now +from utilities.datetime import local_now __all__ = ( 'ReportForm', diff --git a/netbox/extras/forms/scripts.py b/netbox/extras/forms/scripts.py index f67ad3e75..ece96f5e4 100644 --- a/netbox/extras/forms/scripts.py +++ b/netbox/extras/forms/scripts.py @@ -3,7 +3,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import DurationChoices from utilities.forms.widgets import DateTimePicker, NumberWithOptions -from utilities.utils import local_now +from utilities.datetime import local_now __all__ = ( 'ScriptForm', diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py new file mode 100644 index 000000000..af3a93588 --- /dev/null +++ b/netbox/extras/graphql/filters.py @@ -0,0 +1,98 @@ +import strawberry_django + +from extras import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'ConfigContextFilter', + 'ConfigTemplateFilter', + 'CustomFieldFilter', + 'CustomFieldChoiceSetFilter', + 'CustomLinkFilter', + 'EventRuleFilter', + 'ExportTemplateFilter', + 'ImageAttachmentFilter', + 'JournalEntryFilter', + 'ObjectChangeFilter', + 'SavedFilterFilter', + 'TagFilter', + 'WebhookFilter', +) + + +@strawberry_django.filter(models.ConfigContext, lookups=True) +@autotype_decorator(filtersets.ConfigContextFilterSet) +class ConfigContextFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ConfigTemplate, lookups=True) +@autotype_decorator(filtersets.ConfigTemplateFilterSet) +class ConfigTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CustomField, lookups=True) +@autotype_decorator(filtersets.CustomFieldFilterSet) +class CustomFieldFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CustomFieldChoiceSet, lookups=True) +@autotype_decorator(filtersets.CustomFieldChoiceSetFilterSet) +class CustomFieldChoiceSetFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.CustomLink, lookups=True) +@autotype_decorator(filtersets.CustomLinkFilterSet) +class CustomLinkFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ExportTemplate, lookups=True) +@autotype_decorator(filtersets.ExportTemplateFilterSet) +class ExportTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ImageAttachment, lookups=True) +@autotype_decorator(filtersets.ImageAttachmentFilterSet) +class ImageAttachmentFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.JournalEntry, lookups=True) +@autotype_decorator(filtersets.JournalEntryFilterSet) +class JournalEntryFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ObjectChange, lookups=True) +@autotype_decorator(filtersets.ObjectChangeFilterSet) +class ObjectChangeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.SavedFilter, lookups=True) +@autotype_decorator(filtersets.SavedFilterFilterSet) +class SavedFilterFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Tag, lookups=True) +@autotype_decorator(filtersets.TagFilterSet) +class TagFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Webhook, lookups=True) +@autotype_decorator(filtersets.WebhookFilterSet) +class WebhookFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.EventRule, lookups=True) +@autotype_decorator(filtersets.EventRuleFilterSet) +class EventRuleFilter(BaseFilterMixin): + pass diff --git a/netbox/extras/graphql/mixins.py b/netbox/extras/graphql/mixins.py index 68fba5ee6..456c6daa5 100644 --- a/netbox/extras/graphql/mixins.py +++ b/netbox/extras/graphql/mixins.py @@ -1,6 +1,8 @@ -import graphene +from typing import TYPE_CHECKING, Annotated, List + +import strawberry +import strawberry_django from django.contrib.contenttypes.models import ContentType -from graphene.types.generic import GenericScalar from extras.models import ObjectChange @@ -14,56 +16,63 @@ __all__ = ( 'TagsMixin', ) +if TYPE_CHECKING: + from .types import ImageAttachmentType, JournalEntryType, ObjectChangeType, TagType + from tenancy.graphql.types import ContactAssignmentType + +@strawberry.type class ChangelogMixin: - changelog = graphene.List('extras.graphql.types.ObjectChangeType') - def resolve_changelog(self, info): + @strawberry_django.field + def changelog(self, info) -> List[Annotated["ObjectChangeType", strawberry.lazy('.types')]]: content_type = ContentType.objects.get_for_model(self) object_changes = ObjectChange.objects.filter( changed_object_type=content_type, changed_object_id=self.pk ) - return object_changes.restrict(info.context.user, 'view') + return object_changes.restrict(info.context.request.user, 'view') +@strawberry.type class ConfigContextMixin: - config_context = GenericScalar() - def resolve_config_context(self, info): + @strawberry_django.field + def config_context(self) -> strawberry.scalars.JSON: return self.get_config_context() +@strawberry.type class CustomFieldsMixin: - custom_fields = GenericScalar() - def resolve_custom_fields(self, info): + @strawberry_django.field + def custom_fields(self) -> strawberry.scalars.JSON: return self.custom_field_data +@strawberry.type class ImageAttachmentsMixin: - image_attachments = graphene.List('extras.graphql.types.ImageAttachmentType') - def resolve_image_attachments(self, info): - return self.images.restrict(info.context.user, 'view') + @strawberry_django.field + def image_attachments(self, info) -> List[Annotated["ImageAttachmentType", strawberry.lazy('.types')]]: + return self.images.restrict(info.context.request.user, 'view') +@strawberry.type class JournalEntriesMixin: - journal_entries = graphene.List('extras.graphql.types.JournalEntryType') - def resolve_journal_entries(self, info): - return self.journal_entries.restrict(info.context.user, 'view') + @strawberry_django.field + def journal_entries(self, info) -> List[Annotated["JournalEntryType", strawberry.lazy('.types')]]: + return self.journal_entries.all() +@strawberry.type class TagsMixin: - tags = graphene.List('extras.graphql.types.TagType') - def resolve_tags(self, info): - return self.tags.all() + tags: List[Annotated["TagType", strawberry.lazy('.types')]] +@strawberry.type class ContactsMixin: - contacts = graphene.List('tenancy.graphql.types.ContactAssignmentType') - def resolve_contacts(self, info): - return list(self.contacts.all()) + contacts: List[Annotated["ContactAssignmentType", strawberry.lazy('tenancy.graphql.types')]] diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index 09e399e37..f78285035 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -1,80 +1,70 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from extras import models -from netbox.graphql.fields import ObjectField, ObjectListField from .types import * -from utilities.graphql_optimizer import gql_query_optimizer -class ExtrasQuery(graphene.ObjectType): - config_context = ObjectField(ConfigContextType) - config_context_list = ObjectListField(ConfigContextType) +@strawberry.type +class ExtrasQuery: + @strawberry.field + def config_context(self, id: int) -> ConfigContextType: + return models.ConfigContext.objects.get(pk=id) + config_context_list: List[ConfigContextType] = strawberry_django.field() - def resolve_config_context_list(root, info, **kwargs): - return gql_query_optimizer(models.ConfigContext.objects.all(), info) + @strawberry.field + def config_template(self, id: int) -> ConfigTemplateType: + return models.ConfigTemplate.objects.get(pk=id) + config_template_list: List[ConfigTemplateType] = strawberry_django.field() - config_template = ObjectField(ConfigTemplateType) - config_template_list = ObjectListField(ConfigTemplateType) + @strawberry.field + def custom_field(self, id: int) -> CustomFieldType: + return models.CustomField.objects.get(pk=id) + custom_field_list: List[CustomFieldType] = strawberry_django.field() - def resolve_config_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ConfigTemplate.objects.all(), info) + @strawberry.field + def custom_field_choice_set(self, id: int) -> CustomFieldChoiceSetType: + return models.CustomFieldChoiceSet.objects.get(pk=id) + custom_field_choice_set_list: List[CustomFieldChoiceSetType] = strawberry_django.field() - custom_field = ObjectField(CustomFieldType) - custom_field_list = ObjectListField(CustomFieldType) + @strawberry.field + def custom_link(self, id: int) -> CustomLinkType: + return models.CustomLink.objects.get(pk=id) + custom_link_list: List[CustomLinkType] = strawberry_django.field() - def resolve_custom_field_list(root, info, **kwargs): - return gql_query_optimizer(models.CustomField.objects.all(), info) + @strawberry.field + def export_template(self, id: int) -> ExportTemplateType: + return models.ExportTemplate.objects.get(pk=id) + export_template_list: List[ExportTemplateType] = strawberry_django.field() - custom_field_choice_set = ObjectField(CustomFieldChoiceSetType) - custom_field_choice_set_list = ObjectListField(CustomFieldChoiceSetType) + @strawberry.field + def image_attachment(self, id: int) -> ImageAttachmentType: + return models.ImageAttachment.objects.get(pk=id) + image_attachment_list: List[ImageAttachmentType] = strawberry_django.field() - def resolve_custom_field_choices_list(root, info, **kwargs): - return gql_query_optimizer(models.CustomFieldChoiceSet.objects.all(), info) + @strawberry.field + def saved_filter(self, id: int) -> SavedFilterType: + return models.SavedFilter.objects.get(pk=id) + saved_filter_list: List[SavedFilterType] = strawberry_django.field() - custom_link = ObjectField(CustomLinkType) - custom_link_list = ObjectListField(CustomLinkType) + @strawberry.field + def journal_entry(self, id: int) -> JournalEntryType: + return models.JournalEntry.objects.get(pk=id) + journal_entry_list: List[JournalEntryType] = strawberry_django.field() - def resolve_custom_link_list(root, info, **kwargs): - return gql_query_optimizer(models.CustomLink.objects.all(), info) + @strawberry.field + def tag(self, id: int) -> TagType: + return models.Tag.objects.get(pk=id) + tag_list: List[TagType] = strawberry_django.field() - export_template = ObjectField(ExportTemplateType) - export_template_list = ObjectListField(ExportTemplateType) + @strawberry.field + def webhook(self, id: int) -> WebhookType: + return models.Webhook.objects.get(pk=id) + webhook_list: List[WebhookType] = strawberry_django.field() - def resolve_export_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ExportTemplate.objects.all(), info) - - image_attachment = ObjectField(ImageAttachmentType) - image_attachment_list = ObjectListField(ImageAttachmentType) - - def resolve_image_attachment_list(root, info, **kwargs): - return gql_query_optimizer(models.ImageAttachment.objects.all(), info) - - saved_filter = ObjectField(SavedFilterType) - saved_filter_list = ObjectListField(SavedFilterType) - - def resolve_saved_filter_list(root, info, **kwargs): - return gql_query_optimizer(models.SavedFilter.objects.all(), info) - - journal_entry = ObjectField(JournalEntryType) - journal_entry_list = ObjectListField(JournalEntryType) - - def resolve_journal_entry_list(root, info, **kwargs): - return gql_query_optimizer(models.JournalEntry.objects.all(), info) - - tag = ObjectField(TagType) - tag_list = ObjectListField(TagType) - - def resolve_tag_list(root, info, **kwargs): - return gql_query_optimizer(models.Tag.objects.all(), info) - - webhook = ObjectField(WebhookType) - webhook_list = ObjectListField(WebhookType) - - def resolve_webhook_list(root, info, **kwargs): - return gql_query_optimizer(models.Webhook.objects.all(), info) - - event_rule = ObjectField(EventRuleType) - event_rule_list = ObjectListField(EventRuleType) - - def resolve_eventrule_list(root, info, **kwargs): - return gql_query_optimizer(models.EventRule.objects.all(), info) + @strawberry.field + def event_rule(self, id: int) -> EventRuleType: + return models.EventRule.objects.get(pk=id) + event_rule_list: List[EventRuleType] = strawberry_django.field() diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index d99e54976..6bb7ce411 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -1,6 +1,12 @@ -from extras import filtersets, models +from typing import Annotated, List + +import strawberry +import strawberry_django + +from extras import models from extras.graphql.mixins import CustomFieldsMixin, TagsMixin -from netbox.graphql.types import BaseObjectType, ObjectType, OrganizationalObjectType +from netbox.graphql.types import BaseObjectType, ContentTypeType, ObjectType, OrganizationalObjectType +from .filters import * __all__ = ( 'ConfigContextType', @@ -19,104 +25,146 @@ __all__ = ( ) +@strawberry_django.type( + models.ConfigContext, + fields='__all__', + filters=ConfigContextFilter +) class ConfigContextType(ObjectType): + data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None + data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None - class Meta: - model = models.ConfigContext - fields = '__all__' - filterset_class = filtersets.ConfigContextFilterSet + roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] + device_types: List[Annotated["DeviceTypeType", strawberry.lazy('dcim.graphql.types')]] + tags: List[Annotated["TagType", strawberry.lazy('extras.graphql.types')]] + platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] + regions: List[Annotated["RegionType", strawberry.lazy('dcim.graphql.types')]] + cluster_groups: List[Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')]] + tenant_groups: List[Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')]] + cluster_types: List[Annotated["ClusterTypeType", strawberry.lazy('virtualization.graphql.types')]] + clusters: List[Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')]] + locations: List[Annotated["LocationType", strawberry.lazy('dcim.graphql.types')]] + sites: List[Annotated["SiteType", strawberry.lazy('dcim.graphql.types')]] + tenants: List[Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')]] + site_groups: List[Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.ConfigTemplate, + fields='__all__', + filters=ConfigTemplateFilter +) class ConfigTemplateType(TagsMixin, ObjectType): + data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None + data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None - class Meta: - model = models.ConfigTemplate - fields = '__all__' - filterset_class = filtersets.ConfigTemplateFilterSet + virtualmachines: List[Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')]] + devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]] + platforms: List[Annotated["PlatformType", strawberry.lazy('dcim.graphql.types')]] + device_roles: List[Annotated["DeviceRoleType", strawberry.lazy('dcim.graphql.types')]] +@strawberry_django.type( + models.CustomField, + fields='__all__', + filters=CustomFieldFilter +) class CustomFieldType(ObjectType): - - class Meta: - model = models.CustomField - fields = '__all__' - filterset_class = filtersets.CustomFieldFilterSet + related_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None + choice_set: Annotated["CustomFieldChoiceSetType", strawberry.lazy('extras.graphql.types')] | None +@strawberry_django.type( + models.CustomFieldChoiceSet, + exclude=('extra_choices', ), + filters=CustomFieldChoiceSetFilter +) class CustomFieldChoiceSetType(ObjectType): - class Meta: - model = models.CustomFieldChoiceSet - fields = '__all__' - filterset_class = filtersets.CustomFieldChoiceSetFilterSet + choices_for: List[Annotated["CustomFieldType", strawberry.lazy('extras.graphql.types')]] + extra_choices: List[str] | None +@strawberry_django.type( + models.CustomLink, + fields='__all__', + filters=CustomLinkFilter +) class CustomLinkType(ObjectType): - - class Meta: - model = models.CustomLink - fields = '__all__' - filterset_class = filtersets.CustomLinkFilterSet - - -class EventRuleType(OrganizationalObjectType): - - class Meta: - model = models.EventRule - fields = '__all__' - filterset_class = filtersets.EventRuleFilterSet + pass +@strawberry_django.type( + models.ExportTemplate, + fields='__all__', + filters=ExportTemplateFilter +) class ExportTemplateType(ObjectType): - - class Meta: - model = models.ExportTemplate - fields = '__all__' - filterset_class = filtersets.ExportTemplateFilterSet + data_source: Annotated["DataSourceType", strawberry.lazy('core.graphql.types')] | None + data_file: Annotated["DataFileType", strawberry.lazy('core.graphql.types')] | None +@strawberry_django.type( + models.ImageAttachment, + fields='__all__', + filters=ImageAttachmentFilter +) class ImageAttachmentType(BaseObjectType): - - class Meta: - model = models.ImageAttachment - fields = '__all__' - filterset_class = filtersets.ImageAttachmentFilterSet + object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None +@strawberry_django.type( + models.JournalEntry, + fields='__all__', + filters=JournalEntryFilter +) class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType): - - class Meta: - model = models.JournalEntry - fields = '__all__' - filterset_class = filtersets.JournalEntryFilterSet + assigned_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None + created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None +@strawberry_django.type( + models.ObjectChange, + fields='__all__', + filters=ObjectChangeFilter +) class ObjectChangeType(BaseObjectType): - - class Meta: - model = models.ObjectChange - fields = '__all__' - filterset_class = filtersets.ObjectChangeFilterSet + pass +@strawberry_django.type( + models.SavedFilter, + exclude=['content_types',], + filters=SavedFilterFilter +) class SavedFilterType(ObjectType): - - class Meta: - model = models.SavedFilter - fields = '__all__' - filterset_class = filtersets.SavedFilterFilterSet + user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None +@strawberry_django.type( + models.Tag, + exclude=['extras_taggeditem_items', ], + filters=TagFilter +) class TagType(ObjectType): + color: str - class Meta: - model = models.Tag - exclude = ('extras_taggeditem_items',) - filterset_class = filtersets.TagFilterSet + object_types: List[ContentTypeType] +@strawberry_django.type( + models.Webhook, + exclude=['content_types',], + filters=WebhookFilter +) class WebhookType(OrganizationalObjectType): + pass - class Meta: - model = models.Webhook - filterset_class = filtersets.WebhookFilterSet + +@strawberry_django.type( + models.EventRule, + exclude=['content_types',], + filters=EventRuleFilter +) +class EventRuleType(OrganizationalObjectType): + action_object_type: Annotated["ContentTypeType", strawberry.lazy('netbox.graphql.types')] | None diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 2098b7a82..160e8813f 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,7 +14,7 @@ from extras.context_managers import event_tracking from extras.scripts import get_module_and_script from extras.signals import clear_events from utilities.exceptions import AbortTransaction -from utilities.utils import NetBoxFakeRequest +from utilities.request import NetBoxFakeRequest class Command(BaseCommand): diff --git a/netbox/extras/migrations/0111_rename_content_types.py b/netbox/extras/migrations/0111_rename_content_types.py index 7b0fa9459..acd6aef0f 100644 --- a/netbox/extras/migrations/0111_rename_content_types.py +++ b/netbox/extras/migrations/0111_rename_content_types.py @@ -27,7 +27,11 @@ class Migration(migrations.Migration): field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype'), ), migrations.RunSQL( - "ALTER TABLE extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq" + "ALTER TABLE IF EXISTS extras_customfield_content_types_id_seq RENAME TO extras_customfield_object_types_id_seq" + ), + # Pre-v2.10 sequence name (see #15605) + migrations.RunSQL( + "ALTER TABLE IF EXISTS extras_customfield_obj_type_id_seq RENAME TO extras_customfield_object_types_id_seq" ), # Custom links diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index ff39c3e8b..6b52d4c02 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -9,11 +9,11 @@ from jinja2.sandbox import SandboxedEnvironment from extras.querysets import ConfigContextQuerySet from netbox.config import get_config -from netbox.registry import registry from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin -from utilities.jinja2 import ConfigTemplateLoader -from utilities.utils import deepmerge +from netbox.registry import registry +from utilities.data import deepmerge +from utilities.jinja2 import DataFileLoader __all__ = ( 'ConfigContext', @@ -290,7 +290,7 @@ class ConfigTemplate(SyncedDataMixin, CustomLinksMixin, ExportTemplatesMixin, Ta """ # Initialize the template loader & cache the base template code (if applicable) if self.data_file: - loader = ConfigTemplateLoader(data_source=self.data_source) + loader = DataFileLoader(data_source=self.data_source) loader.cache_templates({ self.data_file.path: self.template_code }) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index b55aaa11d..16f10b485 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -22,8 +22,10 @@ from netbox.models import ChangeLoggedModel from netbox.models.features import ( CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, ) +from utilities.html import clean_html +from utilities.querydict import dict_to_querydict from utilities.querysets import RestrictedQuerySet -from utilities.utils import clean_html, dict_to_querydict, render_jinja2 +from utilities.jinja2 import render_jinja2 __all__ = ( 'Bookmark', diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index e857e59b7..551a8b4f0 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -108,7 +108,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): def __str__(self): return self.python_name - @cached_property + @property def module_scripts(self): def _get_name(cls): @@ -137,9 +137,13 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): Syncs the file-based module to the database, adding and removing individual Script objects in the database as needed. """ - db_classes = { - script.name: script for script in self.scripts.all() - } + if self.id: + db_classes = { + script.name: script for script in self.scripts.all() + } + else: + db_classes = {} + db_classes_set = set(db_classes.keys()) module_classes_set = set(self.module_scripts.keys()) @@ -158,10 +162,10 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): def sync_data(self): super().sync_data() - self.sync_classes() def save(self, *args, **kwargs): self.file_root = ManagedFileRootPathChoices.SCRIPTS + self.sync_classes() return super().save(*args, **kwargs) diff --git a/netbox/extras/models/search.py b/netbox/extras/models/search.py index 3c2cebe8e..ae99f1735 100644 --- a/netbox/extras/models/search.py +++ b/netbox/extras/models/search.py @@ -4,9 +4,7 @@ from django.db import models from django.utils.translation import gettext_lazy as _ from netbox.search.utils import get_indexer -from netbox.registry import registry from utilities.fields import RestrictedGenericForeignKey -from utilities.utils import content_type_identifier from ..fields import CachedValueField __all__ = ( diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index f15d8d470..6e381ce70 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import ChangeActionChoices from netbox.models import ChangeLoggedModel from netbox.models.features import * -from utilities.utils import deserialize_object +from utilities.serialization import deserialize_object __all__ = ( 'Branch', diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 27b05638e..d1e329f03 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -5,9 +5,9 @@ from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from taggit.models import TagBase, GenericTaggedItemBase +from netbox.choices import ColorChoices from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin -from utilities.choices import ColorChoices from utilities.fields import ColorField __all__ = ( @@ -37,7 +37,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): to='core.ObjectType', related_name='+', blank=True, - help_text=_("The object type(s) to which this this tag can be applied.") + help_text=_("The object type(s) to which this tag can be applied.") ) clone_fields = ( diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 833ce0036..2813ed7ae 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,7 +1,8 @@ +import importlib import logging from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal @@ -13,7 +14,6 @@ from core.signals import job_end, job_start from extras.constants import EVENT_JOB_END, EVENT_JOB_START from extras.events import process_event_rules from extras.models import EventRule -from extras.validators import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue from netbox.models.features import ChangeLoggingMixin @@ -22,6 +22,30 @@ from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices from .events import enqueue_object, get_snapshots, serialize_for_event from .models import CustomField, ObjectChange, TaggedItem +from .validators import CustomValidator + + +def run_validators(instance, validators): + """ + Run the provided iterable of validators for the instance. + """ + request = current_request.get() + for validator in validators: + + # Loading a validator class by dotted path + if type(validator) is str: + module, cls = validator.rsplit('.', 1) + validator = getattr(importlib.import_module(module), cls)() + + # Constructing a new instance on the fly from a ruleset + elif type(validator) is dict: + validator = CustomValidator(validator) + + elif not issubclass(validator.__class__, CustomValidator): + raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}") + + validator(instance, request) + # # Change logging/webhooks diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 31cd22815..849955cd4 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -65,7 +65,7 @@ def custom_links(context, obj): rendered['link'], rendered['link_target'], cl.button_class, rendered['text'] ) except Exception as e: - template_code += f'' \ + template_code += f'' \ f' {cl.name}\n' # Add grouped links to template diff --git a/netbox/extras/tests/test_custom_validation.py b/netbox/extras/tests/test_custom_validation.py index e375b49f5..652bc241b 100644 --- a/netbox/extras/tests/test_custom_validation.py +++ b/netbox/extras/tests/test_custom_validation.py @@ -5,7 +5,7 @@ from circuits.api.serializers import ProviderSerializer from circuits.forms import ProviderForm from circuits.models import Provider from ipam.models import ASN, RIR -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, ModelViewTestCase, create_tags, post_data diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 0c8b86f93..d4917cde9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -12,7 +12,7 @@ from dcim.models import Manufacturer, Rack, Site from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from ipam.models import VLAN -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine diff --git a/netbox/extras/tests/test_customvalidation.py b/netbox/extras/tests/test_customvalidators.py similarity index 80% rename from netbox/extras/tests/test_customvalidation.py rename to netbox/extras/tests/test_customvalidators.py index d74ad599b..217fddd18 100644 --- a/netbox/extras/tests/test_customvalidation.py +++ b/netbox/extras/tests/test_customvalidators.py @@ -3,11 +3,13 @@ from django.core.exceptions import ValidationError from django.db import transaction from django.test import TestCase, override_settings -from ipam.models import ASN, RIR from dcim.choices import SiteStatusChoices -from dcim.models import Site +from dcim.models import Site, Region from extras.validators import CustomValidator +from ipam.models import ASN, RIR +from users.models import User from utilities.exceptions import AbortRequest +from utilities.request import NetBoxFakeRequest class MyValidator(CustomValidator): @@ -79,6 +81,20 @@ prohibited_validator = CustomValidator({ } }) + +region_validator = CustomValidator({ + 'region.name': { + 'eq': 'Bar', + } +}) + + +request_validator = CustomValidator({ + 'request.user.username': { + 'eq': 'Bob' + } +}) + custom_validator = MyValidator() @@ -145,6 +161,20 @@ class CustomValidatorTest(TestCase): def test_valid(self): Site(name='abcdef123', slug='abcdef123').clean() + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [region_validator]}) + def test_valid(self): + region1 = Region(name='Foo', slug='foo') + region1.save() + region2 = Region(name='Bar', slug='bar') + region2.save() + + # Invalid region + with self.assertRaises(ValidationError): + Site(name='abcdef123', slug='abcdef123', region=region1).clean() + + # Valid region + Site(name='abcdef123', slug='abcdef123', region=region2).clean() + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]}) def test_custom_invalid(self): with self.assertRaises(ValidationError): @@ -154,6 +184,28 @@ class CustomValidatorTest(TestCase): def test_custom_valid(self): Site(name='foo', slug='foo').clean() + @override_settings(CUSTOM_VALIDATORS={'dcim.site': [request_validator]}) + def test_request_validation(self): + alice = User.objects.create(username='Alice') + bob = User.objects.create(username='Bob') + request = NetBoxFakeRequest({ + 'META': {}, + 'POST': {}, + 'GET': {}, + 'FILES': {}, + 'user': alice, + 'path': '', + }) + site = Site(name='abc', slug='abc') + + # Attempt to create the Site as Alice + with self.assertRaises(ValidationError): + request_validator(site, request) + + # Creating the Site as Bob should succeed + request.user = bob + request_validator(site, request) + class CustomValidatorConfigTest(TestCase): @@ -176,7 +228,7 @@ class CustomValidatorConfigTest(TestCase): @override_settings( CUSTOM_VALIDATORS={ 'dcim.site': ( - 'extras.tests.test_customvalidation.MyValidator', + 'extras.tests.test_customvalidators.MyValidator', ) } ) @@ -223,7 +275,7 @@ class ProtectionRulesConfigTest(TestCase): @override_settings( PROTECTION_RULES={ 'dcim.site': ( - 'extras.tests.test_customvalidation.MyValidator', + 'extras.tests.test_customvalidators.MyValidator', ) } ) diff --git a/netbox/extras/validators.py b/netbox/extras/validators.py index 30c9397d5..082f87d64 100644 --- a/netbox/extras/validators.py +++ b/netbox/extras/validators.py @@ -1,4 +1,5 @@ -import importlib +import inspect +import operator from django.core import validators from django.core.exceptions import ValidationError @@ -74,6 +75,8 @@ class CustomValidator: :param validation_rules: A dictionary mapping object attributes to validation rules """ + REQUEST_TOKEN = 'request' + VALIDATORS = { 'eq': IsEqualValidator, 'neq': IsNotEqualValidator, @@ -88,25 +91,56 @@ class CustomValidator: def __init__(self, validation_rules=None): self.validation_rules = validation_rules or {} - assert type(self.validation_rules) is dict, "Validation rules must be passed as a dictionary" + if type(self.validation_rules) is not dict: + raise ValueError(_("Validation rules must be passed as a dictionary")) - def __call__(self, instance): - # Validate instance attributes per validation rules - for attr_name, rules in self.validation_rules.items(): - attr = self._getattr(instance, attr_name) + def __call__(self, instance, request=None): + """ + Validate the instance and (optional) request against the validation rule(s). + """ + for attr_path, rules in self.validation_rules.items(): + + # The rule applies to the current request + if attr_path.split('.')[0] == self.REQUEST_TOKEN: + # Skip if no request has been provided (we can't validate) + if request is None: + continue + attr = self._get_request_attr(request, attr_path) + # The rule applies to the instance + else: + attr = self._get_instance_attr(instance, attr_path) + + # Validate the attribute's value against each of the rules defined for it for descriptor, value in rules.items(): validator = self.get_validator(descriptor, value) try: validator(attr) except ValidationError as exc: - # Re-package the raised ValidationError to associate it with the specific attr - raise ValidationError({attr_name: exc}) + raise ValidationError( + _("Custom validation failed for {attribute}: {exception}").format( + attribute=attr_path, exception=exc + ) + ) # Execute custom validation logic (if any) - self.validate(instance) + # TODO: Remove in v4.1 + # Inspect the validate() method, which may have been overridden, to determine + # whether we should pass the request (maintains backward compatibility for pre-v4.0) + if 'request' in inspect.signature(self.validate).parameters: + self.validate(instance, request) + else: + self.validate(instance) @staticmethod - def _getattr(instance, name): + def _get_request_attr(request, name): + name = name.split('.', maxsplit=1)[1] # Remove token + try: + return operator.attrgetter(name)(request) + except AttributeError: + raise ValidationError(_('Invalid attribute "{name}" for request').format(name=name)) + + @staticmethod + def _get_instance_attr(instance, name): # Attempt to resolve many-to-many fields to their stored values m2m_fields = [f.name for f in instance._meta.local_many_to_many] if name in m2m_fields: @@ -117,14 +151,14 @@ class CustomValidator: return [] # Raise a ValidationError for unknown attributes - if not hasattr(instance, name): + try: + return operator.attrgetter(name)(instance) + except AttributeError: raise ValidationError(_('Invalid attribute "{name}" for {model}').format( name=name, model=instance.__class__.__name__ )) - return getattr(instance, name) - def get_validator(self, descriptor, value): """ Instantiate and return the appropriate validator based on the descriptor given. For @@ -137,7 +171,7 @@ class CustomValidator: validator_cls = self.VALIDATORS.get(descriptor) return validator_cls(value) - def validate(self, instance): + def validate(self, instance, request): """ Custom validation method, to be overridden by the user. Validation failures should raise a ValidationError exception. @@ -151,21 +185,3 @@ class CustomValidator: if field is not None: raise ValidationError({field: message}) raise ValidationError(message) - - -def run_validators(instance, validators): - """ - Run the provided iterable of validators for the instance. - """ - for validator in validators: - - # Loading a validator class by dotted path - if type(validator) is str: - module, cls = validator.rsplit('.', 1) - validator = getattr(importlib.import_module(module), cls)() - - # Constructing a new instance on the fly from a ruleset - elif type(validator) is dict: - validator = CustomValidator(validator) - - validator(instance) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index cb3fdd39c..be3937512 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -18,12 +18,16 @@ from extras.dashboard.utils import get_widget_class from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.views import generic from netbox.views.generic.mixins import TableMixin +from utilities.data import shallow_compare_dict from utilities.forms import ConfirmationForm, get_field_value +from utilities.htmx import htmx_partial from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.query import count_related +from utilities.querydict import normalize_querydict +from utilities.request import copy_safe_request from utilities.rqworker import get_workers_for_queue from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict -from utilities.views import ContentTypePermissionRequiredMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view from . import filtersets, forms, tables from .models import * from .scripts import run_script @@ -1221,7 +1225,7 @@ class ScriptResultView(TableMixin, generic.ObjectView): } # If this is an HTMX request, return only the result HTML - if request.htmx: + if htmx_partial(request): response = render(request, 'extras/htmx/script_result.html', context) if job.completed or not job.started: response.status_code = 286 diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 0627a6765..bfff1f4f4 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -378,7 +378,7 @@ class IPAddressImportForm(NetBoxModelImportForm): # Set as primary for device/VM if self.cleaned_data.get('is_primary'): - parent = self.cleaned_data['device'] or self.cleaned_data['virtual_machine'] + parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine') if self.instance.address.version == 4: parent.primary_ip4 = ipaddress elif self.instance.address.version == 6: diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py new file mode 100644 index 000000000..5f6602416 --- /dev/null +++ b/netbox/ipam/graphql/filters.py @@ -0,0 +1,119 @@ +import strawberry_django + +from ipam import filtersets, models +from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin + +__all__ = ( + 'ASNFilter', + 'ASNRangeFilter', + 'AggregateFilter', + 'FHRPGroupFilter', + 'FHRPGroupAssignmentFilter', + 'IPAddressFilter', + 'IPRangeFilter', + 'PrefixFilter', + 'RIRFilter', + 'RoleFilter', + 'RouteTargetFilter', + 'ServiceFilter', + 'ServiceTemplateFilter', + 'VLANFilter', + 'VLANGroupFilter', + 'VRFFilter', +) + + +@strawberry_django.filter(models.ASN, lookups=True) +@autotype_decorator(filtersets.ASNFilterSet) +class ASNFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ASNRange, lookups=True) +@autotype_decorator(filtersets.ASNRangeFilterSet) +class ASNRangeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Aggregate, lookups=True) +@autotype_decorator(filtersets.AggregateFilterSet) +class AggregateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FHRPGroup, lookups=True) +@autotype_decorator(filtersets.FHRPGroupFilterSet) +class FHRPGroupFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.FHRPGroupAssignment, lookups=True) +@autotype_decorator(filtersets.FHRPGroupAssignmentFilterSet) +class FHRPGroupAssignmentFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.IPAddress, lookups=True) +@autotype_decorator(filtersets.IPAddressFilterSet) +class IPAddressFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.IPRange, lookups=True) +@autotype_decorator(filtersets.IPRangeFilterSet) +class IPRangeFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Prefix, lookups=True) +@autotype_decorator(filtersets.PrefixFilterSet) +class PrefixFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RIR, lookups=True) +@autotype_decorator(filtersets.RIRFilterSet) +class RIRFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Role, lookups=True) +@autotype_decorator(filtersets.RoleFilterSet) +class RoleFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.RouteTarget, lookups=True) +@autotype_decorator(filtersets.RouteTargetFilterSet) +class RouteTargetFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.Service, lookups=True) +@autotype_decorator(filtersets.ServiceFilterSet) +class ServiceFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.ServiceTemplate, lookups=True) +@autotype_decorator(filtersets.ServiceTemplateFilterSet) +class ServiceTemplateFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VLAN, lookups=True) +@autotype_decorator(filtersets.VLANFilterSet) +class VLANFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VLANGroup, lookups=True) +@autotype_decorator(filtersets.VLANGroupFilterSet) +class VLANGroupFilter(BaseFilterMixin): + pass + + +@strawberry_django.filter(models.VRF, lookups=True) +@autotype_decorator(filtersets.VRFFilterSet) +class VRFFilter(BaseFilterMixin): + pass diff --git a/netbox/ipam/graphql/gfk_mixins.py b/netbox/ipam/graphql/gfk_mixins.py deleted file mode 100644 index 01c79690a..000000000 --- a/netbox/ipam/graphql/gfk_mixins.py +++ /dev/null @@ -1,95 +0,0 @@ -import graphene -from dcim.graphql.types import ( - InterfaceType, - LocationType, - RackType, - RegionType, - SiteGroupType, - SiteType, -) -from dcim.models import Interface, Location, Rack, Region, Site, SiteGroup -from ipam.graphql.types import FHRPGroupType, VLANType -from ipam.models import VLAN, FHRPGroup -from virtualization.graphql.types import ClusterGroupType, ClusterType, VMInterfaceType -from virtualization.models import Cluster, ClusterGroup, VMInterface - - -class IPAddressAssignmentType(graphene.Union): - class Meta: - types = ( - InterfaceType, - FHRPGroupType, - VMInterfaceType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is Interface: - return InterfaceType - if type(instance) is FHRPGroup: - return FHRPGroupType - if type(instance) is VMInterface: - return VMInterfaceType - - -class L2VPNAssignmentType(graphene.Union): - class Meta: - types = ( - InterfaceType, - VLANType, - VMInterfaceType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is Interface: - return InterfaceType - if type(instance) is VLAN: - return VLANType - if type(instance) is VMInterface: - return VMInterfaceType - - -class FHRPGroupInterfaceType(graphene.Union): - class Meta: - types = ( - InterfaceType, - VMInterfaceType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is Interface: - return InterfaceType - if type(instance) is VMInterface: - return VMInterfaceType - - -class VLANGroupScopeType(graphene.Union): - class Meta: - types = ( - ClusterType, - ClusterGroupType, - LocationType, - RackType, - RegionType, - SiteType, - SiteGroupType, - ) - - @classmethod - def resolve_type(cls, instance, info): - if type(instance) is Cluster: - return ClusterType - if type(instance) is ClusterGroup: - return ClusterGroupType - if type(instance) is Location: - return LocationType - if type(instance) is Rack: - return RackType - if type(instance) is Region: - return RegionType - if type(instance) is Site: - return SiteType - if type(instance) is SiteGroup: - return SiteGroupType diff --git a/netbox/ipam/graphql/mixins.py b/netbox/ipam/graphql/mixins.py index 283414df3..73cc60ec4 100644 --- a/netbox/ipam/graphql/mixins.py +++ b/netbox/ipam/graphql/mixins.py @@ -1,4 +1,7 @@ -import graphene +from typing import Annotated, List + +import strawberry +import strawberry_django __all__ = ( 'IPAddressesMixin', @@ -6,15 +9,11 @@ __all__ = ( ) +@strawberry.type class IPAddressesMixin: - ip_addresses = graphene.List('ipam.graphql.types.IPAddressType') - - def resolve_ip_addresses(self, info): - return self.ip_addresses.restrict(info.context.user, 'view') + ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] +@strawberry.type class VLANGroupsMixin: - vlan_groups = graphene.List('ipam.graphql.types.VLANGroupType') - - def resolve_vlan_groups(self, info): - return self.vlan_groups.restrict(info.context.user, 'view') + vlan_groups: List[Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')]] diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 6627c540e..c02788c3a 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -1,104 +1,90 @@ -import graphene +from typing import List + +import strawberry +import strawberry_django from ipam import models -from netbox.graphql.fields import ObjectField, ObjectListField -from utilities.graphql_optimizer import gql_query_optimizer from .types import * -class IPAMQuery(graphene.ObjectType): - asn = ObjectField(ASNType) - asn_list = ObjectListField(ASNType) +@strawberry.type +class IPAMQuery: + @strawberry.field + def asn(self, id: int) -> ASNType: + return models.ASN.objects.get(pk=id) + asn_list: List[ASNType] = strawberry_django.field() - def resolve_asn_list(root, info, **kwargs): - return gql_query_optimizer(models.ASN.objects.all(), info) + @strawberry.field + def asn_range(self, id: int) -> ASNRangeType: + return models.ASNRange.objects.get(pk=id) + asn_range_list: List[ASNRangeType] = strawberry_django.field() - asn_range = ObjectField(ASNRangeType) - asn_range_list = ObjectListField(ASNRangeType) + @strawberry.field + def aggregate(self, id: int) -> AggregateType: + return models.Aggregate.objects.get(pk=id) + aggregate_list: List[AggregateType] = strawberry_django.field() - def resolve_asn_range_list(root, info, **kwargs): - return gql_query_optimizer(models.ASNRange.objects.all(), info) + @strawberry.field + def ip_address(self, id: int) -> IPAddressType: + return models.IPAddress.objects.get(pk=id) + ip_address_list: List[IPAddressType] = strawberry_django.field() - aggregate = ObjectField(AggregateType) - aggregate_list = ObjectListField(AggregateType) + @strawberry.field + def ip_range(self, id: int) -> IPRangeType: + return models.IPRange.objects.get(pk=id) + ip_range_list: List[IPRangeType] = strawberry_django.field() - def resolve_aggregate_list(root, info, **kwargs): - return gql_query_optimizer(models.Aggregate.objects.all(), info) + @strawberry.field + def prefix(self, id: int) -> PrefixType: + return models.Prefix.objects.get(pk=id) + prefix_list: List[PrefixType] = strawberry_django.field() - ip_address = ObjectField(IPAddressType) - ip_address_list = ObjectListField(IPAddressType) + @strawberry.field + def rir(self, id: int) -> RIRType: + return models.RIR.objects.get(pk=id) + rir_list: List[RIRType] = strawberry_django.field() - def resolve_ip_address_list(root, info, **kwargs): - return gql_query_optimizer(models.IPAddress.objects.all(), info) + @strawberry.field + def role(self, id: int) -> RoleType: + return models.Role.objects.get(pk=id) + role_list: List[RoleType] = strawberry_django.field() - ip_range = ObjectField(IPRangeType) - ip_range_list = ObjectListField(IPRangeType) + @strawberry.field + def route_target(self, id: int) -> RouteTargetType: + return models.RouteTarget.objects.get(pk=id) + route_target_list: List[RouteTargetType] = strawberry_django.field() - def resolve_ip_range_list(root, info, **kwargs): - return gql_query_optimizer(models.IPRange.objects.all(), info) + @strawberry.field + def service(self, id: int) -> ServiceType: + return models.Service.objects.get(pk=id) + service_list: List[ServiceType] = strawberry_django.field() - prefix = ObjectField(PrefixType) - prefix_list = ObjectListField(PrefixType) + @strawberry.field + def service_template(self, id: int) -> ServiceTemplateType: + return models.ServiceTemplate.objects.get(pk=id) + service_template_list: List[ServiceTemplateType] = strawberry_django.field() - def resolve_prefix_list(root, info, **kwargs): - return gql_query_optimizer(models.Prefix.objects.all(), info) + @strawberry.field + def fhrp_group(self, id: int) -> FHRPGroupType: + return models.FHRPGroup.objects.get(pk=id) + fhrp_group_list: List[FHRPGroupType] = strawberry_django.field() - rir = ObjectField(RIRType) - rir_list = ObjectListField(RIRType) + @strawberry.field + def fhrp_group_assignment(self, id: int) -> FHRPGroupAssignmentType: + return models.FHRPGroupAssignment.objects.get(pk=id) + fhrp_group_assignment_list: List[FHRPGroupAssignmentType] = strawberry_django.field() - def resolve_rir_list(root, info, **kwargs): - return gql_query_optimizer(models.RIR.objects.all(), info) + @strawberry.field + def vlan(self, id: int) -> VLANType: + return models.VLAN.objects.get(pk=id) + vlan_list: List[VLANType] = strawberry_django.field() - role = ObjectField(RoleType) - role_list = ObjectListField(RoleType) + @strawberry.field + def vlan_group(self, id: int) -> VLANGroupType: + return models.VLANGroup.objects.get(pk=id) + vlan_group_list: List[VLANGroupType] = strawberry_django.field() - def resolve_role_list(root, info, **kwargs): - return gql_query_optimizer(models.Role.objects.all(), info) - - route_target = ObjectField(RouteTargetType) - route_target_list = ObjectListField(RouteTargetType) - - def resolve_route_target_list(root, info, **kwargs): - return gql_query_optimizer(models.RouteTarget.objects.all(), info) - - service = ObjectField(ServiceType) - service_list = ObjectListField(ServiceType) - - def resolve_service_list(root, info, **kwargs): - return gql_query_optimizer(models.Service.objects.all(), info) - - service_template = ObjectField(ServiceTemplateType) - service_template_list = ObjectListField(ServiceTemplateType) - - def resolve_service_template_list(root, info, **kwargs): - return gql_query_optimizer(models.ServiceTemplate.objects.all(), info) - - fhrp_group = ObjectField(FHRPGroupType) - fhrp_group_list = ObjectListField(FHRPGroupType) - - def resolve_fhrp_group_list(root, info, **kwargs): - return gql_query_optimizer(models.FHRPGroup.objects.all(), info) - - fhrp_group_assignment = ObjectField(FHRPGroupAssignmentType) - fhrp_group_assignment_list = ObjectListField(FHRPGroupAssignmentType) - - def resolve_fhrp_group_assignment_list(root, info, **kwargs): - return gql_query_optimizer(models.FHRPGroupAssignment.objects.all(), info) - - vlan = ObjectField(VLANType) - vlan_list = ObjectListField(VLANType) - - def resolve_vlan_list(root, info, **kwargs): - return gql_query_optimizer(models.VLAN.objects.all(), info) - - vlan_group = ObjectField(VLANGroupType) - vlan_group_list = ObjectListField(VLANGroupType) - - def resolve_vlan_group_list(root, info, **kwargs): - return gql_query_optimizer(models.VLANGroup.objects.all(), info) - - vrf = ObjectField(VRFType) - vrf_list = ObjectListField(VRFType) - - def resolve_vrf_list(root, info, **kwargs): - return gql_query_optimizer(models.VRF.objects.all(), info) + @strawberry.field + def vrf(self, id: int) -> VRFType: + return models.VRF.objects.get(pk=id) + vrf_list: List[VRFType] = strawberry_django.field() diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index d19837fd1..6c269721e 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -1,9 +1,15 @@ -import graphene +from typing import Annotated, List, Union -from ipam import filtersets, models -from .mixins import IPAddressesMixin +import strawberry +import strawberry_django + +from circuits.graphql.types import ProviderType +from dcim.graphql.types import SiteType +from ipam import models from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from netbox.graphql.types import BaseObjectType, NetBoxObjectType, OrganizationalObjectType +from .filters import * +from .mixins import IPAddressesMixin __all__ = ( 'ASNType', @@ -25,164 +31,252 @@ __all__ = ( ) -class IPAddressFamilyType(graphene.ObjectType): - - value = graphene.Int() - label = graphene.String() - - def __init__(self, value): - self.value = value - self.label = f'IPv{value}' +@strawberry.type +class IPAddressFamilyType: + value: int + label: str +@strawberry.type class BaseIPAddressFamilyType: """ Base type for models that need to expose their IPAddress family type. """ - family = graphene.Field(IPAddressFamilyType) - def resolve_family(self, _): + @strawberry.field + def family(self) -> IPAddressFamilyType: # Note that self, is an instance of models.IPAddress # thus resolves to the address family value. - return IPAddressFamilyType(self.family) + return IPAddressFamilyType(value=self.family, label=f'IPv{self.family}') +@strawberry_django.type( + models.ASN, + fields='__all__', + filters=ASNFilter +) class ASNType(NetBoxObjectType): - asn = graphene.Field(BigInt) + asn: BigInt + rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.ASN - fields = '__all__' - filterset_class = filtersets.ASNFilterSet + sites: List[SiteType] + providers: List[ProviderType] +@strawberry_django.type( + models.ASNRange, + fields='__all__', + filters=ASNRangeFilter +) class ASNRangeType(NetBoxObjectType): - - class Meta: - model = models.ASNRange - fields = '__all__' - filterset_class = filtersets.ASNRangeFilterSet + start: BigInt + end: BigInt + rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None +@strawberry_django.type( + models.Aggregate, + fields='__all__', + filters=AggregateFilter +) class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType): - - class Meta: - model = models.Aggregate - fields = '__all__' - filterset_class = filtersets.AggregateFilterSet + prefix: str + rir: Annotated["RIRType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None +@strawberry_django.type( + models.FHRPGroup, + fields='__all__', + filters=FHRPGroupFilter +) class FHRPGroupType(NetBoxObjectType, IPAddressesMixin): - class Meta: - model = models.FHRPGroup - fields = '__all__' - filterset_class = filtersets.FHRPGroupFilterSet - - def resolve_auth_type(self, info): - return self.auth_type or None + fhrpgroupassignment_set: List[Annotated["FHRPGroupAssignmentType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.FHRPGroupAssignment, + exclude=('interface_type', 'interface_id'), + filters=FHRPGroupAssignmentFilter +) class FHRPGroupAssignmentType(BaseObjectType): - interface = graphene.Field('ipam.graphql.gfk_mixins.FHRPGroupInterfaceType') + group: Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')] - class Meta: - model = models.FHRPGroupAssignment - exclude = ('interface_type', 'interface_id') - filterset_class = filtersets.FHRPGroupAssignmentFilterSet + @strawberry_django.field + def interface(self) -> Annotated[Union[ + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')], + ], strawberry.union("FHRPGroupInterfaceType")]: + return self.interface +@strawberry_django.type( + models.IPAddress, + exclude=('assigned_object_type', 'assigned_object_id', 'address'), + filters=IPAddressFilter +) class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType): - assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType') + address: str + vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + nat_inside: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None - class Meta: - model = models.IPAddress - exclude = ('assigned_object_type', 'assigned_object_id') - filterset_class = filtersets.IPAddressFilterSet + nat_outside: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] + tunnel_terminations: List[Annotated["TunnelTerminationType", strawberry.lazy('vpn.graphql.types')]] + services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] - def resolve_role(self, info): - return self.role or None + @strawberry_django.field + def assigned_object(self) -> Annotated[Union[ + Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')], + Annotated["FHRPGroupType", strawberry.lazy('ipam.graphql.types')], + Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')], + ], strawberry.union("IPAddressAssignmentType")]: + return self.assigned_object +@strawberry_django.type( + models.IPRange, + fields='__all__', + filters=IPRangeFilter +) class IPRangeType(NetBoxObjectType): - - class Meta: - model = models.IPRange - fields = '__all__' - filterset_class = filtersets.IPRangeFilterSet - - def resolve_role(self, info): - return self.role or None + start_address: str + end_address: str + vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None +@strawberry_django.type( + models.Prefix, + fields='__all__', + filters=PrefixFilter +) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): - - class Meta: - model = models.Prefix - fields = '__all__' - filterset_class = filtersets.PrefixFilterSet + prefix: str + site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None + vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None + role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None +@strawberry_django.type( + models.RIR, + fields='__all__', + filters=RIRFilter +) class RIRType(OrganizationalObjectType): - class Meta: - model = models.RIR - fields = '__all__' - filterset_class = filtersets.RIRFilterSet + asn_ranges: List[Annotated["ASNRangeType", strawberry.lazy('ipam.graphql.types')]] + asns: List[Annotated["ASNType", strawberry.lazy('ipam.graphql.types')]] + aggregates: List[Annotated["AggregateType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.Role, + fields='__all__', + filters=RoleFilter +) class RoleType(OrganizationalObjectType): - class Meta: - model = models.Role - fields = '__all__' - filterset_class = filtersets.RoleFilterSet + prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] + ip_ranges: List[Annotated["IPRangeType", strawberry.lazy('ipam.graphql.types')]] + vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.RouteTarget, + fields='__all__', + filters=RouteTargetFilter +) class RouteTargetType(NetBoxObjectType): + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.RouteTarget - fields = '__all__' - filterset_class = filtersets.RouteTargetFilterSet + importing_l2vpns: List[Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]] + exporting_l2vpns: List[Annotated["L2VPNType", strawberry.lazy('vpn.graphql.types')]] + importing_vrfs: List[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]] + exporting_vrfs: List[Annotated["VRFType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.Service, + fields='__all__', + filters=ServiceFilter +) class ServiceType(NetBoxObjectType): + ports: List[int] + device: Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')] | None + virtual_machine: Annotated["VirtualMachineType", strawberry.lazy('virtualization.graphql.types')] | None - class Meta: - model = models.Service - fields = '__all__' - filterset_class = filtersets.ServiceFilterSet + ipaddresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] +@strawberry_django.type( + models.ServiceTemplate, + fields='__all__', + filters=ServiceTemplateFilter +) class ServiceTemplateType(NetBoxObjectType): - - class Meta: - model = models.ServiceTemplate - fields = '__all__' - filterset_class = filtersets.ServiceTemplateFilterSet + ports: List[int] +@strawberry_django.type( + models.VLAN, + fields='__all__', + filters=VLANFilter +) class VLANType(NetBoxObjectType): + site: Annotated["SiteType", strawberry.lazy('ipam.graphql.types')] | None + group: Annotated["VLANGroupType", strawberry.lazy('ipam.graphql.types')] | None + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None + role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None - class Meta: - model = models.VLAN - fields = '__all__' - filterset_class = filtersets.VLANFilterSet + interfaces_as_untagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + vminterfaces_as_untagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] + wirelesslan_set: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]] + prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] + interfaces_as_tagged: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + vminterfaces_as_tagged: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] +@strawberry_django.type( + models.VLANGroup, + exclude=('scope_type', 'scope_id'), + filters=VLANGroupFilter +) class VLANGroupType(OrganizationalObjectType): - scope = graphene.Field('ipam.graphql.gfk_mixins.VLANGroupScopeType') - class Meta: - model = models.VLANGroup - exclude = ('scope_type', 'scope_id') - filterset_class = filtersets.VLANGroupFilterSet + vlans: List[VLANType] + + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["ClusterType", strawberry.lazy('virtualization.graphql.types')], + Annotated["ClusterGroupType", strawberry.lazy('virtualization.graphql.types')], + Annotated["LocationType", strawberry.lazy('dcim.graphql.types')], + Annotated["RackType", strawberry.lazy('dcim.graphql.types')], + Annotated["RegionType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("VLANGroupScopeType")]: + return self.scope +@strawberry_django.type( + models.VRF, + fields='__all__', + filters=VRFFilter +) class VRFType(NetBoxObjectType): + tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None - class Meta: - model = models.VRF - fields = '__all__' - filterset_class = filtersets.VRFFilterSet + interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]] + ip_addresses: List[Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')]] + vminterfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]] + ip_ranges: List[Annotated["IPRangeType", strawberry.lazy('ipam.graphql.types')]] + export_targets: List[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] + import_targets: List[Annotated["RouteTargetType", strawberry.lazy('ipam.graphql.types')]] + prefixes: List[Annotated["PrefixType", strawberry.lazy('ipam.graphql.types')]] diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 3e3261ee9..37b559801 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -8,8 +8,7 @@ from django.utils.translation import gettext_lazy as _ from ipam.choices import * from ipam.constants import * from netbox.models import PrimaryModel -from utilities.utils import array_to_string - +from utilities.data import array_to_string __all__ = ( 'Service', diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 2ff8a8b6e..a3f37fe3c 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -3,8 +3,8 @@ from django.db.models import Count, F, OuterRef, Q, Subquery, Value from django.db.models.expressions import RawSQL from django.db.models.functions import Round +from utilities.query import count_related from utilities.querysets import RestrictedQuerySet -from utilities.utils import count_related __all__ = ( 'ASNRangeQuerySet', diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6870d1e9e..24d82d186 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -9,8 +9,8 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic +from utilities.query import count_related from utilities.tables import get_table_ordering -from utilities.utils import count_related from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface diff --git a/netbox/netbox/api/serializers/base.py b/netbox/netbox/api/serializers/base.py index 4445f62da..58ef45219 100644 --- a/netbox/netbox/api/serializers/base.py +++ b/netbox/netbox/api/serializers/base.py @@ -27,9 +27,13 @@ class BaseModelSerializer(serializers.ModelSerializer): self.nested = nested self._requested_fields = fields + # Disable validators for nested objects (which already exist) + if self.nested: + self.validators = [] + # If this serializer is nested but no fields have been specified, # default to using Meta.brief_fields (if set) - if nested and not fields: + if self.nested and not fields: self._requested_fields = getattr(self.Meta, 'brief_fields', None) super().__init__(*args, **kwargs) @@ -81,8 +85,9 @@ class ValidatedModelSerializer(BaseModelSerializer): attrs.pop('custom_fields', None) # Skip ManyToManyFields + opts = self.Meta.model._meta m2m_values = {} - for field in self.Meta.model._meta.local_many_to_many: + for field in [*opts.local_many_to_many, *opts.related_objects]: if field.name in attrs: m2m_values[field.name] = attrs.pop(field.name) diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py index fb4fab8b0..b1cf40406 100644 --- a/netbox/netbox/api/serializers/generic.py +++ b/netbox/netbox/api/serializers/generic.py @@ -2,9 +2,10 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers +from core.models import ObjectType from netbox.api.fields import ContentTypeField from utilities.api import get_serializer_for_model -from utilities.utils import content_type_identifier +from utilities.object_types import object_type_identifier __all__ = ( 'GenericObjectSerializer', @@ -27,9 +28,9 @@ class GenericObjectSerializer(serializers.Serializer): return model.objects.get(pk=data['object_id']) def to_representation(self, instance): - ct = ContentType.objects.get_for_model(instance) + object_type = ObjectType.objects.get_for_model(instance) data = { - 'object_type': content_type_identifier(ct), + 'object_type': object_type_identifier(object_type), 'object_id': instance.pk, } if 'request' in self.context: diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index c70c68bc0..2b66639c8 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -12,7 +12,7 @@ from django.utils.translation import gettext_lazy as _ from users.constants import CONSTRAINT_TOKEN_USER from users.models import Group, ObjectPermission from utilities.permissions import ( - permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, + permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type, ) UserModel = get_user_model() @@ -284,11 +284,9 @@ class RemoteUserBackend(_RemoteUserBackend): permissions_list = [] for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items(): try: - object_type, action = resolve_permission_ct( - permission_name) - # TODO: Merge multiple actions into a single ObjectPermission per content type - obj_perm = ObjectPermission( - actions=[action], constraints=constraints) + object_type, action = resolve_permission_type(permission_name) + # TODO: Merge multiple actions into a single ObjectPermission per object type + obj_perm = ObjectPermission(actions=[action], constraints=constraints) obj_perm.save() obj_perm.users.add(user) obj_perm.object_types.add(object_type) @@ -303,7 +301,9 @@ class RemoteUserBackend(_RemoteUserBackend): f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}") else: logger.debug( - f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as Group sync is enabled") + f"Skipped initial assignment of permissions and groups to remotely-authenticated user {user} as " + f"Group sync is enabled" + ) return user diff --git a/netbox/netbox/choices.py b/netbox/netbox/choices.py new file mode 100644 index 000000000..fe941056f --- /dev/null +++ b/netbox/netbox/choices.py @@ -0,0 +1,162 @@ +from django.utils.translation import gettext_lazy as _ + +from utilities.choices import ChoiceSet +from utilities.constants import CSV_DELIMITERS + +__all__ = ( + 'ButtonColorChoices', + 'ColorChoices', + 'CSVDelimiterChoices', + 'ImportFormatChoices', + 'ImportMethodChoices', +) + + +# +# Generic color choices +# + +class ColorChoices(ChoiceSet): + COLOR_DARK_RED = 'aa1409' + COLOR_RED = 'f44336' + COLOR_PINK = 'e91e63' + COLOR_ROSE = 'ffe4e1' + COLOR_FUCHSIA = 'ff66ff' + COLOR_PURPLE = '9c27b0' + COLOR_DARK_PURPLE = '673ab7' + COLOR_INDIGO = '3f51b5' + COLOR_BLUE = '2196f3' + COLOR_LIGHT_BLUE = '03a9f4' + COLOR_CYAN = '00bcd4' + COLOR_TEAL = '009688' + COLOR_AQUA = '00ffff' + COLOR_DARK_GREEN = '2f6a31' + COLOR_GREEN = '4caf50' + COLOR_LIGHT_GREEN = '8bc34a' + COLOR_LIME = 'cddc39' + COLOR_YELLOW = 'ffeb3b' + COLOR_AMBER = 'ffc107' + COLOR_ORANGE = 'ff9800' + COLOR_DARK_ORANGE = 'ff5722' + COLOR_BROWN = '795548' + COLOR_LIGHT_GREY = 'c0c0c0' + COLOR_GREY = '9e9e9e' + COLOR_DARK_GREY = '607d8b' + COLOR_BLACK = '111111' + COLOR_WHITE = 'ffffff' + + CHOICES = ( + (COLOR_DARK_RED, _('Dark Red')), + (COLOR_RED, _('Red')), + (COLOR_PINK, _('Pink')), + (COLOR_ROSE, _('Rose')), + (COLOR_FUCHSIA, _('Fuchsia')), + (COLOR_PURPLE, _('Purple')), + (COLOR_DARK_PURPLE, _('Dark Purple')), + (COLOR_INDIGO, _('Indigo')), + (COLOR_BLUE, _('Blue')), + (COLOR_LIGHT_BLUE, _('Light Blue')), + (COLOR_CYAN, _('Cyan')), + (COLOR_TEAL, _('Teal')), + (COLOR_AQUA, _('Aqua')), + (COLOR_DARK_GREEN, _('Dark Green')), + (COLOR_GREEN, _('Green')), + (COLOR_LIGHT_GREEN, _('Light Green')), + (COLOR_LIME, _('Lime')), + (COLOR_YELLOW, _('Yellow')), + (COLOR_AMBER, _('Amber')), + (COLOR_ORANGE, _('Orange')), + (COLOR_DARK_ORANGE, _('Dark Orange')), + (COLOR_BROWN, _('Brown')), + (COLOR_LIGHT_GREY, _('Light Grey')), + (COLOR_GREY, _('Grey')), + (COLOR_DARK_GREY, _('Dark Grey')), + (COLOR_BLACK, _('Black')), + (COLOR_WHITE, _('White')), + ) + + +# +# Button color choices +# + +class ButtonColorChoices(ChoiceSet): + """ + Map standard button color choices to Bootstrap 3 button classes + """ + DEFAULT = 'outline-dark' + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + GREY = 'gray' # Backward compatability for <3.2 + BLACK = 'black' + WHITE = 'white' + + CHOICES = ( + (DEFAULT, _('Default')), + (BLUE, _('Blue')), + (INDIGO, _('Indigo')), + (PURPLE, _('Purple')), + (PINK, _('Pink')), + (RED, _('Red')), + (ORANGE, _('Orange')), + (YELLOW, _('Yellow')), + (GREEN, _('Green')), + (TEAL, _('Teal')), + (CYAN, _('Cyan')), + (GRAY, _('Gray')), + (BLACK, _('Black')), + (WHITE, _('White')), + ) + + +# +# Import Choices +# + +class ImportMethodChoices(ChoiceSet): + DIRECT = 'direct' + UPLOAD = 'upload' + DATA_FILE = 'datafile' + + CHOICES = [ + (DIRECT, _('Direct')), + (UPLOAD, _('Upload')), + (DATA_FILE, _('Data file')), + ] + + +class ImportFormatChoices(ChoiceSet): + AUTO = 'auto' + CSV = 'csv' + JSON = 'json' + YAML = 'yaml' + + CHOICES = [ + (AUTO, _('Auto-detect')), + (CSV, 'CSV'), + (JSON, 'JSON'), + (YAML, 'YAML'), + ] + + +class CSVDelimiterChoices(ChoiceSet): + AUTO = 'auto' + COMMA = CSV_DELIMITERS['comma'] + SEMICOLON = CSV_DELIMITERS['semicolon'] + TAB = CSV_DELIMITERS['tab'] + + CHOICES = [ + (AUTO, _('Auto-detect')), + (COMMA, _('Comma')), + (SEMICOLON, _('Semicolon')), + (TAB, _('Tab')), + ] diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index 024ca85b5..ce4f8c45e 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -8,9 +8,11 @@ def settings_and_registry(request): """ Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }} """ + user_preferences = request.user.config if request.user.is_authenticated else {} return { 'settings': django_settings, 'config': get_config(), 'registry': registry, - 'preferences': request.user.config if request.user.is_authenticated else {}, + 'preferences': user_preferences, + 'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true' } diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index f63f56ff5..d177ce1d8 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -172,7 +172,7 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form) # Limit saved filters to those applicable to the form's model object_type = ObjectType.objects.get_for_model(self.model) self.fields['filter_id'].widget.add_query_params({ - 'object_types_id': object_type.pk, + 'object_type_id': object_type.pk, }) def _get_custom_fields(self, content_type): diff --git a/netbox/netbox/graphql/__init__.py b/netbox/netbox/graphql/__init__.py index bd8e3cb88..e69de29bb 100644 --- a/netbox/netbox/graphql/__init__.py +++ b/netbox/netbox/graphql/__init__.py @@ -1,69 +0,0 @@ -import graphene -from dcim.fields import MACAddressField, WWNField -from django.db import models -from graphene import Dynamic -from graphene_django.converter import convert_django_field, get_django_field_description -from graphene_django.fields import DjangoConnectionField -from ipam.fields import IPAddressField, IPNetworkField -from taggit.managers import TaggableManager - -from .fields import ObjectListField - - -@convert_django_field.register(TaggableManager) -def convert_field_to_tags_list(field, registry=None): - """ - Register conversion handler for django-taggit's TaggableManager - """ - return graphene.List(graphene.String) - - -@convert_django_field.register(IPAddressField) -@convert_django_field.register(IPNetworkField) -@convert_django_field.register(MACAddressField) -@convert_django_field.register(WWNField) -def convert_field_to_string(field, registry=None): - # TODO: Update to use get_django_field_description under django_graphene v3.0 - return graphene.String(description=field.help_text, required=not field.null) - - -@convert_django_field.register(models.ManyToManyField) -@convert_django_field.register(models.ManyToManyRel) -@convert_django_field.register(models.ManyToOneRel) -def convert_field_to_list_or_connection(field, registry=None): - """ - From graphene_django.converter.py we need to monkey-patch this to return - our ObjectListField with filtering support instead of DjangoListField - """ - model = field.related_model - - def dynamic_type(): - _type = registry.get_type_for_model(model) - if not _type: - return - - if isinstance(field, models.ManyToManyField): - description = get_django_field_description(field) - else: - description = get_django_field_description(field.field) - - # If there is a connection, we should transform the field - # into a DjangoConnectionField - if _type._meta.connection: - # Use a DjangoFilterConnectionField if there are - # defined filter_fields or a filterset_class in the - # DjangoObjectType Meta - if _type._meta.filter_fields or _type._meta.filterset_class: - from .filter.fields import DjangoFilterConnectionField - - return DjangoFilterConnectionField(_type, required=True, description=description) - - return DjangoConnectionField(_type, required=True, description=description) - - return ObjectListField( - _type, - required=True, # A Set is always returned, never None. - description=description, - ) - - return Dynamic(dynamic_type) diff --git a/netbox/netbox/graphql/fields.py b/netbox/netbox/graphql/fields.py deleted file mode 100644 index 0f5221b47..000000000 --- a/netbox/netbox/graphql/fields.py +++ /dev/null @@ -1,70 +0,0 @@ -from functools import partial - -import graphene -from graphene_django import DjangoListField -from .utils import get_graphene_type - -__all__ = ( - 'ObjectField', - 'ObjectListField', -) - - -class ObjectField(graphene.Field): - """ - Retrieve a single object, identified by its numeric ID. - """ - def __init__(self, *args, **kwargs): - - if 'id' not in kwargs: - kwargs['id'] = graphene.Int(required=True) - - super().__init__(*args, **kwargs) - - @staticmethod - def object_resolver(django_object_type, root, info, **args): - """ - Return an object given its numeric ID. - """ - manager = django_object_type._meta.model._default_manager - queryset = django_object_type.get_queryset(manager, info) - - return queryset.get(**args) - - def get_resolver(self, parent_resolver): - return partial(self.object_resolver, self._type) - - -class ObjectListField(DjangoListField): - """ - Retrieve a list of objects, optionally filtered by one or more FilterSet filters. - """ - def __init__(self, _type, *args, **kwargs): - filter_kwargs = {} - - # Get FilterSet kwargs - filterset_class = getattr(_type._meta, 'filterset_class', None) - if filterset_class: - for filter_name, filter_field in filterset_class.get_filters().items(): - field_type = get_graphene_type(type(filter_field)) - filter_kwargs[filter_name] = graphene.Argument(field_type) - - super().__init__(_type, args=filter_kwargs, *args, **kwargs) - - @staticmethod - def list_resolver(django_object_type, resolver, default_manager, root, info, **args): - queryset = super(ObjectListField, ObjectListField).list_resolver(django_object_type, resolver, default_manager, root, info, **args) - - # if there are no filter params then don't need to filter - if not args: - return queryset - - filterset_class = django_object_type._meta.filterset_class - if filterset_class: - filterset = filterset_class(data=args if args else None, queryset=queryset, request=info.context) - - if not filterset.is_valid(): - return queryset.none() - return filterset.qs - - return queryset diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py new file mode 100644 index 000000000..3103b06c6 --- /dev/null +++ b/netbox/netbox/graphql/filter_mixins.py @@ -0,0 +1,198 @@ +from functools import partial, partialmethod, wraps +from typing import List + +import django_filters +import strawberry +import strawberry_django +from strawberry import auto +from ipam.fields import ASNField +from netbox.graphql.scalars import BigInt +from utilities.fields import ColorField, CounterCacheField +from utilities.filters import * + + +def map_strawberry_type(field): + should_create_function = False + attr_type = None + + # NetBox Filter types - put base classes after derived classes + if isinstance(field, ContentTypeFilter): + should_create_function = True + attr_type = str | None + elif isinstance(field, MultiValueArrayFilter): + pass + elif isinstance(field, MultiValueCharFilter): + should_create_function = True + attr_type = List[str] | None + elif isinstance(field, MultiValueDateFilter): + attr_type = auto + elif isinstance(field, MultiValueDateTimeFilter): + attr_type = auto + elif isinstance(field, MultiValueDecimalFilter): + pass + elif isinstance(field, MultiValueMACAddressFilter): + should_create_function = True + attr_type = List[str] | None + elif isinstance(field, MultiValueNumberFilter): + should_create_function = True + attr_type = List[str] | None + elif isinstance(field, MultiValueTimeFilter): + pass + elif isinstance(field, MultiValueWWNFilter): + should_create_function = True + attr_type = List[str] | None + elif isinstance(field, NullableCharFieldFilter): + pass + elif isinstance(field, NumericArrayFilter): + should_create_function = True + attr_type = int + elif isinstance(field, TreeNodeMultipleChoiceFilter): + should_create_function = True + attr_type = List[str] | None + + # From django_filters - ordering of these matters as base classes must + # come after derived classes so the base class doesn't get matched first + # a pass for the check (no attr_type) means we don't currently handle + # or use that type + elif issubclass(type(field), django_filters.OrderingFilter): + pass + elif issubclass(type(field), django_filters.BaseRangeFilter): + pass + elif issubclass(type(field), django_filters.BaseInFilter): + pass + elif issubclass(type(field), django_filters.LookupChoiceFilter): + pass + elif issubclass(type(field), django_filters.AllValuesMultipleFilter): + pass + elif issubclass(type(field), django_filters.AllValuesFilter): + pass + elif issubclass(type(field), django_filters.TimeRangeFilter): + pass + elif issubclass(type(field), django_filters.IsoDateTimeFromToRangeFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.DateTimeFromToRangeFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.DateFromToRangeFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.DateRangeFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.RangeFilter): + pass + elif issubclass(type(field), django_filters.NumericRangeFilter): + pass + elif issubclass(type(field), django_filters.NumberFilter): + should_create_function = True + attr_type = int + elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter): + should_create_function = True + attr_type = List[str] | None + elif issubclass(type(field), django_filters.ModelChoiceFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.DurationFilter): + pass + elif issubclass(type(field), django_filters.IsoDateTimeFilter): + pass + elif issubclass(type(field), django_filters.DateTimeFilter): + attr_type = auto + elif issubclass(type(field), django_filters.TimeFilter): + attr_type = auto + elif issubclass(type(field), django_filters.DateFilter): + attr_type = auto + elif issubclass(type(field), django_filters.TypedMultipleChoiceFilter): + pass + elif issubclass(type(field), django_filters.MultipleChoiceFilter): + should_create_function = True + attr_type = List[str] | None + elif issubclass(type(field), django_filters.TypedChoiceFilter): + pass + elif issubclass(type(field), django_filters.ChoiceFilter): + pass + elif issubclass(type(field), django_filters.BooleanFilter): + should_create_function = True + attr_type = bool | None + elif issubclass(type(field), django_filters.UUIDFilter): + should_create_function = True + attr_type = str | None + elif issubclass(type(field), django_filters.CharFilter): + # looks like only used by 'q' + should_create_function = True + attr_type = str | None + + return should_create_function, attr_type + + +def autotype_decorator(filterset): + """ + Decorator used to auto creates a dataclass used by Strawberry based on a filterset. + Must go after the Strawberry decorator as follows: + + @strawberry_django.filter(models.Example, lookups=True) + @autotype_decorator(filtersets.ExampleFilterSet) + class ExampleFilter(BaseFilterMixin): + pass + + The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields + of the filterset, usually just a type specifier is generated, so for + `fields = [created, ]` the dataclass would be: + + class ExampleFilter(BaseFilterMixin): + created: auto + + For other filter fields a function needs to be created for Strawberry with the + naming convention `filter_{fieldname}` which is auto detected and called by + Strawberry, this function uses the filterset to handle the query. + """ + def create_attribute_and_function(cls, fieldname, attr_type, should_create_function): + if fieldname not in cls.__annotations__ and attr_type: + cls.__annotations__[fieldname] = attr_type + + filter_name = f"filter_{fieldname}" + if should_create_function and not hasattr(cls, filter_name): + filter_by_filterset = getattr(cls, 'filter_by_filterset') + setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname)) + + def wrapper(cls): + cls.filterset = filterset + fields = filterset.get_fields() + model = filterset._meta.model + for fieldname in fields.keys(): + should_create_function = False + attr_type = auto + if fieldname not in cls.__annotations__: + field = model._meta.get_field(fieldname) + if isinstance(field, CounterCacheField): + should_create_function = True + attr_type = BigInt | None + elif isinstance(field, ASNField): + should_create_function = True + attr_type = List[str] | None + elif isinstance(field, ColorField): + should_create_function = True + attr_type = List[str] | None + + create_attribute_and_function(cls, fieldname, attr_type, should_create_function) + + declared_filters = filterset.declared_filters + for fieldname, field in declared_filters.items(): + + should_create_function, attr_type = map_strawberry_type(field) + if attr_type is None: + raise NotImplementedError(f"GraphQL Filter field unknown: {fieldname}: {field}") + + create_attribute_and_function(cls, fieldname, attr_type, should_create_function) + + return cls + + return wrapper + + +@strawberry.input +class BaseFilterMixin: + + def filter_by_filterset(self, queryset, key): + return self.filterset(data={key: getattr(self, key)}, queryset=queryset).qs diff --git a/netbox/netbox/graphql/scalars.py b/netbox/netbox/graphql/scalars.py index 8fc186b4d..d14549f65 100644 --- a/netbox/netbox/graphql/scalars.py +++ b/netbox/netbox/graphql/scalars.py @@ -1,23 +1,10 @@ -from graphene import Scalar -from graphql.language import ast -from graphene.types.scalars import MAX_INT, MIN_INT +from typing import Union +import strawberry -class BigInt(Scalar): - """ - Handle any BigInts - """ - @staticmethod - def to_float(value): - num = int(value) - if num > MAX_INT or num < MIN_INT: - return float(num) - return num - - serialize = to_float - parse_value = to_float - - @staticmethod - def parse_literal(node): - if isinstance(node, ast.IntValue): - return BigInt.to_float(node.value) +BigInt = strawberry.scalar( + Union[int, str], # type: ignore + serialize=lambda v: int(v), + parse_value=lambda v: str(v), + description="BigInt field", +) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index 021d6d902..2b4c83405 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -1,4 +1,6 @@ -import graphene +import strawberry +from strawberry_django.optimizer import DjangoOptimizerExtension +from strawberry.schema.config import StrawberryConfig from circuits.graphql.schema import CircuitsQuery from core.graphql.schema import CoreQuery @@ -13,6 +15,7 @@ from vpn.graphql.schema import VPNQuery from wireless.graphql.schema import WirelessQuery +@strawberry.type class Query( UsersQuery, CircuitsQuery, @@ -25,9 +28,14 @@ class Query( VPNQuery, WirelessQuery, *registry['plugins']['graphql_schemas'], # Append plugin schemas - graphene.ObjectType ): pass -schema = graphene.Schema(query=Query, auto_camelcase=False) +schema = strawberry.Schema( + query=Query, + config=StrawberryConfig(auto_camel_case=False), + extensions=[ + DjangoOptimizerExtension, + ] +) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index f131f07cf..64aa3617a 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -1,4 +1,8 @@ -import graphene +from typing import Annotated, List + +import strawberry +from strawberry import auto +import strawberry_django from core.models import ObjectType as ObjectType_ from django.contrib.contenttypes.models import ContentType @@ -8,13 +12,10 @@ from extras.graphql.mixins import ( JournalEntriesMixin, TagsMixin, ) -from graphene_django import DjangoObjectType __all__ = ( 'BaseObjectType', - 'ContentTypeType', 'ObjectType', - 'ObjectTypeType', 'OrganizationalObjectType', 'NetBoxObjectType', ) @@ -24,26 +25,27 @@ __all__ = ( # Base types # -class BaseObjectType(DjangoObjectType): +@strawberry.type +class BaseObjectType: """ Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions. """ - display = graphene.String() - class_type = graphene.String() - - class Meta: - abstract = True @classmethod - def get_queryset(cls, queryset, info): + def get_queryset(cls, queryset, info, **kwargs): # Enforce object permissions on the queryset - return queryset.restrict(info.context.user, 'view') + if hasattr(queryset, 'restrict'): + return queryset.restrict(info.context.request.user, 'view') + else: + return queryset - def resolve_display(parent, info, **kwargs): - return str(parent) + @strawberry_django.field + def display(self) -> str: + return str(self) - def resolve_class_type(parent, info, **kwargs): - return parent.__class__.__name__ + @strawberry_django.field + def class_type(self) -> str: + return self.__class__.__name__ class ObjectType( @@ -53,8 +55,7 @@ class ObjectType( """ Base GraphQL object type for unclassified models which support change logging """ - class Meta: - abstract = True + pass class OrganizationalObjectType( @@ -66,8 +67,7 @@ class OrganizationalObjectType( """ Base type for organizational models """ - class Meta: - abstract = True + pass class NetBoxObjectType( @@ -80,23 +80,24 @@ class NetBoxObjectType( """ GraphQL type for most NetBox models. Includes support for custom fields, change logging, journaling, and tags. """ - class Meta: - abstract = True + pass # # Miscellaneous types # -class ContentTypeType(DjangoObjectType): - - class Meta: - model = ContentType - fields = ('id', 'app_label', 'model') +@strawberry_django.type( + ContentType, + fields=['id', 'app_label', 'model'], +) +class ContentTypeType: + pass -class ObjectTypeType(DjangoObjectType): - - class Meta: - model = ObjectType_ - fields = ('id', 'app_label', 'model') +@strawberry_django.type( + ObjectType_, + fields=['id', 'app_label', 'model'], +) +class ObjectTypeType: + pass diff --git a/netbox/netbox/graphql/utils.py b/netbox/netbox/graphql/utils.py deleted file mode 100644 index c71d49204..000000000 --- a/netbox/netbox/graphql/utils.py +++ /dev/null @@ -1,25 +0,0 @@ -import graphene -from django_filters import filters - - -def get_graphene_type(filter_cls): - """ - Return the appropriate Graphene scalar type for a django_filters Filter - """ - if issubclass(filter_cls, filters.BooleanFilter): - field_type = graphene.Boolean - elif issubclass(filter_cls, filters.NumberFilter): - # TODO: Floats? BigInts? - field_type = graphene.Int - elif issubclass(filter_cls, filters.DateFilter): - field_type = graphene.Date - elif issubclass(filter_cls, filters.DateTimeFilter): - field_type = graphene.DateTime - else: - field_type = graphene.String - - # Multi-value filters should be handled as lists - if issubclass(filter_cls, filters.MultipleChoiceFilter): - return graphene.List(field_type) - - return field_type diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index e1573dba6..b347d71b4 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -1,20 +1,26 @@ +import json + from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.http import HttpResponseNotFound, HttpResponseForbidden +from django.http import HttpResponse +from django.template import loader from django.urls import reverse -from graphene_django.views import GraphQLView as GraphQLView_ +from django.views.decorators.csrf import csrf_exempt from rest_framework.exceptions import AuthenticationFailed +from strawberry.django.views import GraphQLView from netbox.api.authentication import TokenAuthentication from netbox.config import get_config -class GraphQLView(GraphQLView_): +class NetBoxGraphQLView(GraphQLView): """ - Extends graphene_django's GraphQLView to support DRF's token-based authentication. + Extends strawberry's GraphQLView to support DRF's token-based authentication. """ graphiql_template = 'graphiql.html' + @csrf_exempt def dispatch(self, request, *args, **kwargs): config = get_config() @@ -34,11 +40,15 @@ class GraphQLView(GraphQLView_): # Enforce LOGIN_REQUIRED if settings.LOGIN_REQUIRED and not request.user.is_authenticated: - - # If this is a human user, send a redirect to the login page - if self.request_wants_html(request): + if request.accepts("text/html"): return redirect_to_login(reverse('graphql')) - - return HttpResponseForbidden("No credentials provided.") + else: + return HttpResponseForbidden("No credentials provided.") return super().dispatch(request, *args, **kwargs) + + def render_graphql_ide(self, request): + template = loader.get_template("graphiql.html") + context = {"SUBSCRIPTION_ENABLED": json.dumps(self.subscriptions_enabled)} + + return HttpResponse(template.render(context, request)) diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index cb7d2c8ba..6e7da9ab0 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -13,7 +13,8 @@ from django.http import Http404, HttpResponseRedirect from extras.context_managers import event_tracking from netbox.config import clear_config, get_config from netbox.views import handler_500 -from utilities.api import is_api_request, rest_api_server_error +from utilities.api import is_api_request +from utilities.error_handlers import handle_rest_api_exception __all__ = ( 'CoreMiddleware', @@ -71,7 +72,7 @@ class CoreMiddleware: # Cleanly handle exceptions that occur from REST API requests if is_api_request(request): - return rest_api_server_error(request) + return handle_rest_api_exception(request) # Ignore Http404s (defer to Django's built-in 404 handling) if isinstance(exception, Http404): @@ -211,7 +212,7 @@ class MaintenanceModeMiddleware: 'operations. Please try again later.' if is_api_request(request): - return rest_api_server_error(request, error=error_message) + return handle_rest_api_exception(request, error=error_message) messages.error(request, error_message) return HttpResponseRedirect(request.path_info) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index bff9ee59f..000e717a4 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -17,7 +17,7 @@ from netbox.config import get_config from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder -from utilities.utils import serialize_object +from utilities.serialization import serialize_object from utilities.views import register_model_view __all__ = ( diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index 63d2af9c1..d13282f7e 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -1,8 +1,6 @@ from dataclasses import dataclass from typing import Sequence, Optional -from utilities.choices import ButtonColorChoices - __all__ = ( 'get_model_item', diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 621bd4f5d..688c5a3ad 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -1,7 +1,6 @@ from django.utils.translation import gettext_lazy as _ from netbox.registry import registry -from utilities.choices import ButtonColorChoices from . import * # diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py index aae569412..01b8a0442 100644 --- a/netbox/netbox/plugins/navigation.py +++ b/netbox/netbox/plugins/navigation.py @@ -1,8 +1,9 @@ -from netbox.navigation import MenuGroup -from utilities.choices import ButtonColorChoices from django.utils.text import slugify from django.utils.translation import gettext as _ +from netbox.choices import ButtonColorChoices +from netbox.navigation import MenuGroup + __all__ = ( 'PluginMenu', 'PluginMenuButton', diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index fd247a82a..d27bb67ca 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -73,7 +73,7 @@ def register_graphql_schema(graphql_schema): """ Register a GraphQL schema class for inclusion in NetBox's GraphQL API. """ - registry['plugins']['graphql_schemas'].append(graphql_schema) + registry['plugins']['graphql_schemas'].extend(graphql_schema) def register_user_preferences(plugin_name, preferences): diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 9a6fe490c..1414ea850 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -23,6 +23,14 @@ PREFERENCES = { ), default='light', ), + 'ui.htmx_navigation': UserPreference( + label=_('HTMX Navigation'), + choices=( + ('', _('Disabled')), + ('true', _('Enabled')), + ), + default=False + ), 'locale.language': UserPreference( label=_('Language'), choices=( diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index a9e867b9f..227a79205 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -14,8 +14,9 @@ from netaddr.core import AddrFormatError from core.models import ObjectType from extras.models import CachedValue, CustomField from netbox.registry import registry +from utilities.object_types import object_type_identifier from utilities.querysets import RestrictedPrefetch -from utilities.utils import content_type_identifier, title +from utilities.string import title from . import FieldTypes, LookupTypes, get_indexer DEFAULT_LOOKUP_TYPE = LookupTypes.PARTIAL @@ -156,7 +157,7 @@ class CachedValueSearchBackend(SearchBackend): # related objects necessary to render the prescribed display attributes (display_attrs). for object_type in object_types: model = object_type.model_class() - indexer = registry['search'].get(content_type_identifier(object_type)) + indexer = registry['search'].get(object_type_identifier(object_type)) if not (display_attrs := getattr(indexer, 'display_attrs', None)): continue diff --git a/netbox/netbox/search/utils.py b/netbox/netbox/search/utils.py index 824fbfb3d..9ae5edee5 100644 --- a/netbox/netbox/search/utils.py +++ b/netbox/netbox/search/utils.py @@ -1,14 +1,14 @@ from netbox.registry import registry -from utilities.utils import content_type_identifier +from utilities.object_types import object_type_identifier __all__ = ( 'get_indexer', ) -def get_indexer(content_type): +def get_indexer(object_type): """ Return the registered search indexer for the given ContentType. """ - ct_identifier = content_type_identifier(content_type) - return registry['search'].get(ct_identifier) + identifier = object_type_identifier(object_type) + return registry['search'].get(identifier) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 52f8a6dd3..18feee262 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -15,25 +15,17 @@ from django.core.validators import URLValidator from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ -try: - import sentry_sdk -except ModuleNotFoundError: - pass - -from netbox.config import PARAMS +from netbox.config import PARAMS as CONFIG_PARAMS from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW from netbox.plugins import PluginConfig - +from utilities.string import trailing_slash # # Environment setup # -VERSION = '4.0.0-dev' - -# Hostname +VERSION = '4.0-beta1' HOSTNAME = platform.node() - # Set the base directory two levels up BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -47,7 +39,7 @@ if sys.version_info < (3, 10): # Configuration import # -# Import configuration parameters +# Import the configuration module config_path = os.getenv('NETBOX_CONFIGURATION', 'netbox.configuration') try: configuration = importlib.import_module(config_path) @@ -59,45 +51,28 @@ except ModuleNotFoundError as e: ) raise -# Enforce required configuration parameters -for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']: +# Check for missing required configuration parameters +for parameter in ('ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS'): if not hasattr(configuration, parameter): raise ImproperlyConfigured(f"Required parameter {parameter} is missing from configuration.") -# Set required parameters -ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') -DATABASE = getattr(configuration, 'DATABASE') -REDIS = getattr(configuration, 'REDIS') -SECRET_KEY = getattr(configuration, 'SECRET_KEY') - -# Enforce minimum length for SECRET_KEY -if type(SECRET_KEY) is not str: - raise ImproperlyConfigured(f"SECRET_KEY must be a string (found {type(SECRET_KEY).__name__})") -if len(SECRET_KEY) < 50: - raise ImproperlyConfigured( - f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n" - f" python {BASE_DIR}/generate_secret_key.py" - ) - -# Calculate a unique deployment ID from the secret key -DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] - # Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) ALLOW_TOKEN_RETRIEVAL = getattr(configuration, 'ALLOW_TOKEN_RETRIEVAL', True) +ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') # Required AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []) -BASE_PATH = getattr(configuration, 'BASE_PATH', '') -if BASE_PATH: - BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' +BASE_PATH = trailing_slash(getattr(configuration, 'BASE_PATH', '')) +CHANGELOG_SKIP_EMPTY_CHANGES = getattr(configuration, 'CHANGELOG_SKIP_EMPTY_CHANGES', True) CENSUS_REPORTING_ENABLED = getattr(configuration, 'CENSUS_REPORTING_ENABLED', True) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') +CSRF_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}' CSRF_COOKIE_SECURE = getattr(configuration, 'CSRF_COOKIE_SECURE', False) CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATA_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'DATA_UPLOAD_MAX_MEMORY_SIZE', 2621440) +DATABASE = getattr(configuration, 'DATABASE') # Required DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) @@ -118,6 +93,7 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) DJANGO_ADMIN_ENABLED = getattr(configuration, 'DJANGO_ADMIN_ENABLED', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) +ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False) EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', ( 'extras.events.process_event_queue', )) @@ -128,6 +104,7 @@ HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us') +LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) @@ -138,24 +115,25 @@ METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) +REDIS = getattr(configuration, 'REDIS') # Required RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) -REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False) +REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False) -REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') -REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME') -REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME') -REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_REMOTE_USER_EMAIL') REMOTE_AUTH_GROUP_HEADER = getattr(configuration, 'REMOTE_AUTH_GROUP_HEADER', 'HTTP_REMOTE_USER_GROUP') +REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REMOTE_AUTH_GROUP_SYNC_ENABLED = getattr(configuration, 'REMOTE_AUTH_GROUP_SYNC_ENABLED', False) +REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER') REMOTE_AUTH_SUPERUSER_GROUPS = getattr(configuration, 'REMOTE_AUTH_SUPERUSER_GROUPS', []) REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) +REMOTE_AUTH_USER_EMAIL = getattr(configuration, 'REMOTE_AUTH_USER_EMAIL', 'HTTP_REMOTE_USER_EMAIL') +REMOTE_AUTH_USER_FIRST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_FIRST_NAME', 'HTTP_REMOTE_USER_FIRST_NAME') +REMOTE_AUTH_USER_LAST_NAME = getattr(configuration, 'REMOTE_AUTH_USER_LAST_NAME', 'HTTP_REMOTE_USER_LAST_NAME') REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) -REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') # Required by extras/migrations/0109_script_models.py REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) @@ -163,15 +141,17 @@ RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60) RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') +SECRET_KEY = getattr(configuration, 'SECRET_KEY') # Required SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) -SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) -SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) +SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') +SESSION_COOKIE_PATH = CSRF_COOKIE_PATH SESSION_COOKIE_SECURE = getattr(configuration, 'SESSION_COOKIE_SECURE', False) +SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') @@ -179,50 +159,50 @@ STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') -ENABLE_LOCALIZATION = getattr(configuration, 'ENABLE_LOCALIZATION', False) -CHANGELOG_SKIP_EMPTY_CHANGES = getattr(configuration, 'CHANGELOG_SKIP_EMPTY_CHANGES', True) -# Check for hard-coded dynamic config parameters -for param in PARAMS: +# Load any dynamic configuration parameters which have been hard-coded in the configuration file +for param in CONFIG_PARAMS: if hasattr(configuration, param.name): globals()[param.name] = getattr(configuration, param.name) +# Enforce minimum length for SECRET_KEY +if type(SECRET_KEY) is not str: + raise ImproperlyConfigured(f"SECRET_KEY must be a string (found {type(SECRET_KEY).__name__})") +if len(SECRET_KEY) < 50: + raise ImproperlyConfigured( + f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n" + f" python {BASE_DIR}/generate_secret_key.py" + ) + # Validate update repo URL and timeout if RELEASE_CHECK_URL: - validator = URLValidator( - message=( - "RELEASE_CHECK_URL must be a valid API URL. Example: " - "https://api.github.com/repos/netbox-community/netbox" - ) - ) try: - validator(RELEASE_CHECK_URL) - except ValidationError as err: - raise ImproperlyConfigured(str(err)) + URLValidator()(RELEASE_CHECK_URL) + except ValidationError as e: + raise ImproperlyConfigured( + "RELEASE_CHECK_URL must be a valid URL. Example: https://api.github.com/repos/netbox-community/netbox" + ) # # Database # +# Set the database engine if 'ENGINE' not in DATABASE: - # Only PostgreSQL is supported if METRICS_ENABLED: - DATABASE.update({ - 'ENGINE': 'django_prometheus.db.backends.postgresql' - }) + DATABASE.update({'ENGINE': 'django_prometheus.db.backends.postgresql'}) else: - DATABASE.update({ - 'ENGINE': 'django.db.backends.postgresql' - }) + DATABASE.update({'ENGINE': 'django.db.backends.postgresql'}) +# Define the DATABASES setting for Django DATABASES = { 'default': DATABASE, } # -# Media storage +# Storage backend # if STORAGE_BACKEND is not None: @@ -230,7 +210,6 @@ if STORAGE_BACKEND is not None: # django-storages if STORAGE_BACKEND.startswith('storages.'): - try: import storages.utils # type: ignore except ModuleNotFoundError as e: @@ -261,9 +240,7 @@ if STORAGE_CONFIG and STORAGE_BACKEND is None: # Background task queuing if 'tasks' not in REDIS: - raise ImproperlyConfigured( - "REDIS section in configuration.py is missing the 'tasks' subsection." - ) + raise ImproperlyConfigured("REDIS section in configuration.py is missing the 'tasks' subsection.") TASKS_REDIS = REDIS['tasks'] TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost') TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379) @@ -283,9 +260,7 @@ TASKS_REDIS_CA_CERT_PATH = TASKS_REDIS.get('CA_CERT_PATH', False) # Caching if 'caching' not in REDIS: - raise ImproperlyConfigured( - "REDIS section in configuration.py is missing caching subsection." - ) + raise ImproperlyConfigured("REDIS section in configuration.py is missing caching subsection.") CACHING_REDIS_HOST = REDIS['caching'].get('HOST', 'localhost') CACHING_REDIS_PORT = REDIS['caching'].get('PORT', 6379) CACHING_REDIS_DATABASE = REDIS['caching'].get('DATABASE', 0) @@ -297,11 +272,13 @@ CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'defau CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis' CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False) CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False) +CACHING_REDIS_URL = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}' +# Configure Django's default cache to use Redis CACHES = { 'default': { 'BACKEND': 'django_redis.cache.RedisCache', - 'LOCATION': f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}', + 'LOCATION': CACHING_REDIS_URL, 'OPTIONS': { 'CLIENT_CLASS': 'django_redis.client.DefaultClient', 'PASSWORD': CACHING_REDIS_PASSWORD, @@ -309,7 +286,6 @@ CACHES = { } } - if CACHING_REDIS_SENTINELS: DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory' CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}' @@ -322,6 +298,7 @@ if CACHING_REDIS_CA_CERT_PATH: CACHES['default']['OPTIONS'].setdefault('CONNECTION_POOL_KWARGS', {}) CACHES['default']['OPTIONS']['CONNECTION_POOL_KWARGS']['ssl_ca_certs'] = CACHING_REDIS_CA_CERT_PATH + # # Sessions # @@ -352,10 +329,11 @@ SERVER_EMAIL = EMAIL.get('FROM_EMAIL') # -# Django +# Django core settings # INSTALLED_APPS = [ + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', @@ -365,12 +343,11 @@ INSTALLED_APPS = [ 'django.forms', 'corsheaders', 'debug_toolbar', - 'graphiql_debug_toolbar', 'django_filters', 'django_htmx', 'django_tables2', 'django_prometheus', - 'graphene_django', + 'strawberry_django', 'mptt', 'rest_framework', 'social_django', @@ -392,13 +369,12 @@ INSTALLED_APPS = [ 'drf_spectacular', 'drf_spectacular_sidecar', ] - -if DJANGO_ADMIN_ENABLED: - INSTALLED_APPS.insert(0, 'django.contrib.admin') +if not DJANGO_ADMIN_ENABLED: + INSTALLED_APPS.remove('django.contrib.admin') # Middleware MIDDLEWARE = [ - 'graphiql_debug_toolbar.middleware.DebugToolbarMiddleware', + "strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware", 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -415,12 +391,13 @@ MIDDLEWARE = [ 'netbox.middleware.MaintenanceModeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', ] - if not ENABLE_LOCALIZATION: - MIDDLEWARE.remove("django.middleware.locale.LocaleMiddleware") + MIDDLEWARE.remove('django.middleware.locale.LocaleMiddleware') +# URLs ROOT_URLCONF = 'netbox.urls' +# Templates TEMPLATES_DIR = BASE_DIR + '/templates' TEMPLATES = [ { @@ -455,9 +432,14 @@ AUTHENTICATION_BACKENDS = [ 'netbox.authentication.ObjectPermissionBackend', ] +# Use our custom User model AUTH_USER_MODEL = 'users.User' -# Time zones +# Authentication URLs +LOGIN_URL = f'/{BASE_PATH}login/' +LOGIN_REDIRECT_URL = f'/{BASE_PATH}' + +# Use timezone-aware datetime objects USE_TZ = True # WSGI @@ -476,8 +458,8 @@ STATICFILES_DIRS = ( ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')), # Prefix with /docs ) -# Media -MEDIA_URL = '/{}media/'.format(BASE_PATH) +# Media URL +MEDIA_URL = f'/{BASE_PATH}media/' # Disable default limit of 1000 fields per request. Needed for bulk deletion of objects. (Added in Django 1.10.) DATA_UPLOAD_MAX_NUMBER_FIELDS = None @@ -487,12 +469,17 @@ MESSAGE_TAGS = { messages.ERROR: 'danger', } -# Authentication URLs -LOGIN_URL = f'/{BASE_PATH}login/' -LOGIN_REDIRECT_URL = f'/{BASE_PATH}' - DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +SERIALIZATION_MODULES = { + 'json': 'utilities.serializers.json', +} + + +# +# Permissions & authentication +# + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( @@ -521,10 +508,6 @@ MAINTENANCE_EXEMPT_PATHS = ( LOGOUT_REDIRECT_URL ) -SERIALIZATION_MODULES = { - 'json': 'utilities.serializers.json', -} - # # Sentry @@ -532,7 +515,7 @@ SERIALIZATION_MODULES = { if SENTRY_ENABLED: try: - from sentry_sdk.integrations.django import DjangoIntegration + import sentry_sdk except ModuleNotFoundError: raise ImproperlyConfigured("SENTRY_ENABLED is True but the sentry-sdk package is not installed.") if not SENTRY_DSN: @@ -541,7 +524,7 @@ if SENTRY_ENABLED: sentry_sdk.init( dsn=SENTRY_DSN, release=VERSION, - integrations=[DjangoIntegration()], + integrations=[sentry_sdk.integrations.django.DjangoIntegration()], sample_rate=SENTRY_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, send_default_pii=True, @@ -557,6 +540,8 @@ if SENTRY_ENABLED: # Census collection # +# Calculate a unique deployment ID from the secret key +DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] CENSUS_URL = 'https://census.netbox.dev/api/v1/' CENSUS_PARAMS = { 'version': VERSION, @@ -674,17 +659,6 @@ SPECTACULAR_SETTINGS = { 'POSTPROCESSING_HOOKS': [], } -# -# Graphene -# - -GRAPHENE = { - # Avoids naming collision on models with 'type' field; see - # https://github.com/graphql-python/graphene-django/issues/185 - 'DJANGO_CHOICE_FIELD_ENUM_V3_NAMING': True, -} - - # # Django RQ (events backend) # @@ -711,17 +685,16 @@ RQ_PARAMS.update({ 'PASSWORD': TASKS_REDIS_PASSWORD, 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, }) - if TASKS_REDIS_CA_CERT_PATH: RQ_PARAMS.setdefault('REDIS_CLIENT_KWARGS', {}) RQ_PARAMS['REDIS_CLIENT_KWARGS']['ssl_ca_certs'] = TASKS_REDIS_CA_CERT_PATH +# Define named RQ queues RQ_QUEUES = { RQ_QUEUE_HIGH: RQ_PARAMS, RQ_QUEUE_DEFAULT: RQ_PARAMS, RQ_QUEUE_LOW: RQ_PARAMS, } - # Add any queues defined in QUEUE_MAPPINGS RQ_QUEUES.update({ queue: RQ_PARAMS for queue in set(QUEUE_MAPPINGS.values()) if queue not in RQ_QUEUES @@ -731,6 +704,7 @@ RQ_QUEUES.update({ # Localization # +# Supported translation languages LANGUAGES = ( ('en', _('English')), ('es', _('Spanish')), @@ -740,38 +714,43 @@ LANGUAGES = ( ('ru', _('Russian')), ('tr', _('Turkish')), ) - LOCALE_PATHS = ( BASE_DIR + '/translations', ) - if not ENABLE_LOCALIZATION: USE_I18N = False - USE_L10N = False + +# +# Strawberry (GraphQL) +# +STRAWBERRY_DJANGO = { + "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, +} # # Plugins # +# Register any configured plugins for plugin_name in PLUGINS: - # Import plugin module try: + # Import the plugin module plugin = importlib.import_module(plugin_name) except ModuleNotFoundError as e: if getattr(e, 'name') == plugin_name: raise ImproperlyConfigured( - "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the " - "correct Python environment.".format(plugin_name) + f"Unable to import plugin {plugin_name}: Module not found. Check that the plugin module has been " + f"installed within the correct Python environment." ) raise e - # Determine plugin config and add to INSTALLED_APPS. try: + # Load the PluginConfig plugin_config: PluginConfig = plugin.config except AttributeError: raise ImproperlyConfigured( - "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " - "and point to the PluginConfig subclass.".format(plugin_name) + f"Plugin {plugin_name} does not provide a 'config' variable. This should be defined in the plugin's " + f"__init__.py file and point to the PluginConfig subclass." ) plugin_module = "{}.{}".format(plugin_config.__module__, plugin_config.__name__) # type: ignore @@ -794,12 +773,12 @@ for plugin_name in PLUGINS: raise ImproperlyConfigured( f"Failed to load django_apps specified by plugin {plugin_name}: {django_apps} " f"The module {app} cannot be imported. Check that the necessary package has been " - "installed within the correct Python environment." + f"installed within the correct Python environment." ) INSTALLED_APPS.extend(django_apps) - # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurence + # Preserve uniqueness of the INSTALLED_APPS list, we keep the last occurrence sorted_apps = reversed(list(dict.fromkeys(reversed(INSTALLED_APPS)))) INSTALLED_APPS = list(sorted_apps) @@ -817,9 +796,7 @@ for plugin_name in PLUGINS: # we use the plugin name as a prefix for queue name's defined in the plugin config # ex: mysuperplugin.mysuperqueue1 if type(plugin_config.queues) is not list: - raise ImproperlyConfigured( - "Plugin {} queues must be a list.".format(plugin_name) - ) + raise ImproperlyConfigured(f"Plugin {plugin_name} queues must be a list.") RQ_QUEUES.update({ f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues }) diff --git a/netbox/netbox/staging.py b/netbox/netbox/staging.py index ec38dcadc..4d37fb7ad 100644 --- a/netbox/netbox/staging.py +++ b/netbox/netbox/staging.py @@ -6,7 +6,7 @@ from django.db.models.signals import m2m_changed, pre_delete, post_save from extras.choices import ChangeActionChoices from extras.models import StagedChange -from utilities.utils import serialize_object +from utilities.serialization import serialize_object logger = logging.getLogger('netbox.staging') diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 442e5f260..193bf8a17 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -18,9 +18,10 @@ from django_tables2.columns import library from django_tables2.utils import Accessor from extras.choices import CustomFieldTypeChoices +from utilities.object_types import object_type_identifier, object_type_name from utilities.permissions import get_permission_for_model from utilities.templatetags.builtins.filters import render_markdown -from utilities.utils import content_type_identifier, content_type_name, get_viewname +from utilities.views import get_viewname __all__ = ( 'ActionsColumn', @@ -338,12 +339,12 @@ class ContentTypeColumn(tables.Column): def render(self, value): if value is None: return None - return content_type_name(value, include_app=False) + return object_type_name(value, include_app=False) def value(self, value): if value is None: return None - return content_type_identifier(value) + return object_type_identifier(value) class ContentTypesColumn(tables.ManyToManyColumn): @@ -357,11 +358,11 @@ class ContentTypesColumn(tables.ManyToManyColumn): super().__init__(separator=separator, *args, **kwargs) def transform(self, obj): - return content_type_name(obj, include_app=False) + return object_type_name(obj, include_app=False) def value(self, value): return ','.join([ - content_type_identifier(ct) for ct in self.filter(value) + object_type_identifier(ot) for ot in self.filter(value) ]) diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8a31c995c..a87d66664 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -17,7 +17,9 @@ from extras.models import CustomField, CustomLink from netbox.registry import registry from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.utils import get_viewname, highlight_string, title +from utilities.html import highlight +from utilities.string import title +from utilities.views import get_viewname from .template_code import * __all__ = ( @@ -274,6 +276,6 @@ class SearchTable(tables.Table): if not self.highlight: return value - value = highlight_string(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) + value = highlight(value, self.highlight, trim_pre=self.trim_length, trim_post=self.trim_length) return mark_safe(value) diff --git a/netbox/netbox/tests/dummy_plugin/graphql.py b/netbox/netbox/tests/dummy_plugin/graphql.py index 27ecd9ce0..2651f4e9e 100644 --- a/netbox/netbox/tests/dummy_plugin/graphql.py +++ b/netbox/netbox/tests/dummy_plugin/graphql.py @@ -1,21 +1,26 @@ -import graphene -from graphene_django import DjangoObjectType - -from netbox.graphql.fields import ObjectField, ObjectListField +from typing import List +import strawberry +import strawberry_django from . import models -class DummyModelType(DjangoObjectType): - - class Meta: - model = models.DummyModel - fields = '__all__' +@strawberry_django.type( + models.DummyModel, + fields='__all__', +) +class DummyModelType: + pass -class DummyQuery(graphene.ObjectType): - dummymodel = ObjectField(DummyModelType) - dummymodel_list = ObjectListField(DummyModelType) +@strawberry.type +class DummyQuery: + @strawberry.field + def dummymodel(self, id: int) -> DummyModelType: + return None + dummymodel_list: List[DummyModelType] = strawberry_django.field() -schema = DummyQuery +schema = [ + DummyQuery, +] diff --git a/netbox/netbox/tests/test_import.py b/netbox/netbox/tests/test_import.py index b0b21a07d..f382d0112 100644 --- a/netbox/netbox/tests/test_import.py +++ b/netbox/netbox/tests/test_import.py @@ -2,8 +2,8 @@ from django.test import override_settings from core.models import ObjectType from dcim.models import * +from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from users.models import ObjectPermission -from utilities.choices import CSVDelimiterChoices, ImportFormatChoices from utilities.testing import ModelViewTestCase, create_tags diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 43cfc1d4f..1ce929513 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,16 +1,16 @@ from django.conf import settings from django.conf.urls import include from django.urls import path -from django.views.decorators.csrf import csrf_exempt from django.views.static import serve from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from account.views import LoginView, LogoutView from netbox.api.views import APIRootView, StatusView from netbox.graphql.schema import schema -from netbox.graphql.views import GraphQLView +from netbox.graphql.views import NetBoxGraphQLView from netbox.plugins.urls import plugin_patterns, plugin_api_patterns from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx +from strawberry.django.views import GraphQLView _patterns = [ @@ -60,7 +60,7 @@ _patterns = [ path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'), # GraphQL - path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'), + path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'), # Serving static media in Django to pipe it through LoginRequiredMiddleware path('media/