100 Commits
Author SHA1 Message Date
MrBlake 32d62f26b2 feat: repeat license expiration reminders daily 2026-07-29 12:44:22 +02:00
MrBlake 35ddffc903 fix: render expiration notifications in bell menu 2026-07-29 12:38:36 +02:00
MrBlake 919b2d01e5 fix: use NetBox SMTP sender address 2026-07-29 12:26:57 +02:00
MrBlake 157c891cde fix: restore license expiration email delivery 2026-07-29 11:28:55 +02:00
MrBlake 3599dc703e fix: restrict license email notification settings 2026-07-29 11:04:46 +02:00
MrBlake 294e20b891 feat: add license expiration notifications 2026-07-29 11:01:00 +02:00
MrBlake 9f3bddfbb1 fix: handle NetBox form clean return value
Read installation destinations from self.cleaned_data because NetBox 4.6.5 form mixins return None while cleaning newly created objects.
2026-07-27 12:31:26 +02:00
MrBlake 71a5db0ece fix: clarify installation platform selection
Group device, virtual machine, and cluster as the required platform destination and validate that exactly one is selected with an actionable localized error.
2026-07-27 12:28:26 +02:00
MrBlake adfc2ae72c feat: extend software license lifecycle data
Make license type optional and add lifetime or recurring renewal terms, license file and key storage, and provider portal URLs across the UI, API, filters, imports, translations, and migrations.
2026-07-27 12:16:14 +02:00
MrBlake 5887129546 feat: add German localization
Internationalize plugin navigation, forms, tables, model labels, detail templates, choices, and validation messages. Ship a compiled German Django message catalog and bump the plugin version to 1.10.0.
2026-07-24 15:41:35 +02:00
MrBlake 7ca670c717 fix: render tenancy fields in license form
Define an explicit tenancy fieldset and bump the package version to 1.9.1 so Git/Pip upgrades replace older installations.
2026-07-24 15:34:01 +02:00
MrBlake e1c817229b feat: aufräumaktion 2026-07-24 15:22:59 +02:00
MrBlake 01d40ddecc feat: add tenancy support for software licenses
Add tenant group and tenant assignments across the model, forms, filters, API, tables, imports, and tests. Replace the Docker-based setup with Git/Pip installation documentation for NetBox 4.6.5.
2026-07-24 15:22:08 +02:00
wkoot 8a2dabc382 Update README.md 2026-06-25 18:18:33 +02:00
wkootandArtem Kotik f84a6bde2a Version bump to NetBox v4.6.3 (#82)
* Add support for NetBox 4.6
* Test NetBox v4.6.3 and Python 3.14

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
Co-authored-by: Artem Kotik <artem.i.kotik@ringcentral.com>
2026-06-25 18:06:27 +02:00
wkoot 811490e429 Add zizmor scan action (#79) 2026-06-19 14:12:46 +02:00
wkootandmikedekker 7ce718ff9d Fix filters with default order by ID (#78)
Closes #75

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
Co-authored-by: mikedekker <mike@dekker.me>
2025-12-18 13:30:38 +01:00
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 9e87afcde7 Bump SonarSource/sonarqube-scan-action from 5 to 6 in /.github/workflows (#74)
Bumps [SonarSource/sonarqube-scan-action](https://github.com/sonarsource/sonarqube-scan-action) from 5 to 6.
- [Release notes](https://github.com/sonarsource/sonarqube-scan-action/releases)
- [Commits](https://github.com/sonarsource/sonarqube-scan-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: SonarSource/sonarqube-scan-action
  dependency-version: '6'
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-26 23:17:19 +02:00
wkoot e03195c9b1 Bump sonarqube-scan-action to v5 (#73) 2025-09-02 09:49:18 +02:00
wkoot 59730a5184 Update CI spellchecker (#72) 2025-06-04 11:31:05 +02:00
wkoot 69594bc33a Release version 1.8.2
Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-06-02 22:11:32 +02:00
Nate Cohen 766ecfc25e Fix permission check for adding software product installations (#71) 2025-06-02 22:05:43 +02:00
wkoot d773a407a2 Fix filtering on foreign objects (#67)
Closes #65

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-04-11 12:29:03 +02:00
wkoot 175a01b257 Release plugin version 1.8.0 (#64)
Closes #63

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-25 20:13:25 +01:00
wkoot 334a76e640 Add SPDX license expressions (#62)
Closes #47

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-25 18:37:00 +01:00
wkoot ef45789b30 Show SLM installations on native objects (#61)
Closes #49

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-24 16:57:18 +01:00
wkoot 4bd02ab6f9 Add bulk import and bulk edit (#60)
Closes #4

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-19 23:07:47 +01:00
wkoot 410b8f6617 Render SoftwareProduct manufacturer consistently (#59)
Closes #58

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-17 23:01:51 +01:00
wkoot 306d853a16 Fix unencoded search link of installations table cells (#53)
Closes #48

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-13 22:58:22 +01:00
wkoot 193f04a208 Implement list filtering forms (#57)
Closes #54

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-13 22:16:32 +01:00
wkoot c3ed952b5f Fix model detail view listing of linked objects (#56)
Closes #55

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-12 23:30:55 +01:00
wkoot 87fdeb1c24 Version bump to NetBox v4.2.3 and friends (#52)
Closes #51

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
2025-02-11 20:41:03 +01:00
wkootandEric Hester a0a98bb407 Implement bulk imports and edits for SLM models (#46)
Partially implements #4
Based on @erichester76's https://github.com/ICTU/netbox_slm/pull/44

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
Co-authored-by: Eric Hester <eric.hester@umbrella.tech>
2024-10-14 13:41:22 +02:00
wkootandEric Hester 3539539c4e Implement top_level_menu toggle (#45)
Closes #42
Based on @erichester76's https://github.com/ICTU/netbox_slm/pull/44

Signed-off-by: wkoot <3715211+wkoot@users.noreply.github.com>
Co-authored-by: Eric Hester <eric.hester@umbrella.tech>
2024-10-14 11:13:58 +02:00
wkoot 72646a6287 Update PluginMenuItem permissions and remove bulk import/edit (#41)
Closes #37
2024-07-09 23:00:58 +02:00
wkoot 86215f43a6 Prepare for upgrade to NetBox v4.0.6 (#40)
Closes #38
2024-07-09 22:15:58 +02:00
wkoot 230b3b5115 Add new fields to models and upgrade to NetBox v3.7.2 (#36)
Closes #10, #26, #35
2024-02-13 12:11:49 +01:00
wkoot 1411e3a5cb Update MANIFEST.in, so all files are included (#34)
Closes #32
2023-12-08 10:14:23 +01:00
wkoot d082af4a9b Add Installation foreign key to VM Cluster (#29)
Closes #17
2023-10-04 18:23:08 +02:00
wkoot 2501e41b7c Add 'stored_location_url' to SoftwareLicense (#28)
Closes #27
2023-09-22 02:42:02 +02:00
wkootandManuel Hartung aa41f8a402 Assorted fixes (#25)
* Updated imports for Netbox 3.6.1

Closes #21 and #23

* Changed Fieldtypes of Start and Expiration Date

Closes #22

* Bump to NetBox v3.6.1

* Add pyproject.toml and python workflow

---------

Co-authored-by: Manuel Hartung <manuel.hartung@atos.net>
2023-09-15 09:41:36 +02:00
wkoot 6c512e0e9b Add SoftwareLicense (#20) 2023-06-30 15:11:28 +02:00
wkoot 5b96496b46 Replace NetBoxModelCSVForm with NetBoxModelImportForm (#19)
Closes #18
2023-06-06 21:16:13 +02:00
wkoot 88edcde8d6 Unify api serializers (#16) 2023-04-19 10:14:32 +02:00
wkoot 7f18a3ad6f Version bump NetBox 3.4.7 (#15)
Closes #14
2023-03-31 10:02:35 +02:00
wkoot 1925be271a Set up continuous integration (#13)
Closes #3
2023-03-29 22:00:48 +02:00
Hedde van der Heide a53486ca23 bump version to 1.2 2022-05-19 11:08:57 +02:00
Hedde van der Heide 4bc418a5d0 fixed filter bug versions api and set required on manufacturer for softwareproducts add 2022-05-19 11:06:34 +02:00
Hedde van der Heide e952111a53 version bump 1.1 2022-05-03 15:21:18 +02:00
Hedde van der Heide e73cb5fa1a Merge branch 'master' of https://github.com/ICTU/netbox_slm 2022-05-03 15:19:55 +02:00
Hedde van der Heide 56552bdd02 fixed filter search 2022-05-03 15:19:53 +02:00
Hedde van der Heide f914e78812 Update README.md 2022-05-02 12:39:47 +02:00
Hedde van der Heide c9a7b2315e Update README.md 2022-05-02 12:37:36 +02:00
Hedde van der Heide a8c9fc5fbe Delete LICENSE.md 2022-05-02 12:37:18 +02:00
Hedde van der Heide a1f6e3dc92 Create LICENSE 2022-05-02 12:37:11 +02:00
Hedde van der Heide 4f8053c0f6 Rename LICENSE to LICENSE.md 2022-05-02 12:32:20 +02:00
Hedde van der Heide 3b3a6d63d4 Update and rename README.rst to README.md 2022-05-02 12:32:08 +02:00
Hedde van der Heide ce283dea52 updated README 2022-04-22 14:04:29 +02:00
Hedde van der Heide f3579cce63 updated README 2022-04-22 13:57:49 +02:00
Hedde van der Heide 3bb711ea84 updated README 2022-04-22 13:57:45 +02:00
Hedde van der Heide 9d76bb421d normalized project structure 2022-04-22 13:52:26 +02:00
Hedde van der Heide 7a24221444 moved top level files 2022-04-22 13:33:17 +02:00
Hedde van der Heide ac198ef7cf Large overhaul of naming conventions and code clean ups 2022-04-22 13:30:10 +02:00
Hedde van der Heide 6d05b1b6ca bumped version 2022-04-15 09:09:41 +02:00
Hedde van der Heide 049fb045c0 added missing migrations (though stale) 2022-04-15 09:06:28 +02:00
Hedde van der Heide 9e81b94d31 bumped version 2022-04-15 09:02:19 +02:00
Hedde van der Heide 18fed2c029 fixed reverse relationship naming (to default) and added ordering methods for calculated attributes 2022-04-15 09:00:36 +02:00
Hedde van der Heide a02acbc561 fixed installation ordering issue with field methods 2022-04-14 11:35:25 +02:00
Hedde van der Heide 74d648b94d update README.rst, added warning 2022-04-10 11:24:50 +02:00
Hedde van der Heide e9422d70e3 update README.rst 2022-04-10 11:22:33 +02:00
Hedde van der Heide 15b61fd7f9 bumped version for next release 2022-04-10 11:18:22 +02:00
Hedde van der Heide c264536850 Merge branch 'master' of https://github.com/ICTU/netbox_slm 2022-04-10 11:10:06 +02:00
Hedde van der Heide 2dbcaa4877 3.2.X compatibility fixes 2022-04-10 11:10:00 +02:00
Hedde van der Heide 6884bb3b94 Merge remote-tracking branch 'origin/feature/netbox-3.1.X-compatibility' 2022-04-07 09:31:42 +02:00
Hedde van der Heide bf47320864 fixed import no longer working under netbox >=3.1.X: 2022-04-07 09:30:28 +02:00
Hedde van der Heide f7bb83c913 Update README.rst 2022-03-28 12:35:56 +02:00
Hedde van der Heide 0519796af6 Update README.rst 2022-03-28 12:35:32 +02:00
Hedde van der Heide 19fc473286 version bump 0.94 2022-03-28 11:14:03 +02:00
Hedde van der Heide f4188b957f fixed 404 bug, added default ordering for some tables 2022-03-28 11:07:33 +02:00
Hedde van der Heide 51736942e7 fixed display bug installations detail, merged device/vm columns and added type for installations table view 2022-03-24 11:15:53 +01:00
Hedde van der Heide bf63046edb fixed bugs and updated to version 0.93: 2022-02-11 12:52:51 +01:00
Hedde van der Heide 72274e59b5 fixed bugs and updated to version 0.93: 2022-02-11 12:51:37 +01:00
Hedde van der Heide ae06bea12d tmp fix with setup 2022-01-21 13:16:24 +01:00
Hedde van der Heide 6937a0ecca added MANIFEST.in to include templates 2022-01-21 13:04:45 +01:00
Hedde van der Heide e7eb5b46e4 added MANIFEST.in to include templates 2022-01-21 13:02:42 +01:00
Hedde van der Heide 154c0de9f4 added MANIFEST.in to include templates 2022-01-21 12:57:21 +01:00
Hedde van der Heide c84aa3650b added MANIFEST.in to include templates 2022-01-21 12:52:33 +01:00
Hedde van der Heide fb29bc4258 added MANIFEST.in to include templates 2022-01-21 12:48:39 +01:00
Hedde van der Heide 7e59a5644b added MANIFEST.in to include templates 2022-01-21 12:47:03 +01:00
Hedde van der Heide f1dbedcfba added MANIFEST.in to include templates 2022-01-21 12:44:47 +01:00
Hedde van der Heide bbb00d667d added MANIFEST.in to include templates 2022-01-21 12:35:31 +01:00
Hedde van der Heide 743759f0e6 Merge pull request #1 from ICTU/installation-instruction-improvements
More detailed instructions.
2022-01-10 16:45:54 +01:00
Frank Niessink 3ed18c5767 Improve wording. 2022-01-10 14:57:58 +01:00
Frank Niessink e926f01141 Fix instructions. 2022-01-10 14:56:05 +01:00
Frank Niessink bc0b77f962 More detailed instructions. 2022-01-10 14:39:59 +01:00
Hedde van der Heide cec3d50313 updated lock 2022-01-05 12:54:59 +01:00
Hedde van der Heide d70f8afc41 Update Pipfile 2022-01-05 12:52:09 +01:00
Hedde van der Heide 0122275ee1 Update README.md 2022-01-05 12:42:57 +01:00
Hedde van der Heide 267cb8db63 added release guide 2022-01-05 09:41:54 +01:00
Hedde van der Heide adea4437c9 fixed breaking changes (partially, templates require another fix) 2021-12-23 11:09:58 +01:00
86 changed files with 4550 additions and 1791 deletions
+3
View File
@@ -0,0 +1,3 @@
COMPOSE_PATH_SEPARATOR=:
COMPOSE_FILE=ci/docker-compose.yml:ci/docker-compose.ci.yml
COMPOSE_PROJECT_NAME=netbox-docker
+6
View File
@@ -1,3 +1,4 @@
**/netbox
**/netbox-docker
**/*.pyc
**/__pycache__
@@ -5,3 +6,8 @@
**/.vscode
**/dist
**/netbox_slm.egg-info
ci/docker-compose.override.yml
ci/reports
out/production
*.iml
build/
+152
View File
@@ -0,0 +1,152 @@
# Changelog
## [Unreleased]
### Fixed
* Make the required single platform destination explicit when creating an installation
* Fix installation form submission on NetBox v4.6.5 when form mixins return no cleaned-data value
* Prevent the notification log creation flag from shadowing Django's translation function during email delivery
* Use NetBox's configured `EMAIL['FROM_EMAIL']` value as the SMTP sender instead of Django's localhost fallback
* Register the expiration event with a concrete string so NetBox 4.6 can render the notification dropdown
### Added
* Optional tenant group and tenant assignments for software licenses
* German translation for navigation, forms, tables, detail views, choices, and validation messages
* Optional lifetime or recurring renewal interval for licenses
* Optional license file, license key, and provider portal URL
* Daily license expiration notifications for users who subscribe to a license
* Default reminders one month and one week before expiration plus an optional custom interval
* Optional SMTP email in addition to mandatory NetBox notifications
### Changed
* Support NetBox v4.6.5
* Replace the Docker-based setup with Git/Pip installation documentation
* Render tenant group and tenant in a dedicated license form section
* Bump the package version so Git/Pip upgrades replace older installations
* Make the license type field optional
* Show the plugin under Licences in the main menu
* Restrict SMTP notification activation to administrators and explicitly authorized users
* Repeat NetBox and optional email reminders on every daily job run from the first due interval through expiration
## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
### Changed
* Support NetBox v4.6.3 (#76)
* Support Python 3.14 (#76)
## [1.8.3](https://github.com/ICTU/netbox_slm/releases/tag/1.8.3) - 2025-12-18
### Fixed
* Filters with default order by ID (#75)
## [1.8.2](https://github.com/ICTU/netbox_slm/releases/tag/1.8.2) - 2025-06-02
### Fixed
* Permission check for adding software product installations (#70)
## [1.8.1](https://github.com/ICTU/netbox_slm/releases/tag/1.8.1) - 2025-04-11
### Fixed
* Filtering on foreign objects (#65)
## [1.8.0](https://github.com/ICTU/netbox_slm/releases/tag/1.8.0) - 2025-02-25
### Added
* Toggle to show plugin as top level menu item (#42)
* Bulk import and bulk edit (#4)
* Changelog with backdated changes (#51)
* List filtering (#54)
* Show installations on native objects (#49)
* SPDX license expressions (#47)
### Changed
* Support NetBox v4.2.3 (#51)
* Render objects consistently (#58)
### Fixed
* Model detail view listing of linked objects (#55)
* Search link of installations table cells (#48)
## [1.7.0](https://github.com/ICTU/netbox_slm/releases/tag/1.7.0) - 2024-07-09
### Changed
* Support NetBox v4.0.6 (#38)
### Fixed
* Hidden plugin data when not logged in (#37)
### Removed
* Broken bulk import and bulk edit (#4)
## [1.6.0](https://github.com/ICTU/netbox_slm/releases/tag/1.6.0) - 2024-02-13
### Added
* Additional `SoftwareProductVersion` fields (#10)
* Additional `SoftwareLicense` fields (#26)
### Changed
* Support NetBox v3.7.2 (#35)
## [1.5](https://github.com/ICTU/netbox_slm/releases/tag/1.5) - 2023-10-04
### Added
* Hyperlink option for `SoftwareLicense.stored_location` (#27)
### Changed
* Update docs and imports for newer NetBox versions (#23)
* Support NetBox v3.6.1 (#21)
* Allow linking `SoftwareProductInstallation` to `Cluster` (#17)
### Fixed
* Date picker for start and expiration dates (#22)
## [1.4](https://github.com/ICTU/netbox_slm/releases/tag/1.4) - 2023-06-30
### Added
* License information for software installations (#8)
### Changed
* Replace `NetBoxModelCSVForm` with `NetBoxModelImportForm` (#18)
## [1.3](https://github.com/ICTU/netbox_slm/releases/tag/1.3) - 2023-04-19
### Added
* Set up continuous integration (#3)
### Changed
* Support NetBox v3.4.7 (#14)
### Fixed
* Unify API serialization (#12)
## [1.2](https://github.com/ICTU/netbox_slm/releases/tag/1.2) - 2022-05-19
* Fixed filtering of relevant versions when adding a new installation
* Manufacturer is now required for the add software product form
## [1.1](https://github.com/ICTU/netbox_slm/releases/tag/1.1) - 2022-05-03
* Fixed search filter
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+7
View File
@@ -0,0 +1,7 @@
include LICENSE
include README.md
include SECURITY.md
graft netbox_slm
prune netbox_slm/tests
global-exclude *.py[cod]
global-exclude __pycache__
-11
View File
@@ -1,11 +0,0 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]
[requires]
python_version = "3.9"
+136 -129
View File
@@ -1,153 +1,160 @@
## Software Lifecycle Management
# NetBox SLM
### _Netbox Plugin for lifecycle management of software components, including versions and installations_
NetBox SLM erweitert NetBox um die Verwaltung von Softwareprodukten, Versionen, Installationen und Lizenzen.
Lizenzen können optional einer NetBox-Mandantengruppe und einem Mandanten zugeordnet werden.
---
Diese Variante ist für **NetBox 4.6.5** vorgesehen.
### Installation Guide
Die Oberfläche ist auf Englisch und Deutsch verfügbar. Die Sprache richtet sich nach der in NetBox für den
Benutzer ausgewählten Sprache.
Depending on running in Docker (see the netbox-docker repository) add netbox_slm to the plugin_requirements.txt (requires a valid pypi version)
## Funktionen
```
netbox-slm==0.1
- Softwareprodukte und Hersteller verwalten
- Versionen, Release-Typen und Supportzeiträume erfassen
- Installationen Geräten, virtuellen Maschinen oder Clustern zuordnen
- Softwarelizenzen einschließlich Laufzeit, Umfang und Ablageort verwalten
- Lifetime-Lizenzen oder Erneuerungsintervalle in Tagen, Monaten oder Jahren erfassen
- Lizenzdateien, Lizenzschlüssel und Links zum Anbieterportal hinterlegen
- Ablaufwarnungen standardmäßig einen Monat und eine Woche vor Lizenzende
- Zusätzliche eigene Vorwarnzeit in Tagen, Wochen oder Monaten
- NetBox-Benachrichtigungen und optionaler E-Mail-Versand über die NetBox-SMTP-Konfiguration
- Lizenzen Mandantengruppen und Mandanten zuordnen
- Bedienung über die NetBox-Oberfläche und REST-API
- Filter, Bulk-Import und Bulk-Bearbeitung
> **Hinweis:** Lizenzschlüssel werden in der NetBox-Datenbank und Lizenzdateien im konfigurierten NetBox-
> Medienverzeichnis gespeichert. Der Zugriff auf Datenbank, REST-API und Medienverzeichnis sollte entsprechend
> geschützt und gesichert werden.
## Installation
NetBox installiert lokale Erweiterungen aus `/opt/netbox/local_requirements.txt`. Die Git-URL muss dort dauerhaft
eingetragen werden, damit das Plugin bei einem späteren NetBox-Upgrade erneut installiert wird.
### 1. Git-Repository als lokale Anforderung eintragen
```bash
echo "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" >> /opt/netbox/local_requirements.txt
```
Change the configuration.py to include the plugin:
Vorher kann geprüft werden, ob der Eintrag bereits vorhanden ist:
```bash
grep -qxF "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" /opt/netbox/local_requirements.txt || \
echo "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" >> /opt/netbox/local_requirements.txt
```
### 2. Plugin mit Pip installieren
Die Installation muss im Python-Virtual-Environment von NetBox erfolgen:
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
```
Alternativ können alle lokalen Anforderungen installiert werden:
```bash
/opt/netbox/venv/bin/pip install -r /opt/netbox/local_requirements.txt
```
Die installierte Version kann anschließend geprüft werden:
```bash
/opt/netbox/venv/bin/pip show netbox-slm
```
Für diese Variante muss dort mindestens Version `1.12.0` stehen.
## Ablaufbenachrichtigungen
Für Lizenzen mit Ablaufdatum sind Benachrichtigungen standardmäßig aktiviert. Die Standardtermine liegen einen
Monat und eine Woche vor dem Ablaufdatum. Pro Lizenz kann zusätzlich eine eigene Vorwarnzeit festgelegt oder die
Standardtermine deaktiviert werden.
Empfänger entscheiden selbst, ob sie informiert werden: Vertrieb, zuständiger Techniker oder andere Benutzer öffnen
die Lizenz und wählen die NetBox-Aktion **Abonnieren**. Mit **Abonnement beenden** können sie die Meldungen wieder
abschalten. Ohne Abonnenten wird keine Nachricht versendet.
Die NetBox-Nachricht wird immer erzeugt. Wird an der Lizenz zusätzlich **E-Mail-Benachrichtigungen** aktiviert, sendet
das Plugin auch eine E-Mail an die im Benutzerkonto hinterlegte Adresse. Dafür muss der SMTP-Versand in NetBox
konfiguriert sein.
Die Aktivierung des E-Mail-Versands darf nur durch Administratoren oder Benutzer mit der Berechtigung
`netbox_slm.manage_softwarelicense_email_notifications` geändert werden. Die eigentlichen SMTP-Zugangsdaten werden
ausschließlich in der NetBox-Serverkonfiguration gepflegt und sind im Plugin nicht einsehbar.
Die Prüfung läuft einmal täglich als NetBox-Systemjob. Sobald der erste konfigurierte Erinnerungstermin erreicht ist,
werden die Abonnenten bei jedem Joblauf erneut informiert: immer über NetBox und bei aktivierter E-Mail-Option
zusätzlich per SMTP. Dies wird bis einschließlich des Ablaufdatums wiederholt. Der NetBox-RQ-Worker muss dafür mit
Scheduler-Unterstützung laufen; dies ist bei der regulären NetBox-Installation mit `rqworker` standardmäßig der Fall.
### 3. Plugin in NetBox aktivieren
In `/opt/netbox/netbox/netbox/configuration.py` ergänzen:
```python
PLUGINS = [
'netbox_slm',
"netbox_slm",
]
```
### Releasing Guide
Falls bereits andere Plugins eingetragen sind, wird `"netbox_slm"` lediglich zur vorhandenen Liste hinzugefügt.
Eine zusätzliche `PLUGINS_CONFIG`-Konfiguration ist für den Standardbetrieb nicht erforderlich.
_TODO_
### 4. Datenbank migrieren und statische Dateien aktualisieren
### Developer Guide (local installation)
_Follow the steps below on your local system to run netbox and the netbox_slm plugin in developer mode_
### Setup
The goal below is to run all Netbox components in Docker and run a local Netbox Django copy with auto-reload to develop the plugin pointing to the Dockerized postgres and redis instances, basically ignoring the netbox docker runtime server.
### Gotcha's
_Netbox's exception handler seems to supress initial Exceptions, obfuscating missing imports or other Django related issues by re-raising an opague error, e.g. netbox_slm plugin namespace doesnotexist. It's recommended to often check the runtime server or patch Netbox's global exception handler._
### Steps
```
from your projects directory clone the netbox repository
$ git clone https://github.com/netbox-community/netbox
$ cd netbox
$ git checkout f5356b8 (to pin 3.0.9)
install the virtual environment
$ pipenv shell
$ pipenv install
create and edit netbox/configuration.py (based on the template file) add these lines at the end of the file;
DEBUG = True
SECRET_KEY = 'dummy'
DEVELOPER = True
PLUGINS = [
'netbox_slm',
]
````
The Netbox installation above will be used to run Django management commands like runserver, makemigrations and migrate, which will be explained in the next steps below;
```
from your projects directory clone the netbox-slm repository
$ git clone https://github.com/ICTU/netbox_slm
$ cd netbox_slm
$ ./start-netbox.sh
````
This will start Netbox locally (requires Docker) and forward the redis and postgres ports to the localhost (make sure there's no processes using these ports or change the dockerfiles accordingly)
Note, you can also start and stop netbox by hand:
```
$ cd netbox-docker
$ docker-compose up -d
or stop the stack with
$ docker-compose down
# to start fresh:
$ docker-compose down
$ docker volume rm netbox-docker_netbox-postgres-data # et cetera
$ docker-compose up -d --force-recreate
this will require you to re-run the migrate commando's for netbox-slm, see further down below
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
```
Go back to the netbox configuration.py file and update the postgres and redis connection strings (username, password) to the ones the netbox docker backend is using, for example (using default user and passwords from the netbox docker example):
### 5. NetBox-Dienste neu starten
```
<<collapsed>>
Bei einer üblichen systemd-Installation:
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
DATABASE = {
'NAME': 'netbox', # Database name
'USER': 'netbox', # PostgreSQL username
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
}
# Redis database settings. Redis is used for caching and for queuing background tasks such as webhook events. A separate
# configuration exists for each. Full connection details are required in both sections, and it is strongly recommended
# to use two separate database IDs.
REDIS = {
'tasks': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': 'H733Kdjndks81',
'DATABASE': 0,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
# Comment out `HOST` and `PORT` lines and uncomment the following if using Redis Sentinel
# 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
# 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': 'H733Kdjndks81',
'DATABASE': 1,
'SSL': False,
# Set this to True to skip TLS certificate verification
# This can expose the connection to attacks, be careful
# 'INSECURE_SKIP_TLS_VERIFY': False,
}
}
<<collapsed>>
```bash
sudo systemctl restart netbox netbox-rq
```
Now you can run commands from the netbox repository like this;
Je nach Installation können die Namen der Dienste abweichen.
```
$ cd netbox/netbox
$ export PYTHONPATH=../../netbox-slm/src/
$ python3 manage.py migrate netbox_slm
$ python3 manage.py runserver 8001
## Aktualisierung
```bash
/opt/netbox/venv/bin/pip install --upgrade --force-reinstall \
"git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git"
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
sudo systemctl restart netbox netbox-rq
```
Visit http://127.0.0.1:8001 in the browesr to see the auto reloading version of the netbox UI. Port 8000 is taken by the docker ran variant.
Der Eintrag in `/opt/netbox/local_requirements.txt` bleibt dabei bestehen.
## Deinstallation
1. `"netbox_slm"` aus der `PLUGINS`-Liste entfernen.
2. Die Git-Zeile aus `/opt/netbox/local_requirements.txt` entfernen.
3. Das Python-Paket deinstallieren:
```bash
/opt/netbox/venv/bin/pip uninstall netbox-slm
```
Die Plugin-Tabellen werden dabei nicht automatisch aus der Datenbank gelöscht.
## Ursprung und Lizenz
Dieses Repository basiert auf dem ursprünglichen Projekt
[ICTU/netbox_slm](https://github.com/ICTU/netbox_slm). **ICTU** ist der ursprüngliche Autor und Herausgeber.
[Mart Visser](https://github.com/MartVisser) wird im Originalprojekt als Ansprechpartner genannt.
Die Anpassungen in diesem Fork werden unter
[git.mrblake.cc/MrBlake/NetBox-SLM](https://git.mrblake.cc/MrBlake/NetBox-SLM) gepflegt.
Das Projekt steht unter der [Apache License 2.0](LICENSE). Die Hinweise auf die ursprünglichen Urheber bleiben
erhalten.
+50
View File
@@ -0,0 +1,50 @@
"""
Copyright 2022-2025 ICTU
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from netbox.plugins import PluginConfig
from django.utils.translation import gettext, gettext_lazy as _
__version__ = "1.13.0"
class SLMConfig(PluginConfig):
name = "netbox_slm"
verbose_name = _("Licences")
version = __version__
description = _("Licence Management NetBox Plugin.")
author = "ICTU"
author_email = "open-source-projects@ictu.nl"
base_url = "slm"
required_settings = []
default_settings = {
"top_level_menu": True,
"link_cluster_installations": "right",
"link_device_installations": "right",
"link_virtualmachine_installations": "right",
}
def ready(self):
super().ready()
from netbox.events import EVENT_TYPE_KIND_WARNING, EventType
from netbox_slm.events import LICENSE_EXPIRING
EventType(LICENSE_EXPIRING, gettext("License expiring"), kind=EVENT_TYPE_KIND_WARNING).register()
from netbox_slm import jobs # noqa: F401
config = SLMConfig
+7
View File
@@ -0,0 +1,7 @@
from django.apps import apps
from django.conf import settings
from django.contrib import admin
if settings.DEBUG:
for model in apps.get_app_config("netbox_slm").get_models():
admin.site.register(model)
+187
View File
@@ -0,0 +1,187 @@
from rest_framework import serializers
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
from django.utils.translation import gettext_lazy as _
from netbox.api.serializers import NetBoxModelSerializer
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
class SoftwareLicenseSerializer(NetBoxModelSerializer):
display = SerializerMethodField()
url = HyperlinkedIdentityField(view_name="plugins-api:netbox_slm-api:softwarelicense-detail")
class Meta:
model = SoftwareLicense
fields = (
"id",
"display",
"url",
"name",
"description",
"type",
"spdx_expression",
"stored_location",
"stored_location_url",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_file",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
"comments",
"custom_field_data",
"created",
"last_updated",
)
brief_fields = ("id", "display", "url", "name", "description")
def get_display(self, obj):
return f"{obj}"
def validate(self, attrs):
request = self.context.get("request")
if (
"email_notifications" in attrs
and request is not None
and not request.user.has_perm("netbox_slm.manage_softwarelicense_email_notifications")
):
raise serializers.ValidationError(
{"email_notifications": _("You do not have permission to change email notifications.")}
)
tenant_group = attrs.get("tenant_group", getattr(self.instance, "tenant_group", None))
tenant = attrs.get("tenant", getattr(self.instance, "tenant", None))
if tenant_group and tenant and tenant.group_id != tenant_group.pk:
raise serializers.ValidationError(
{"tenant": _("The selected tenant does not belong to the selected tenant group.")}
)
lifetime = attrs.get("lifetime", getattr(self.instance, "lifetime", False))
renewal_interval = attrs.get("renewal_interval", getattr(self.instance, "renewal_interval", None))
renewal_interval_unit = attrs.get(
"renewal_interval_unit", getattr(self.instance, "renewal_interval_unit", "")
)
expiration_date = attrs.get("expiration_date", getattr(self.instance, "expiration_date", None))
if bool(renewal_interval) != bool(renewal_interval_unit):
raise serializers.ValidationError(
{"renewal_interval": _("Renewal interval and unit must be specified together.")}
)
if lifetime and (renewal_interval or renewal_interval_unit or expiration_date):
raise serializers.ValidationError(
{"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")}
)
custom_interval = attrs.get(
"custom_notification_interval", getattr(self.instance, "custom_notification_interval", None)
)
custom_interval_unit = attrs.get(
"custom_notification_interval_unit",
getattr(self.instance, "custom_notification_interval_unit", ""),
)
if bool(custom_interval) != bool(custom_interval_unit):
raise serializers.ValidationError(
{
"custom_notification_interval": _(
"Custom notification interval and unit must be specified together."
)
}
)
return attrs
class SoftwareProductSerializer(NetBoxModelSerializer):
display = SerializerMethodField()
url = HyperlinkedIdentityField(view_name="plugins-api:netbox_slm-api:softwareproduct-detail")
class Meta:
model = SoftwareProduct
fields = (
"id",
"display",
"url",
"name",
"description",
"manufacturer",
"description",
"tags",
"comments",
"custom_field_data",
"created",
"last_updated",
)
brief_fields = ("id", "display", "url", "name", "description")
def get_display(self, obj):
return f"{obj}"
class SoftwareProductInstallationSerializer(NetBoxModelSerializer):
display = SerializerMethodField()
url = HyperlinkedIdentityField(view_name="plugins-api:netbox_slm-api:softwareproductinstallation-detail")
class Meta:
model = SoftwareProductInstallation
fields = (
"id",
"display",
"url",
"device",
"virtualmachine",
"cluster",
"software_product",
"version",
"tags",
"comments",
"custom_field_data",
"created",
"last_updated",
)
brief_fields = ("id", "display", "url")
def get_display(self, obj):
return f"{obj}"
class SoftwareProductVersionSerializer(NetBoxModelSerializer):
display = SerializerMethodField()
url = HyperlinkedIdentityField(view_name="plugins-api:netbox_slm-api:softwareproductversion-detail")
class Meta:
model = SoftwareProductVersion
fields = (
"id",
"display",
"url",
"name",
"description",
"release_date",
"documentation_url",
"end_of_support",
"filename",
"file_checksum",
"file_link",
"release_type",
"software_product",
"tags",
"comments",
"custom_field_data",
"created",
"last_updated",
)
brief_fields = ("id", "display", "url", "name", "description")
def get_display(self, obj):
return f"{obj}"
+18
View File
@@ -0,0 +1,18 @@
from netbox.api.routers import NetBoxRouter
from netbox_slm.api.views import (
NetboxSLMRootView,
SoftwareProductViewSet,
SoftwareProductVersionViewSet,
SoftwareProductInstallationViewSet,
SoftwareLicenseViewSet,
)
router = NetBoxRouter()
router.APIRootView = NetboxSLMRootView
router.register("softwareproducts", SoftwareProductViewSet)
router.register("softwareproductversions", SoftwareProductVersionViewSet)
router.register("softwareproductinstallations", SoftwareProductInstallationViewSet)
router.register("softwarelicenses", SoftwareLicenseViewSet)
urlpatterns = router.urls
+50
View File
@@ -0,0 +1,50 @@
from rest_framework.routers import APIRootView
from netbox.api.viewsets import NetBoxModelViewSet
from netbox_slm.api.serializers import (
SoftwareProductSerializer,
SoftwareProductVersionSerializer,
SoftwareProductInstallationSerializer,
SoftwareLicenseSerializer,
)
from netbox_slm.filtersets import (
SoftwareProductFilterSet,
SoftwareProductVersionFilterSet,
SoftwareProductInstallationFilterSet,
SoftwareLicenseFilterSet,
)
from netbox_slm.models import (
SoftwareProduct,
SoftwareProductVersion,
SoftwareProductInstallation,
SoftwareLicense,
)
class NetboxSLMRootView(APIRootView):
def get_view_name(self):
return "NetboxSLM"
class SoftwareProductViewSet(NetBoxModelViewSet):
queryset = SoftwareProduct.objects.all().order_by("id")
serializer_class = SoftwareProductSerializer
filterset_class = SoftwareProductFilterSet
class SoftwareProductVersionViewSet(NetBoxModelViewSet):
queryset = SoftwareProductVersion.objects.all().order_by("id")
serializer_class = SoftwareProductVersionSerializer
filterset_class = SoftwareProductVersionFilterSet
class SoftwareProductInstallationViewSet(NetBoxModelViewSet):
queryset = SoftwareProductInstallation.objects.all().order_by("id")
serializer_class = SoftwareProductInstallationSerializer
filterset_class = SoftwareProductInstallationFilterSet
class SoftwareLicenseViewSet(NetBoxModelViewSet):
queryset = SoftwareLicense.objects.all().order_by("id")
serializer_class = SoftwareLicenseSerializer
filterset_class = SoftwareLicenseFilterSet
+1
View File
@@ -0,0 +1 @@
LICENSE_EXPIRING = "netbox_slm_license_expiring"
+153
View File
@@ -0,0 +1,153 @@
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter
from dcim.models import Device, Manufacturer
from netbox.filtersets import NetBoxModelFilterSet
from netbox_slm.models import (
SoftwareProduct,
SoftwareProductVersion,
SoftwareProductInstallation,
SoftwareLicense,
SoftwareReleaseTypes,
RenewalIntervalUnits,
)
from virtualization.models import Cluster, VirtualMachine
from tenancy.models import Tenant, TenantGroup
class SoftwareProductFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProduct instances."""
name = CharFilter(lookup_expr="icontains")
description = CharFilter(lookup_expr="icontains")
manufacturer_id = ModelMultipleChoiceFilter(queryset=Manufacturer.objects.all())
class Meta:
model = SoftwareProduct
fields = tuple()
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
| Q(description__icontains=value)
| Q(manufacturer__name__icontains=value)
| Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
class SoftwareProductVersionFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProductVersion instances."""
name = CharFilter(lookup_expr="icontains")
description = CharFilter(lookup_expr="icontains")
filename = CharFilter(lookup_expr="icontains")
release_type = MultipleChoiceFilter(choices=SoftwareReleaseTypes.choices)
manufacturer_id = ModelMultipleChoiceFilter(
field_name="software_product__manufacturer",
queryset=Manufacturer.objects.all(),
)
software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(),
label=_("Software Product"),
)
class Meta:
model = SoftwareProductVersion
fields = tuple()
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
| Q(description__icontains=value)
| Q(software_product__name__icontains=value)
| Q(software_product__manufacturer__name__icontains=value)
| Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
class SoftwareProductInstallationFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareProductInstallation instances."""
device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all())
virtualmachine_id = ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(),
label=_("Virtual Machine"),
)
cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all())
software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(),
label=_("Software Product"),
)
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
class Meta:
model = SoftwareProductInstallation
fields = tuple()
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = (
Q(software_product__name__icontains=value)
| Q(software_product__manufacturer__name__icontains=value)
| Q(version__name__icontains=value)
| Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
"""Filter capabilities for SoftwareLicense instances."""
name = CharFilter(lookup_expr="icontains")
description = CharFilter(lookup_expr="icontains")
type = CharFilter(lookup_expr="icontains")
spdx_expression = CharFilter(lookup_expr="icontains", label=_("SPDX expression"))
stored_location = CharFilter(lookup_expr="icontains")
renewal_interval = NumberFilter()
renewal_interval_unit = MultipleChoiceFilter(choices=RenewalIntervalUnits.choices)
software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(),
label=_("Software Product"),
)
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
installation_id = ModelMultipleChoiceFilter(queryset=SoftwareProductInstallation.objects.all())
tenant_group_id = ModelMultipleChoiceFilter(queryset=TenantGroup.objects.all(), label=_("Tenant Group"))
tenant_id = ModelMultipleChoiceFilter(queryset=Tenant.objects.all(), label=_("Tenant"))
class Meta:
model = SoftwareLicense
fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
| Q(description__icontains=value)
| Q(software_product__name__icontains=value)
| Q(version__name__icontains=value)
| Q(installation__device__name__icontains=value)
| Q(installation__virtualmachine__name__icontains=value)
| Q(installation__cluster__name__icontains=value)
| Q(tenant_group__name__icontains=value)
| Q(tenant__name__icontains=value)
| Q(comments__icontains=value)
)
return queryset.filter(qs_filter)
+24
View File
@@ -0,0 +1,24 @@
from .software_license import (
SoftwareLicenseForm,
SoftwareLicenseFilterForm,
SoftwareLicenseBulkImportForm,
SoftwareLicenseBulkEditForm,
)
from .software_product import (
SoftwareProductForm,
SoftwareProductFilterForm,
SoftwareProductBulkImportForm,
SoftwareProductBulkEditForm,
)
from .software_product_installation import (
SoftwareProductInstallationForm,
SoftwareProductInstallationFilterForm,
SoftwareProductInstallationBulkImportForm,
SoftwareProductInstallationBulkEditForm,
)
from .software_product_version import (
SoftwareProductVersionForm,
SoftwareProductVersionFilterForm,
SoftwareProductVersionBulkImportForm,
SoftwareProductVersionBulkEditForm,
)
+344
View File
@@ -0,0 +1,344 @@
from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField, Textarea
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import (
SoftwareProduct,
SoftwareProductVersion,
SoftwareProductInstallation,
SoftwareLicense,
RenewalIntervalUnits,
NotificationIntervalUnits,
spdx_license_names,
)
from tenancy.models import Tenant, TenantGroup
from utilities.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import (
CommentField,
DynamicModelChoiceField,
TagFilterField,
LaxURLField,
DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import APISelect, DatePicker
class SoftwareLicenseForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
FieldSet(
"name",
"description",
"software_product",
"version",
"installation",
name=_("Software License"),
),
FieldSet("tenant_group", "tenant", name=_("Tenancy")),
FieldSet(
"type",
"spdx_expression",
"stored_location",
"stored_location_url",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
name=_("License Details"),
),
FieldSet("license_file", "license_key", "provider_portal_url", name=_("License Credentials")),
FieldSet(
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
name=_("Expiration Notifications"),
),
FieldSet("tags", name=_("Tags")),
)
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
stored_location_url = LaxURLField(required=False)
start_date = DateField(required=False, widget=DatePicker())
expiration_date = DateField(required=False, widget=DatePicker())
renewal_interval = IntegerField(required=False, min_value=1)
license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3}))
provider_portal_url = LaxURLField(required=False)
custom_notification_interval = IntegerField(required=False, min_value=1)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductversion-list")}),
query_params=dict(software_product="$software_product"),
)
installation = DynamicModelChoiceField(
queryset=SoftwareProductInstallation.objects.all(),
required=False,
widget=APISelect(
attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductinstallation-list")}
),
query_params=dict(software_product="$software_product"),
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_("Tenant Group"),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label=_("Tenant"),
)
class Meta:
model = SoftwareLicense
fields = (
"name",
"description",
"software_product",
"type",
"spdx_expression",
"stored_location",
"stored_location_url",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_file",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
"comments",
)
class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
model = SoftwareLicense
fieldsets = (
FieldSet("q", "filter_id", "tag"),
FieldSet(
"name",
"description",
"type",
"spdx_expression",
"stored_location",
"support",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"expiration_notifications_enabled",
"email_notifications",
"software_product_id",
"version_id",
"installation_id",
"tenant_group_id",
"tenant_id",
),
)
selector_fields = ("q", "filter_id", "name")
tag = TagFilterField(model)
name = CharField(required=False)
description = CharField(required=False)
type = CharField(required=False)
spdx_expression = CharField(required=False, label=_("SPDX expression"))
stored_location = CharField(required=False)
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
lifetime = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
renewal_interval = IntegerField(required=False, min_value=1)
renewal_interval_unit = ChoiceField(required=False, choices=RenewalIntervalUnits.choices)
expiration_notifications_enabled = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
email_notifications = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
label=_("Version"),
)
installation_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductInstallation.objects.all(),
required=False,
label=_("Installation"),
)
tenant_group_id = DynamicModelMultipleChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_("Tenant Group"),
)
tenant_id = DynamicModelMultipleChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group_id"},
label=_("Tenant"),
)
class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
support = NullBooleanField(
required=False, help_text=_("Support (values other than 'True' and 'False' are ignored)")
)
class Meta:
model = SoftwareLicense
fields = (
"name",
"description",
"software_product",
"type",
"spdx_expression",
"stored_location",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version",
"installation",
"tenant_group",
"tenant",
"tags",
)
class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
model = SoftwareLicense
fieldsets = (
FieldSet(
"type",
"spdx_expression",
"stored_location",
"stored_location_url",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
),
)
nullable_fields = (
"type",
"spdx_expression",
"stored_location",
"stored_location_url",
"start_date",
"expiration_date",
"renewal_interval",
"renewal_interval_unit",
"support",
"license_amount",
"license_key",
"provider_portal_url",
"custom_notification_interval",
"custom_notification_interval_unit",
"version",
"installation",
"tenant_group",
"tenant",
)
tag = TagFilterField(model)
comments = CommentField()
type = CharField(required=False)
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
stored_location = CharField(required=False)
stored_location_url = LaxURLField(required=False)
start_date = DateField(required=False, widget=DatePicker())
expiration_date = DateField(required=False, widget=DatePicker())
lifetime = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
renewal_interval = IntegerField(required=False, min_value=1)
renewal_interval_unit = ChoiceField(required=False, choices=RenewalIntervalUnits.choices)
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
license_amount = IntegerField(required=False, min_value=0)
license_key = CharField(required=False, widget=Textarea(attrs={"rows": 3}))
provider_portal_url = LaxURLField(required=False)
expiration_notifications_enabled = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
default_notification_intervals = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
custom_notification_interval = IntegerField(required=False, min_value=1)
custom_notification_interval_unit = ChoiceField(required=False, choices=NotificationIntervalUnits.choices)
email_notifications = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductversion-list")}),
query_params=dict(software_product="$software_product"),
)
installation = DynamicModelChoiceField(
queryset=SoftwareProductInstallation.objects.all(),
required=False,
widget=APISelect(
attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductinstallation-list")}
),
query_params=dict(software_product="$software_product"),
)
tenant_group = DynamicModelChoiceField(
queryset=TenantGroup.objects.all(),
required=False,
label=_("Tenant Group"),
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False,
query_params={"group_id": "$tenant_group"},
label=_("Tenant"),
)
+76
View File
@@ -0,0 +1,76 @@
from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import SoftwareProduct
from utilities.forms.fields import (
CommentField,
DynamicModelChoiceField,
TagFilterField,
DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
class SoftwareProductForm(NetBoxModelForm):
comments = CommentField()
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=True,
)
class Meta:
model = SoftwareProduct
fields = (
"name",
"description",
"manufacturer",
"tags",
"comments",
)
class SoftwareProductFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProduct
fieldsets = (
FieldSet("q", "filter_id", "tag"),
FieldSet("name", "description", "manufacturer_id"),
)
selector_fields = ("q", "filter_id", "name")
tag = TagFilterField(model)
name = CharField(required=False)
description = CharField(required=False)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_("Manufacturer"),
)
class SoftwareProductBulkImportForm(NetBoxModelImportForm):
class Meta:
model = SoftwareProduct
fields = (
"name",
"description",
"manufacturer",
"tags",
)
class SoftwareProductBulkEditForm(NetBoxModelBulkEditForm):
model = SoftwareProduct
fieldsets = (FieldSet("manufacturer"),)
nullable_fields = ("manufacturer", "comments")
tag = TagFilterField(model)
comments = CommentField()
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
)
@@ -0,0 +1,164 @@
from django.forms import ValidationError
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Device
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import SoftwareProductInstallation, SoftwareProduct, SoftwareProductVersion
from utilities.forms.fields import (
CommentField,
DynamicModelChoiceField,
TagFilterField,
DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import APISelect
from virtualization.models import Cluster, VirtualMachine
class SoftwareProductInstallationForm(NetBoxModelForm):
comments = CommentField()
fieldsets = (
FieldSet("device", "virtualmachine", "cluster", name=_("Platform Destination (select exactly one)")),
FieldSet("software_product", "version", name=_("Software")),
FieldSet("tags", name=_("Tags")),
)
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
help_text=_("Select either a device, a virtual machine, or a cluster."),
)
virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_("Virtual Machine"),
help_text=_("Leave empty when another platform destination is selected."),
)
cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
help_text=_("Leave empty when another platform destination is selected."),
)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=True,
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductversion-list")}),
query_params=dict(software_product="$software_product"),
)
class Meta:
model = SoftwareProductInstallation
fields = (
"device",
"virtualmachine",
"cluster",
"software_product",
"version",
"tags",
"comments",
)
def clean_version(self):
version = self.cleaned_data["version"]
software_product = self.cleaned_data["software_product"]
if version not in software_product.softwareproductversion_set.all():
raise ValidationError(
_("Version '%(version)s' does not belong to %(product)s. Select a compatible version.")
% {"version": version, "product": software_product}
)
return version
def clean(self):
super().clean()
destinations = [
self.cleaned_data.get("device"),
self.cleaned_data.get("virtualmachine"),
self.cleaned_data.get("cluster"),
]
if sum(destination is not None for destination in destinations) != 1:
raise ValidationError(_("Select exactly one platform destination: device, virtual machine, or cluster."))
return self.cleaned_data
class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProductInstallation
fieldsets = (
FieldSet("q", "filter_id", "tag"),
FieldSet("device_id", "virtualmachine_id", "cluster_id", "software_product_id", "version_id"),
)
selector_fields = ("filter_id", "q")
tag = TagFilterField(model)
device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(),
required=False,
label=_("Device"),
)
virtualmachine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_("Virtual Machine"),
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_("Cluster"),
)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
label=_("Version"),
)
class SoftwareProductInstallationBulkImportForm(NetBoxModelImportForm):
class Meta:
model = SoftwareProductInstallation
fields = (
"device",
"virtualmachine",
"cluster",
"software_product",
"version",
"tags",
)
class SoftwareProductInstallationBulkEditForm(NetBoxModelBulkEditForm):
model = SoftwareProductInstallation
fieldsets = (FieldSet("device", "virtualmachine", "cluster", "software_product", "version"),)
nullable_fields = ("device", "virtualmachine", "cluster", "comments")
tag = TagFilterField(model)
comments = CommentField()
device = DynamicModelChoiceField(queryset=Device.objects.all(), required=False)
virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
label=_("Virtual Machine"),
)
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=False,
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductversion-list")}),
query_params=dict(software_product="$software_product"),
)
@@ -0,0 +1,129 @@
from django.forms import DateField, CharField, MultipleChoiceField
from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareReleaseTypes
from utilities.forms.fields import (
CommentField,
DynamicModelChoiceField,
TagFilterField,
DynamicModelMultipleChoiceField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import APISelect, DatePicker
class SoftwareProductVersionForm(NetBoxModelForm):
comments = CommentField()
release_date = DateField(required=False, widget=DatePicker())
end_of_support = DateField(required=False, widget=DatePicker())
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
label=_("Software Product"),
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}),
)
class Meta:
model = SoftwareProductVersion
fields = (
"name",
"description",
"software_product",
"release_date",
"documentation_url",
"end_of_support",
"filename",
"file_checksum",
"file_link",
"release_type",
"tags",
"comments",
)
class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProductVersion
fieldsets = (
FieldSet("q", "filter_id", "tag"),
FieldSet("name", "description", "filename", "release_type", "manufacturer_id", "software_product_id"),
)
selector_fields = ("q", "filter_id", "name")
tag = TagFilterField(model)
name = CharField(required=False)
description = CharField(required=False)
filename = CharField(required=False)
release_type = MultipleChoiceField(required=False, choices=SoftwareReleaseTypes.choices)
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_("Manufacturer"),
)
software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm):
release_type = CharField(
help_text=_("Release type (possible values: %(values)s)") % {"values": SoftwareReleaseTypes.values}
)
class Meta:
model = SoftwareProductVersion
fields = (
"name",
"description",
"software_product",
"release_date",
"end_of_support",
"filename",
"file_checksum",
"release_type",
"tags",
)
class SoftwareProductVersionBulkEditForm(NetBoxModelBulkEditForm):
model = SoftwareProductVersion
fieldsets = (
FieldSet(
"release_date",
"documentation_url",
"end_of_support",
"filename",
"file_checksum",
"file_link",
"release_type",
"software_product",
),
)
nullable_fields = ("release_date", "documentation_url", "end_of_support", "filename", "file_checksum", "file_link")
tag = TagFilterField(model)
comments = CommentField()
release_date = DateField(required=False, widget=DatePicker())
documentation_url = CharField(required=False)
end_of_support = DateField(required=False, widget=DatePicker())
filename = CharField(required=False)
file_checksum = CharField(required=False)
file_link = CharField(required=False)
release_type = MultipleChoiceField(required=False, choices=SoftwareReleaseTypes.choices)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
label=_("Software Product"),
)
+91
View File
@@ -0,0 +1,91 @@
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.mail import send_mail
from django.utils import timezone
from django.utils.translation import gettext as _
from core.choices import JobIntervalChoices
from extras.models import Notification
from netbox.jobs import JobRunner, system_job
from netbox_slm.events import LICENSE_EXPIRING
from netbox_slm.models import SoftwareLicense, SoftwareLicenseNotificationLog
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class LicenseExpirationNotificationJob(JobRunner):
class Meta:
name = "Software License Expiration Notifications"
def run(self, *args, **kwargs):
today = timezone.localdate()
licenses = SoftwareLicense.objects.filter(
expiration_notifications_enabled=True,
lifetime=False,
expiration_date__gte=today,
).prefetch_related("subscriptions__user")
for license in licenses:
due_dates = [date for date in license.get_notification_dates() if date <= today]
if not due_dates:
continue
for subscription in license.subscriptions.all():
user = subscription.user
if not user.is_active:
continue
# If notifications were enabled late, send only the most recent reminder instead of one per elapsed
# interval. Normally this still produces one reminder at each configured threshold.
self._notify(license, user, max(due_dates))
def _notify(self, license, user, notification_date):
log = SoftwareLicenseNotificationLog.objects.get_or_create(
license=license,
user=user,
expiration_date=license.expiration_date,
notification_date=notification_date,
)[0]
now = timezone.now()
object_type = ContentType.objects.get_for_model(license)
Notification.objects.update_or_create(
object_type=object_type,
object_id=license.pk,
user=user,
defaults={
"created": now,
"object_repr": Notification.get_object_repr(license),
"event_type": LICENSE_EXPIRING,
"read": None,
},
)
log.netbox_sent_at = now
log.save(update_fields=("netbox_sent_at",))
self.logger.info(f"Created NetBox expiration notification for license {license.pk} and user {user}")
if license.email_notifications and user.email:
subject = _("License %(license)s expires on %(date)s") % {
"license": license,
"date": license.expiration_date,
}
body = _(
"The software license '%(license)s' for '%(product)s' expires on %(date)s."
) % {
"license": license,
"product": license.software_product,
"date": license.expiration_date,
}
if license.tenant:
body += "\n" + _("Tenant: %(tenant)s") % {"tenant": license.tenant}
if license.provider_portal_url:
body += "\n" + _("Provider portal: %(url)s") % {"url": license.provider_portal_url}
try:
from_email = settings.SERVER_EMAIL or settings.EMAIL_HOST_USER
send_mail(subject, body, from_email, [user.email], fail_silently=False)
except Exception:
self.logger.exception(f"Failed to send expiration email for license {license.pk} to user {user}")
else:
log.email_sent_at = now
log.save(update_fields=("email_sent_at",))
self.logger.info(f"Sent expiration email for license {license.pk} to user {user}")
Binary file not shown.
+419
View File
@@ -0,0 +1,419 @@
msgid ""
msgstr ""
"Project-Id-Version: netbox-slm 1.9.1\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
msgid "Add"
msgstr "Hinzufügen"
msgid "Import"
msgstr "Importieren"
msgid "Software Lifecycle"
msgstr "Software-Lebenszyklus"
msgid "Software Products"
msgstr "Softwareprodukte"
msgid "Software Product"
msgstr "Softwareprodukt"
msgid "Software Product Version"
msgstr "Softwareproduktversion"
msgid "Software Product Installation"
msgstr "Softwareproduktinstallation"
msgid "Software License"
msgstr "Softwarelizenz"
msgid "License Details"
msgstr "Lizenzdetails"
msgid "Versions"
msgstr "Versionen"
msgid "Version"
msgstr "Version"
msgid "Installations"
msgstr "Installationen"
msgid "Installation"
msgstr "Installation"
msgid "Add an installation"
msgstr "Installation hinzufügen"
msgid "Licenses"
msgstr "Lizenzen"
msgid "Tenancy"
msgstr "Mandantenzuordnung"
msgid "Tenant Group"
msgstr "Mandantengruppe"
msgid "Tenant"
msgstr "Mandant"
msgid "Manufacturer"
msgstr "Hersteller"
msgid "Device"
msgstr "Gerät"
msgid "Virtual Machine"
msgstr "Virtuelle Maschine"
msgid "Cluster"
msgstr "Cluster"
msgid "Name"
msgstr "Name"
msgid "Description"
msgstr "Beschreibung"
msgid "Type"
msgstr "Typ"
msgid "SPDX expression"
msgstr "SPDX-Ausdruck"
msgid "Stored location"
msgstr "Ablageort"
msgid "Start date"
msgstr "Startdatum"
msgid "Expiration date"
msgstr "Ablaufdatum"
msgid "Support"
msgstr "Support"
msgid "License amount"
msgstr "Lizenzanzahl"
msgid "Release date"
msgstr "Veröffentlichungsdatum"
msgid "Documentation URL"
msgstr "Dokumentations-URL"
msgid "End of support"
msgstr "Supportende"
msgid "Filename"
msgstr "Dateiname"
msgid "File checksum"
msgstr "Dateiprüfsumme"
msgid "Release type"
msgstr "Veröffentlichungstyp"
msgid "Release candidate"
msgstr "Veröffentlichungskandidat"
msgid "Stable release"
msgstr "Stabile Veröffentlichung"
msgid "Alpha"
msgstr "Alpha"
msgid "Beta"
msgstr "Beta"
msgid "Tags"
msgstr "Tags"
msgid "None"
msgstr "Keine"
msgid "Link"
msgstr "Link"
msgid "Support (values other than 'True' and 'False' are ignored)"
msgstr "Support (andere Werte als True und False werden ignoriert)"
msgid "Release type (possible values: %(values)s)"
msgstr "Veröffentlichungstyp (mögliche Werte: %(values)s)"
msgid "Version '%(version)s' does not belong to %(product)s. Select a compatible version."
msgstr "Version %(version)s gehört nicht zu %(product)s. Wähle eine kompatible Version aus."
msgid "Installation requires exactly one platform destination."
msgstr "Eine Installation benötigt genau ein Plattformziel."
msgid "%(value)s is not a known SPDX license expression"
msgstr "%(value)s ist kein bekannter SPDX-Lizenzausdruck"
msgid "The selected tenant does not belong to the selected tenant group."
msgstr "Der ausgewählte Mandant gehört nicht zur ausgewählten Mandantengruppe."
msgid "Software Lifecycle Management"
msgstr "Software-Lebenszyklusverwaltung"
msgid "Software Lifecycle Management NetBox Plugin."
msgstr "NetBox-Plugin zur Verwaltung des Software-Lebenszyklus."
msgid "name"
msgstr "Name"
msgid "comments"
msgstr "Kommentare"
msgid "description"
msgstr "Beschreibung"
msgid "manufacturer"
msgstr "Hersteller"
msgid "software product"
msgstr "Softwareprodukt"
msgid "software products"
msgstr "Softwareprodukte"
msgid "release date"
msgstr "Veröffentlichungsdatum"
msgid "documentation URL"
msgstr "Dokumentations-URL"
msgid "end of support"
msgstr "Supportende"
msgid "filename"
msgstr "Dateiname"
msgid "file checksum"
msgstr "Dateiprüfsumme"
msgid "file link"
msgstr "Dateilink"
msgid "release type"
msgstr "Veröffentlichungstyp"
msgid "software product version"
msgstr "Softwareproduktversion"
msgid "software product versions"
msgstr "Softwareproduktversionen"
msgid "device"
msgstr "Gerät"
msgid "virtual machine"
msgstr "Virtuelle Maschine"
msgid "cluster"
msgstr "Cluster"
msgid "version"
msgstr "Version"
msgid "software product installation"
msgstr "Softwareproduktinstallation"
msgid "software product installations"
msgstr "Softwareproduktinstallationen"
msgid "type"
msgstr "Typ"
msgid "stored location"
msgstr "Ablageort"
msgid "stored location URL"
msgstr "Ablageort-URL"
msgid "start date"
msgstr "Startdatum"
msgid "expiration date"
msgstr "Ablaufdatum"
msgid "support"
msgstr "Support"
msgid "license amount"
msgstr "Lizenzanzahl"
msgid "installation"
msgstr "Installation"
msgid "tenant group"
msgstr "Mandantengruppe"
msgid "tenant"
msgstr "Mandant"
msgid "software license"
msgstr "Softwarelizenz"
msgid "software licenses"
msgstr "Softwarelizenzen"
msgid "Days"
msgstr "Tage"
msgid "Months"
msgstr "Monate"
msgid "Years"
msgstr "Jahre"
msgid "lifetime license"
msgstr "Lifetime-Lizenz"
msgid "renewal interval"
msgstr "Erneuerungsintervall"
msgid "renewal interval unit"
msgstr "Einheit des Erneuerungsintervalls"
msgid "license file"
msgstr "Lizenzdatei"
msgid "license key"
msgstr "Lizenzschlüssel"
msgid "provider portal URL"
msgstr "Anbieterportal-URL"
msgid "License Credentials"
msgstr "Lizenzdaten"
msgid "Lifetime license"
msgstr "Lifetime-Lizenz"
msgid "Renewal interval"
msgstr "Erneuerungsintervall"
msgid "License file"
msgstr "Lizenzdatei"
msgid "Download license file"
msgstr "Lizenzdatei herunterladen"
msgid "License key"
msgstr "Lizenzschlüssel"
msgid "Provider Portal"
msgstr "Anbieterportal"
msgid "Yes"
msgstr "Ja"
msgid "No"
msgstr "Nein"
msgid "Renewal interval and unit must be specified together."
msgstr "Erneuerungsintervall und Einheit müssen gemeinsam angegeben werden."
msgid "A lifetime license cannot have a renewal interval or expiration date."
msgstr "Eine Lifetime-Lizenz darf kein Erneuerungsintervall und kein Ablaufdatum haben."
msgid "Platform Destination (select exactly one)"
msgstr "Plattformziel (genau eines auswählen)"
msgid "Software"
msgstr "Software"
msgid "Select either a device, a virtual machine, or a cluster."
msgstr "Wähle entweder ein Gerät, eine virtuelle Maschine oder einen Cluster aus."
msgid "Leave empty when another platform destination is selected."
msgstr "Leer lassen, wenn ein anderes Plattformziel ausgewählt wurde."
msgid "Select exactly one platform destination: device, virtual machine, or cluster."
msgstr "Wähle genau ein Plattformziel aus: Gerät, virtuelle Maschine oder Cluster."
msgid "Weeks"
msgstr "Wochen"
msgid "expiration notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "default notification intervals"
msgstr "Standard-Benachrichtigungsintervalle"
msgid "custom notification interval"
msgstr "Eigene Vorwarnzeit"
msgid "custom notification interval unit"
msgstr "Einheit der eigenen Vorwarnzeit"
msgid "email notifications"
msgstr "E-Mail-Benachrichtigungen"
msgid "Expiration Notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "Custom notification interval and unit must be specified together."
msgstr "Eigene Vorwarnzeit und Einheit müssen gemeinsam angegeben werden."
msgid "License expiring"
msgstr "Lizenz läuft ab"
msgid "License %(license)s expires on %(date)s"
msgstr "Lizenz %(license)s läuft am %(date)s ab"
msgid "The software license '%(license)s' for '%(product)s' expires on %(date)s."
msgstr "Die Softwarelizenz %(license)s für %(product)s läuft am %(date)s ab."
msgid "Tenant: %(tenant)s"
msgstr "Mandant: %(tenant)s"
msgid "Provider portal: %(url)s"
msgstr "Anbieterportal: %(url)s"
msgid "Expiration notifications"
msgstr "Ablaufbenachrichtigungen"
msgid "Default notification intervals"
msgstr "Standard-Benachrichtigungsintervalle"
msgid "One month and one week before expiration"
msgstr "Einen Monat und eine Woche vor Ablauf"
msgid "Disabled"
msgstr "Deaktiviert"
msgid "Enabled"
msgstr "Aktiviert"
msgid "Custom notification interval"
msgstr "Eigene Vorwarnzeit"
msgid "Email notifications"
msgstr "E-Mail-Benachrichtigungen"
msgid "Notification recipients"
msgstr "Benachrichtigungsempfänger"
msgid "No subscribers. Users can subscribe to this license using the Subscribe action."
msgstr "Keine Abonnenten. Benutzer können diese Lizenz über die Aktion Abonnieren abonnieren."
msgid "Licences"
msgstr "Lizenzen"
msgid "Licence Management NetBox Plugin."
msgstr "NetBox-Plugin zur Lizenzverwaltung."
msgid "Can manage license email notifications"
msgstr "Kann E-Mail-Benachrichtigungen für Lizenzen verwalten"
msgid "You do not have permission to change email notifications."
msgstr "Sie haben keine Berechtigung, E-Mail-Benachrichtigungen zu ändern."
msgid "Only administrators or explicitly authorized users can change this setting."
msgstr "Nur Administratoren oder ausdrücklich berechtigte Benutzer können diese Einstellung ändern."
@@ -0,0 +1,20 @@
# Generated by Django 3.2.9 on 2022-01-24 08:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0133_port_colors'),
('netbox_slm', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='softwareproduct',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='software_products', to='dcim.manufacturer'),
),
]
@@ -0,0 +1,43 @@
# Generated by Django 3.2.9 on 2022-04-07 12:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('netbox_slm', '0002_alter_softwareproduct_manufacturer'),
]
operations = [
migrations.AlterField(
model_name='softwareproduct',
name='created',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='softwareproduct',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='created',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='softwareproductversion',
name='created',
field=models.DateTimeField(auto_now_add=True, null=True),
),
migrations.AlterField(
model_name='softwareproductversion',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 3.2.9 on 2022-04-15 07:05
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0153_created_datetimefield'),
('virtualization', '0029_created_datetimefield'),
('netbox_slm', '0003_auto_20220407_1209'),
]
operations = [
migrations.AlterField(
model_name='softwareproduct',
name='manufacturer',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.manufacturer'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.device'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='software_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproduct'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='version',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproductversion'),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='virtualmachine',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.virtualmachine'),
),
migrations.AlterField(
model_name='softwareproductversion',
name='software_product',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproduct'),
),
]
@@ -0,0 +1,54 @@
# Generated by Django 4.1.8 on 2023-06-27 08:10
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('extras', '0084_staging'),
('netbox_slm', '0004_auto_20220415_0705'),
]
operations = [
migrations.AlterField(
model_name='softwareproduct',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
migrations.AlterField(
model_name='softwareproductinstallation',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
migrations.AlterField(
model_name='softwareproductversion',
name='custom_field_data',
field=models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
),
migrations.CreateModel(
name='SoftwareLicense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
('name', models.CharField(max_length=128)),
('description', models.CharField(blank=True, max_length=255, null=True)),
('type', models.CharField(max_length=128)),
('stored_location', models.CharField(blank=True, max_length=255, null=True)),
('start_date', models.DateField(blank=True, null=True)),
('expiration_date', models.DateField(blank=True, null=True)),
('installation', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproductinstallation')),
('software_product', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproduct')),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
('version', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='netbox_slm.softwareproductversion')),
],
options={
'abstract': False,
},
),
]
@@ -0,0 +1,19 @@
# Generated by Django 4.2.5 on 2023-09-22 00:34
from django.db import migrations
import netbox_slm.models
class Migration(migrations.Migration):
dependencies = [
('netbox_slm', '0005_add_software_license'),
]
operations = [
migrations.AddField(
model_name='softwarelicense',
name='stored_location_url',
field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 4.2.5 on 2023-10-04 10:07
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0036_virtualmachine_config_template'),
('netbox_slm', '0006_softwarelicense_stored_location_url'),
]
operations = [
migrations.AddField(
model_name='softwareproductinstallation',
name='cluster',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='virtualization.cluster'),
),
migrations.AlterField(
model_name='softwarelicense',
name='installation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='netbox_slm.softwareproductinstallation'),
),
migrations.AddConstraint(
model_name='softwareproductinstallation',
constraint=models.CheckConstraint(condition=models.Q(models.Q(('cluster__isnull', True), ('device__isnull', False), ('virtualmachine__isnull', True)), models.Q(('cluster__isnull', True), ('device__isnull', True), ('virtualmachine__isnull', False)), models.Q(('cluster__isnull', False), ('device__isnull', True), ('virtualmachine__isnull', True)), _connector='OR'), name='netbox_slm_softwareproductinstallation_platform', violation_error_message='Installation requires exactly one platform destination.'),
),
]
@@ -0,0 +1,79 @@
# Generated by Django 4.2.9 on 2024-02-07 21:01
from django.db import migrations, models
import netbox_slm.models
class Migration(migrations.Migration):
dependencies = [
('netbox_slm', '0007_softwareproductinstallation_cluster'),
]
operations = [
migrations.AddField(
model_name='softwarelicense',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='softwarelicense',
name='license_amount',
field=models.PositiveIntegerField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='softwarelicense',
name='support',
field=models.BooleanField(blank=True, default=None, null=True),
),
migrations.AddField(
model_name='softwareproduct',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='softwareproductinstallation',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='comments',
field=models.TextField(blank=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='documentation_url',
field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='end_of_support',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='file_checksum',
field=models.CharField(blank=True, max_length=128, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='file_link',
field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='filename',
field=models.CharField(blank=True, max_length=64, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='release_date',
field=models.DateField(blank=True, null=True),
),
migrations.AddField(
model_name='softwareproductversion',
name='release_type',
field=models.CharField(default='S', max_length=3),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.1.5 on 2025-02-17 21:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_slm", "0008_software_release_types_and_more"),
]
operations = [
migrations.AddField(
model_name="softwareproductversion",
name="description",
field=models.CharField(blank=True, max_length=255, null=True),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1.5 on 2025-02-24 18:03
import netbox_slm.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("netbox_slm", "0009_softwareproductversion_description"),
]
operations = [
migrations.AddField(
model_name="softwarelicense",
name="spdx_expression",
field=models.CharField(
blank=True,
max_length=64,
null=True,
validators=[netbox_slm.models.validate_spdx_expression],
),
),
]
@@ -0,0 +1,34 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("tenancy", "0001_initial"),
("netbox_slm", "0010_softwarelicense_spdx_expression"),
]
operations = [
migrations.AddField(
model_name="softwarelicense",
name="tenant_group",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="software_licenses",
to="tenancy.tenantgroup",
),
),
migrations.AddField(
model_name="softwarelicense",
name="tenant",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name="software_licenses",
to="tenancy.tenant",
),
),
]
@@ -0,0 +1,51 @@
from django.db import migrations, models
import django.core.validators
import netbox_slm.models
class Migration(migrations.Migration):
dependencies = [
("netbox_slm", "0011_softwarelicense_tenancy"),
]
operations = [
migrations.AlterField(
model_name="softwarelicense",
name="type",
field=models.CharField(blank=True, max_length=128),
),
migrations.AddField(
model_name="softwarelicense",
name="lifetime",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="softwarelicense",
name="renewal_interval",
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name="softwarelicense",
name="renewal_interval_unit",
field=models.CharField(
blank=True,
choices=[("days", "Days"), ("months", "Months"), ("years", "Years")],
max_length=8,
),
),
migrations.AddField(
model_name="softwarelicense",
name="license_file",
field=models.FileField(blank=True, null=True, upload_to="netbox_slm/licenses/"),
),
migrations.AddField(
model_name="softwarelicense",
name="license_key",
field=models.TextField(blank=True),
),
migrations.AddField(
model_name="softwarelicense",
name="provider_portal_url",
field=netbox_slm.models.LaxURLField(blank=True, max_length=1024, null=True),
),
]
@@ -0,0 +1,77 @@
import django.core.validators
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("netbox_slm", "0012_softwarelicense_renewal_and_credentials"),
]
operations = [
migrations.AddField(
model_name="softwarelicense",
name="expiration_notifications_enabled",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="softwarelicense",
name="default_notification_intervals",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="softwarelicense",
name="custom_notification_interval",
field=models.PositiveIntegerField(
blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]
),
),
migrations.AddField(
model_name="softwarelicense",
name="custom_notification_interval_unit",
field=models.CharField(
blank=True,
choices=[("days", "Days"), ("weeks", "Weeks"), ("months", "Months")],
max_length=8,
),
),
migrations.AddField(
model_name="softwarelicense",
name="email_notifications",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="SoftwareLicenseNotificationLog",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("expiration_date", models.DateField()),
("notification_date", models.DateField()),
("netbox_sent_at", models.DateTimeField(blank=True, null=True)),
("email_sent_at", models.DateTimeField(blank=True, null=True)),
(
"license",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notification_logs",
to="netbox_slm.softwarelicense",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="softwarelicensenotificationlog",
constraint=models.UniqueConstraint(
fields=("license", "user", "expiration_date", "notification_date"),
name="netbox_slm_license_notification_unique",
),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("netbox_slm", "0013_license_expiration_notifications"),
]
operations = [
migrations.AlterModelOptions(
name="softwarelicense",
options={
"permissions": [
(
"manage_softwarelicense_email_notifications",
"Can manage license email notifications",
)
],
"verbose_name": "software license",
"verbose_name_plural": "software licenses",
},
),
]
+355
View File
@@ -0,0 +1,355 @@
import calendar
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.urls import reverse
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from license_expression import Licensing, get_spdx_licensing
from netbox.models import NetBoxModel
from utilities.querysets import RestrictedQuerySet
from utilities.validators import EnhancedURLValidator
spdx_licensing: Licensing = get_spdx_licensing()
class LaxURLField(models.URLField):
"""
NetBox Custom Field approach, based on utilities.forms.fields.LaxURLField
Overriding default_validators is needed, as they are always added
"""
default_validators = [EnhancedURLValidator()]
class SoftwareProduct(NetBoxModel):
name = models.CharField(_("name"), max_length=128)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey(
to="dcim.Manufacturer", verbose_name=_("manufacturer"), on_delete=models.PROTECT, null=True, blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product")
verbose_name_plural = _("software products")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproduct", kwargs={"pk": self.pk})
def get_installation_count(self):
count = SoftwareProductInstallation.objects.filter(software_product_id=self.pk).count()
search_target = reverse(
"plugins:netbox_slm:softwareproductinstallation_list", query={"software_product_id": self.pk}
)
return format_html("<a href='{}'>{}</a>", search_target, count) if count else "0"
class SoftwareReleaseTypes(models.TextChoices):
ALPHA = "A", _("Alpha")
BETA = "B", _("Beta")
RELEASE_CANDIDATE = "RC", _("Release candidate")
STABLE = "S", _("Stable release")
class RenewalIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
MONTHS = "months", _("Months")
YEARS = "years", _("Years")
class NotificationIntervalUnits(models.TextChoices):
DAYS = "days", _("Days")
WEEKS = "weeks", _("Weeks")
MONTHS = "months", _("Months")
class SoftwareProductVersion(NetBoxModel):
name = models.CharField(_("name"), max_length=64)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
release_date = models.DateField(_("release date"), null=True, blank=True)
documentation_url = LaxURLField(_("documentation URL"), max_length=1024, null=True, blank=True)
end_of_support = models.DateField(_("end of support"), null=True, blank=True)
filename = models.CharField(_("filename"), max_length=64, null=True, blank=True)
file_checksum = models.CharField(_("file checksum"), max_length=128, null=True, blank=True)
file_link = LaxURLField(_("file link"), max_length=1024, null=True, blank=True)
release_type = models.CharField(
_("release type"),
max_length=3,
choices=SoftwareReleaseTypes.choices,
default=SoftwareReleaseTypes.STABLE,
)
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct",
verbose_name=_("software product"),
on_delete=models.PROTECT,
)
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product version")
verbose_name_plural = _("software product versions")
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproductversion", kwargs={"pk": self.pk})
def get_installation_count(self):
count = SoftwareProductInstallation.objects.filter(version_id=self.pk).count()
search_target = reverse("plugins:netbox_slm:softwareproductinstallation_list", query={"version_id": self.pk})
return format_html("<a href='{}'>{}</a>", search_target, count) if count else "0"
class SoftwareProductInstallation(NetBoxModel):
comments = models.TextField(_("comments"), blank=True)
device = models.ForeignKey(
to="dcim.Device", verbose_name=_("device"), on_delete=models.PROTECT, null=True, blank=True
)
virtualmachine = models.ForeignKey(
to="virtualization.VirtualMachine",
verbose_name=_("virtual machine"),
on_delete=models.PROTECT,
null=True,
blank=True,
)
cluster = models.ForeignKey(
to="virtualization.Cluster", verbose_name=_("cluster"), on_delete=models.PROTECT, null=True, blank=True
)
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT
)
version = models.ForeignKey(
to="netbox_slm.SoftwareProductVersion", verbose_name=_("version"), on_delete=models.PROTECT
)
objects = RestrictedQuerySet.as_manager()
def __str__(self):
return f"{self.pk} ({self.platform})"
class Meta:
verbose_name = _("software product installation")
verbose_name_plural = _("software product installations")
constraints = [
models.CheckConstraint(
name="%(app_label)s_%(class)s_platform",
condition=(
models.Q(device__isnull=False, virtualmachine__isnull=True, cluster__isnull=True)
| models.Q(device__isnull=True, virtualmachine__isnull=False, cluster__isnull=True)
| models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False)
),
violation_error_message=_(
"Select exactly one platform destination: device, virtual machine, or cluster."
),
)
]
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproductinstallation", kwargs={"pk": self.pk})
@property
def platform(self):
return self.device or self.virtualmachine or self.cluster
def render_type(self):
if self.device:
return "device"
if self.virtualmachine:
return "virtualmachine"
return "cluster"
def spdx_license_names():
names = [(item[0], item[0]) for item in spdx_licensing.known_symbols.items() if not item[1].is_exception]
names.sort()
names.insert(0, ("", "---------")) # set default value
return names
def validate_spdx_expression(value):
expression_info = spdx_licensing.validate(value)
if expression_info.errors:
raise ValidationError(_("%(value)s is not a known SPDX license expression") % {"value": value})
class SoftwareLicense(NetBoxModel):
name = models.CharField(_("name"), max_length=128)
comments = models.TextField(_("comments"), blank=True)
description = models.CharField(_("description"), max_length=255, null=True, blank=True)
type = models.CharField(_("type"), max_length=128, blank=True)
spdx_expression = models.CharField(
_("SPDX expression"), max_length=64, null=True, blank=True, validators=[validate_spdx_expression]
)
stored_location = models.CharField(_("stored location"), max_length=255, null=True, blank=True)
stored_location_url = LaxURLField(_("stored location URL"), max_length=1024, null=True, blank=True)
start_date = models.DateField(_("start date"), null=True, blank=True)
expiration_date = models.DateField(_("expiration date"), null=True, blank=True)
support = models.BooleanField(_("support"), default=None, null=True, blank=True)
license_amount = models.PositiveIntegerField(_("license amount"), default=None, null=True, blank=True)
lifetime = models.BooleanField(_("lifetime license"), default=False)
renewal_interval = models.PositiveIntegerField(
_("renewal interval"), null=True, blank=True, validators=[MinValueValidator(1)]
)
renewal_interval_unit = models.CharField(
_("renewal interval unit"), max_length=8, choices=RenewalIntervalUnits.choices, blank=True
)
license_file = models.FileField(_("license file"), upload_to="netbox_slm/licenses/", null=True, blank=True)
license_key = models.TextField(_("license key"), blank=True)
provider_portal_url = LaxURLField(_("provider portal URL"), max_length=1024, null=True, blank=True)
expiration_notifications_enabled = models.BooleanField(_("expiration notifications"), default=True)
default_notification_intervals = models.BooleanField(_("default notification intervals"), default=True)
custom_notification_interval = models.PositiveIntegerField(
_("custom notification interval"), null=True, blank=True, validators=[MinValueValidator(1)]
)
custom_notification_interval_unit = models.CharField(
_("custom notification interval unit"),
max_length=8,
choices=NotificationIntervalUnits.choices,
blank=True,
)
email_notifications = models.BooleanField(_("email notifications"), default=False)
software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", verbose_name=_("software product"), on_delete=models.PROTECT
)
version = models.ForeignKey(
to="netbox_slm.SoftwareProductVersion",
verbose_name=_("version"),
on_delete=models.PROTECT,
null=True,
blank=True,
)
installation = models.ForeignKey(
to="netbox_slm.SoftwareProductInstallation",
verbose_name=_("installation"),
on_delete=models.SET_NULL,
null=True,
blank=True,
)
tenant_group = models.ForeignKey(
to="tenancy.TenantGroup",
verbose_name=_("tenant group"),
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="software_licenses",
)
tenant = models.ForeignKey(
to="tenancy.Tenant",
verbose_name=_("tenant"),
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="software_licenses",
)
objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software license")
verbose_name_plural = _("software licenses")
permissions = [
("manage_softwarelicense_email_notifications", _("Can manage license email notifications")),
]
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwarelicense", kwargs={"pk": self.pk})
def clean(self):
super().clean()
if self.tenant_group and self.tenant and self.tenant.group_id != self.tenant_group_id:
raise ValidationError(
{"tenant": _("The selected tenant does not belong to the selected tenant group.")}
)
if bool(self.renewal_interval) != bool(self.renewal_interval_unit):
raise ValidationError(
{"renewal_interval": _("Renewal interval and unit must be specified together.")}
)
if self.lifetime and (self.renewal_interval or self.renewal_interval_unit or self.expiration_date):
raise ValidationError(
{"lifetime": _("A lifetime license cannot have a renewal interval or expiration date.")}
)
if bool(self.custom_notification_interval) != bool(self.custom_notification_interval_unit):
raise ValidationError(
{
"custom_notification_interval": _(
"Custom notification interval and unit must be specified together."
)
}
)
@staticmethod
def _subtract_months(value, months):
month_index = value.year * 12 + value.month - 1 - months
year, zero_based_month = divmod(month_index, 12)
month = zero_based_month + 1
day = min(value.day, calendar.monthrange(year, month)[1])
return value.replace(year=year, month=month, day=day)
def get_notification_dates(self):
"""Return unique reminder dates for the current expiration date."""
if not self.expiration_notifications_enabled or self.lifetime or not self.expiration_date:
return []
dates = set()
if self.default_notification_intervals:
dates.add(self._subtract_months(self.expiration_date, 1))
dates.add(self.expiration_date - timedelta(weeks=1))
if self.custom_notification_interval and self.custom_notification_interval_unit:
amount = self.custom_notification_interval
if self.custom_notification_interval_unit == NotificationIntervalUnits.MONTHS:
dates.add(self._subtract_months(self.expiration_date, amount))
elif self.custom_notification_interval_unit == NotificationIntervalUnits.WEEKS:
dates.add(self.expiration_date - timedelta(weeks=amount))
else:
dates.add(self.expiration_date - timedelta(days=amount))
return sorted(dates)
@property
def stored_location_txt(self):
if self.stored_location_url and not self.stored_location:
return _("Link")
return self.stored_location
class SoftwareLicenseNotificationLog(models.Model):
license = models.ForeignKey(
to="netbox_slm.SoftwareLicense", on_delete=models.CASCADE, related_name="notification_logs"
)
user = models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
expiration_date = models.DateField()
notification_date = models.DateField()
netbox_sent_at = models.DateTimeField(null=True, blank=True)
email_sent_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=("license", "user", "expiration_date", "notification_date"),
name="netbox_slm_license_notification_unique",
)
]
+92
View File
@@ -0,0 +1,92 @@
from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config
from django.utils.translation import gettext_lazy as _
from . import SLMConfig
slm_items = (
PluginMenuItem(
link="plugins:netbox_slm:softwareproduct_list",
link_text=_("Software Products"),
permissions=["netbox_slm.add_softwareproduct"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproduct"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_bulk_import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproduct"],
),
),
),
PluginMenuItem(
link="plugins:netbox_slm:softwareproductversion_list",
link_text=_("Versions"),
permissions=["netbox_slm.add_softwareproductversion"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductversion"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_bulk_import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductversion"],
),
),
),
PluginMenuItem(
link="plugins:netbox_slm:softwareproductinstallation_list",
link_text=_("Installations"),
permissions=["netbox_slm.add_softwareproductinstallation"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductinstallation"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_bulk_import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductinstallation"],
),
),
),
PluginMenuItem(
link="plugins:netbox_slm:softwarelicense_list",
link_text=_("Licenses"),
permissions=["netbox_slm.add_softwarelicense"],
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwarelicense_add",
_("Add"),
"mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwarelicense"],
),
PluginMenuButton(
"plugins:netbox_slm:softwarelicense_bulk_import",
_("Import"),
"mdi mdi-upload",
permissions=["netbox_slm.import_softwarelicense"],
),
),
),
)
if get_plugin_config("netbox_slm", "top_level_menu"):
menu = PluginMenu(
label=_("Licences"),
groups=((SLMConfig.verbose_name, slm_items),),
icon_class="mdi mdi-content-save",
)
else:
# auto imported by default PluginConfig.menu_items = navigation.menu_items
menu_items = slm_items
+197
View File
@@ -0,0 +1,197 @@
import django_tables2 as tables
from django.db.models import Count, F, Value
from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, ToggleColumn, columns
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
class SoftwareProductTable(NetBoxTable):
"""Table for displaying SoftwareProduct objects."""
pk = ToggleColumn()
name = tables.LinkColumn()
manufacturer = tables.Column(accessor="manufacturer", linkify=True)
installations = tables.Column(accessor="get_installation_count")
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproduct_list")
class Meta(NetBoxTable.Meta):
model = SoftwareProduct
fields = (
"pk",
"name",
"description",
"manufacturer",
"installations",
"tags",
)
default_columns = (
"pk",
"name",
"manufacturer",
"installations",
"tags",
)
def order_installations(self, queryset, is_descending):
queryset = queryset.annotate(count=Count("softwareproductinstallation__id")).order_by(
("-" if is_descending else "") + "count"
)
return queryset, True
class SoftwareProductVersionTable(NetBoxTable):
"""Table for displaying SoftwareProductVersion objects."""
pk = ToggleColumn()
name = tables.LinkColumn()
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
installations = tables.Column(accessor="get_installation_count")
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductversion_list")
class Meta(NetBoxTable.Meta):
model = SoftwareProductVersion
fields = (
"pk",
"name",
"description",
"software_product",
"manufacturer",
"release_date",
"end_of_support",
"release_type",
"installations",
"tags",
)
default_columns = (
"pk",
"name",
"manufacturer",
"software_product",
"release_date",
"installations",
"tags",
)
def order_installations(self, queryset, is_descending):
queryset = queryset.annotate(count=Count("softwareproductinstallation__id")).order_by(
("-" if is_descending else "") + "count"
)
return queryset, True
class SoftwareProductInstallationTable(NetBoxTable):
"""Table for displaying SoftwareProductInstallation objects."""
pk = ToggleColumn()
platform = tables.Column(accessor="platform", linkify=True)
type = tables.Column(accessor="render_type")
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
version = tables.Column(accessor="version", linkify=True)
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list")
class Meta(NetBoxTable.Meta):
model = SoftwareProductInstallation
fields = (
"pk",
"platform",
"type",
"manufacturer",
"software_product",
"version",
"tags",
)
default_columns = (
"pk",
"platform",
"type",
"manufacturer",
"software_product",
"version",
"tags",
)
def order_platform(self, queryset, is_descending):
device_annotate = queryset.filter(device__isnull=False).annotate(platform_value=F("device__name"))
vm_annotate = queryset.filter(virtualmachine__isnull=False).annotate(platform_value=F("virtualmachine__name"))
cluster_annotate = queryset.filter(cluster__isnull=False).annotate(platform_value=F("cluster__name"))
queryset_union = device_annotate.union(vm_annotate).union(cluster_annotate)
return queryset_union.order_by(f"{'-' if is_descending else ''}platform_value"), True
def order_type(self, queryset, is_descending):
device_annotate = queryset.filter(device__isnull=False).annotate(render_type=Value("device"))
vm_annotate = queryset.filter(virtualmachine__isnull=False).annotate(render_type=Value("virtualmachine"))
cluster_annotate = queryset.filter(cluster__isnull=False).annotate(render_type=Value("cluster"))
queryset_union = device_annotate.union(vm_annotate).union(cluster_annotate)
return queryset_union.order_by(f"{'-' if is_descending else ''}render_type"), True
class SoftwareLicenseTable(NetBoxTable):
"""Table for displaying SoftwareLicense objects."""
pk = ToggleColumn()
name = tables.LinkColumn()
type = tables.Column()
spdx_expression = tables.Column(verbose_name=_("SPDX expression"))
stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
version = tables.Column(accessor="version", linkify=True)
installation = tables.Column(accessor="installation", linkify=True)
tenant_group = tables.Column(accessor="tenant_group", verbose_name=_("Tenant Group"), linkify=True)
tenant = tables.Column(accessor="tenant", linkify=True)
provider_portal_url = tables.Column(verbose_name=_("Provider Portal"), linkify=True)
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list")
class Meta(NetBoxTable.Meta):
model = SoftwareLicense
fields = (
"pk",
"name",
"description",
"type",
"spdx_expression",
"stored_location",
"start_date",
"expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"manufacturer",
"software_product",
"version",
"installation",
"tenant_group",
"tenant",
"support",
"license_amount",
"provider_portal_url",
"expiration_notifications_enabled",
"email_notifications",
"tags",
)
default_columns = (
"pk",
"name",
"type",
"manufacturer",
"software_product",
"installation",
"tenant_group",
"tenant",
"expiration_date",
"lifetime",
"expiration_notifications_enabled",
"tags",
)
def render_installation(self, **kwargs):
return f"{kwargs['record'].installation.platform}"
+36
View File
@@ -0,0 +1,36 @@
from netbox.plugins import PluginTemplateExtension, get_plugin_config
installations = dict(
device=get_plugin_config("netbox_slm", "link_device_installations"),
cluster=get_plugin_config("netbox_slm", "link_cluster_installations"),
virtualmachine=get_plugin_config("netbox_slm", "link_virtualmachine_installations"),
)
class InstallationsCard(PluginTemplateExtension):
models = ["dcim.device", "virtualization.cluster", "virtualization.virtualmachine"]
def get_object_name(self):
object_model_meta = self.context["object"]._meta # one of the self.models defined above
return object_model_meta.model_name
def render_card(self):
return self.render(
"netbox_slm/installations_card_include.html",
extra_context={
"search_obj": self.get_object_name(),
},
)
def left_page(self):
return self.render_card() if installations[self.get_object_name()] == "left" else ""
def right_page(self):
return self.render_card() if installations[self.get_object_name()] == "right" else ""
def full_width_page(self):
return self.render_card() if installations[self.get_object_name()] == "full" else ""
# auto imported by default PluginConfig.template_extensions = template_content.template_extensions
template_extensions = [InstallationsCard]
@@ -0,0 +1,22 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">
{% trans "Installations" %}
{% if perms.netbox_slm.add_softwareproductinstallation %}
<div class="card-actions">
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation_add' %}?{{ search_obj }}={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an installation" %}
</a>
</div>
{% endif %}
</h2>
{% if search_obj == "cluster" %}
{% htmx_table "plugins:netbox_slm:softwareproductinstallation_list" cluster_id=object.pk %}
{% elif search_obj == "device" %}
{% htmx_table "plugins:netbox_slm:softwareproductinstallation_list" device_id=object.pk %}
{% elif search_obj == "virtualmachine" %}
{% htmx_table "plugins:netbox_slm:softwareproductinstallation_list" virtualmachine_id=object.pk %}
{% endif %}
</div>
@@ -0,0 +1,165 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
{% trans "Software License" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.type }}</td>
</tr>
<tr>
<th scope="row">{% trans "SPDX expression" %}</th>
<td>{{ object.spdx_expression }}</td>
</tr>
<tr>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Installation" %}</th>
<td>{{ object.installation|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant Group" %}</th>
<td>{{ object.tenant_group|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Stored location" %}</th>
{% if object.stored_location_url %}
<td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td>
{% else %}
<td>{{ object.stored_location_txt }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "Start date" %}</th>
<td>{{ object.start_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Expiration date" %}</th>
<td>{{ object.expiration_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Lifetime license" %}</th>
<td>{% if object.lifetime %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Renewal interval" %}</th>
<td>
{% if object.renewal_interval %}
{{ object.renewal_interval }} {{ object.get_renewal_interval_unit_display }}
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Support" %}</th>
<td>{{ object.support }}</td>
</tr>
<tr>
<th scope="row">{% trans "License amount" %}</th>
<td>{{ object.license_amount }}</td>
</tr>
<tr>
<th scope="row">{% trans "License file" %}</th>
<td>
{% if object.license_file %}
<a href="{{ object.license_file.url }}">{% trans "Download license file" %}</a>
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "License key" %}</th>
<td>{% if object.license_key %}<code>{{ object.license_key }}</code>{% else %}{{ None|placeholder }}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Provider Portal" %}</th>
<td>
{% if object.provider_portal_url %}
<a href="{{ object.provider_portal_url }}">{{ object.provider_portal_url }}</a>
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Expiration notifications" %}</th>
<td>{% if object.expiration_notifications_enabled %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Default notification intervals" %}</th>
<td>
{% if object.default_notification_intervals %}
{% trans "One month and one week before expiration" %}
{% else %}
{% trans "Disabled" %}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Custom notification interval" %}</th>
<td>
{% if object.custom_notification_interval %}
{{ object.custom_notification_interval }} {{ object.get_custom_notification_interval_unit_display }}
{% else %}
{{ None|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Email notifications" %}</th>
<td>{% if object.email_notifications %}{% trans "Enabled" %}{% else %}{% trans "Disabled" %}{% endif %}</td>
</tr>
<tr>
<th scope="row">{% trans "Notification recipients" %}</th>
<td>
{% for subscription in object.subscriptions.all %}
{{ subscription.user|linkify }}{% if not forloop.last %}, {% endif %}
{% empty %}
{% trans "No subscribers. Users can subscribe to this license using the Subscribe action." %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,77 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
{% trans "Software Product" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Versions" %}</th>
<td>
{% for version in object.softwareproductversion_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm">
{{ version }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Installations" %}</th>
<td>
{% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,65 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
{% trans "Software Product Installation" %}
</h5>
<table class="table table-hover attr-table">
{% if object.device %}
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
{% elif object.virtualmachine %}
<tr>
<th scope="row">{% trans "Virtual Machine" %}</th>
<td>{{ object.virtualmachine|linkify }}</td>
</tr>
{% else %}
<tr>
<th scope="row">{% trans "Cluster" %}</th>
<td>{{ object.cluster|linkify }}</td>
</tr>
{% endif %}
<tr>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,101 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row my-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
{% trans "Software Product Version" %}
</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td>
</tr>
<tr>
<th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.software_product.manufacturer|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Release date" %}</th>
<td>{{ object.release_date }}</td>
</tr>
<tr>
<th scope="row">{% trans "Documentation URL" %}</th>
{% if object.documentation_url %}
<td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td>
{% else %}
<td>{{ object.documentation_url }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "End of support" %}</th>
<td>{{ object.end_of_support }}</td>
</tr>
<tr>
<th scope="row">{% trans "Filename" %}</th>
{% if object.file_link %}
<td><a href="{{ object.file_link }}">{{ object.filename }}</a></td>
{% else %}
<td>{{ object.filename }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "File checksum" %}</th>
<td>{{ object.file_checksum }}</td>
</tr>
<tr>
<th scope="row">{% trans "Release type" %}</th>
<td>{{ object.get_release_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Installations" %}</th>
<td>
{% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Licenses" %}</th>
<td>
{% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }}
</a>
{% empty %}
{% trans "None" %}
{% endfor %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterType, VirtualMachine
class SlmBaseTestCase(TestCase):
p_name: str = "test product"
v_name: str = "test version"
l_name: str = "test license"
test_url: str = "https://github.com/ICTU/netbox_slm"
vm: VirtualMachine
software_product: SoftwareProduct
software_product_version: SoftwareProductVersion
software_product_installation: SoftwareProductInstallation
@classmethod
def setUpClass(cls):
super().setUpClass()
manufacturer = Manufacturer.objects.create(name="test manufacturer")
device_type = DeviceType.objects.create(model="test device type", manufacturer=manufacturer)
device_role = DeviceRole.objects.create(name="test device role")
site = Site.objects.create(name="test site")
cls.device = Device.objects.create(name="test device", device_type=device_type, role=device_role, site=site)
cls.vm = VirtualMachine.objects.create(name="test VM")
cluster_type = ClusterType.objects.create(name="test cluster type")
cls.cluster = Cluster.objects.create(name="test cluster", type=cluster_type)
cls.tenant_group = TenantGroup.objects.create(name="test tenant group", slug="test-tenant-group")
cls.tenant = Tenant.objects.create(name="test tenant", slug="test-tenant", group=cls.tenant_group)
cls.software_product = SoftwareProduct.objects.create(name=cls.p_name)
cls.software_product_version = SoftwareProductVersion.objects.create(
name=cls.v_name, software_product=cls.software_product
)
cls.software_product_installation = SoftwareProductInstallation.objects.create(
virtualmachine=cls.vm, software_product=cls.software_product, version=cls.software_product_version
)
cls.software_license = SoftwareLicense.objects.create(
name=cls.l_name,
software_product=cls.software_product,
version=cls.software_product_version,
installation=cls.software_product_installation,
stored_location_url=cls.test_url,
tenant_group=cls.tenant_group,
tenant=cls.tenant,
)
@classmethod
def tearDownClass(cls):
SoftwareLicense.objects.all().delete()
SoftwareProductInstallation.objects.all().delete()
SoftwareProductVersion.objects.all().delete()
SoftwareProduct.objects.all().delete()
Tenant.objects.all().delete()
TenantGroup.objects.all().delete()
Cluster.objects.all().delete()
ClusterType.objects.all().delete()
VirtualMachine.objects.all().delete()
Device.objects.all().delete()
Site.objects.all().delete()
DeviceRole.objects.all().delete()
DeviceType.objects.all().delete()
Manufacturer.objects.all().delete()
super().tearDownClass()
+51
View File
@@ -0,0 +1,51 @@
from unittest.mock import patch
from django.contrib.auth import get_user_model
from .base import SlmBaseTestCase
class FunctionalTestCase(SlmBaseTestCase):
"""Functional test cases that require a client and user with permissions"""
def setUp(self):
test_user, _ = get_user_model().objects.get_or_create(username="test", is_superuser=True)
self.client.force_login(test_user)
@classmethod
def tearDownClass(cls):
get_user_model().objects.all().delete()
super().tearDownClass()
def test_template_content(self):
cluster_response = self.client.get(f"/virtualization/clusters/{self.cluster.pk}/")
self.assertContains(cluster_response, f'<a href="/plugins/slm/installations/add/?cluster={self.cluster.pk}"')
self.assertContains(cluster_response, "</span> Add an installation")
device_response = self.client.get(f"/dcim/devices/{self.device.pk}/")
self.assertContains(device_response, f'<a href="/plugins/slm/installations/add/?device={self.device.pk}"')
self.assertContains(device_response, "</span> Add an installation")
vm_response = self.client.get(f"/virtualization/virtual-machines/{self.vm.pk}/")
self.assertTemplateUsed(vm_response, "netbox_slm/installations_card_include.html")
self.assertContains(vm_response, f'<a href="/plugins/slm/installations/add/?virtualmachine={self.vm.pk}"')
def test_setting_skip_content(self):
# self.settings(PLUGINS_CONFIG=dict(netbox_slm=dict(link_virtualmachine_installations=None)))
with patch.dict("netbox_slm.template_content.installations", {"virtualmachine": None}):
vm_response = self.client.get(f"/virtualization/virtual-machines/{self.vm.pk}/")
self.assertTemplateNotUsed(vm_response, "netbox_slm/installations_card_include.html")
def test_setting_left_content(self):
# self.settings(PLUGINS_CONFIG=dict(netbox_slm=dict(link_device_installations="left")))
with patch.dict("netbox_slm.template_content.installations", {"device": "left"}):
device_response = self.client.get(f"/dcim/devices/{self.device.pk}/")
self.assertContains(device_response, f'<a href="/plugins/slm/installations/add/?device={self.device.pk}"')
self.assertContains(device_response, "</span> Add an installation")
def test_setting_full_content(self):
# self.settings(PLUGINS_CONFIG=dict(netbox_slm=dict(link_cluster_installations="full")))
with patch.dict("netbox_slm.template_content.installations", {"cluster": "full"}):
cluster_response = self.client.get(f"/virtualization/clusters/{self.cluster.pk}/")
self.assertContains(cluster_response, f'<a href="/plugins/slm/installations/add/?cluster={self.cluster.pk}"')
self.assertContains(cluster_response, "</span> Add an installation")
+210
View File
@@ -0,0 +1,210 @@
from datetime import date, timedelta
from unittest.mock import Mock, patch
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.test import override_settings
from django.utils import timezone
from django.utils.translation import gettext, override
from extras.models import Notification, Subscription
from netbox_slm.forms import SoftwareLicenseForm, SoftwareProductInstallationForm
from netbox_slm.jobs import LicenseExpirationNotificationJob
from netbox_slm.models import (
NotificationIntervalUnits,
RenewalIntervalUnits,
SoftwareLicense,
SoftwareLicenseNotificationLog,
)
from tenancy.models import Tenant, TenantGroup
from .base import SlmBaseTestCase
class ModelTestCase(SlmBaseTestCase):
"""Test basic model functionality and custom overrides"""
def test_model_name(self):
self.assertEqual(self.p_name, str(self.software_product))
self.assertEqual(self.v_name, str(self.software_product_version))
self.assertTrue(str(self.software_product_installation)[0].isdigit()) # starts with PK
self.assertEqual(self.l_name, str(self.software_license))
def test_absolute_url(self):
self.assertEqual(
f"/plugins/slm/software-products/{self.software_product.pk}/", self.software_product.get_absolute_url()
)
self.assertEqual(
f"/plugins/slm/versions/{self.software_product_version.pk}/",
self.software_product_version.get_absolute_url(),
)
self.assertEqual(
f"/plugins/slm/installations/{self.software_product_installation.pk}/",
self.software_product_installation.get_absolute_url(),
)
self.assertEqual(f"/plugins/slm/licenses/{self.software_license.pk}/", self.software_license.get_absolute_url())
def test_get_installation_count(self):
self.assertEqual(
f"<a href='/plugins/slm/installations/?software_product_id={self.software_product.pk}'>1</a>",
self.software_product.get_installation_count(),
)
self.assertEqual(
f"<a href='/plugins/slm/installations/?version_id={self.software_product_version.pk}'>1</a>",
self.software_product_version.get_installation_count(),
)
def test_product_installation_methods(self):
self.assertEqual("virtualmachine", self.software_product_installation.render_type())
self.assertEqual(self.vm, self.software_product_installation.platform)
self.software_product_installation.virtualmachine = None
self.software_product_installation.device = self.device
self.software_product_installation.save()
self.assertEqual("device", self.software_product_installation.render_type())
self.assertEqual(self.device, self.software_product_installation.platform)
self.software_product_installation.device = None
self.software_product_installation.cluster = self.cluster
self.software_product_installation.save()
self.assertEqual("cluster", self.software_product_installation.render_type())
self.assertEqual(self.cluster, self.software_product_installation.platform)
def test_installation_form_requires_exactly_one_platform_destination(self):
base_data = {
"software_product": self.software_product.pk,
"version": self.software_product_version.pk,
}
no_destination_form = SoftwareProductInstallationForm(data=base_data)
self.assertFalse(no_destination_form.is_valid())
self.assertIn("Select exactly one platform destination", str(no_destination_form.non_field_errors()))
one_destination_form = SoftwareProductInstallationForm(data={**base_data, "device": self.device.pk})
self.assertTrue(one_destination_form.is_valid(), one_destination_form.errors)
multiple_destinations_form = SoftwareProductInstallationForm(
data={**base_data, "device": self.device.pk, "cluster": self.cluster.pk}
)
self.assertFalse(multiple_destinations_form.is_valid())
self.assertIn("Select exactly one platform destination", str(multiple_destinations_form.non_field_errors()))
def test_software_license_stored_location_txt(self):
self.assertEqual("Link", self.software_license.stored_location_txt)
self.software_license.stored_location = "GitHub"
self.software_license.save()
self.assertEqual("GitHub", self.software_license.stored_location_txt)
def test_software_license_tenancy(self):
self.assertEqual(self.tenant_group, self.software_license.tenant_group)
self.assertEqual(self.tenant, self.software_license.tenant)
def test_software_license_form_renders_tenancy_fields(self):
form = SoftwareLicenseForm(instance=self.software_license)
self.assertIn("tenant_group", form.fields)
self.assertIn("tenant", form.fields)
self.assertTrue(
any("tenant_group" in fieldset.items and "tenant" in fieldset.items for fieldset in form.fieldsets)
)
def test_software_license_form_contains_renewal_and_credentials(self):
form = SoftwareLicenseForm(instance=self.software_license)
for field_name in (
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"license_file",
"license_key",
"provider_portal_url",
):
self.assertIn(field_name, form.fields)
def test_software_license_accepts_optional_type_and_renewal_interval(self):
license = SoftwareLicense(
name="renewable license",
software_product=self.software_product,
renewal_interval=12,
renewal_interval_unit=RenewalIntervalUnits.MONTHS,
)
license.full_clean()
def test_software_license_requires_renewal_interval_and_unit_together(self):
license = SoftwareLicense(
name="invalid renewable license",
software_product=self.software_product,
renewal_interval=12,
)
with self.assertRaisesMessage(ValidationError, "must be specified together"):
license.full_clean()
def test_lifetime_license_rejects_expiration(self):
license = SoftwareLicense(
name="invalid lifetime license",
software_product=self.software_product,
lifetime=True,
expiration_date="2030-01-01",
)
with self.assertRaisesMessage(ValidationError, "cannot have a renewal interval or expiration date"):
license.full_clean()
def test_license_notification_dates(self):
license = SoftwareLicense(
name="expiring license",
software_product=self.software_product,
expiration_date=date(2027, 3, 31),
custom_notification_interval=6,
custom_notification_interval_unit=NotificationIntervalUnits.MONTHS,
)
self.assertEqual(
[date(2026, 9, 30), date(2027, 2, 28), date(2027, 3, 24)],
license.get_notification_dates(),
)
@override_settings(SERVER_EMAIL="netbox@example.com", EMAIL_HOST_USER="smtp-user")
@patch("netbox_slm.jobs.send_mail")
def test_expiration_job_notifies_subscribers_on_every_run(self, send_mail):
user = get_user_model().objects.create_user(
username="license-recipient",
email="recipient@example.com",
)
license = SoftwareLicense.objects.create(
name="expiring subscribed license",
software_product=self.software_product,
expiration_date=timezone.localdate() + timedelta(days=1),
default_notification_intervals=False,
custom_notification_interval=1,
custom_notification_interval_unit=NotificationIntervalUnits.DAYS,
email_notifications=True,
)
Subscription.objects.create(
user=user,
object_type=ContentType.objects.get_for_model(license),
object_id=license.pk,
)
runner = LicenseExpirationNotificationJob.__new__(LicenseExpirationNotificationJob)
runner.logger = Mock()
runner.run()
runner.run()
notification = Notification.objects.get(user=user, object_id=license.pk)
self.assertIsInstance(str(notification.event), str)
self.assertEqual(1, SoftwareLicenseNotificationLog.objects.filter(user=user, license=license).count())
self.assertEqual(2, send_mail.call_count)
self.assertEqual("netbox@example.com", send_mail.call_args.args[2])
def test_german_translation(self):
with override("de"):
self.assertEqual("Softwarelizenz", gettext("Software License"))
self.assertEqual("Mandantengruppe", gettext("Tenant Group"))
self.assertEqual("Lizenzen", gettext("Licenses"))
def test_software_license_rejects_tenant_from_other_group(self):
other_group = TenantGroup.objects.create(name="other group", slug="other-group")
other_tenant = Tenant.objects.create(name="other tenant", slug="other-tenant", group=other_group)
self.software_license.tenant = other_tenant
with self.assertRaisesMessage(ValidationError, "does not belong to the selected tenant group"):
self.software_license.full_clean()
+15
View File
@@ -0,0 +1,15 @@
from django.urls import path, include
from netbox_slm import views # noqa F401
from utilities.urls import get_model_urls
urlpatterns = [
path("software-products/", include(get_model_urls("netbox_slm", "softwareproduct", detail=False))),
path("software-products/<int:pk>/", include(get_model_urls("netbox_slm", "softwareproduct"))),
path("versions/", include(get_model_urls("netbox_slm", "softwareproductversion", detail=False))),
path("versions/<int:pk>/", include(get_model_urls("netbox_slm", "softwareproductversion"))),
path("installations/", include(get_model_urls("netbox_slm", "softwareproductinstallation", detail=False))),
path("installations/<int:pk>/", include(get_model_urls("netbox_slm", "softwareproductinstallation"))),
path("licenses/", include(get_model_urls("netbox_slm", "softwarelicense", detail=False))),
path("licenses/<int:pk>/", include(get_model_urls("netbox_slm", "softwarelicense"))),
]
+32
View File
@@ -0,0 +1,32 @@
from .software_license import (
SoftwareLicenseListView,
SoftwareLicenseView,
SoftwareLicenseEditView,
SoftwareLicenseDeleteView,
SoftwareLicenseBulkDeleteView,
SoftwareLicenseBulkImportView,
)
from .software_product import (
SoftwareProductListView,
SoftwareProductView,
SoftwareProductEditView,
SoftwareProductDeleteView,
SoftwareProductBulkDeleteView,
SoftwareProductBulkImportView,
)
from .software_product_installation import (
SoftwareProductInstallationListView,
SoftwareProductInstallationView,
SoftwareProductInstallationEditView,
SoftwareProductInstallationDeleteView,
SoftwareProductInstallationBulkDeleteView,
SoftwareProductInstallationBulkImportView,
)
from .software_product_version import (
SoftwareProductVersionListView,
SoftwareProductVersionView,
SoftwareProductVersionEditView,
SoftwareProductVersionDeleteView,
SoftwareProductVersionBulkDeleteView,
SoftwareProductVersionBulkImportView,
)
+75
View File
@@ -0,0 +1,75 @@
from netbox.views import generic
from django.utils.translation import gettext_lazy as _
from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareLicense
from utilities.views import register_model_view
class EmailNotificationPermissionMixin:
"""Prevent users without the dedicated permission from changing SMTP delivery."""
email_permission = "netbox_slm.manage_softwarelicense_email_notifications"
def dispatch(self, request, *args, **kwargs):
form_attribute = "model_form" if hasattr(self, "model_form") else "form"
base_form = getattr(self, form_attribute)
can_manage_email = request.user.has_perm(self.email_permission)
class PermissionAwareForm(base_form):
def __init__(self, *form_args, **form_kwargs):
super().__init__(*form_args, **form_kwargs)
if not can_manage_email and "email_notifications" in self.fields:
field = self.fields["email_notifications"]
field.disabled = True
field.help_text = _(
"Only administrators or explicitly authorized users can change this setting."
)
setattr(self, form_attribute, PermissionAwareForm)
return super().dispatch(request, *args, **kwargs)
@register_model_view(SoftwareLicense, "list", path="", detail=False)
class SoftwareLicenseListView(generic.ObjectListView):
queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet
filterset_form = forms.SoftwareLicenseFilterForm
table = tables.SoftwareLicenseTable
@register_model_view(SoftwareLicense)
class SoftwareLicenseView(generic.ObjectView):
queryset = SoftwareLicense.objects.all()
@register_model_view(SoftwareLicense, "add", detail=False)
@register_model_view(SoftwareLicense, "edit")
class SoftwareLicenseEditView(EmailNotificationPermissionMixin, generic.ObjectEditView):
queryset = SoftwareLicense.objects.all()
form = forms.SoftwareLicenseForm
@register_model_view(SoftwareLicense, "delete")
class SoftwareLicenseDeleteView(generic.ObjectDeleteView):
queryset = SoftwareLicense.objects.all()
@register_model_view(SoftwareLicense, "bulk_import", detail=False)
class SoftwareLicenseBulkImportView(EmailNotificationPermissionMixin, generic.BulkImportView):
queryset = SoftwareLicense.objects.all()
model_form = forms.SoftwareLicenseBulkImportForm
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
class SoftwareLicenseBulkEditView(EmailNotificationPermissionMixin, generic.BulkEditView):
queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet
table = tables.SoftwareLicenseTable
form = forms.SoftwareLicenseBulkEditForm
@register_model_view(SoftwareLicense, "bulk_delete", path="delete", detail=False)
class SoftwareLicenseBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet
table = tables.SoftwareLicenseTable
+54
View File
@@ -0,0 +1,54 @@
from netbox.views import generic
from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareProduct
from utilities.views import register_model_view
@register_model_view(SoftwareProduct, "list", path="", detail=False)
class SoftwareProductListView(generic.ObjectListView):
queryset = SoftwareProduct.objects.all()
filterset = filtersets.SoftwareProductFilterSet
filterset_form = forms.SoftwareProductFilterForm
table = tables.SoftwareProductTable
@register_model_view(SoftwareProduct)
class SoftwareProductView(generic.ObjectView):
queryset = SoftwareProduct.objects.all()
def get_extra_context(self, request, instance):
versions = instance.softwareproductversion_set.all()
return {"versions": versions}
@register_model_view(SoftwareProduct, "add", detail=False)
@register_model_view(SoftwareProduct, "edit")
class SoftwareProductEditView(generic.ObjectEditView):
queryset = SoftwareProduct.objects.all()
form = forms.SoftwareProductForm
@register_model_view(SoftwareProduct, "delete")
class SoftwareProductDeleteView(generic.ObjectDeleteView):
queryset = SoftwareProduct.objects.all()
@register_model_view(SoftwareProduct, "bulk_import", detail=False)
class SoftwareProductBulkImportView(generic.BulkImportView):
queryset = SoftwareProduct.objects.all()
model_form = forms.SoftwareProductBulkImportForm
@register_model_view(SoftwareProduct, "bulk_edit", path="edit", detail=False)
class SoftwareProductBulkEditView(generic.BulkEditView):
queryset = SoftwareProduct.objects.all()
filterset = filtersets.SoftwareProductFilterSet
table = tables.SoftwareProductTable
form = forms.SoftwareProductBulkEditForm
@register_model_view(SoftwareProduct, "bulk_delete", path="delete", detail=False)
class SoftwareProductBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProduct.objects.all()
filterset = filtersets.SoftwareProductFilterSet
table = tables.SoftwareProductTable
@@ -0,0 +1,50 @@
from netbox.views import generic
from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareProductInstallation
from utilities.views import register_model_view
@register_model_view(SoftwareProductInstallation, "list", path="", detail=False)
class SoftwareProductInstallationListView(generic.ObjectListView):
queryset = SoftwareProductInstallation.objects.all()
filterset = filtersets.SoftwareProductInstallationFilterSet
filterset_form = forms.SoftwareProductInstallationFilterForm
table = tables.SoftwareProductInstallationTable
@register_model_view(SoftwareProductInstallation)
class SoftwareProductInstallationView(generic.ObjectView):
queryset = SoftwareProductInstallation.objects.all()
@register_model_view(SoftwareProductInstallation, "add", detail=False)
@register_model_view(SoftwareProductInstallation, "edit")
class SoftwareProductInstallationEditView(generic.ObjectEditView):
queryset = SoftwareProductInstallation.objects.all()
form = forms.SoftwareProductInstallationForm
@register_model_view(SoftwareProductInstallation, "delete")
class SoftwareProductInstallationDeleteView(generic.ObjectDeleteView):
queryset = SoftwareProductInstallation.objects.all()
@register_model_view(SoftwareProductInstallation, "bulk_import", detail=False)
class SoftwareProductInstallationBulkImportView(generic.BulkImportView):
queryset = SoftwareProductInstallation.objects.all()
model_form = forms.SoftwareProductInstallationBulkImportForm
@register_model_view(SoftwareProductInstallation, "bulk_edit", path="edit", detail=False)
class SoftwareProductInstallationBulkEditView(generic.BulkEditView):
queryset = SoftwareProductInstallation.objects.all()
filterset = filtersets.SoftwareProductInstallationFilterSet
table = tables.SoftwareProductInstallationTable
form = forms.SoftwareProductInstallationBulkEditForm
@register_model_view(SoftwareProductInstallation, "bulk_delete", path="delete", detail=False)
class SoftwareProductInstallationBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductInstallation.objects.all()
filterset = filtersets.SoftwareProductInstallationFilterSet
table = tables.SoftwareProductInstallationTable
@@ -0,0 +1,54 @@
from netbox.views import generic
from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareProductVersion
from utilities.views import register_model_view
@register_model_view(SoftwareProductVersion, "list", path="", detail=False)
class SoftwareProductVersionListView(generic.ObjectListView):
queryset = SoftwareProductVersion.objects.all()
filterset = filtersets.SoftwareProductVersionFilterSet
filterset_form = forms.SoftwareProductVersionFilterForm
table = tables.SoftwareProductVersionTable
@register_model_view(SoftwareProductVersion)
class SoftwareProductVersionView(generic.ObjectView):
queryset = SoftwareProductVersion.objects.all()
def get_extra_context(self, request, instance):
installation_count = instance.get_installation_count()
return {"installations": installation_count}
@register_model_view(SoftwareProductVersion, "add", detail=False)
@register_model_view(SoftwareProductVersion, "edit")
class SoftwareProductVersionEditView(generic.ObjectEditView):
queryset = SoftwareProductVersion.objects.all()
form = forms.SoftwareProductVersionForm
@register_model_view(SoftwareProductVersion, "delete")
class SoftwareProductVersionDeleteView(generic.ObjectDeleteView):
queryset = SoftwareProductVersion.objects.all()
@register_model_view(SoftwareProductVersion, "bulk_import", detail=False)
class SoftwareProductVersionBulkImportView(generic.BulkImportView):
queryset = SoftwareProductVersion.objects.all()
model_form = forms.SoftwareProductVersionBulkImportForm
@register_model_view(SoftwareProductVersion, "bulk_edit", path="edit", detail=False)
class SoftwareProductVersionBulkEditView(generic.BulkEditView):
queryset = SoftwareProductVersion.objects.all()
filterset = filtersets.SoftwareProductVersionFilterSet
table = tables.SoftwareProductVersionTable
form = forms.SoftwareProductVersionBulkEditForm
@register_model_view(SoftwareProductVersion, "bulk_delete", path="delete", detail=False)
class SoftwareProductVersionBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductVersion.objects.all()
filterset = filtersets.SoftwareProductVersionFilterSet
table = tables.SoftwareProductVersionTable
+84
View File
@@ -0,0 +1,84 @@
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[project]
name = "netbox-slm"
dynamic = ["version"]
description = "Software Lifecycle Management Netbox Plugin"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "Apache-2.0" }
authors = [
{name = "ICTU", email = "open-source-projects@ictu.nl"},
]
classifiers = [
"Development Status :: 5 - Production/Stable",
"Environment :: Plugins",
"Environment :: Web Environment",
"Framework :: Django",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python",
"Topic :: Internet :: WWW/HTTP",
"Topic :: Internet :: WWW/HTTP :: Dynamic Content",
]
dependencies = [
"license-expression == 30.4.4",
]
[project.optional-dependencies]
build = [
"build == 1.5.0",
"setuptools == 82.0.1",
"twine == 6.2.0",
]
ci = [
"coverage == 7.14.3",
"ruff == 0.15.19",
]
[project.urls]
Homepage = "https://github.com/ICTU/netbox_slm/"
Changelog = "https://github.com/ICTU/netbox_slm/blob/master/CHANGELOG.md"
Issues = "https://github.com/ICTU/netbox_slm/issues/"
[tool.setuptools.dynamic]
version = {attr = "netbox_slm.__version__"}
[tool.setuptools.packages.find]
include = ["netbox_slm"]
[tool.ruff]
line-length = 120
target-version = "py312"
src = ["netbox_slm"]
[tool.ruff.lint]
select = [
"E", # pycodestyle
"F", # Pyflakes
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = [
"D104", # https://beta.ruff.rs/docs/rules/#pydocstyle-d - don't require doc strings in __init__.py files
"F401", # https://beta.ruff.rs/docs/rules/#pyflakes-f - don't complain about unused imports in __init__.py files
]
"netbox_slm/migrations/*.py" = [
"E501", # https://beta.ruff.rs/docs/rules/line-too-long/ - don't check on Django generated migration files
]
[tool.ruff.format]
exclude = ["netbox_slm/migrations/*.py"]
+14
View File
@@ -0,0 +1,14 @@
# Project metadata
sonar.organization=ictu
sonar.projectKey=ICTU_netbox_slm
sonar.projectName=netbox-slm
sonar.projectVersion=1.9.0
# Path is relative to the sonar-project.properties file
sonar.sources=netbox_slm
sonar.exclusions=netbox_slm/tests/**
sonar.tests=netbox_slm/tests
sonar.python.version=3.11, 3.12, 3.13, 3.14
# Python test reports
sonar.python.coverage.reportPaths=/ci/reports/coverage.xml
View File
-17
View File
@@ -1,17 +0,0 @@
from extras.plugins import PluginConfig
class SLMConfig(PluginConfig):
name = 'netbox_slm'
verbose_name = 'Software Lifecycle Management'
description = 'Software Lifecycle Management'
version = '0.1'
author = 'Hedde van der Heide'
author_email = 'hedde.vanderheide@ictu.nl'
base_url = 'slm'
required_settings = []
default_settings = {
'version_info': False
}
config = SLMConfig
-14
View File
@@ -1,14 +0,0 @@
from django.contrib import admin
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion
class SoftwareProductVersionInline(admin.TabularInline):
model = SoftwareProductVersion
fields = ('name',)
extra = 0
@admin.register(SoftwareProduct)
class SoftwareProductAdmin(admin.ModelAdmin):
list_display = ('name',)
inlines = (SoftwareProductVersionInline,)
-40
View File
@@ -1,40 +0,0 @@
from rest_framework import serializers
from netbox.api.serializers import PrimaryModelSerializer, OrganizationalModelSerializer
# from netbox_slm.api.nested_serializers import (
# NestedRecordSerializer,
# NestedNameServerSerializer,
# )
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion
class SoftwareProductSerializer(PrimaryModelSerializer):
display = serializers.SerializerMethodField()
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_slm-api:softwareproduct-detail"
)
class Meta:
model = SoftwareProduct
fields = [
'id', 'display', 'url', 'name', 'tags', 'custom_field_data', 'created', 'last_updated',
]
def get_display(self, obj):
return obj.name
class SoftwareProductVersionSerializer(PrimaryModelSerializer):
display = serializers.SerializerMethodField()
url = serializers.HyperlinkedIdentityField(
view_name="plugins-api:netbox_slm-api:softwareproductversion-detail"
)
class Meta:
model = SoftwareProductVersion
fields = [
'id', 'display', 'url', 'name', 'software_product', 'tags', 'custom_field_data', 'created', 'last_updated',
]
def get_display(self, obj):
return obj.name
-13
View File
@@ -1,13 +0,0 @@
from netbox.api import OrderedDefaultRouter
from netbox_slm.api.views import (
NetboxSLMRootView,
SoftwareProductViewSet,
SoftwareProductVersionViewSet,
)
router = OrderedDefaultRouter()
router.APIRootView = NetboxSLMRootView
router.register("softwareproducts", SoftwareProductViewSet)
router.register("softwareproductversions", SoftwareProductVersionViewSet)
urlpatterns = router.urls
-44
View File
@@ -1,44 +0,0 @@
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from extras.api.views import CustomFieldModelViewSet
from netbox_slm.api.serializers import SoftwareProductSerializer, SoftwareProductVersionSerializer
from netbox_slm.filters import SoftwareProductFilter, SoftwareProductVersionFilter
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion
class NetboxSLMRootView(APIRootView):
"""
NetboxSLM API root view
"""
def get_view_name(self):
return "NetboxSLM"
class SoftwareProductViewSet(CustomFieldModelViewSet):
queryset = SoftwareProduct.objects.all()
serializer_class = SoftwareProductSerializer
filterset_class = SoftwareProductFilter
# @action(detail=True, methods=["get"])
# def records(self, request, pk=None):
# records = Record.objects.filter(zone=pk)
# serializer = RecordSerializer(records, many=True, context={"request": request})
# return Response(serializer.data)
class SoftwareProductVersionViewSet(CustomFieldModelViewSet):
queryset = SoftwareProductVersion.objects.all()
serializer_class = SoftwareProductVersionSerializer
filterset_class = SoftwareProductVersionFilter
# @action(detail=True, methods=["get"])
# def records(self, request, pk=None):
# records = Record.objects.filter(zone=pk)
# serializer = RecordSerializer(records, many=True, context={"request": request})
# return Response(serializer.data)
# for reference: https://github.com/auroraresearchlab/netbox-dns/blob/main/netbox_dns/api/views.py
-50
View File
@@ -1,50 +0,0 @@
from django.forms import BoundField
from django.urls import reverse
from utilities.forms import DynamicModelMultipleChoiceField
class CustomDynamicModelMultipleChoiceField(DynamicModelMultipleChoiceField):
def get_bound_field(self, form, field_name):
bound_field = BoundField(form, self, field_name)
# Set initial value based on prescribed child fields (if not already set)
if not self.initial and self.initial_params:
filter_kwargs = {}
for kwarg, child_field in self.initial_params.items():
value = form.initial.get(child_field.lstrip("$"))
if value:
filter_kwargs[kwarg] = value
if filter_kwargs:
self.initial = self.queryset.filter(**filter_kwargs).first()
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
# will be populated on-demand via the APISelect widget.
data = bound_field.value()
if data:
field_name = getattr(self, "to_field_name") or "pk"
filter = self.filter(field_name=field_name)
try:
self.queryset = filter.filter(self.queryset, data)
except (TypeError, ValueError):
# Catch any error caused by invalid initial data passed from the user
self.queryset = self.queryset.none()
else:
self.queryset = self.queryset.none()
# Set the data URL on the APISelect widget (if not already set)
widget = bound_field.field.widget
if not widget.attrs.get("data-url"):
app_label = self.queryset.model._meta.app_label
model_name = self.queryset.model._meta.model_name
#
# Custom url for work with plugin api
#
data_url = reverse(
"plugins-api:{}-api:{}-list".format(app_label, model_name)
)
widget.attrs["data-url"] = data_url
return bound_field
-75
View File
@@ -1,75 +0,0 @@
import django_filters
from django.db.models import Q
from django.utils.translation import gettext as _
from extras.filters import TagFilter
from netbox.filtersets import PrimaryModelFilterSet
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from utilities.forms import DynamicModelMultipleChoiceField
class BaseFilter(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method="search",
label="Search",
)
class SoftwareProductFilter(BaseFilter):
"""Filter capabilities for SoftwareProduct instances."""
name = django_filters.CharFilter(
lookup_expr="icontains",
)
# tag = TagFilter()
class Meta:
model = SoftwareProduct
fields = ("name",) # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) # | Q(status__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductVersionFilter(BaseFilter):
name = django_filters.CharFilter(
lookup_expr="icontains", label="Version"
)
# software_product_id = django_filters.ModelMultipleChoiceFilter(
# queryset=SoftwareProduct.objects.all(),
# label='SoftwareProduct (ID)',
# )
# software_product = django_filters.ModelMultipleChoiceFilter(
# field_name='software_product__name',
# queryset=SoftwareProduct.objects.all(),
# to_field_name='name',
# label='SoftwareProduct (name)',
# )
# tag = TagFilter()
class Meta:
model = SoftwareProductVersion
fields = ("name", "software_product") # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(name__icontains=value) # | Q(status__icontains=value)
return queryset.filter(qs_filter)
class SoftwareProductInstallationFilter(BaseFilter):
class Meta:
model = SoftwareProductInstallation
fields = tuple() # "tag")
def search(self, queryset, name, value):
"""Perform the filtered search."""
if not value.strip():
return queryset
qs_filter = Q(software_product__name__icontains=value) | Q(version__name__icontains=value)
return queryset.filter(qs_filter)
-202
View File
@@ -1,202 +0,0 @@
from django import forms
from django.urls import reverse_lazy
from django.utils.translation import gettext as _
from extras.forms import (
CustomFieldModelForm,
CustomFieldModelCSVForm,
AddRemoveTagsForm,
CustomFieldModelBulkEditForm,
CustomFieldModelFilterForm,
)
from dcim.models import Manufacturer, Device
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from utilities.forms import (
BootstrapMixin, DynamicModelChoiceField, APISelect, DynamicModelMultipleChoiceField
)
from virtualization.models import VirtualMachine
class SoftwareProductForm(BootstrapMixin, CustomFieldModelForm):
"""Form for creating a new SoftwareProduct object."""
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
# initial_params={
# 'device_types': 'device_type'
# }
)
# tags = DynamicModelMultipleChoiceField(
# queryset=Tag.objects.all(),
# required=False,
# )
class Meta:
model = SoftwareProduct
fields = ("name", "manufacturer", "description",) # "tags")
class SoftwareProductFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
"""Form for filtering SoftwareProduct instances."""
model = SoftwareProduct
q = forms.CharField(required=False, label="Search")
name = forms.CharField(
required=False,
label="Name",
)
# tag = TagFilterField(SoftwareProduct)
class SoftwareProductCSVForm(CustomFieldModelCSVForm):
class Meta:
model = SoftwareProduct
fields = ("name",)
class SoftwareProductBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
widget=forms.MultipleHiddenInput(),
)
class Meta:
nullable_fields = []
class SoftwareProductVersionForm(BootstrapMixin, CustomFieldModelForm):
"""Form for creating a new SoftwareProductVersion object."""
name = forms.CharField(label=_("Version"))
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=False,
widget=APISelect(
attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}
),
)
# tags = DynamicModelMultipleChoiceField(
# queryset=Tag.objects.all(),
# required=False,
# )
class Meta:
model = SoftwareProductVersion
fields = ("name", "software_product",) # "tags")
# def clean(self):
# import pdb;pdb.set_trace()
# return super(SoftwareProductVersionForm, self).clean()
class SoftwareProductVersionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
"""Form for filtering SoftwareProductVersion instances."""
model = SoftwareProductVersion
q = forms.CharField(required=False, label="Search")
name = forms.CharField(
required=False,
label="Name",
)
# tag = TagFilterField(SoftwareProduct)
class SoftwareProductVersionCSVForm(CustomFieldModelCSVForm):
class Meta:
model = SoftwareProductVersion
fields = ("name",)
class SoftwareProductVersionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(),
widget=forms.MultipleHiddenInput(),
)
class Meta:
nullable_fields = []
class SoftwareProductInstallationForm(BootstrapMixin, CustomFieldModelForm):
"""Form for creating a new SoftwareProductInstallation object."""
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
# initial_params={
# 'device_types': 'device_type'
# }
)
virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
# initial_params={
# 'device_types': 'device_type'
# }
)
software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(),
required=True,
widget=APISelect(
attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}
),
)
version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(),
required=True,
widget=APISelect(
attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproductversion-list")}
),
query_params={
'software_product': '$software_product',
}
)
# todo need version and device ?
# tags = DynamicModelMultipleChoiceField(
# queryset=Tag.objects.all(),
# required=False,
# )
class Meta:
model = SoftwareProductInstallation
fields = ("device", "virtualmachine", "software_product", "version",) # "tags")
# def clean(self):
# import pdb;pdb.set_trace()
# return super(SoftwareProductInstallationForm, self).clean()
class SoftwareProductInstallationFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
"""Form for filtering SoftwareProductInstallation instances."""
model = SoftwareProductInstallation
q = forms.CharField(required=False, label="Search")
# tag = TagFilterField(SoftwareProduct)
class SoftwareProductInstallationCSVForm(CustomFieldModelCSVForm):
class Meta:
model = SoftwareProductInstallation
fields = tuple()
class SoftwareProductInstallationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=SoftwareProductInstallation.objects.all(),
widget=forms.MultipleHiddenInput(),
)
class Meta:
nullable_fields = []
View File
-97
View File
@@ -1,97 +0,0 @@
from django.db import models
from django.urls import reverse, reverse_lazy
from django.utils import safestring
from extras.utils import extras_features
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProduct(PrimaryModel):
"""
A SoftwareProduct represents ...
"""
name = models.CharField(max_length=128)
description = models.CharField(max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='software_products'
)
objects = RestrictedQuerySet.as_manager()
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproduct", kwargs={"pk": self.pk})
def get_installation_count(self):
count = SoftwareProductInstallation.objects.filter(software_product_id=self.pk).count()
return safestring.mark_safe("<a href=\"{url}\">{count}</a>".format(
url=reverse_lazy("plugins:netbox_slm:softwareproductinstallation_list") + f"?q={self.name}",
count=count
)) if count else "0"
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProductVersion(PrimaryModel):
software_product = models.ForeignKey(
to='netbox_slm.SoftwareProduct',
on_delete=models.PROTECT,
related_name='softwareproduct_versions'
)
name = models.CharField(max_length=64)
objects = RestrictedQuerySet.as_manager()
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproductversion", kwargs={"pk": self.pk})
def get_installation_count(self):
count = SoftwareProductInstallation.objects.filter(version_id=self.pk).count()
return safestring.mark_safe("<a href=\"{url}\">{count}</a>".format(
url=reverse_lazy("plugins:netbox_slm:softwareproductinstallation_list") + f"?q={self.name}",
count=count
)) if count else "0"
# @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SoftwareProductInstallation(PrimaryModel):
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.PROTECT,
related_name='softwareproduct_installations',
null=True,
blank=True
)
virtualmachine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.PROTECT,
related_name='softwareproduct_installations',
null = True,
blank = True
)
software_product = models.ForeignKey(
to='netbox_slm.SoftwareProduct',
on_delete=models.PROTECT,
related_name='software_products'
)
version = models.ForeignKey(
to='netbox_slm.SoftwareProductVersion',
on_delete=models.PROTECT,
related_name='softwareproduct_versions'
)
objects = RestrictedQuerySet.as_manager()
def __str__(self):
return f"{self.pk}"
def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwareproductinstallation", kwargs={"pk": self.pk})
-65
View File
@@ -1,65 +0,0 @@
from extras.plugins import PluginMenuButton, PluginMenuItem
from utilities.choices import ButtonColorChoices
menu_items = (
PluginMenuItem(
link='plugins:netbox_slm:softwareproduct_list',
link_text='Software Products',
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_add",
"Add",
"mdi mdi-plus-thick",
ButtonColorChoices.GREEN,
permissions=["netbox_slm.add_softwareproduct"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproduct_import",
"Import",
"mdi mdi-upload",
ButtonColorChoices.CYAN,
permissions=["netbox_slm.add_softwareproduct"],
),
)
),
PluginMenuItem(
link='plugins:netbox_slm:softwareproductversion_list',
link_text='Versions',
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_add",
"Add",
"mdi mdi-plus-thick",
ButtonColorChoices.GREEN,
permissions=["netbox_slm.add_softwareproductversion"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_import",
"Import",
"mdi mdi-upload",
ButtonColorChoices.CYAN,
permissions=["netbox_slm.add_softwareproductversion"],
),
)
),
PluginMenuItem(
link='plugins:netbox_slm:softwareproductinstallation_list',
link_text='Installations',
buttons=(
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_add",
"Add",
"mdi mdi-plus-thick",
ButtonColorChoices.GREEN,
permissions=["netbox_slm.add_softwareproductinstallation"],
),
PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_import",
"Import",
"mdi mdi-upload",
ButtonColorChoices.CYAN,
permissions=["netbox_slm.add_softwareproductinstallation"],
),
)
),
)
View File
-125
View File
@@ -1,125 +0,0 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
from utilities.tables import BaseTable, ChoiceFieldColumn, ToggleColumn
class SoftwareProductTable(BaseTable):
"""Table for displaying SoftwareProduct objects."""
pk = ToggleColumn()
name = tables.LinkColumn()
manufacturer = tables.Column(
accessor=Accessor('manufacturer'),
linkify=True
)
installations = tables.Column(accessor='get_installation_count', verbose_name='Installations')
# tags = TagColumn(
# url_name="plugins:netbox_dns:zone_list",
# )
class Meta(BaseTable.Meta):
model = SoftwareProduct
fields = (
"pk",
"name",
"manufacturer",
"description",
"installations",
# "tags",
)
default_columns = (
"pk",
"name",
"manufacturer",
"description",
"installations",
# "tags",
)
class SoftwareProductVersionTable(BaseTable):
"""Table for displaying SoftwareProductVersion objects."""
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Version')
software_product = tables.Column(
accessor=Accessor('software_product'),
linkify=True
)
manufacturer = tables.Column(
accessor=Accessor('software_product__manufacturer'),
linkify=True
)
installations = tables.Column(accessor='get_installation_count', verbose_name='Installations')
# tags = TagColumn(
# url_name="plugins:netbox_dns:zone_list",
# )
class Meta(BaseTable.Meta):
model = SoftwareProductVersion
fields = (
"pk",
"name",
"software_product",
"manufacturer",
"installations",
# "tags",
)
default_columns = (
"pk",
"name",
"software_product",
"manufacturer",
"installations",
# "tags",
)
class SoftwareProductInstallationTable(BaseTable):
"""Table for displaying SoftwareProductInstallation objects."""
pk = ToggleColumn()
name = tables.LinkColumn()
device = tables.Column(
accessor=Accessor('device'),
linkify=True
)
virtualmachine = tables.Column(
accessor=Accessor('virtualmachine'),
linkify=True
)
software_product = tables.Column(
accessor=Accessor('software_product'),
linkify=True
)
version = tables.Column(
accessor=Accessor('version'),
linkify=True
)
# tags = TagColumn(
# url_name="plugins:netbox_dns:zone_list",
# )
class Meta(BaseTable.Meta):
model = SoftwareProductInstallation
fields = (
"pk",
"name",
"device",
"virtualmachine",
"software_product",
"version",
# "tags",
)
default_columns = (
"pk",
"device",
"virtualmachine",
"software_product",
"version",
# "tags",
)
View File
@@ -1,93 +0,0 @@
{% extends 'base/layout.html' %}
{% load buttons %}
{% load custom_links %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load view_helpers %}
{% block header %}
{# Breadcrumbs #}
<nav class="breadcrumb-container px-3" aria-label="breadcrumb">
<ol class="breadcrumb">
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url object|plugin_viewname:'list' %}">{{ object|meta:'verbose_name_plural'|bettertitle }}</a></li>
{% endblock %}
</ol>
</nav>
{{ block.super }}
{% endblock %}
{% block title %}{{ object }}{% endblock %}
{% block subtitle %}
<div class="object-subtitle">
<span>Created {{ object.created|annotated_date }}</span>
<span class="separator">&middot;</span>
<span>Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</span>
</div>
{% endblock %}
{% block controls %}
{# Clone/Edit/Delete Buttons #}
<div class="controls">
<div class="control-group">
{% plugin_buttons object %}
{# Extra buttons #}
{% block extra_controls %}{% endblock %}
{% if object.clone_fields and request.user|can_add:object %}
{% plugin_clone_button object %}
{% endif %}
{% if request.user|can_change:object %}
{% plugin_edit_button object %}
{% endif %}
{% if request.user|can_delete:object %}
{% plugin_delete_button object %}
{% endif %}
</div>
<div class="control-group">
{% custom_links object %}
</div>
</div>
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
{% block tab_items %}
<li class="nav-item" role="presentation">
<a class="nav-link{% if not active_tab %} active{% endif %}" href="{{ object.get_absolute_url }}">{{ object|meta:"verbose_name"|bettertitle }}</a>
</li>
{% endblock tab_items %}
{% if perms.extras.view_journalentry %}
{% with journal_viewname=object|plugin_viewname:'journal' %}
{% url journal_viewname pk=object.pk as journal_url %}
{% if journal_url %}
<li role="presentation" class="nav-item">
<a href="{{ journal_url }}" class="nav-link{% if active_tab == 'journal'%} active{% endif %}">
Journal {% badge object.journal_entries.count %}
</a>
</li>
{% endif %}
{% endwith %}
{% endif %}
{% if perms.extras.view_objectchange %}
{% with changelog_viewname=object|plugin_viewname:'changelog' %}
{% url changelog_viewname pk=object.pk as changelog_url %}
{% if changelog_url %}
<li role="presentation" class="nav-item">
<a href="{{ changelog_url }}" class="nav-link{% if active_tab == 'changelog'%} active{% endif %}">Change Log</a>
</li>
{% endif %}
{% endwith %}
{% endif %}
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}{% endblock %}
</div>
{% endblock %}
@@ -1,141 +0,0 @@
{% extends 'base/layout.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load static %}
{% load view_helpers %}
{% block controls %}
<div class="controls">
<div class="control-group">
{% block extra_controls %}{% endblock %}
{% if permissions.add and 'add' in action_buttons %}
{% add_button content_type.model_class|plugin_viewname:"add" %}
{% endif %}
{% if permissions.add and 'import' in action_buttons %}
{% import_button content_type.model_class|plugin_viewname:"import" %}
{% endif %}
{% if 'export' in action_buttons %}
{% export_button content_type %}
{% endif %}
</div>
</div>
{% endblock controls %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
{% block tab_items %}
<li class="nav-item" role="presentation">
<button class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% block title %}{{ content_type.model_class|meta:"verbose_name_plural"|bettertitle }}{% endblock %}
{% badge table.page.paginator.count %}
</button>
</li>
{% if filter_form %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="filters-form-tab" data-bs-toggle="tab" data-bs-target="#filters-form" type="button" role="tab" aria-controls="object-list" aria-selected="false">
Filters
{% if filter_form %}{% badge filter_form.changed_data|length bg_class="primary" %}{% endif %}
</button>
</li>
{% endif %}
{% endblock tab_items %}
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{# Object list #}
<div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
{# Applied filters #}
{% if filter_form %}
{% applied_filters filter_form request.GET %}
{% endif %}
{# "Select all" form #}
{% if table.paginator.num_pages > 1 %}
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
<div id="select-all-box" class="d-none card noprint">
<form method="post" class="form col-md-12">
{% csrf_token %}
<div class="card-body">
<div class="float-end">
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled>
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled>
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
</button>
{% endif %}
</div>
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
</label>
</div>
</div>
</form>
</div>
{% endwith %}
{% endif %}
{# Object table controls #}
{% include 'inc/table_controls.html' with table_modal="ObjectTable_config" %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
{# Object table #}
<div class="card">
<div class="card-body">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>
</div>
{# Form buttons #}
{% if permissions.change or permissions.delete %}
{% with bulk_edit_url=content_type.model_class|plugin_validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|plugin_validated_viewname:"bulk_delete" %}
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% block bulk_buttons %}{% endblock %}
{% if bulk_edit_url and permissions.change %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% if bulk_delete_url and permissions.delete %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
</button>
{% endif %}
</div>
</div>
{% endwith %}
{% endif %}
</form>
{# Paginator #}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
{# Filter form #}
{% if filter_form %}
<div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
{% include 'inc/filter_list.html' %}
</div>
{% endif %}
</div>
{# Table config form #}
{% table_config_form table table_name="ObjectTable" %}
{% endblock content-wrapper %}
@@ -1,76 +0,0 @@
{% extends 'netbox_slm/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load view_helpers %}
{% load perms %}
{% block extra_controls %}
{{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header">
Software Product
</div>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Versions</th>
<td>
{% for version in object.softwareproduct_versions.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}">
<span class="badge" style="color: #ffffff; background-color: #9e9e9e">{{ version }}</span>
</a>
{% endfor %}
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/custom_fields_panel.html' %}
</div>
<div class="col col-md-6">
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='plugins:netbox_slm:softwareproduct_list' %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Versions</h5>
<div class="card-body">
<table class="table table-hover attr-table table-striped">
<thead>
<tr>
<th scope="col">NAME</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
{% for version in versions %}
<tr>
<td>{{ version.name|truncatechars:32 }}</td>
<td class="noprint text-end text-nowrap">
{% if request.user|can_change:object %}
{% plugin_edit_button version %}
{% endif %}
{% if request.user|can_delete:object %}
{% plugin_delete_button version %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -1,82 +0,0 @@
{% extends 'netbox_slm/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load view_helpers %}
{% load perms %}
{% block extra_controls %}
{{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header">
Software Product
</div>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Installation</th>
<td>{{ object }}</td>
</tr>
<tr>
<th scope="row">bla</th>
<td>
<!-- {% for version in object.softwareproduct_versions.all %}-->
<!-- <a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}">-->
<!-- <span class="badge" style="color: #ffffff; background-color: #9e9e9e">{{ version }}</span>-->
<!-- </a>-->
<!-- {% endfor %}-->
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/custom_fields_panel.html' %}
</div>
<div class="col col-md-6">
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='plugins:netbox_slm:softwareproduct_list' %}
</div>
</div>
<!--<div class="row">-->
<!-- <div class="col col-md-12">-->
<!-- <div class="card">-->
<!-- <h5 class="card-header">Records</h5>-->
<!-- <div class="card-body">-->
<!-- <table class="table table-hover attr-table table-striped">-->
<!-- <thead>-->
<!-- <tr>-->
<!-- <th scope="col">TYPE</th>-->
<!-- <th scope="col">NAME</th>-->
<!-- <th scope="col">VALUE</th>-->
<!-- <th scope="col">TTL</th>-->
<!-- <th scope="col">Actions</th>-->
<!-- </tr>-->
<!-- </thead>-->
<!-- <tbody>-->
<!-- {% for record in records %}-->
<!-- <tr>-->
<!-- <td>{{ record.type }}</td>-->
<!-- <td>{{ record.name|truncatechars:32 }}</td>-->
<!-- <td>{{ record.value|truncatechars:64 }}</td>-->
<!-- <td>{{ record.ttl }}</td>-->
<!-- <td class="noprint text-end text-nowrap">-->
<!-- {% if request.user|can_change:object %}-->
<!-- {% plugin_edit_button record %}-->
<!-- {% endif %}-->
<!-- {% if request.user|can_delete:object %}-->
<!-- {% plugin_delete_button record %}-->
<!-- {% endif %}-->
<!-- </td>-->
<!-- </tr>-->
<!-- {% endfor %}-->
<!-- </tbody>-->
<!-- </table>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</div>-->
{% endblock %}
@@ -1,82 +0,0 @@
{% extends 'netbox_slm/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load view_helpers %}
{% load perms %}
{% block extra_controls %}
{{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header">
Software Product
</div>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Installations</th>
<td>
<!-- {% for version in object.softwareproduct_versions.all %}-->
<!-- <a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}">-->
<!-- <span class="badge" style="color: #ffffff; background-color: #9e9e9e">{{ version }}</span>-->
<!-- </a>-->
<!-- {% endfor %}-->
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/custom_fields_panel.html' %}
</div>
<div class="col col-md-6">
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='plugins:netbox_slm:softwareproduct_list' %}
</div>
</div>
<!--<div class="row">-->
<!-- <div class="col col-md-12">-->
<!-- <div class="card">-->
<!-- <h5 class="card-header">Records</h5>-->
<!-- <div class="card-body">-->
<!-- <table class="table table-hover attr-table table-striped">-->
<!-- <thead>-->
<!-- <tr>-->
<!-- <th scope="col">TYPE</th>-->
<!-- <th scope="col">NAME</th>-->
<!-- <th scope="col">VALUE</th>-->
<!-- <th scope="col">TTL</th>-->
<!-- <th scope="col">Actions</th>-->
<!-- </tr>-->
<!-- </thead>-->
<!-- <tbody>-->
<!-- {% for record in records %}-->
<!-- <tr>-->
<!-- <td>{{ record.type }}</td>-->
<!-- <td>{{ record.name|truncatechars:32 }}</td>-->
<!-- <td>{{ record.value|truncatechars:64 }}</td>-->
<!-- <td>{{ record.ttl }}</td>-->
<!-- <td class="noprint text-end text-nowrap">-->
<!-- {% if request.user|can_change:object %}-->
<!-- {% plugin_edit_button record %}-->
<!-- {% endif %}-->
<!-- {% if request.user|can_delete:object %}-->
<!-- {% plugin_delete_button record %}-->
<!-- {% endif %}-->
<!-- </td>-->
<!-- </tr>-->
<!-- {% endfor %}-->
<!-- </tbody>-->
<!-- </table>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</div>-->
{% endblock %}
@@ -1,91 +0,0 @@
{% extends 'netbox_slm/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load view_helpers %}
{% load perms %}
{% block extra_controls %}
{% comment %}
<a href="{% url 'plugins:netbox_slm:record_add' %}?zone={{ object.id }}" class="btn btn-sm btn-primary" role="button">
<i class="mdi mdi-plus-thick"></i> Add Record
</a>
{% endcomment %}
{{ block.super }}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<div class="card-header">
Zone
</div>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Versions</th>
<td>
<ul>
<!-- {% for nameserver in object.nameservers.all %}-->
<!-- <li>-->
<!-- <a href="{% url 'plugins:netbox_slm:nameserver' pk=nameserver.pk %}">-->
<!-- {{ nameserver }}-->
<!-- </a>-->
<!-- </li>-->
<!-- {% endfor %}-->
</ul>
</td>
</tr>
</table>
</div>
</div>
{% include 'inc/custom_fields_panel.html' %}
</div>
<div class="col col-md-6">
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='plugins:netbox_slm:softwareproduct_list' %}
</div>
</div>
<!--<div class="row">-->
<!-- <div class="col col-md-12">-->
<!-- <div class="card">-->
<!-- <h5 class="card-header">Records</h5>-->
<!-- <div class="card-body">-->
<!-- <table class="table table-hover attr-table table-striped">-->
<!-- <thead>-->
<!-- <tr>-->
<!-- <th scope="col">TYPE</th>-->
<!-- <th scope="col">NAME</th>-->
<!-- <th scope="col">VALUE</th>-->
<!-- <th scope="col">TTL</th>-->
<!-- <th scope="col">Actions</th>-->
<!-- </tr>-->
<!-- </thead>-->
<!-- <tbody>-->
<!-- {% for record in records %}-->
<!-- <tr>-->
<!-- <td>{{ record.type }}</td>-->
<!-- <td>{{ record.name|truncatechars:32 }}</td>-->
<!-- <td>{{ record.value|truncatechars:64 }}</td>-->
<!-- <td>{{ record.ttl }}</td>-->
<!-- <td class="noprint text-end text-nowrap">-->
<!-- {% if request.user|can_change:object %}-->
<!-- {% plugin_edit_button record %}-->
<!-- {% endif %}-->
<!-- {% if request.user|can_delete:object %}-->
<!-- {% plugin_delete_button record %}-->
<!-- {% endif %}-->
<!-- </td>-->
<!-- </tr>-->
<!-- {% endfor %}-->
<!-- </tbody>-->
<!-- </table>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!--</div>-->
{% endblock %}
@@ -1,94 +0,0 @@
from django import template
from django.urls import NoReverseMatch, reverse
from django import template
from utilities.templatetags.buttons import _get_viewname
from utilities.utils import prepare_cloned_fields
register = template.Library()
@register.filter()
def plugin_validated_viewname(model, action):
"""Return the view name for :
* the given model and action if valid
* or the given model and action if valid inside a plugin
* or None if invalid.
"""
core_viewname = f"{model._meta.app_label}:{model._meta.model_name}_{action}"
plugin_viewname = (
f"plugins:{model._meta.app_label}:{model._meta.model_name}_{action}"
)
try:
# Validate and return the view name. We don't return the actual URL yet because many of the templates
# are written to pass a name to {% url %}.
reverse(core_viewname)
return core_viewname
except NoReverseMatch:
try:
reverse(plugin_viewname)
return plugin_viewname
except NoReverseMatch:
return None
@register.filter()
def plugin_viewname(model, action):
"""
Return the view name for the given model and action. Does not perform any validation.
"""
return f"plugins:{model._meta.app_label}:{model._meta.model_name}_{action}"
#
# Instance buttons
#
@register.inclusion_tag("buttons/clone.html")
def plugin_clone_button(instance):
viewname = _get_viewname(instance, "add")
url = reverse(f"plugins:{viewname}")
# Populate cloned field values
param_string = prepare_cloned_fields(instance)
if param_string:
url = f"{url}?{param_string}"
return {
"url": url,
}
@register.inclusion_tag("buttons/edit.html")
def plugin_edit_button(instance, **kwargs):
viewname = _get_viewname(instance, "edit")
url = reverse(f"plugins:{viewname}", kwargs={"pk": instance.pk, **kwargs})
return {
"url": url,
}
@register.inclusion_tag("buttons/delete.html")
def plugin_delete_button(instance, **kwargs):
viewname = _get_viewname(instance, "delete")
url = reverse(f"plugins:{viewname}", kwargs={"pk": instance.pk, **kwargs})
return {
"url": url,
}
#
# List buttons
#
@register.inclusion_tag("buttons/add.html")
def plugin_add_button(url):
url = reverse(f"plugins:{url}")
return {
"add_url": url,
}
-54
View File
@@ -1,54 +0,0 @@
from django.urls import path
from extras.views import ObjectChangeLogView
from netbox_slm import views
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
urlpatterns = [
# Software Products
path("software-products/", views.SoftwareProductListView.as_view(), name='softwareproduct_list'),
path("software-products/add/", views.SoftwareProductEditView.as_view(), name='softwareproduct_add'),
path("software-products/import/", views.SoftwareProductBulkImportView.as_view(), name="softwareproduct_import"),
path("software-products/edit/", views.SoftwareProductBulkEditView.as_view(), name="softwareproduct_bulk_edit"),
path("software-products/delete/", views.SoftwareProductBulkDeleteView.as_view(), name="softwareproduct_bulk_delete"),
path("software-products/<int:pk>/", views.SoftwareProductView.as_view(), name="softwareproduct"),
path("software-products/<int:pk>/delete/", views.SoftwareProductDeleteView.as_view(), name="softwareproduct_delete"),
path("software-products/<int:pk>/edit/", views.SoftwareProductEditView.as_view(), name="softwareproduct_edit"),
path(
"software-products/<int:pk>/changelog/",
ObjectChangeLogView.as_view(),
name="softwareproduct_changelog",
kwargs={"model": SoftwareProduct},
),
# Software Product Versions
path("versions/", views.SoftwareProductVersionListView.as_view(), name='softwareproductversion_list'),
path("versions/add/", views.SoftwareProductVersionEditView.as_view(), name='softwareproductversion_add'),
path("versions/import/", views.SoftwareProductVersionBulkImportView.as_view(), name="softwareproductversion_import"),
path("versions/edit/", views.SoftwareProductVersionBulkEditView.as_view(), name="softwareproductversion_bulk_edit"),
path("versions/delete/", views.SoftwareProductVersionBulkDeleteView.as_view(), name="softwareproductversion_bulk_delete"),
path("versions/<int:pk>/", views.SoftwareProductVersionView.as_view(), name="softwareproductversion"),
path("versions/<int:pk>/delete/", views.SoftwareProductVersionDeleteView.as_view(), name="softwareproductversion_delete"),
path("versions/<int:pk>/edit/", views.SoftwareProductVersionEditView.as_view(), name="softwareproductversion_edit"),
path(
"versions/<int:pk>/changelog/",
ObjectChangeLogView.as_view(),
name="softwareproductversion_changelog",
kwargs={"model": SoftwareProductVersion},
),
# Software Product Versions
path("installations/", views.SoftwareProductInstallationListView.as_view(), name='softwareproductinstallation_list'),
path("installations/add/", views.SoftwareProductInstallationEditView.as_view(), name='softwareproductinstallation_add'),
path("installations/import/", views.SoftwareProductInstallationBulkImportView.as_view(), name="softwareproductinstallation_import"),
path("installations/edit/", views.SoftwareProductInstallationBulkEditView.as_view(), name="softwareproductinstallation_bulk_edit"),
path("installations/delete/", views.SoftwareProductInstallationBulkDeleteView.as_view(), name="softwareproductinstallation_bulk_delete"),
path("installations/<int:pk>/", views.SoftwareProductInstallationView.as_view(), name="softwareproductinstallation"),
path("installations/<int:pk>/delete/", views.SoftwareProductInstallationDeleteView.as_view(), name="softwareproductinstallation_delete"),
path("installations/<int:pk>/edit/", views.SoftwareProductInstallationEditView.as_view(), name="softwareproductinstallation_edit"),
path(
"installations/<int:pk>/changelog/",
ObjectChangeLogView.as_view(),
name="softwareproductinstallation_changelog",
kwargs={"model": SoftwareProductInstallation},
),
]
-165
View File
@@ -1,165 +0,0 @@
from netbox.views import generic
from netbox_slm.filters import SoftwareProductFilter, SoftwareProductVersionFilter, SoftwareProductInstallationFilter
from netbox_slm.forms import (
SoftwareProductForm, SoftwareProductFilterForm, SoftwareProductCSVForm, SoftwareProductBulkEditForm,
SoftwareProductVersionForm, SoftwareProductVersionFilterForm, SoftwareProductVersionCSVForm,
SoftwareProductVersionBulkEditForm, SoftwareProductInstallationForm, SoftwareProductInstallationFilterForm,
SoftwareProductInstallationCSVForm, SoftwareProductInstallationBulkEditForm
)
from netbox_slm.models import (
SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation
)
from netbox_slm.tables import SoftwareProductTable, SoftwareProductVersionTable, SoftwareProductInstallationTable
class SoftwareProductListView(generic.ObjectListView):
"""View for listing all existing SoftwareProducts."""
queryset = SoftwareProduct.objects.all()
filterset = SoftwareProductFilter
filterset_form = SoftwareProductFilterForm
table = SoftwareProductTable
template_name = "netbox_slm/object_list.html"
class SoftwareProductView(generic.ObjectView):
"""Display SoftwareProduct details"""
queryset = SoftwareProduct.objects.all()
def get_extra_context(self, request, instance):
versions = instance.softwareproduct_versions.all()
return {"versions": versions}
class SoftwareProductEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProduct instance."""
queryset = SoftwareProduct.objects.all()
model_form = SoftwareProductForm
class SoftwareProductDeleteView(generic.ObjectDeleteView):
"""View for deleting a SoftwareProduct instance"""
queryset = SoftwareProduct.objects.all()
class SoftwareProductBulkImportView(generic.BulkImportView):
queryset = SoftwareProduct.objects.all()
model_form = SoftwareProductCSVForm
table = SoftwareProductTable
class SoftwareProductBulkEditView(generic.BulkEditView):
queryset = SoftwareProduct.objects.all()
filterset = SoftwareProductFilter
table = SoftwareProductTable
form = SoftwareProductBulkEditForm
class SoftwareProductBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProduct.objects.all()
table = SoftwareProductTable
class SoftwareProductVersionListView(generic.ObjectListView):
"""View for listing all existing SoftwareProductVersions."""
queryset = SoftwareProductVersion.objects.all()
filterset = SoftwareProductVersionFilter
filterset_form = SoftwareProductVersionFilterForm
table = SoftwareProductVersionTable
template_name = "netbox_slm/object_list.html"
class SoftwareProductVersionView(generic.ObjectView):
"""Display SoftwareProductVersion details"""
queryset = SoftwareProductVersion.objects.all()
# def get_extra_context(self, request, instance):
# records = instance.record_set.all()
# return {"records": records}
class SoftwareProductVersionEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProductVersion instance."""
queryset = SoftwareProductVersion.objects.all()
model_form = SoftwareProductVersionForm
class SoftwareProductVersionDeleteView(generic.ObjectDeleteView):
"""View for deleting a SoftwareProductVersion instance"""
queryset = SoftwareProductVersion.objects.all()
class SoftwareProductVersionBulkImportView(generic.BulkImportView):
queryset = SoftwareProductVersion.objects.all()
model_form = SoftwareProductVersionCSVForm
table = SoftwareProductVersionTable
class SoftwareProductVersionBulkEditView(generic.BulkEditView):
queryset = SoftwareProductVersion.objects.all()
filterset = SoftwareProductVersionFilter
table = SoftwareProductVersionTable
form = SoftwareProductVersionBulkEditForm
class SoftwareProductVersionBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductVersion.objects.all()
table = SoftwareProductVersionTable
class SoftwareProductInstallationListView(generic.ObjectListView):
"""View for listing all existing SoftwareProductInstallations."""
queryset = SoftwareProductInstallation.objects.all()
filterset = SoftwareProductInstallationFilter
filterset_form = SoftwareProductInstallationFilterForm
table = SoftwareProductInstallationTable
template_name = "netbox_slm/object_list.html"
class SoftwareProductInstallationView(generic.ObjectView):
"""Display SoftwareProductInstallation details"""
queryset = SoftwareProductVersion.objects.all()
# def get_extra_context(self, request, instance):
# records = instance.record_set.all()
# return {"records": records}
class SoftwareProductInstallationEditView(generic.ObjectEditView):
"""View for editing and creating a SoftwareProductInstallation instance."""
queryset = SoftwareProductInstallation.objects.all()
model_form = SoftwareProductInstallationForm
class SoftwareProductInstallationDeleteView(generic.ObjectDeleteView):
"""View for deleting a SoftwareProductInstallation instance"""
queryset = SoftwareProductInstallation.objects.all()
class SoftwareProductInstallationBulkImportView(generic.BulkImportView):
queryset = SoftwareProductInstallation.objects.all()
model_form = SoftwareProductInstallationCSVForm
table = SoftwareProductInstallationTable
class SoftwareProductInstallationBulkEditView(generic.BulkEditView):
queryset = SoftwareProductInstallation.objects.all()
filterset = SoftwareProductInstallationFilter
table = SoftwareProductInstallationTable
form = SoftwareProductInstallationBulkEditForm
class SoftwareProductInstallationBulkDeleteView(generic.BulkDeleteView):
queryset = SoftwareProductInstallation.objects.all()
table = SoftwareProductInstallationTable
-14
View File
@@ -1,14 +0,0 @@
from setuptools import find_packages, setup
setup(
name='netbox-slm',
version='0.9',
description='Software Lifecycle Management Netbox Plugin',
url='https://github.com/ICTU/netbox_slm',
author='Hedde van der Heide',
license='Apache 2.0',
install_requires=[],
packages=find_packages(),
include_package_data=True,
zip_safe=False,
)
-17
View File
@@ -1,17 +0,0 @@
git clone -b release https://github.com/netbox-community/netbox-docker.git
cd netbox-docker
tee docker-compose.override.yml <<EOF
version: '3.4'
services:
netbox:
ports:
- 8000:8080
postgres:
ports:
- 5432:5432
redis:
ports:
- 6379:6379
EOF
docker-compose pull
docker-compose up