14 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
50 changed files with 1737 additions and 996 deletions
-43
View File
@@ -1,43 +0,0 @@
---
name: Docker Image CI
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Build the Docker image
run: docker compose build --no-cache
- name: Verify the Docker image
# smoke test by checking if the migrations for app netbox_slm can be displayed
run: docker compose run netbox sh -c "/opt/netbox/venv/bin/python manage.py showmigrations netbox_slm"
- name: Run Django tests with coverage for netbox_slm
run: |
chmod go+w ci/reports
docker compose run netbox sh -c "\
/opt/netbox/venv/bin/coverage run --source='netbox_slm' manage.py test netbox_slm &&\
/opt/netbox/venv/bin/coverage report --fail-under=0 &&\
/opt/netbox/venv/bin/coverage xml -o /ci/reports/coverage.xml"
sed -i "s/<source>\/opt\/netbox\/netbox<\/source>/<source><\/source>/" ci/reports/coverage.xml
timeout-minutes: 6
- name: Sonar scan
if: env.SONAR_TOKEN != null
uses: SonarSource/sonarqube-scan-action@713881670b6b3676cda39549040e2d88c70d582e # v8.2.0
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
-25
View File
@@ -1,25 +0,0 @@
---
name: Markdown lint
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
vale:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: errata-ai/vale-action@85f9f7f2c5f449ac0ae5b66662961bae3f77ca6a # v2.1.2
with:
fail_on_error: true
filter_mode: nofilter
vale_flags: "--glob=*.md --config=ci/.vale.ini"
-40
View File
@@ -1,40 +0,0 @@
---
name: Python
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- python-version: 3.11
- python-version: 3.12
- python-version: 3.13
- python-version: 3.14
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Install ci tools
run: pip install .[ci]
- name: Run ruff lint
run: ruff check netbox_slm
- name: Run ruff format
run: ruff format --check netbox_slm
-25
View File
@@ -1,25 +0,0 @@
---
name: GitHub Actions Security Analysis with zizmor
on:
push:
branches:
- master
pull_request:
types: [opened, synchronize, reopened]
permissions: {}
jobs:
zizmor:
runs-on: ubuntu-latest
permissions:
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
+29
View File
@@ -2,6 +2,35 @@
## [Unreleased] ## [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 ## [1.9.0](https://github.com/ICTU/netbox_slm/releases/tag/1.9.0) - 2026-06-25
### Changed ### Changed
-133
View File
@@ -1,133 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement by
[opening a new issue in this repository's issue tracker][open issue].
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
[open issue]: https://github.com/ICTU/netbox_slm/issues/new
-34
View File
@@ -1,34 +0,0 @@
# NetBox SLM contributing guidelines
## What do I need to know to help?
If you are looking to help to with a code contribution our project uses, you can find inspiration in the [issue list](https://github.com/ICTU/netbox_slm/issues/).
Additionally, review the [README](./README.md) document and [NetBox Plugin Development](https://docs.netbox.dev/en/stable/plugins/development/) docs.
## How do I make a contribution?
Never made an open source contribution before? Wondering how contributions work in the in our project? Here's a quick rundown!
1. Find an issue that you are interested in addressing or a feature that you would like to add.
2. Fork the repository associated with the issue to your local GitHub organization. This means that you will have a copy of the repository under **your-GitHub-username/repository-name**.
3. Clone the repository to your local machine using `git clone https://github.com/github-username/repository-name.git`.
4. Create a new branch for your fix using `git checkout -b branch-name-here`.
5. Make the appropriate changes for the issue you are trying to address or the feature that you want to add.
6. Use git `add insert-paths-of-changed-files-here` to add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index.
7. Use `git commit -m "Insert a short message of the changes made here"` to store the contents of the index with a descriptive message.
8. Push the changes to the remote repository using `git push origin branch-name-here`.
9. Submit a pull request to the upstream repository.
10. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #4352".
11. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it!
12. Wait for the pull request to be reviewed by a maintainer.
13. Make changes to the pull request if the reviewing maintainer recommends them.
14. Celebrate your success after your pull request is merged!
## Where can I go for help?
If you need help, you can ask questions by [opening a new issue in this repository's issue tracker](https://github.com/ICTU/netbox_slm/issues/new).
## What does the Code of Conduct mean for me?
Our [Code of Conduct](./CODE_OF_CONDUCT.md) means that you are responsible for treating everyone on the project with respect and courtesy regardless of their identity.
If you are the victim of any inappropriate behavior or comments as described in our Code of Conduct, we are here for you and will do the best to ensure that the abuser is reprimanded appropriately, per our code.
-153
View File
@@ -1,153 +0,0 @@
# Maintenance related tasks
## Version upgrade workflow
1. Update version spec in `netbox_slm/__init__.py` and `sonar-project.properties`
1. Update NetBox base image versions in `ci/Dockerfile`, `ci/Dockerfile-CI` and `ci/docker-compose.yml`
1. Ensure that the `PYTHON_VERSION` in the `Dockerfile`s is the same as the NetBox community docker base image
1. Check for any runtime errors and warnings in the `netbox-*` container logs
1. Create new version tag on GitHub, following semantic versioning as: `MAJOR.MINOR.PATCH`
1. Update the `CHANGELOG.md` with new version information and move `[Unreleased]` items to new version section
1. Build the package: `python -m build`
1. Upload the distributions to PyPI: `twine upload --skip-existing dist/*`
## 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 PostgreSQL and Redis container instances, basically ignoring the
NetBox docker runtime server.
### Steps
from your projects directory clone the NetBox repository
```shell
$ git clone https://github.com/netbox-community/netbox
$ cd netbox
```
install the virtual environment
```shell
$ pipenv shell
$ pipenv install
```
create and edit `netbox/configuration.py` (based on the template file) add these lines at the end of the file;
```python
DEBUG = True
SECRET_KEY = 'dummy'
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
```shell
$ 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 PostgreSQL ports to the localhost (make sure theres no processes
using these ports or change the Dockerfile(s) accordingly)
Note, you can also start and stop NetBox by hand:
```shell
$ cd netbox-docker
$ docker compose up -d
```
or stop the stack with
```shell
$ docker compose down
```
#### to start fresh:
```shell
$ 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
Go back to the NetBox `configuration.py` file and update the PostgreSQL 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):
```python
# 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,
}
}
```
Now you can run commands from the NetBox repository like this;
```shell
$ cd netbox/netbox
$ export PYTHONPATH=../../netbox_slm/netbox_slm/ # or with the pipenv activated run `python3 setup.py develop` from the netbox_slm directory
$ python3 manage.py migrate netbox_slm
$ python3 manage.py runserver 8001
```
Visit http://127.0.0.1:8001 in the browser to see the auto reloading version of the NetBox UI.
Port 8000 is taken by the Docker ran variant.
+140 -42
View File
@@ -1,62 +1,160 @@
# NetBox SLM # NetBox SLM
<p align="center"><i>NetBox SLM is a plugin for lifecycle management of software components, including versions and installations.</i></p> 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.
<div align="center"> Diese Variante ist für **NetBox 4.6.5** vorgesehen.
<a href="https://pypi.org/project/netbox_slm/"><img src="https://img.shields.io/pypi/v/netbox_slm" alt="PyPi"/></a>
<a href="https://github.com/ICTU/netbox_slm/stargazers"><img src="https://img.shields.io/github/stars/ICTU/netbox_slm" alt="Stars Badge"/></a>
<a href="https://github.com/ICTU/netbox_slm/network/members"><img src="https://img.shields.io/github/forks/ICTU/netbox_slm" alt="Forks Badge"/></a>
<a href="https://github.com/ICTU/netbox_slm/pulls"><img src="https://img.shields.io/github/issues-pr/ICTU/netbox_slm" alt="Pull Requests Badge"/></a>
<a href="https://github.com/ICTU/netbox_slm/issues"><img src="https://img.shields.io/github/issues/ICTU/netbox_slm" alt="Issues Badge"/></a>
<a href="https://github.com/ICTU/netbox_slm/graphs/contributors"><img alt="GitHub contributors" src="https://img.shields.io/github/contributors/ICTU/netbox_slm?color=2b9348"></a>
<a href="https://github.com/ICTU/netbox_slm/blob/master/LICENSE"><img src="https://img.shields.io/github/license/ICTU/netbox_slm?color=2b9348" alt="License Badge"/></a>
</div>
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.
## Installation Guide ## Funktionen
When using the Docker version of NetBox, first follow the `netbox-docker` [quickstart](https://github.com/netbox-community/netbox-docker#quickstart) instructions to clone the `netbox-docker` repository and set up the ``docker-compose.override.yml``. - 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
Note that this plugin is only tested against a single NetBox version at this time, see [Dockerfile-CI](https://github.com/ICTU/netbox_slm/blob/master/ci/Dockerfile-CI). > **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.
Next, follow these instructions (based on the NetBox docker variant [instructions](https://github.com/netbox-community/netbox-docker/wiki/Configuration#custom-configuration-files)) to install the NetBox SLM plugin: ## Installation
1. Add ``netbox_slm`` to the ``PLUGINS`` list in NetBox installiert lokale Erweiterungen aus `/opt/netbox/local_requirements.txt`. Die Git-URL muss dort dauerhaft
``configuration/plugins.py``. eingetragen werden, damit das Plugin bei einem späteren NetBox-Upgrade erneut installiert wird.
2. Create a ``plugin_requirements.txt`` with ``netbox-slm`` as
contents.
3. Create a ``Dockerfile-SLM`` with contents:
```dockerfile ### 1. Git-Repository als lokale Anforderung eintragen
FROM netboxcommunity/netbox:vX.Y.Z
COPY ../pyproject.toml /tmp/ ```bash
RUN uv pip install -r /tmp/pyproject.toml echo "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" >> /opt/netbox/local_requirements.txt
``` ```
4. Create a ``docker-compose.override.yml`` with contents: Vorher kann geprüft werden, ob der Eintrag bereits vorhanden ist:
```yaml ```bash
version: "3.7" grep -qxF "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" /opt/netbox/local_requirements.txt || \
services: echo "git+https://git.mrblake.cc/MrBlake/NetBox-SLM.git" >> /opt/netbox/local_requirements.txt
netbox:
ports:
- "8000:8080"
build:
context: .
dockerfile: Dockerfile-SLM
image: netbox:slm
netbox-worker:
image: netbox:slm
netbox-housekeeping:
image: netbox:slm
``` ```
Now, build the image: ``docker compose build --no-cache`` ### 2. Plugin mit Pip installieren
And finally, run NetBox with the SLM plugin: ``docker compose up`` 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"
```
## Get in touch Alternativ können alle lokalen Anforderungen installiert werden:
Point of contact for this repository is [Mart Visser](https://github.com/MartVisser), who can be reached by [opening a new issue in this repository's issue tracker](https://github.com/ICTU/netbox_slm/issues/new). ```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",
]
```
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.
### 4. Datenbank migrieren und statische Dateien aktualisieren
```bash
cd /opt/netbox/netbox
/opt/netbox/venv/bin/python manage.py migrate
/opt/netbox/venv/bin/python manage.py collectstatic --no-input
```
### 5. NetBox-Dienste neu starten
Bei einer üblichen systemd-Installation:
```bash
sudo systemctl restart netbox netbox-rq
```
Je nach Installation können die Namen der Dienste abweichen.
## 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
```
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.
-16
View File
@@ -1,16 +0,0 @@
# NetBox SLM Security Policy
## Current status
NetBox SLM is a plugin for [NetBox](https://github.com/netbox-community/netbox/) with mostly default configuration.
In order to stay up to date, monitor NetBox security findings and update accordingly.
## Supported Versions
Only the latest version of NetBox SLM is currently being supported with security updates.
The intention is to keep the plugin compatible with the most recent NetBox version(s), there is no incentive to patch older tags.
## Reporting a Vulnerability
You can privately [report a vulnerability issue in this repository's issue tracker](https://github.com/ICTU/netbox_slm/security/advisories/new).
The aim is to get back to you within 24 hours, with a confirmation of the issue and a brief action plan or a request for more information.
-12
View File
@@ -1,12 +0,0 @@
StylesPath = styles
MinAlertLevel = suggestion
Vocab = Base
Packages = proselint
[*.md]
BasedOnStyles = Vale, proselint
[ci/styles/**]
BasedOnStyles =
-18
View File
@@ -1,18 +0,0 @@
ARG PYTHON_VERSION=3.14
FROM python:${PYTHON_VERSION}-alpine AS compile-image
# Python version build arg is not actually supported by NetBox container image, only used for maintenance convenience
WORKDIR /build
RUN python3 -m venv /opt/netbox/venv &&\
source /opt/netbox/venv/bin/activate
COPY ../ /build
RUN pip install -U .[build] &&\
python -m build &&\
pip install --no-index /build
FROM netboxcommunity/netbox:v4.6.3
ARG PYTHON_VERSION=3.14
COPY --from=compile-image /opt/netbox/venv/lib/python${PYTHON_VERSION}/site-packages/netbox_slm /opt/netbox/venv/lib/python${PYTHON_VERSION}/site-packages/netbox_slm
-5
View File
@@ -1,5 +0,0 @@
FROM netboxcommunity/netbox:v4.6.3
ARG PYTHON_VERSION=3.14
COPY ../pyproject.toml /tmp/
RUN uv pip install -r /tmp/pyproject.toml --extra ci
-48
View File
@@ -1,48 +0,0 @@
"""
Minimal NetBox config for NetBox SLM plugin testing, docs: https://netbox.readthedocs.io/en/stable/configuration/
Based on https://github.com/netbox-community/netbox/blob/v4.6.3/netbox/netbox/configuration_testing.py
"""
from os import environ
ALLOWED_HOSTS = ['*']
DATABASES = {
'default': {
'NAME': environ.get('DB_NAME', 'netbox'),
'USER': environ.get('DB_USER', ''),
'PASSWORD': environ.get('DB_PASSWORD', ''),
'HOST': environ.get('DB_HOST', 'localhost'),
'PORT': environ.get('DB_PORT', ''),
'CONN_MAX_AGE': int(environ.get('DB_CONN_MAX_AGE', '300')),
}
}
DEBUG = environ.get('DEBUG', 'False').lower() == 'true'
DEVELOPER = environ.get('DEVELOPER', 'False').lower() == 'true'
PLUGINS = ["netbox_slm"]
REDIS = {
'tasks': {
'HOST': environ.get('REDIS_HOST', 'localhost'),
'PORT': int(environ.get('REDIS_PORT', 6379)),
'PASSWORD': environ.get('REDIS_PASSWORD', ''),
'DATABASE': int(environ.get('REDIS_DATABASE', 0)),
'SSL': environ.get('REDIS_SSL', 'False').lower() == 'true',
},
'caching': {
'HOST': environ.get('REDIS_CACHE_HOST', environ.get('REDIS_HOST', 'localhost')),
'PORT': int(environ.get('REDIS_CACHE_PORT', environ.get('REDIS_PORT', 6379))),
'PASSWORD': environ.get('REDIS_CACHE_PASSWORD', environ.get('REDIS_PASSWORD', '')),
'DATABASE': int(environ.get('REDIS_CACHE_DATABASE', 1)),
'SSL': environ.get('REDIS_CACHE_SSL', environ.get('REDIS_SSL', 'False')).lower() == 'true',
},
}
SECRET_KEY = 'dummydummydummydummydummydummydummydummydummydummy'
DEFAULT_PERMISSIONS = {}
API_TOKEN_PEPPERS = {
1: 'TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE-TEST-VALUE-DO-NOT-USE',
}
-32
View File
@@ -1,32 +0,0 @@
---
services:
netbox:
ports:
- "8001:8080"
build:
context: ..
dockerfile: ci/Dockerfile-CI
image: netbox:slm
environment:
- COVERAGE_FILE=/tmp/.coverage
healthcheck:
disable: true
volumes:
- ./config:/etc/netbox/config:z,ro
- ./reports:/ci/reports:rw
- ../netbox_slm:/opt/netbox/netbox/netbox_slm:ro
netbox-worker:
image: netbox:slm
depends_on:
- netbox
healthcheck:
disable: true
volumes:
- ./config:/etc/netbox/config:z,ro
- ../netbox_slm:/opt/netbox/netbox/netbox_slm:ro
postgres:
ports:
- "5432:5432"
redis:
ports:
- "6379:6379"
-89
View File
@@ -1,89 +0,0 @@
---
# based on https://github.com/netbox-community/netbox-docker/blob/5.0.1/docker-compose.yml
services:
netbox: &netbox
image: docker.io/netboxcommunity/netbox:${VERSION-v4.6.3}
depends_on:
- postgres
- redis
- redis-cache
env_file: env/netbox.env
user: "netbox:root"
healthcheck:
test: curl -f http://localhost:8080/login/ || exit 1
start_period: 90s
timeout: 3s
interval: 15s
volumes:
- ./config:/etc/netbox/config:z,ro
- netbox-media-files:/opt/netbox/netbox/media:rw
- netbox-reports-files:/opt/netbox/netbox/reports:rw
- netbox-scripts-files:/opt/netbox/netbox/scripts:rw
netbox-worker:
<<: *netbox
depends_on:
netbox:
condition: service_healthy
command:
- /opt/netbox/venv/bin/python
- /opt/netbox/netbox/manage.py
- rqworker
healthcheck:
test: ps -aux | grep -v grep | grep -q rqworker || exit 1
start_period: 20s
timeout: 3s
interval: 15s
# postgres
postgres:
image: docker.io/postgres:18-alpine
healthcheck:
test: pg_isready -q -t 2 -d $$POSTGRES_DB -U $$POSTGRES_USER
start_period: 20s
timeout: 30s
interval: 10s
retries: 5
env_file: env/postgres.env
volumes:
- netbox-postgres:/var/lib/postgresql
# redis
redis:
image: docker.io/valkey/valkey:9.0-alpine
command:
- sh
- -c # this is to evaluate the $REDIS_PASSWORD from the env
- valkey-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose
healthcheck: &redis-healthcheck
test: '[ $$(valkey-cli --pass "$${REDIS_PASSWORD}" ping) = ''PONG'' ]'
start_period: 5s
timeout: 3s
interval: 1s
retries: 5
env_file: env/redis.env
volumes:
- netbox-redis-data:/data
redis-cache:
image: docker.io/valkey/valkey:9.0-alpine
command:
- sh
- -c # this is to evaluate the $REDIS_PASSWORD from the env
- valkey-server --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose
healthcheck: *redis-healthcheck
env_file: env/redis-cache.env
volumes:
- netbox-redis-cache-data:/data
volumes:
netbox-media-files:
driver: local
netbox-postgres:
driver: local
netbox-redis-cache-data:
driver: local
netbox-redis-data:
driver: local
netbox-reports-files:
driver: local
netbox-scripts-files:
driver: local
-42
View File
@@ -1,42 +0,0 @@
CORS_ORIGIN_ALLOW_ALL=True
DB_HOST=postgres
DB_NAME=netbox
DB_PASSWORD=J5brHrAXFLQSif0K
DB_USER=netbox
EMAIL_FROM=netbox@bar.com
EMAIL_PASSWORD=
EMAIL_PORT=25
EMAIL_SERVER=localhost
EMAIL_SSL_CERTFILE=
EMAIL_SSL_KEYFILE=
EMAIL_TIMEOUT=5
EMAIL_USERNAME=netbox
# EMAIL_USE_SSL and EMAIL_USE_TLS are mutually exclusive, i.e. they can't both be `true`!
EMAIL_USE_SSL=false
EMAIL_USE_TLS=false
GRAPHQL_ENABLED=true
HOUSEKEEPING_INTERVAL=86400
MAX_PAGE_SIZE=1000
MEDIA_ROOT=/opt/netbox/netbox/media
METRICS_ENABLED=false
NAPALM_PASSWORD=
NAPALM_TIMEOUT=10
NAPALM_USERNAME=
REDIS_CACHE_DATABASE=1
REDIS_CACHE_HOST=redis-cache
REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY=false
REDIS_CACHE_PASSWORD=t4Ph722qJ5QHeQ1qfu36
REDIS_CACHE_SSL=false
REDIS_DATABASE=0
REDIS_HOST=redis
REDIS_INSECURE_SKIP_TLS_VERIFY=false
REDIS_PASSWORD=H733Kdjndks81
REDIS_SSL=false
RELEASE_CHECK_URL=https://api.github.com/repos/netbox-community/netbox/releases
SECRET_KEY=r8OwDznj!!dci#P9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
SKIP_SUPERUSER=false
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
SUPERUSER_EMAIL=admin@example.com
SUPERUSER_NAME=admin
SUPERUSER_PASSWORD=admin
WEBHOOKS_ENABLED=true
-3
View File
@@ -1,3 +0,0 @@
POSTGRES_DB=netbox
POSTGRES_PASSWORD=J5brHrAXFLQSif0K
POSTGRES_USER=netbox
-1
View File
@@ -1 +0,0 @@
REDIS_PASSWORD=t4Ph722qJ5QHeQ1qfu36
-1
View File
@@ -1 +0,0 @@
REDIS_PASSWORD=H733Kdjndks81
View File
@@ -1,66 +0,0 @@
Anchore
APIs
Caddy
Checkmarx
Cobertura
Dependabot
Docker-composition
Dockerfile
DTDs
ESLint
Gravatar
Jira
JMeter
JUnit
NCover
Nginx
OJAudit
OpenShift
PDFs
Pydantic
Robocop
Snyk
Trello
Trivy
UUIDs
Wekan
[Hh]ostname
[Uu]nmerged
asyncio
autoformatting
breakpoint
clearable
cloc
discoverability
donut
errored
favicon
fixme
hostnames?
hotspots?
lookback
misconfigured
mypy
namespace
npm
parameterizable
phpldapadmin
[Pp]erformancetest
severities
sparkline
subfolders
submenus
suppressions
todo
tooltips?
tracebacks?
unencrypted
unicode
unmerged
upvotes
url
xml
NetBox
NetBox SLM
Mart
Visser
@@ -1,3 +0,0 @@
docker composition
Docker composition
docker-composition
+14 -3
View File
@@ -15,15 +15,16 @@ limitations under the License.
""" """
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from django.utils.translation import gettext, gettext_lazy as _
__version__ = "1.9.0" __version__ = "1.13.0"
class SLMConfig(PluginConfig): class SLMConfig(PluginConfig):
name = "netbox_slm" name = "netbox_slm"
verbose_name = "Software Lifecycle Management" verbose_name = _("Licences")
version = __version__ version = __version__
description = "Software Lifecycle Management Netbox Plugin." description = _("Licence Management NetBox Plugin.")
author = "ICTU" author = "ICTU"
author_email = "open-source-projects@ictu.nl" author_email = "open-source-projects@ictu.nl"
base_url = "slm" base_url = "slm"
@@ -35,5 +36,15 @@ class SLMConfig(PluginConfig):
"link_virtualmachine_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 config = SLMConfig
+63
View File
@@ -1,4 +1,6 @@
from rest_framework import serializers
from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField from rest_framework.serializers import SerializerMethodField, HyperlinkedIdentityField
from django.utils.translation import gettext_lazy as _
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
@@ -22,11 +24,24 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
"stored_location_url", "stored_location_url",
"start_date", "start_date",
"expiration_date", "expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support", "support",
"license_amount", "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", "software_product",
"version", "version",
"installation", "installation",
"tenant_group",
"tenant",
"tags", "tags",
"comments", "comments",
"custom_field_data", "custom_field_data",
@@ -38,6 +53,54 @@ class SoftwareLicenseSerializer(NetBoxModelSerializer):
def get_display(self, obj): def get_display(self, obj):
return f"{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): class SoftwareProductSerializer(NetBoxModelSerializer):
display = SerializerMethodField() display = SerializerMethodField()
+1
View File
@@ -0,0 +1 @@
LICENSE_EXPIRING = "netbox_slm_license_expiring"
+16 -7
View File
@@ -1,5 +1,6 @@
from django.db.models import Q from django.db.models import Q
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter from django.utils.translation import gettext_lazy as _
from django_filters import CharFilter, ModelMultipleChoiceFilter, MultipleChoiceFilter, NumberFilter
from dcim.models import Device, Manufacturer from dcim.models import Device, Manufacturer
from netbox.filtersets import NetBoxModelFilterSet from netbox.filtersets import NetBoxModelFilterSet
@@ -9,8 +10,10 @@ from netbox_slm.models import (
SoftwareProductInstallation, SoftwareProductInstallation,
SoftwareLicense, SoftwareLicense,
SoftwareReleaseTypes, SoftwareReleaseTypes,
RenewalIntervalUnits,
) )
from virtualization.models import Cluster, VirtualMachine from virtualization.models import Cluster, VirtualMachine
from tenancy.models import Tenant, TenantGroup
class SoftwareProductFilterSet(NetBoxModelFilterSet): class SoftwareProductFilterSet(NetBoxModelFilterSet):
@@ -54,7 +57,7 @@ class SoftwareProductVersionFilterSet(NetBoxModelFilterSet):
software_product_id = ModelMultipleChoiceFilter( software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
label="Software Product", label=_("Software Product"),
) )
class Meta: class Meta:
@@ -81,12 +84,12 @@ class SoftwareProductInstallationFilterSet(NetBoxModelFilterSet):
device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all()) device_id = ModelMultipleChoiceFilter(queryset=Device.objects.all())
virtualmachine_id = ModelMultipleChoiceFilter( virtualmachine_id = ModelMultipleChoiceFilter(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
label="Virtual Machine", label=_("Virtual Machine"),
) )
cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all()) cluster_id = ModelMultipleChoiceFilter(queryset=Cluster.objects.all())
software_product_id = ModelMultipleChoiceFilter( software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
label="Software Product", label=_("Software Product"),
) )
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all()) version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
@@ -113,19 +116,23 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
name = CharFilter(lookup_expr="icontains") name = CharFilter(lookup_expr="icontains")
description = CharFilter(lookup_expr="icontains") description = CharFilter(lookup_expr="icontains")
type = CharFilter(lookup_expr="icontains") type = CharFilter(lookup_expr="icontains")
spdx_expression = CharFilter(lookup_expr="icontains", label="SPDX expression") spdx_expression = CharFilter(lookup_expr="icontains", label=_("SPDX expression"))
stored_location = CharFilter(lookup_expr="icontains") stored_location = CharFilter(lookup_expr="icontains")
renewal_interval = NumberFilter()
renewal_interval_unit = MultipleChoiceFilter(choices=RenewalIntervalUnits.choices)
software_product_id = ModelMultipleChoiceFilter( software_product_id = ModelMultipleChoiceFilter(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
label="Software Product", label=_("Software Product"),
) )
version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all()) version_id = ModelMultipleChoiceFilter(queryset=SoftwareProductVersion.objects.all())
installation_id = ModelMultipleChoiceFilter(queryset=SoftwareProductInstallation.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: class Meta:
model = SoftwareLicense model = SoftwareLicense
fields = ("support",) fields = ("support", "lifetime", "expiration_notifications_enabled", "email_notifications")
def search(self, queryset, name, value): def search(self, queryset, name, value):
"""Perform the filtered search.""" """Perform the filtered search."""
@@ -139,6 +146,8 @@ class SoftwareLicenseFilterSet(NetBoxModelFilterSet):
| Q(installation__device__name__icontains=value) | Q(installation__device__name__icontains=value)
| Q(installation__virtualmachine__name__icontains=value) | Q(installation__virtualmachine__name__icontains=value)
| Q(installation__cluster__name__icontains=value) | Q(installation__cluster__name__icontains=value)
| Q(tenant_group__name__icontains=value)
| Q(tenant__name__icontains=value)
| Q(comments__icontains=value) | Q(comments__icontains=value)
) )
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
+155 -10
View File
@@ -1,5 +1,6 @@
from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField from django.forms import CharField, DateField, ChoiceField, IntegerField, NullBooleanField, Textarea
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
from netbox_slm.models import ( from netbox_slm.models import (
@@ -7,8 +8,11 @@ from netbox_slm.models import (
SoftwareProductVersion, SoftwareProductVersion,
SoftwareProductInstallation, SoftwareProductInstallation,
SoftwareLicense, SoftwareLicense,
RenewalIntervalUnits,
NotificationIntervalUnits,
spdx_license_names, spdx_license_names,
) )
from tenancy.models import Tenant, TenantGroup
from utilities.forms.constants import BOOLEAN_WITH_BLANK_CHOICES from utilities.forms.constants import BOOLEAN_WITH_BLANK_CHOICES
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, CommentField,
@@ -23,16 +27,55 @@ from utilities.forms.widgets import APISelect, DatePicker
class SoftwareLicenseForm(NetBoxModelForm): class SoftwareLicenseForm(NetBoxModelForm):
comments = CommentField() 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") spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
stored_location_url = LaxURLField(required=False) stored_location_url = LaxURLField(required=False)
start_date = DateField(required=False, widget=DatePicker()) start_date = DateField(required=False, widget=DatePicker())
expiration_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( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=True, required=True,
label="Software Product", label=_("Software Product"),
) )
version = DynamicModelChoiceField( version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
@@ -48,6 +91,17 @@ class SoftwareLicenseForm(NetBoxModelForm):
), ),
query_params=dict(software_product="$software_product"), 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: class Meta:
model = SoftwareLicense model = SoftwareLicense
@@ -61,10 +115,23 @@ class SoftwareLicenseForm(NetBoxModelForm):
"stored_location_url", "stored_location_url",
"start_date", "start_date",
"expiration_date", "expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support", "support",
"license_amount", "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", "version",
"installation", "installation",
"tenant_group",
"tenant",
"tags", "tags",
"comments", "comments",
) )
@@ -81,9 +148,16 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
"spdx_expression", "spdx_expression",
"stored_location", "stored_location",
"support", "support",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"expiration_notifications_enabled",
"email_notifications",
"software_product_id", "software_product_id",
"version_id", "version_id",
"installation_id", "installation_id",
"tenant_group_id",
"tenant_id",
), ),
) )
selector_fields = ("q", "filter_id", "name") selector_fields = ("q", "filter_id", "name")
@@ -93,29 +167,47 @@ class SoftwareLicenseFilterForm(NetBoxModelFilterSetForm):
name = CharField(required=False) name = CharField(required=False)
description = CharField(required=False) description = CharField(required=False)
type = CharField(required=False) type = CharField(required=False)
spdx_expression = CharField(required=False, label="SPDX expression") spdx_expression = CharField(required=False, label=_("SPDX expression"))
stored_location = CharField(required=False) stored_location = CharField(required=False)
support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES) 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( software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", label=_("Software Product"),
) )
version_id = DynamicModelMultipleChoiceField( version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
required=False, required=False,
label="Version", label=_("Version"),
) )
installation_id = DynamicModelMultipleChoiceField( installation_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductInstallation.objects.all(), queryset=SoftwareProductInstallation.objects.all(),
required=False, required=False,
label="Installation", 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): class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
support = NullBooleanField(required=False, help_text="Support (values other than 'True' and 'False' are ignored)") support = NullBooleanField(
required=False, help_text=_("Support (values other than 'True' and 'False' are ignored)")
)
class Meta: class Meta:
model = SoftwareLicense model = SoftwareLicense
@@ -128,10 +220,22 @@ class SoftwareLicenseBulkImportForm(NetBoxModelImportForm):
"stored_location", "stored_location",
"start_date", "start_date",
"expiration_date", "expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support", "support",
"license_amount", "license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"version", "version",
"installation", "installation",
"tenant_group",
"tenant",
"tags", "tags",
) )
@@ -146,11 +250,23 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"stored_location_url", "stored_location_url",
"start_date", "start_date",
"expiration_date", "expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"support", "support",
"license_amount", "license_amount",
"license_key",
"provider_portal_url",
"expiration_notifications_enabled",
"default_notification_intervals",
"custom_notification_interval",
"custom_notification_interval_unit",
"email_notifications",
"software_product", "software_product",
"version", "version",
"installation", "installation",
"tenant_group",
"tenant",
), ),
) )
nullable_fields = ( nullable_fields = (
@@ -160,28 +276,46 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
"stored_location_url", "stored_location_url",
"start_date", "start_date",
"expiration_date", "expiration_date",
"renewal_interval",
"renewal_interval_unit",
"support", "support",
"license_amount", "license_amount",
"license_key",
"provider_portal_url",
"custom_notification_interval",
"custom_notification_interval_unit",
"version", "version",
"installation", "installation",
"tenant_group",
"tenant",
) )
tag = TagFilterField(model) tag = TagFilterField(model)
comments = CommentField() comments = CommentField()
type = CharField(required=False) type = CharField(required=False)
spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label="SPDX expression") spdx_expression = ChoiceField(required=False, choices=spdx_license_names(), label=_("SPDX expression"))
stored_location = CharField(required=False) stored_location = CharField(required=False)
stored_location_url = LaxURLField(required=False) stored_location_url = LaxURLField(required=False)
start_date = DateField(required=False, widget=DatePicker()) start_date = DateField(required=False, widget=DatePicker())
expiration_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) support = ChoiceField(required=False, choices=BOOLEAN_WITH_BLANK_CHOICES)
license_amount = IntegerField(required=False, min_value=0) 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( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", label=_("Software Product"),
) )
version = DynamicModelChoiceField( version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
@@ -197,3 +331,14 @@ class SoftwareLicenseBulkEditForm(NetBoxModelBulkEditForm):
), ),
query_params=dict(software_product="$software_product"), 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"),
)
+2 -1
View File
@@ -1,4 +1,5 @@
from django.forms import CharField from django.forms import CharField
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -46,7 +47,7 @@ class SoftwareProductFilterForm(NetBoxModelFilterSetForm):
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,
label="Manufacturer", label=_("Manufacturer"),
) )
@@ -1,5 +1,6 @@
from django.forms import ValidationError from django.forms import ValidationError
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Device from dcim.models import Device
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -17,18 +18,32 @@ from virtualization.models import Cluster, VirtualMachine
class SoftwareProductInstallationForm(NetBoxModelForm): class SoftwareProductInstallationForm(NetBoxModelForm):
comments = CommentField() 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) device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
help_text=_("Select either a device, a virtual machine, or a cluster."),
)
virtualmachine = DynamicModelChoiceField( virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
label="Virtual Machine", 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."),
) )
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=True, required=True,
label="Software Product", label=_("Software Product"),
) )
version = DynamicModelChoiceField( version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
@@ -54,11 +69,22 @@ class SoftwareProductInstallationForm(NetBoxModelForm):
software_product = self.cleaned_data["software_product"] software_product = self.cleaned_data["software_product"]
if version not in software_product.softwareproductversion_set.all(): if version not in software_product.softwareproductversion_set.all():
raise ValidationError( raise ValidationError(
f"Version '{version}' doesn't exist on {software_product}, make sure you've " _("Version '%(version)s' does not belong to %(product)s. Select a compatible version.")
f"selected a compatible version or first select the software product." % {"version": version, "product": software_product}
) )
return version 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): class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
model = SoftwareProductInstallation model = SoftwareProductInstallation
@@ -73,27 +99,27 @@ class SoftwareProductInstallationFilterForm(NetBoxModelFilterSetForm):
device_id = DynamicModelMultipleChoiceField( device_id = DynamicModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
label="Device", label=_("Device"),
) )
virtualmachine_id = DynamicModelMultipleChoiceField( virtualmachine_id = DynamicModelMultipleChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
label="Virtual Machine", label=_("Virtual Machine"),
) )
cluster_id = DynamicModelMultipleChoiceField( cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False, required=False,
label="Cluster", label=_("Cluster"),
) )
software_product_id = DynamicModelMultipleChoiceField( software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", label=_("Software Product"),
) )
version_id = DynamicModelMultipleChoiceField( version_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
required=False, required=False,
label="Version", label=_("Version"),
) )
@@ -122,13 +148,13 @@ class SoftwareProductInstallationBulkEditForm(NetBoxModelBulkEditForm):
virtualmachine = DynamicModelChoiceField( virtualmachine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
label="Virtual Machine", label=_("Virtual Machine"),
) )
cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False) cluster = DynamicModelChoiceField(queryset=Cluster.objects.all(), required=False)
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", label=_("Software Product"),
) )
version = DynamicModelChoiceField( version = DynamicModelChoiceField(
queryset=SoftwareProductVersion.objects.all(), queryset=SoftwareProductVersion.objects.all(),
+8 -5
View File
@@ -1,5 +1,6 @@
from django.forms import DateField, CharField, MultipleChoiceField from django.forms import DateField, CharField, MultipleChoiceField
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.utils.translation import gettext_lazy as _
from dcim.models import Manufacturer from dcim.models import Manufacturer
from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm, NetBoxModelImportForm, NetBoxModelBulkEditForm
@@ -23,7 +24,7 @@ class SoftwareProductVersionForm(NetBoxModelForm):
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=True, required=True,
label="Software Product", label=_("Software Product"),
widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}), widget=APISelect(attrs={"data-url": reverse_lazy("plugins-api:netbox_slm-api:softwareproduct-list")}),
) )
@@ -64,17 +65,19 @@ class SoftwareProductVersionFilterForm(NetBoxModelFilterSetForm):
manufacturer_id = DynamicModelMultipleChoiceField( manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False, required=False,
label="Manufacturer", label=_("Manufacturer"),
) )
software_product_id = DynamicModelMultipleChoiceField( software_product_id = DynamicModelMultipleChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", label=_("Software Product"),
) )
class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm): class SoftwareProductVersionBulkImportForm(NetBoxModelImportForm):
release_type = CharField(help_text=f"Release type (possible values: {SoftwareReleaseTypes.values})") release_type = CharField(
help_text=_("Release type (possible values: %(values)s)") % {"values": SoftwareReleaseTypes.values}
)
class Meta: class Meta:
model = SoftwareProductVersion model = SoftwareProductVersion
@@ -122,5 +125,5 @@ class SoftwareProductVersionBulkEditForm(NetBoxModelBulkEditForm):
software_product = DynamicModelChoiceField( software_product = DynamicModelChoiceField(
queryset=SoftwareProduct.objects.all(), queryset=SoftwareProduct.objects.all(),
required=False, required=False,
label="Software Product", 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,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",
},
),
]
+217 -41
View File
@@ -1,7 +1,13 @@
import calendar
from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from license_expression import Licensing, get_spdx_licensing from license_expression import Licensing, get_spdx_licensing
from netbox.models import NetBoxModel from netbox.models import NetBoxModel
@@ -21,14 +27,20 @@ class LaxURLField(models.URLField):
class SoftwareProduct(NetBoxModel): class SoftwareProduct(NetBoxModel):
name = models.CharField(max_length=128) name = models.CharField(_("name"), max_length=128)
comments = models.TextField(blank=True) comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True)
manufacturer = models.ForeignKey(to="dcim.Manufacturer", on_delete=models.PROTECT, 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() objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product")
verbose_name_plural = _("software products")
def __str__(self): def __str__(self):
return self.name return self.name
@@ -44,25 +56,38 @@ class SoftwareProduct(NetBoxModel):
class SoftwareReleaseTypes(models.TextChoices): class SoftwareReleaseTypes(models.TextChoices):
ALPHA = "A", "Alpha" ALPHA = "A", _("Alpha")
BETA = "B", "Beta" BETA = "B", _("Beta")
RELEASE_CANDIDATE = "RC", "Release candidate" RELEASE_CANDIDATE = "RC", _("Release candidate")
STABLE = "S", "Stable release" 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): class SoftwareProductVersion(NetBoxModel):
name = models.CharField(max_length=64) name = models.CharField(_("name"), max_length=64)
comments = models.TextField(blank=True) comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True)
release_date = models.DateField(null=True, blank=True) release_date = models.DateField(_("release date"), null=True, blank=True)
documentation_url = LaxURLField(max_length=1024, null=True, blank=True) documentation_url = LaxURLField(_("documentation URL"), max_length=1024, null=True, blank=True)
end_of_support = models.DateField(null=True, blank=True) end_of_support = models.DateField(_("end of support"), null=True, blank=True)
filename = models.CharField(max_length=64, null=True, blank=True) filename = models.CharField(_("filename"), max_length=64, null=True, blank=True)
file_checksum = models.CharField(max_length=128, null=True, blank=True) file_checksum = models.CharField(_("file checksum"), max_length=128, null=True, blank=True)
file_link = LaxURLField(max_length=1024, null=True, blank=True) file_link = LaxURLField(_("file link"), max_length=1024, null=True, blank=True)
release_type = models.CharField( release_type = models.CharField(
_("release type"),
max_length=3, max_length=3,
choices=SoftwareReleaseTypes.choices, choices=SoftwareReleaseTypes.choices,
default=SoftwareReleaseTypes.STABLE, default=SoftwareReleaseTypes.STABLE,
@@ -70,11 +95,16 @@ class SoftwareProductVersion(NetBoxModel):
software_product = models.ForeignKey( software_product = models.ForeignKey(
to="netbox_slm.SoftwareProduct", to="netbox_slm.SoftwareProduct",
verbose_name=_("software product"),
on_delete=models.PROTECT, on_delete=models.PROTECT,
) )
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
class Meta:
verbose_name = _("software product version")
verbose_name_plural = _("software product versions")
def __str__(self): def __str__(self):
return self.name return self.name
@@ -88,15 +118,27 @@ class SoftwareProductVersion(NetBoxModel):
class SoftwareProductInstallation(NetBoxModel): class SoftwareProductInstallation(NetBoxModel):
comments = models.TextField(blank=True) comments = models.TextField(_("comments"), blank=True)
device = models.ForeignKey(to="dcim.Device", on_delete=models.PROTECT, null=True, blank=True) device = models.ForeignKey(
virtualmachine = models.ForeignKey( to="dcim.Device", verbose_name=_("device"), on_delete=models.PROTECT, null=True, blank=True
to="virtualization.VirtualMachine", 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
) )
cluster = models.ForeignKey(to="virtualization.Cluster", on_delete=models.PROTECT, null=True, blank=True)
software_product = models.ForeignKey(to="netbox_slm.SoftwareProduct", on_delete=models.PROTECT)
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@@ -104,6 +146,8 @@ class SoftwareProductInstallation(NetBoxModel):
return f"{self.pk} ({self.platform})" return f"{self.pk} ({self.platform})"
class Meta: class Meta:
verbose_name = _("software product installation")
verbose_name_plural = _("software product installations")
constraints = [ constraints = [
models.CheckConstraint( models.CheckConstraint(
name="%(app_label)s_%(class)s_platform", name="%(app_label)s_%(class)s_platform",
@@ -112,7 +156,9 @@ class SoftwareProductInstallation(NetBoxModel):
| models.Q(device__isnull=True, virtualmachine__isnull=False, 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) | models.Q(device__isnull=True, virtualmachine__isnull=True, cluster__isnull=False)
), ),
violation_error_message="Installation requires exactly one platform destination.", violation_error_message=_(
"Select exactly one platform destination: device, virtual machine, or cluster."
),
) )
] ]
@@ -141,39 +187,169 @@ def spdx_license_names():
def validate_spdx_expression(value): def validate_spdx_expression(value):
expression_info = spdx_licensing.validate(value) expression_info = spdx_licensing.validate(value)
if expression_info.errors: if expression_info.errors:
raise ValidationError(f"{value} is not a known SPDX license expression") raise ValidationError(_("%(value)s is not a known SPDX license expression") % {"value": value})
class SoftwareLicense(NetBoxModel): class SoftwareLicense(NetBoxModel):
name = models.CharField(max_length=128) name = models.CharField(_("name"), max_length=128)
comments = models.TextField(blank=True) comments = models.TextField(_("comments"), blank=True)
description = models.CharField(max_length=255, null=True, blank=True) description = models.CharField(_("description"), max_length=255, null=True, blank=True)
type = models.CharField(max_length=128) type = models.CharField(_("type"), max_length=128, blank=True)
spdx_expression = models.CharField(max_length=64, null=True, blank=True, validators=[validate_spdx_expression]) spdx_expression = models.CharField(
stored_location = models.CharField(max_length=255, null=True, blank=True) _("SPDX expression"), max_length=64, null=True, blank=True, validators=[validate_spdx_expression]
stored_location_url = LaxURLField(max_length=1024, null=True, blank=True) )
start_date = models.DateField(null=True, blank=True) stored_location = models.CharField(_("stored location"), max_length=255, null=True, blank=True)
expiration_date = models.DateField(null=True, blank=True) stored_location_url = LaxURLField(_("stored location URL"), max_length=1024, null=True, blank=True)
support = models.BooleanField(default=None, null=True, blank=True) start_date = models.DateField(_("start date"), null=True, blank=True)
license_amount = models.PositiveIntegerField(default=None, 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", on_delete=models.PROTECT) software_product = models.ForeignKey(
version = models.ForeignKey(to="netbox_slm.SoftwareProductVersion", on_delete=models.PROTECT, null=True, blank=True) 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( installation = models.ForeignKey(
to="netbox_slm.SoftwareProductInstallation", on_delete=models.SET_NULL, null=True, blank=True 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() 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): def __str__(self):
return self.name return self.name
def get_absolute_url(self): def get_absolute_url(self):
return reverse("plugins:netbox_slm:softwarelicense", kwargs={"pk": self.pk}) 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 @property
def stored_location_txt(self): def stored_location_txt(self):
if self.stored_location_url and not self.stored_location: if self.stored_location_url and not self.stored_location:
return "Link" return _("Link")
return self.stored_location 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",
)
]
+14 -13
View File
@@ -1,21 +1,22 @@
from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config from netbox.plugins import PluginMenuButton, PluginMenuItem, PluginMenu, get_plugin_config
from django.utils.translation import gettext_lazy as _
from . import SLMConfig from . import SLMConfig
slm_items = ( slm_items = (
PluginMenuItem( PluginMenuItem(
link="plugins:netbox_slm:softwareproduct_list", link="plugins:netbox_slm:softwareproduct_list",
link_text="Software Products", link_text=_("Software Products"),
permissions=["netbox_slm.add_softwareproduct"], permissions=["netbox_slm.add_softwareproduct"],
buttons=( buttons=(
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproduct_add", "plugins:netbox_slm:softwareproduct_add",
"Add", _("Add"),
"mdi mdi-plus-thick", "mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproduct"], permissions=["netbox_slm.add_softwareproduct"],
), ),
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproduct_bulk_import", "plugins:netbox_slm:softwareproduct_bulk_import",
"Import", _("Import"),
"mdi mdi-upload", "mdi mdi-upload",
permissions=["netbox_slm.import_softwareproduct"], permissions=["netbox_slm.import_softwareproduct"],
), ),
@@ -23,18 +24,18 @@ slm_items = (
), ),
PluginMenuItem( PluginMenuItem(
link="plugins:netbox_slm:softwareproductversion_list", link="plugins:netbox_slm:softwareproductversion_list",
link_text="Versions", link_text=_("Versions"),
permissions=["netbox_slm.add_softwareproductversion"], permissions=["netbox_slm.add_softwareproductversion"],
buttons=( buttons=(
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_add", "plugins:netbox_slm:softwareproductversion_add",
"Add", _("Add"),
"mdi mdi-plus-thick", "mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductversion"], permissions=["netbox_slm.add_softwareproductversion"],
), ),
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproductversion_bulk_import", "plugins:netbox_slm:softwareproductversion_bulk_import",
"Import", _("Import"),
"mdi mdi-upload", "mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductversion"], permissions=["netbox_slm.import_softwareproductversion"],
), ),
@@ -42,18 +43,18 @@ slm_items = (
), ),
PluginMenuItem( PluginMenuItem(
link="plugins:netbox_slm:softwareproductinstallation_list", link="plugins:netbox_slm:softwareproductinstallation_list",
link_text="Installations", link_text=_("Installations"),
permissions=["netbox_slm.add_softwareproductinstallation"], permissions=["netbox_slm.add_softwareproductinstallation"],
buttons=( buttons=(
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_add", "plugins:netbox_slm:softwareproductinstallation_add",
"Add", _("Add"),
"mdi mdi-plus-thick", "mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwareproductinstallation"], permissions=["netbox_slm.add_softwareproductinstallation"],
), ),
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwareproductinstallation_bulk_import", "plugins:netbox_slm:softwareproductinstallation_bulk_import",
"Import", _("Import"),
"mdi mdi-upload", "mdi mdi-upload",
permissions=["netbox_slm.import_softwareproductinstallation"], permissions=["netbox_slm.import_softwareproductinstallation"],
), ),
@@ -61,18 +62,18 @@ slm_items = (
), ),
PluginMenuItem( PluginMenuItem(
link="plugins:netbox_slm:softwarelicense_list", link="plugins:netbox_slm:softwarelicense_list",
link_text="Licenses", link_text=_("Licenses"),
permissions=["netbox_slm.add_softwarelicense"], permissions=["netbox_slm.add_softwarelicense"],
buttons=( buttons=(
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwarelicense_add", "plugins:netbox_slm:softwarelicense_add",
"Add", _("Add"),
"mdi mdi-plus-thick", "mdi mdi-plus-thick",
permissions=["netbox_slm.add_softwarelicense"], permissions=["netbox_slm.add_softwarelicense"],
), ),
PluginMenuButton( PluginMenuButton(
"plugins:netbox_slm:softwarelicense_bulk_import", "plugins:netbox_slm:softwarelicense_bulk_import",
"Import", _("Import"),
"mdi mdi-upload", "mdi mdi-upload",
permissions=["netbox_slm.import_softwarelicense"], permissions=["netbox_slm.import_softwarelicense"],
), ),
@@ -82,7 +83,7 @@ slm_items = (
if get_plugin_config("netbox_slm", "top_level_menu"): if get_plugin_config("netbox_slm", "top_level_menu"):
menu = PluginMenu( menu = PluginMenu(
label="Software Lifecycle", label=_("Licences"),
groups=((SLMConfig.verbose_name, slm_items),), groups=((SLMConfig.verbose_name, slm_items),),
icon_class="mdi mdi-content-save", icon_class="mdi mdi-content-save",
) )
+20 -4
View File
@@ -1,5 +1,6 @@
import django_tables2 as tables import django_tables2 as tables
from django.db.models import Count, F, Value 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.tables import NetBoxTable, ToggleColumn, columns
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
@@ -45,7 +46,7 @@ class SoftwareProductVersionTable(NetBoxTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.LinkColumn() name = tables.LinkColumn()
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True) software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True) manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
installations = tables.Column(accessor="get_installation_count") installations = tables.Column(accessor="get_installation_count")
@@ -89,7 +90,7 @@ class SoftwareProductInstallationTable(NetBoxTable):
platform = tables.Column(accessor="platform", linkify=True) platform = tables.Column(accessor="platform", linkify=True)
type = tables.Column(accessor="render_type") type = tables.Column(accessor="render_type")
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True) manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True) software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
version = tables.Column(accessor="version", linkify=True) version = tables.Column(accessor="version", linkify=True)
tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list") tags = columns.TagColumn(url_name="plugins:netbox_slm:softwareproductinstallation_list")
@@ -137,13 +138,16 @@ class SoftwareLicenseTable(NetBoxTable):
name = tables.LinkColumn() name = tables.LinkColumn()
type = tables.Column() type = tables.Column()
spdx_expression = tables.Column(verbose_name="SPDX expression") spdx_expression = tables.Column(verbose_name=_("SPDX expression"))
stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url) stored_location = tables.Column(accessor="stored_location_txt", linkify=lambda record: record.stored_location_url)
manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True) manufacturer = tables.Column(accessor="software_product__manufacturer", linkify=True)
software_product = tables.Column(accessor="software_product", verbose_name="Software Product", linkify=True) software_product = tables.Column(accessor="software_product", verbose_name=_("Software Product"), linkify=True)
version = tables.Column(accessor="version", linkify=True) version = tables.Column(accessor="version", linkify=True)
installation = tables.Column(accessor="installation", 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") tags = columns.TagColumn(url_name="plugins:netbox_slm:softwarelicense_list")
@@ -158,12 +162,20 @@ class SoftwareLicenseTable(NetBoxTable):
"stored_location", "stored_location",
"start_date", "start_date",
"expiration_date", "expiration_date",
"lifetime",
"renewal_interval",
"renewal_interval_unit",
"manufacturer", "manufacturer",
"software_product", "software_product",
"version", "version",
"installation", "installation",
"tenant_group",
"tenant",
"support", "support",
"license_amount", "license_amount",
"provider_portal_url",
"expiration_notifications_enabled",
"email_notifications",
"tags", "tags",
) )
default_columns = ( default_columns = (
@@ -173,7 +185,11 @@ class SoftwareLicenseTable(NetBoxTable):
"manufacturer", "manufacturer",
"software_product", "software_product",
"installation", "installation",
"tenant_group",
"tenant",
"expiration_date", "expiration_date",
"lifetime",
"expiration_notifications_enabled",
"tags", "tags",
) )
@@ -1,12 +1,13 @@
{% load helpers %} {% load helpers %}
{% load i18n %}
<div class="card"> <div class="card">
<h2 class="card-header"> <h2 class="card-header">
Installations {% trans "Installations" %}
{% if perms.netbox_slm.add_softwareproductinstallation %} {% if perms.netbox_slm.add_softwareproductinstallation %}
<div class="card-actions"> <div class="card-actions">
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation_add' %}?{{ search_obj }}={{ object.pk }}" class="btn btn-ghost-primary btn-sm"> <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> Add an installation <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add an installation" %}
</a> </a>
</div> </div>
{% endif %} {% endif %}
@@ -3,45 +3,54 @@
{% load static %} {% load static %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %}
{% block content %} {% block content %}
<div class="row my-3"> <div class="row my-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Software License {% trans "Software License" %}
</h5> </h5>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Name</th> <th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Description</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td> <td>{{ object.description }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Type</th> <th scope="row">{% trans "Type" %}</th>
<td>{{ object.type }}</td> <td>{{ object.type }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">SPDX expression</th> <th scope="row">{% trans "SPDX expression" %}</th>
<td>{{ object.spdx_expression }}</td> <td>{{ object.spdx_expression }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Software Product</th> <th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td> <td>{{ object.software_product|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Version</th> <th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td> <td>{{ object.version|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Installation</th> <th scope="row">{% trans "Installation" %}</th>
<td>{{ object.installation|linkify }}</td> <td>{{ object.installation|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Stored location</th> <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 %} {% if object.stored_location_url %}
<td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td> <td><a href="{{ object.stored_location_url }}">{{ object.stored_location_txt }}</a></td>
{% else %} {% else %}
@@ -49,21 +58,97 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<th scope="row">Start date</th> <th scope="row">{% trans "Start date" %}</th>
<td>{{ object.start_date }}</td> <td>{{ object.start_date }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Expiration date</th> <th scope="row">{% trans "Expiration date" %}</th>
<td>{{ object.expiration_date }}</td> <td>{{ object.expiration_date }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Support</th> <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> <td>{{ object.support }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">License amount</th> <th scope="row">{% trans "License amount" %}</th>
<td>{{ object.license_amount }}</td> <td>{{ object.license_amount }}</td>
</tr> </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> </table>
</div> </div>
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
@@ -3,60 +3,61 @@
{% load static %} {% load static %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %}
{% block content %} {% block content %}
<div class="row my-3"> <div class="row my-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Software Product {% trans "Software Product" %}
</h5> </h5>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Name</th> <th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Description</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td> <td>{{ object.description }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Manufacturer</th> <th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.manufacturer|linkify }}</td> <td>{{ object.manufacturer|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Versions</th> <th scope="row">{% trans "Versions" %}</th>
<td> <td>
{% for version in object.softwareproductversion_set.all %} {% for version in object.softwareproductversion_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwareproductversion' pk=version.pk %}" class="btn btn-sm">
{{ version }} {{ version }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Installations</th> <th scope="row">{% trans "Installations" %}</th>
<td> <td>
{% for installation in object.softwareproductinstallation_set.all %} {% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }} {{ installation }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Licenses</th> <th scope="row">{% trans "Licenses" %}</th>
<td> <td>
{% for license in object.softwarelicense_set.all %} {% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }} {{ license }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
@@ -3,48 +3,49 @@
{% load static %} {% load static %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %}
{% block content %} {% block content %}
<div class="row my-3"> <div class="row my-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Software Product Installation {% trans "Software Product Installation" %}
</h5> </h5>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
{% if object.device %} {% if object.device %}
<tr> <tr>
<th scope="row">Device</th> <th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td> <td>{{ object.device|linkify }}</td>
</tr> </tr>
{% elif object.virtualmachine %} {% elif object.virtualmachine %}
<tr> <tr>
<th scope="row">Virtual Machine</th> <th scope="row">{% trans "Virtual Machine" %}</th>
<td>{{ object.virtualmachine|linkify }}</td> <td>{{ object.virtualmachine|linkify }}</td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<th scope="row">Cluster</th> <th scope="row">{% trans "Cluster" %}</th>
<td>{{ object.cluster|linkify }}</td> <td>{{ object.cluster|linkify }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
<th scope="row">Software Product</th> <th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td> <td>{{ object.software_product|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Version</th> <th scope="row">{% trans "Version" %}</th>
<td>{{ object.version|linkify }}</td> <td>{{ object.version|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Licenses</th> <th scope="row">{% trans "Licenses" %}</th>
<td> <td>
{% for license in object.softwarelicense_set.all %} {% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }} {{ license }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
@@ -3,37 +3,38 @@
{% load static %} {% load static %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %}
{% block content %} {% block content %}
<div class="row my-3"> <div class="row my-3">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Software Product Version {% trans "Software Product Version" %}
</h5> </h5>
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr> <tr>
<th scope="row">Name</th> <th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td> <td>{{ object.name }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Description</th> <th scope="row">{% trans "Description" %}</th>
<td>{{ object.description }}</td> <td>{{ object.description }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Manufacturer</th> <th scope="row">{% trans "Manufacturer" %}</th>
<td>{{ object.software_product.manufacturer|linkify }}</td> <td>{{ object.software_product.manufacturer|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Software Product</th> <th scope="row">{% trans "Software Product" %}</th>
<td>{{ object.software_product|linkify }}</td> <td>{{ object.software_product|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Release date</th> <th scope="row">{% trans "Release date" %}</th>
<td>{{ object.release_date }}</td> <td>{{ object.release_date }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Documentation url</th> <th scope="row">{% trans "Documentation URL" %}</th>
{% if object.documentation_url %} {% if object.documentation_url %}
<td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td> <td><a href="{{ object.documentation_url }}">{{ object.documentation_url }}</a></td>
{% else %} {% else %}
@@ -41,11 +42,11 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<th scope="row">End of support</th> <th scope="row">{% trans "End of support" %}</th>
<td>{{ object.end_of_support }}</td> <td>{{ object.end_of_support }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Filename</th> <th scope="row">{% trans "Filename" %}</th>
{% if object.file_link %} {% if object.file_link %}
<td><a href="{{ object.file_link }}">{{ object.filename }}</a></td> <td><a href="{{ object.file_link }}">{{ object.filename }}</a></td>
{% else %} {% else %}
@@ -53,34 +54,34 @@
{% endif %} {% endif %}
</tr> </tr>
<tr> <tr>
<th scope="row">File checksum</th> <th scope="row">{% trans "File checksum" %}</th>
<td>{{ object.file_checksum }}</td> <td>{{ object.file_checksum }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Release type</th> <th scope="row">{% trans "Release type" %}</th>
<td>{{ object.get_release_type_display }}</td> <td>{{ object.get_release_type_display }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">Installations</th> <th scope="row">{% trans "Installations" %}</th>
<td> <td>
{% for installation in object.softwareproductinstallation_set.all %} {% for installation in object.softwareproductinstallation_set.all %}
<a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwareproductinstallation' pk=installation.pk %}" class="btn btn-sm">
{{ installation }} {{ installation }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th scope="row">Licenses</th> <th scope="row">{% trans "Licenses" %}</th>
<td> <td>
{% for license in object.softwarelicense_set.all %} {% for license in object.softwarelicense_set.all %}
<a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm"> <a href="{% url 'plugins:netbox_slm:softwarelicense' pk=license.pk %}" class="btn btn-sm">
{{ license }} {{ license }}
</a> </a>
{% empty %} {% empty %}
None {% trans "None" %}
{% endfor %} {% endfor %}
</td> </td>
</tr> </tr>
+7
View File
@@ -2,6 +2,7 @@ from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense from netbox_slm.models import SoftwareProduct, SoftwareProductVersion, SoftwareProductInstallation, SoftwareLicense
from tenancy.models import Tenant, TenantGroup
from virtualization.models import Cluster, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterType, VirtualMachine
@@ -26,6 +27,8 @@ class SlmBaseTestCase(TestCase):
cls.vm = VirtualMachine.objects.create(name="test VM") cls.vm = VirtualMachine.objects.create(name="test VM")
cluster_type = ClusterType.objects.create(name="test cluster type") cluster_type = ClusterType.objects.create(name="test cluster type")
cls.cluster = Cluster.objects.create(name="test cluster", type=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 = SoftwareProduct.objects.create(name=cls.p_name)
cls.software_product_version = SoftwareProductVersion.objects.create( cls.software_product_version = SoftwareProductVersion.objects.create(
@@ -40,6 +43,8 @@ class SlmBaseTestCase(TestCase):
version=cls.software_product_version, version=cls.software_product_version,
installation=cls.software_product_installation, installation=cls.software_product_installation,
stored_location_url=cls.test_url, stored_location_url=cls.test_url,
tenant_group=cls.tenant_group,
tenant=cls.tenant,
) )
@classmethod @classmethod
@@ -48,6 +53,8 @@ class SlmBaseTestCase(TestCase):
SoftwareProductInstallation.objects.all().delete() SoftwareProductInstallation.objects.all().delete()
SoftwareProductVersion.objects.all().delete() SoftwareProductVersion.objects.all().delete()
SoftwareProduct.objects.all().delete() SoftwareProduct.objects.all().delete()
Tenant.objects.all().delete()
TenantGroup.objects.all().delete()
Cluster.objects.all().delete() Cluster.objects.all().delete()
ClusterType.objects.all().delete() ClusterType.objects.all().delete()
VirtualMachine.objects.all().delete() VirtualMachine.objects.all().delete()
+152
View File
@@ -1,3 +1,24 @@
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 from .base import SlmBaseTestCase
@@ -50,9 +71,140 @@ class ModelTestCase(SlmBaseTestCase):
self.assertEqual("cluster", self.software_product_installation.render_type()) self.assertEqual("cluster", self.software_product_installation.render_type())
self.assertEqual(self.cluster, self.software_product_installation.platform) 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): def test_software_license_stored_location_txt(self):
self.assertEqual("Link", self.software_license.stored_location_txt) self.assertEqual("Link", self.software_license.stored_location_txt)
self.software_license.stored_location = "GitHub" self.software_license.stored_location = "GitHub"
self.software_license.save() self.software_license.save()
self.assertEqual("GitHub", self.software_license.stored_location_txt) 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()
+28 -3
View File
@@ -1,9 +1,34 @@
from netbox.views import generic from netbox.views import generic
from django.utils.translation import gettext_lazy as _
from netbox_slm import filtersets, forms, tables from netbox_slm import filtersets, forms, tables
from netbox_slm.models import SoftwareLicense from netbox_slm.models import SoftwareLicense
from utilities.views import register_model_view 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) @register_model_view(SoftwareLicense, "list", path="", detail=False)
class SoftwareLicenseListView(generic.ObjectListView): class SoftwareLicenseListView(generic.ObjectListView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
@@ -19,7 +44,7 @@ class SoftwareLicenseView(generic.ObjectView):
@register_model_view(SoftwareLicense, "add", detail=False) @register_model_view(SoftwareLicense, "add", detail=False)
@register_model_view(SoftwareLicense, "edit") @register_model_view(SoftwareLicense, "edit")
class SoftwareLicenseEditView(generic.ObjectEditView): class SoftwareLicenseEditView(EmailNotificationPermissionMixin, generic.ObjectEditView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
form = forms.SoftwareLicenseForm form = forms.SoftwareLicenseForm
@@ -30,13 +55,13 @@ class SoftwareLicenseDeleteView(generic.ObjectDeleteView):
@register_model_view(SoftwareLicense, "bulk_import", detail=False) @register_model_view(SoftwareLicense, "bulk_import", detail=False)
class SoftwareLicenseBulkImportView(generic.BulkImportView): class SoftwareLicenseBulkImportView(EmailNotificationPermissionMixin, generic.BulkImportView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
model_form = forms.SoftwareLicenseBulkImportForm model_form = forms.SoftwareLicenseBulkImportForm
@register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False) @register_model_view(SoftwareLicense, "bulk_edit", path="edit", detail=False)
class SoftwareLicenseBulkEditView(generic.BulkEditView): class SoftwareLicenseBulkEditView(EmailNotificationPermissionMixin, generic.BulkEditView):
queryset = SoftwareLicense.objects.all() queryset = SoftwareLicense.objects.all()
filterset = filtersets.SoftwareLicenseFilterSet filterset = filtersets.SoftwareLicenseFilterSet
table = tables.SoftwareLicenseTable table = tables.SoftwareLicenseTable
-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.7'
services:
netbox:
ports:
- 8000:8080
postgres:
ports:
- 5432:5432
redis:
ports:
- 6379:6379
EOF
docker-compose pull
docker-compose up