mirror of
https://github.com/lucaspalomodevelop/netbox-acls.git
synced 2026-03-12 23:27:23 +00:00
parent
fa38ef4df2
commit
108fbb6751
@ -1,8 +1,8 @@
|
|||||||
ARG NETBOX_VARIANT=v3.4
|
ARG NETBOX_VARIANT=v3.5
|
||||||
|
|
||||||
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
|
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
|
||||||
|
|
||||||
ARG NETBOX_INITIALIZERS_VARIANT=3.4.*
|
ARG NETBOX_INITIALIZERS_VARIANT=3.5.*
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,7 @@ from os.path import abspath, dirname, join
|
|||||||
# Read secret from file
|
# Read secret from file
|
||||||
def _read_secret(secret_name, default=None):
|
def _read_secret(secret_name, default=None):
|
||||||
try:
|
try:
|
||||||
f = open("/run/secrets/" + secret_name, encoding="utf-8")
|
f = open(f"/run/secrets/{secret_name}", encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
return default
|
return default
|
||||||
else:
|
else:
|
||||||
@ -74,8 +74,7 @@ REDIS = {
|
|||||||
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
|
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
|
||||||
),
|
),
|
||||||
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
|
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
|
||||||
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower()
|
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() == "true",
|
||||||
== "true",
|
|
||||||
"INSECURE_SKIP_TLS_VERIFY": environ.get(
|
"INSECURE_SKIP_TLS_VERIFY": environ.get(
|
||||||
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
|
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
|
||||||
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
|
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
|
||||||
@ -252,9 +251,7 @@ REMOTE_AUTH_BACKEND = environ.get(
|
|||||||
"netbox.authentication.RemoteUserBackend",
|
"netbox.authentication.RemoteUserBackend",
|
||||||
)
|
)
|
||||||
REMOTE_AUTH_HEADER = environ.get("REMOTE_AUTH_HEADER", "HTTP_REMOTE_USER")
|
REMOTE_AUTH_HEADER = environ.get("REMOTE_AUTH_HEADER", "HTTP_REMOTE_USER")
|
||||||
REMOTE_AUTH_AUTO_CREATE_USER = (
|
REMOTE_AUTH_AUTO_CREATE_USER = environ.get("REMOTE_AUTH_AUTO_CREATE_USER", "True").lower() == "true"
|
||||||
environ.get("REMOTE_AUTH_AUTO_CREATE_USER", "True").lower() == "true"
|
|
||||||
)
|
|
||||||
REMOTE_AUTH_DEFAULT_GROUPS = list(
|
REMOTE_AUTH_DEFAULT_GROUPS = list(
|
||||||
filter(None, environ.get("REMOTE_AUTH_DEFAULT_GROUPS", "").split(" ")),
|
filter(None, environ.get("REMOTE_AUTH_DEFAULT_GROUPS", "").split(" ")),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -78,23 +78,31 @@
|
|||||||
"extensions": [
|
"extensions": [
|
||||||
"DavidAnson.vscode-markdownlint",
|
"DavidAnson.vscode-markdownlint",
|
||||||
"GitHub.codespaces",
|
"GitHub.codespaces",
|
||||||
"GitHub.copilot",
|
|
||||||
"GitHub.copilot-labs",
|
"GitHub.copilot-labs",
|
||||||
"GitHub.vscode-pull-request-github",
|
"GitHub.vscode-pull-request-github",
|
||||||
"Gruntfuggly.todo-tree",
|
"Gruntfuggly.todo-tree",
|
||||||
"Tyriar.sort-lines",
|
"Tyriar.sort-lines",
|
||||||
"aaron-bond.better-comments",
|
"aaron-bond.better-comments",
|
||||||
"batisteo.vscode-django",
|
"batisteo.vscode-django",
|
||||||
|
"charliermarsh.ruff",
|
||||||
"codezombiech.gitignore",
|
"codezombiech.gitignore",
|
||||||
"esbenp.prettier-vscode",
|
"esbenp.prettier-vscode",
|
||||||
|
"exiasr.hadolint",
|
||||||
"formulahendry.auto-rename-tag",
|
"formulahendry.auto-rename-tag",
|
||||||
"mintlify.document",
|
"mintlify.document",
|
||||||
|
"ms-python.isort",
|
||||||
|
"ms-python.pylint",
|
||||||
"ms-python.python",
|
"ms-python.python",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
|
"ms-vscode.makefile-tools",
|
||||||
"mutantdino.resourcemonitor",
|
"mutantdino.resourcemonitor",
|
||||||
|
"oderwat.indent-rainbow",
|
||||||
"paulomenezes.duplicated-code",
|
"paulomenezes.duplicated-code",
|
||||||
|
"redhat.vscode-yaml",
|
||||||
"searKing.preview-vscode",
|
"searKing.preview-vscode",
|
||||||
"sourcery.sourcery"
|
"sourcery.sourcery",
|
||||||
|
"wholroyd.jinja",
|
||||||
|
"yzhang.markdown-all-in-one"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
2
.devcontainer/env/netbox.env
vendored
2
.devcontainer/env/netbox.env
vendored
@ -15,7 +15,7 @@ REDIS_DATABASE=0
|
|||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_INSECURE_SKIP_TLS_VERIFY=false
|
REDIS_INSECURE_SKIP_TLS_VERIFY=false
|
||||||
REDIS_PASSWORD=H733Kdjndks81
|
REDIS_PASSWORD=H733Kdjndks81
|
||||||
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
|
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNjaa
|
||||||
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
|
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
|
||||||
SUPERUSER_EMAIL=admin@example.com
|
SUPERUSER_EMAIL=admin@example.com
|
||||||
SUPERUSER_NAME=admin
|
SUPERUSER_NAME=admin
|
||||||
|
|||||||
@ -10,6 +10,7 @@ pycodestyle
|
|||||||
pydocstyle
|
pydocstyle
|
||||||
pylint
|
pylint
|
||||||
pylint-django
|
pylint-django
|
||||||
|
ruff
|
||||||
|
sourcery-analytics
|
||||||
wily
|
wily
|
||||||
yapf
|
yapf
|
||||||
sourcery-analytics
|
|
||||||
|
|||||||
2
.flake8
2
.flake8
@ -1,3 +1,3 @@
|
|||||||
[flake8]
|
[flake8]
|
||||||
max-line-length = 160
|
max-line-length = 140
|
||||||
extend-ignore = E203
|
extend-ignore = E203
|
||||||
|
|||||||
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@ -23,14 +23,14 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox access-list plugin version
|
label: NetBox access-list plugin version
|
||||||
description: What version of the NetBox access-list plugin are you currently running?
|
description: What version of the NetBox access-list plugin are you currently running?
|
||||||
placeholder: v1.2.0
|
placeholder: v1.3.0
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.3
|
placeholder: v3.5.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@ -15,7 +15,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.4.3
|
placeholder: v3.5.4
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
[settings]
|
|
||||||
profile = black
|
|
||||||
|
|
||||||
; vertical hanging indent mode also used in black configuration
|
|
||||||
multi_line_output = 3
|
|
||||||
|
|
||||||
; necessary because black expect the trailing comma
|
|
||||||
include_trailing_comma = true
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"threshold": 10,
|
"threshold": 10,
|
||||||
"ignore": ["**/tests/**"]
|
"ignore": ["**/migrations/**", "**/tests/**"]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,13 +21,13 @@ repos:
|
|||||||
- "--profile=black"
|
- "--profile=black"
|
||||||
exclude: ^.devcontainer/
|
exclude: ^.devcontainer/
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 23.1.0
|
rev: 23.3.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3
|
language_version: python3
|
||||||
exclude: ^.devcontainer/
|
exclude: ^.devcontainer/
|
||||||
- repo: https://github.com/asottile/add-trailing-comma
|
- repo: https://github.com/asottile/add-trailing-comma
|
||||||
rev: v2.4.0
|
rev: v2.5.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: add-trailing-comma
|
- id: add-trailing-comma
|
||||||
args:
|
args:
|
||||||
@ -38,13 +38,13 @@ repos:
|
|||||||
- id: flake8
|
- id: flake8
|
||||||
exclude: ^.devcontainer/
|
exclude: ^.devcontainer/
|
||||||
- repo: https://github.com/asottile/pyupgrade
|
- repo: https://github.com/asottile/pyupgrade
|
||||||
rev: v3.3.1
|
rev: v3.7.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyupgrade
|
- id: pyupgrade
|
||||||
args:
|
args:
|
||||||
- "--py39-plus"
|
- "--py39-plus"
|
||||||
- repo: https://github.com/adrienverge/yamllint
|
- repo: https://github.com/adrienverge/yamllint
|
||||||
rev: v1.29.0
|
rev: v1.32.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamllint
|
- id: yamllint
|
||||||
- repo: https://github.com/econchick/interrogate
|
- repo: https://github.com/econchick/interrogate
|
||||||
@ -59,9 +59,13 @@ repos:
|
|||||||
# - id: htmlhint
|
# - id: htmlhint
|
||||||
# args: [--config, .htmlhintrc]
|
# args: [--config, .htmlhintrc]
|
||||||
- repo: https://github.com/igorshubovych/markdownlint-cli
|
- repo: https://github.com/igorshubovych/markdownlint-cli
|
||||||
rev: v0.33.0
|
rev: v0.35.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: markdownlint
|
- id: markdownlint
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.0.272
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
#- repo: local
|
#- repo: local
|
||||||
# hooks:
|
# hooks:
|
||||||
# - id: wily
|
# - id: wily
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
ARG NETBOX_VARIANT=v3.4
|
ARG NETBOX_VARIANT=v3.5
|
||||||
|
|
||||||
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
|
FROM netboxcommunity/netbox:${NETBOX_VARIANT}
|
||||||
|
|
||||||
RUN mkdir -pv /plugins/netbox-acls
|
RUN mkdir -pv /plugins/netbox-acls
|
||||||
COPY . /plugins/netbox-acls
|
COPY . /plugins/netbox-acls
|
||||||
|
|
||||||
RUN /opt/netbox/venv/bin/python3 /plugins/netbox-acls/setup.py develop
|
RUN /opt/netbox/venv/bin/python3 /plugins/netbox-acls/setup.py develop && \
|
||||||
RUN cp -rf /plugins/netbox-acls/netbox_acls/ /opt/netbox/venv/lib/python3.10/site-packages/netbox_acls
|
cp -rf /plugins/netbox-acls/netbox_acls/ /opt/netbox/venv/lib/python3.10/site-packages/netbox_acls
|
||||||
|
|||||||
@ -41,6 +41,7 @@ Each Plugin Version listed below has been tested with its corresponding NetBox V
|
|||||||
| 3.2 | 1.0.1 |
|
| 3.2 | 1.0.1 |
|
||||||
| 3.3 | 1.1.0 |
|
| 3.3 | 1.1.0 |
|
||||||
| 3.4 | 1.2.2 |
|
| 3.4 | 1.2.2 |
|
||||||
|
| 3.5 | 1.3.0 |
|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ from os.path import abspath, dirname
|
|||||||
# Read secret from file
|
# Read secret from file
|
||||||
def _read_secret(secret_name, default=None):
|
def _read_secret(secret_name, default=None):
|
||||||
try:
|
try:
|
||||||
f = open("/run/secrets/" + secret_name, encoding="utf-8")
|
f = open(f"/run/secrets/{secret_name}", encoding="utf-8")
|
||||||
except OSError:
|
except OSError:
|
||||||
return default
|
return default
|
||||||
else:
|
else:
|
||||||
@ -81,8 +81,7 @@ REDIS = {
|
|||||||
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
|
environ.get("REDIS_CACHE_PASSWORD", environ.get("REDIS_PASSWORD", "")),
|
||||||
),
|
),
|
||||||
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
|
"DATABASE": int(environ.get("REDIS_CACHE_DATABASE", 1)),
|
||||||
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower()
|
"SSL": environ.get("REDIS_CACHE_SSL", environ.get("REDIS_SSL", "False")).lower() == "true",
|
||||||
== "true",
|
|
||||||
"INSECURE_SKIP_TLS_VERIFY": environ.get(
|
"INSECURE_SKIP_TLS_VERIFY": environ.get(
|
||||||
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
|
"REDIS_CACHE_INSECURE_SKIP_TLS_VERIFY",
|
||||||
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
|
environ.get("REDIS_INSECURE_SKIP_TLS_VERIFY", "False"),
|
||||||
|
|||||||
2
env/netbox.env
vendored
2
env/netbox.env
vendored
@ -14,7 +14,7 @@ REDIS_DATABASE=0
|
|||||||
REDIS_HOST=redis
|
REDIS_HOST=redis
|
||||||
REDIS_INSECURE_SKIP_TLS_VERIFY=false
|
REDIS_INSECURE_SKIP_TLS_VERIFY=false
|
||||||
REDIS_PASSWORD=H733Kdjndks81
|
REDIS_PASSWORD=H733Kdjndks81
|
||||||
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNj
|
SECRET_KEY=r8OwDznj!!dciP9ghmRfdu1Ysxm0AiPeDCQhKE+N_rClfWNjaa
|
||||||
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
|
SUPERUSER_API_TOKEN=0123456789abcdef0123456789abcdef01234567
|
||||||
SUPERUSER_EMAIL=admin@example.com
|
SUPERUSER_EMAIL=admin@example.com
|
||||||
SUPERUSER_NAME=admin
|
SUPERUSER_NAME=admin
|
||||||
|
|||||||
@ -17,8 +17,8 @@ class NetBoxACLsConfig(PluginConfig):
|
|||||||
version = __version__
|
version = __version__
|
||||||
description = "Manage simple ACLs in NetBox"
|
description = "Manage simple ACLs in NetBox"
|
||||||
base_url = "access-lists"
|
base_url = "access-lists"
|
||||||
min_version = "3.4.0"
|
min_version = "3.5.0"
|
||||||
max_version = "3.4.99"
|
max_version = "3.5.99"
|
||||||
|
|
||||||
|
|
||||||
config = NetBoxACLsConfig
|
config = NetBoxACLsConfig
|
||||||
|
|||||||
@ -4,8 +4,7 @@ while Django itself handles the database abstraction.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from drf_spectacular.utils import extend_schema_field
|
||||||
from drf_yasg.utils import swagger_serializer_method
|
|
||||||
from ipam.api.serializers import NestedPrefixSerializer
|
from ipam.api.serializers import NestedPrefixSerializer
|
||||||
from netbox.api.fields import ContentTypeField
|
from netbox.api.fields import ContentTypeField
|
||||||
from netbox.api.serializers import NetBoxModelSerializer
|
from netbox.api.serializers import NetBoxModelSerializer
|
||||||
@ -32,13 +31,9 @@ __all__ = [
|
|||||||
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
|
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
|
||||||
error_message_no_remark = "Action is set to remark, you MUST add a remark."
|
error_message_no_remark = "Action is set to remark, you MUST add a remark."
|
||||||
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
|
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
|
||||||
error_message_action_remark_source_prefix_set = (
|
error_message_action_remark_source_prefix_set = "Action is set to remark, Source Prefix CANNOT be set."
|
||||||
"Action is set to remark, Source Prefix CANNOT be set."
|
|
||||||
)
|
|
||||||
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
|
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
|
||||||
error_message_remark_without_action_remark = (
|
error_message_remark_without_action_remark = "CANNOT set remark unless action is set to remark."
|
||||||
"CANNOT set remark unless action is set to remark."
|
|
||||||
)
|
|
||||||
# Sets a standard error message for ACL rules no associated to an ACL of the same type.
|
# Sets a standard error message for ACL rules no associated to an ACL of the same type.
|
||||||
error_message_acl_type = "Provided parent Access List is not of right type."
|
error_message_acl_type = "Provided parent Access List is not of right type."
|
||||||
|
|
||||||
@ -81,7 +76,7 @@ class AccessListSerializer(NetBoxModelSerializer):
|
|||||||
"rule_count",
|
"rule_count",
|
||||||
)
|
)
|
||||||
|
|
||||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
@extend_schema_field(serializers.DictField())
|
||||||
def get_assigned_object(self, obj):
|
def get_assigned_object(self, obj):
|
||||||
serializer = get_serializer_for_model(
|
serializer = get_serializer_for_model(
|
||||||
obj.assigned_object,
|
obj.assigned_object,
|
||||||
@ -99,11 +94,7 @@ class AccessListSerializer(NetBoxModelSerializer):
|
|||||||
error_message = {}
|
error_message = {}
|
||||||
|
|
||||||
# Check if Access List has no existing rules before change the Access List's type.
|
# Check if Access List has no existing rules before change the Access List's type.
|
||||||
if (
|
if self.instance and self.instance.type != data.get("type") and self.instance.rule_count > 0:
|
||||||
self.instance
|
|
||||||
and self.instance.type != data.get("type")
|
|
||||||
and self.instance.rule_count > 0
|
|
||||||
):
|
|
||||||
error_message["type"] = [
|
error_message["type"] = [
|
||||||
"This ACL has ACL rules associated, CANNOT change ACL type.",
|
"This ACL has ACL rules associated, CANNOT change ACL type.",
|
||||||
]
|
]
|
||||||
@ -149,7 +140,7 @@ class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer):
|
|||||||
"last_updated",
|
"last_updated",
|
||||||
)
|
)
|
||||||
|
|
||||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
@extend_schema_field(serializers.DictField())
|
||||||
def get_assigned_object(self, obj):
|
def get_assigned_object(self, obj):
|
||||||
serializer = get_serializer_for_model(
|
serializer = get_serializer_for_model(
|
||||||
obj.assigned_object,
|
obj.assigned_object,
|
||||||
@ -168,24 +159,14 @@ class ACLInterfaceAssignmentSerializer(NetBoxModelSerializer):
|
|||||||
acl_host = data["access_list"].assigned_object
|
acl_host = data["access_list"].assigned_object
|
||||||
|
|
||||||
if data["assigned_object_type"].model == "interface":
|
if data["assigned_object_type"].model == "interface":
|
||||||
interface_host = (
|
interface_host = data["assigned_object_type"].get_object_for_this_type(id=data["assigned_object_id"]).device
|
||||||
data["assigned_object_type"]
|
|
||||||
.get_object_for_this_type(id=data["assigned_object_id"])
|
|
||||||
.device
|
|
||||||
)
|
|
||||||
elif data["assigned_object_type"].model == "vminterface":
|
elif data["assigned_object_type"].model == "vminterface":
|
||||||
interface_host = (
|
interface_host = data["assigned_object_type"].get_object_for_this_type(id=data["assigned_object_id"]).virtual_machine
|
||||||
data["assigned_object_type"]
|
|
||||||
.get_object_for_this_type(id=data["assigned_object_id"])
|
|
||||||
.virtual_machine
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
interface_host = None
|
interface_host = None
|
||||||
# Check that the associated interface's parent host has the selected ACL defined.
|
# Check that the associated interface's parent host has the selected ACL defined.
|
||||||
if acl_host != interface_host:
|
if acl_host != interface_host:
|
||||||
error_acl_not_assigned_to_host = (
|
error_acl_not_assigned_to_host = "Access List not present on the selected interface's host."
|
||||||
"Access List not present on the selected interface's host."
|
|
||||||
)
|
|
||||||
error_message["access_list"] = [error_acl_not_assigned_to_host]
|
error_message["access_list"] = [error_acl_not_assigned_to_host]
|
||||||
error_message["assigned_object_id"] = [error_acl_not_assigned_to_host]
|
error_message["assigned_object_id"] = [error_acl_not_assigned_to_host]
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,5 @@ ACL_HOST_ASSIGNMENT_MODELS = Q(
|
|||||||
)
|
)
|
||||||
|
|
||||||
ACL_INTERFACE_ASSIGNMENT_MODELS = Q(
|
ACL_INTERFACE_ASSIGNMENT_MODELS = Q(
|
||||||
Q(app_label="dcim", model="interface")
|
Q(app_label="dcim", model="interface") | Q(app_label="virtualization", model="vminterface"),
|
||||||
| Q(app_label="virtualization", model="vminterface"),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,11 +7,11 @@ Draft for a possible BulkEditForm, but may not be worth wile.
|
|||||||
# from django.core.exceptions import ValidationError
|
# from django.core.exceptions import ValidationError
|
||||||
# from django.utils.safestring import mark_safe
|
# from django.utils.safestring import mark_safe
|
||||||
# from netbox.forms import NetBoxModelBulkEditForm
|
# from netbox.forms import NetBoxModelBulkEditForm
|
||||||
# from utilities.forms import (
|
# from utilities.forms.utils import add_blank_choice
|
||||||
|
# from utilities.forms.fields import (
|
||||||
# ChoiceField,
|
# ChoiceField,
|
||||||
# DynamicModelChoiceField,
|
# DynamicModelChoiceField,
|
||||||
# StaticSelect,
|
# StaticSelect,
|
||||||
# add_blank_choice,
|
|
||||||
# )
|
# )
|
||||||
# from virtualization.models import VirtualMachine
|
# from virtualization.models import VirtualMachine
|
||||||
|
|
||||||
|
|||||||
@ -6,13 +6,13 @@ from dcim.models import Device, Interface, Region, Site, SiteGroup, VirtualChass
|
|||||||
from django import forms
|
from django import forms
|
||||||
from ipam.models import Prefix
|
from ipam.models import Prefix
|
||||||
from netbox.forms import NetBoxModelFilterSetForm
|
from netbox.forms import NetBoxModelFilterSetForm
|
||||||
from utilities.forms import (
|
from utilities.forms.fields import (
|
||||||
ChoiceField,
|
ChoiceField,
|
||||||
DynamicModelChoiceField,
|
DynamicModelChoiceField,
|
||||||
DynamicModelMultipleChoiceField,
|
DynamicModelMultipleChoiceField,
|
||||||
TagFilterField,
|
TagFilterField,
|
||||||
add_blank_choice,
|
|
||||||
)
|
)
|
||||||
|
from utilities.forms.utils import add_blank_choice
|
||||||
from virtualization.models import VirtualMachine, VMInterface
|
from virtualization.models import VirtualMachine, VMInterface
|
||||||
|
|
||||||
from ..choices import (
|
from ..choices import (
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from ipam.models import Prefix
|
from ipam.models import Prefix
|
||||||
from netbox.forms import NetBoxModelForm
|
from netbox.forms import NetBoxModelForm
|
||||||
from utilities.forms import CommentField, DynamicModelChoiceField
|
from utilities.forms.fields import CommentField, DynamicModelChoiceField
|
||||||
from virtualization.models import (
|
from virtualization.models import (
|
||||||
Cluster,
|
Cluster,
|
||||||
ClusterGroup,
|
ClusterGroup,
|
||||||
@ -39,20 +39,14 @@ help_text_acl_rule_logic = mark_safe(
|
|||||||
# Sets a standard help_text value to be used by the various classes for acl action
|
# Sets a standard help_text value to be used by the various classes for acl action
|
||||||
help_text_acl_action = "Action the rule will take (remark, deny, or allow)."
|
help_text_acl_action = "Action the rule will take (remark, deny, or allow)."
|
||||||
# Sets a standard help_text value to be used by the various classes for acl index
|
# Sets a standard help_text value to be used by the various classes for acl index
|
||||||
help_text_acl_rule_index = (
|
help_text_acl_rule_index = "Determines the order of the rule in the ACL processing. AKA Sequence Number."
|
||||||
"Determines the order of the rule in the ACL processing. AKA Sequence Number."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
|
# Sets a standard error message for ACL rules with an action of remark, but no remark set.
|
||||||
error_message_no_remark = "Action is set to remark, you MUST add a remark."
|
error_message_no_remark = "Action is set to remark, you MUST add a remark."
|
||||||
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
|
# Sets a standard error message for ACL rules with an action of remark, but no source_prefix is set.
|
||||||
error_message_action_remark_source_prefix_set = (
|
error_message_action_remark_source_prefix_set = "Action is set to remark, Source Prefix CANNOT be set."
|
||||||
"Action is set to remark, Source Prefix CANNOT be set."
|
|
||||||
)
|
|
||||||
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
|
# Sets a standard error message for ACL rules with an action not set to remark, but no remark is set.
|
||||||
error_message_remark_without_action_remark = (
|
error_message_remark_without_action_remark = "CANNOT set remark unless action is set to remark."
|
||||||
"CANNOT set remark unless action is set to remark."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AccessListForm(NetBoxModelForm):
|
class AccessListForm(NetBoxModelForm):
|
||||||
@ -149,7 +143,6 @@ class AccessListForm(NetBoxModelForm):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
# Initialize helper selectors
|
# Initialize helper selectors
|
||||||
instance = kwargs.get("instance")
|
instance = kwargs.get("instance")
|
||||||
initial = kwargs.get("initial", {}).copy()
|
initial = kwargs.get("initial", {}).copy()
|
||||||
@ -168,9 +161,7 @@ class AccessListForm(NetBoxModelForm):
|
|||||||
if instance.assigned_object.cluster:
|
if instance.assigned_object.cluster:
|
||||||
initial["cluster"] = instance.assigned_object.cluster
|
initial["cluster"] = instance.assigned_object.cluster
|
||||||
if instance.assigned_object.cluster.group:
|
if instance.assigned_object.cluster.group:
|
||||||
initial[
|
initial["cluster_group"] = instance.assigned_object.cluster.group
|
||||||
"cluster_group"
|
|
||||||
] = instance.assigned_object.cluster.group
|
|
||||||
|
|
||||||
if instance.assigned_object.cluster.type:
|
if instance.assigned_object.cluster.type:
|
||||||
initial["cluster_type"] = instance.assigned_object.cluster.type
|
initial["cluster_type"] = instance.assigned_object.cluster.type
|
||||||
@ -199,13 +190,9 @@ class AccessListForm(NetBoxModelForm):
|
|||||||
virtual_machine = cleaned_data.get("virtual_machine")
|
virtual_machine = cleaned_data.get("virtual_machine")
|
||||||
|
|
||||||
# Check if more than one host type selected.
|
# Check if more than one host type selected.
|
||||||
if (
|
if (device and virtual_chassis) or (device and virtual_machine) or (virtual_chassis and virtual_machine):
|
||||||
(device and virtual_chassis)
|
|
||||||
or (device and virtual_machine)
|
|
||||||
or (virtual_chassis and virtual_machine)
|
|
||||||
):
|
|
||||||
raise forms.ValidationError(
|
raise forms.ValidationError(
|
||||||
"Access Lists must be assigned to one host (either a device, virtual chassis or virtual machine) at a time.",
|
"Access Lists must be assigned to one host at a time. Either a device, virtual chassis or virtual machine."
|
||||||
)
|
)
|
||||||
# Check if no hosts selected.
|
# Check if no hosts selected.
|
||||||
if not device and not virtual_chassis and not virtual_machine:
|
if not device and not virtual_chassis and not virtual_machine:
|
||||||
@ -230,28 +217,20 @@ class AccessListForm(NetBoxModelForm):
|
|||||||
).exists()
|
).exists()
|
||||||
|
|
||||||
# Check if duplicate entry.
|
# Check if duplicate entry.
|
||||||
if (
|
if ("name" in self.changed_data or host_type in self.changed_data) and existing_acls:
|
||||||
"name" in self.changed_data or host_type in self.changed_data
|
error_same_acl_name = "An ACL with this name is already associated to this host."
|
||||||
) and existing_acls:
|
|
||||||
error_same_acl_name = (
|
|
||||||
"An ACL with this name is already associated to this host."
|
|
||||||
)
|
|
||||||
error_message |= {
|
error_message |= {
|
||||||
host_type: [error_same_acl_name],
|
host_type: [error_same_acl_name],
|
||||||
"name": [error_same_acl_name],
|
"name": [error_same_acl_name],
|
||||||
}
|
}
|
||||||
if self.instance.pk:
|
# Check if Access List has no existing rules before change the Access List's type.
|
||||||
# Check if Access List has no existing rules before change the Access List's type.
|
if self.instance.pk and (
|
||||||
if (
|
(acl_type == ACLTypeChoices.TYPE_EXTENDED and self.instance.aclstandardrules.exists())
|
||||||
acl_type == ACLTypeChoices.TYPE_EXTENDED
|
or (acl_type == ACLTypeChoices.TYPE_STANDARD and self.instance.aclextendedrules.exists())
|
||||||
and self.instance.aclstandardrules.exists()
|
):
|
||||||
) or (
|
error_message["type"] = [
|
||||||
acl_type == ACLTypeChoices.TYPE_STANDARD
|
"This ACL has ACL rules associated, CANNOT change ACL type.",
|
||||||
and self.instance.aclextendedrules.exists()
|
]
|
||||||
):
|
|
||||||
error_message["type"] = [
|
|
||||||
"This ACL has ACL rules associated, CANNOT change ACL type.",
|
|
||||||
]
|
|
||||||
|
|
||||||
if error_message:
|
if error_message:
|
||||||
raise forms.ValidationError(error_message)
|
raise forms.ValidationError(error_message)
|
||||||
@ -261,9 +240,7 @@ class AccessListForm(NetBoxModelForm):
|
|||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
# Set assigned object
|
# Set assigned object
|
||||||
self.instance.assigned_object = (
|
self.instance.assigned_object = (
|
||||||
self.cleaned_data.get("device")
|
self.cleaned_data.get("device") or self.cleaned_data.get("virtual_chassis") or self.cleaned_data.get("virtual_machine")
|
||||||
or self.cleaned_data.get("virtual_chassis")
|
|
||||||
or self.cleaned_data.get("virtual_machine")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return super().save(*args, **kwargs)
|
return super().save(*args, **kwargs)
|
||||||
@ -324,7 +301,6 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
|
|||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
# Initialize helper selectors
|
# Initialize helper selectors
|
||||||
instance = kwargs.get("instance")
|
instance = kwargs.get("instance")
|
||||||
initial = kwargs.get("initial", {}).copy()
|
initial = kwargs.get("initial", {}).copy()
|
||||||
@ -376,15 +352,15 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
|
|||||||
|
|
||||||
# Check if both interface and vminterface are set.
|
# Check if both interface and vminterface are set.
|
||||||
if interface and vminterface:
|
if interface and vminterface:
|
||||||
error_too_many_interfaces = "Access Lists must be assigned to one type of interface at a time (VM interface or physical interface)"
|
error_too_many_interfaces = (
|
||||||
|
"Access Lists must be assigned to one type of interface at a time (VM interface or physical interface)"
|
||||||
|
)
|
||||||
error_message |= {
|
error_message |= {
|
||||||
"interface": [error_too_many_interfaces],
|
"interface": [error_too_many_interfaces],
|
||||||
"vminterface": [error_too_many_interfaces],
|
"vminterface": [error_too_many_interfaces],
|
||||||
}
|
}
|
||||||
elif not (interface or vminterface):
|
elif not (interface or vminterface):
|
||||||
error_no_interface = (
|
error_no_interface = "An Access List assignment but specify an Interface or VM Interface."
|
||||||
"An Access List assignment but specify an Interface or VM Interface."
|
|
||||||
)
|
|
||||||
error_message |= {
|
error_message |= {
|
||||||
"interface": [error_no_interface],
|
"interface": [error_no_interface],
|
||||||
"vminterface": [error_no_interface],
|
"vminterface": [error_no_interface],
|
||||||
@ -410,9 +386,7 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
|
|||||||
|
|
||||||
# Check that an interface's parent device/virtual_machine is assigned to the Access List.
|
# Check that an interface's parent device/virtual_machine is assigned to the Access List.
|
||||||
if access_list_host != host:
|
if access_list_host != host:
|
||||||
error_acl_not_assigned_to_host = (
|
error_acl_not_assigned_to_host = "Access List not present on selected host."
|
||||||
"Access List not present on selected host."
|
|
||||||
)
|
|
||||||
error_message |= {
|
error_message |= {
|
||||||
"access_list": [error_acl_not_assigned_to_host],
|
"access_list": [error_acl_not_assigned_to_host],
|
||||||
assigned_object_type: [error_acl_not_assigned_to_host],
|
assigned_object_type: [error_acl_not_assigned_to_host],
|
||||||
@ -437,9 +411,7 @@ class ACLInterfaceAssignmentForm(NetBoxModelForm):
|
|||||||
assigned_object_type=assigned_object_type_id,
|
assigned_object_type=assigned_object_type_id,
|
||||||
direction=direction,
|
direction=direction,
|
||||||
).exists():
|
).exists():
|
||||||
error_interface_already_assigned = (
|
error_interface_already_assigned = "Interfaces can only have 1 Access List assigned in each direction."
|
||||||
"Interfaces can only have 1 Access List assigned in each direction."
|
|
||||||
)
|
|
||||||
error_message |= {
|
error_message |= {
|
||||||
"direction": [error_interface_already_assigned],
|
"direction": [error_interface_already_assigned],
|
||||||
assigned_object_type: [error_interface_already_assigned],
|
assigned_object_type: [error_interface_already_assigned],
|
||||||
|
|||||||
@ -3,6 +3,7 @@ from netbox.graphql.fields import ObjectField, ObjectListField
|
|||||||
|
|
||||||
from .types import *
|
from .types import *
|
||||||
|
|
||||||
|
|
||||||
class Query(ObjectType):
|
class Query(ObjectType):
|
||||||
"""
|
"""
|
||||||
Defines the queries available to this plugin via the graphql api.
|
Defines the queries available to this plugin via the graphql api.
|
||||||
|
|||||||
@ -72,4 +72,3 @@ class ACLStandardRuleType(NetBoxObjectType):
|
|||||||
model = models.ACLStandardRule
|
model = models.ACLStandardRule
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
filterset_class = filtersets.ACLStandardRuleFilterSet
|
filterset_class = filtersets.ACLStandardRuleFilterSet
|
||||||
|
|
||||||
|
|||||||
@ -282,7 +282,9 @@ class Migration(migrations.Migration):
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
# migrations.AddConstraint(
|
# migrations.AddConstraint(
|
||||||
# model_name='accesslist',
|
# model_name="accesslist",
|
||||||
# constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='accesslist_assigned_object'),
|
# constraint=models.UniqueConstraint(
|
||||||
|
# fields=("assigned_object_type", "assigned_object_id"), name="accesslist_assigned_object"
|
||||||
|
# ),
|
||||||
# ),
|
# ),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -6,7 +6,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("contenttypes", "0002_remove_content_type_name"),
|
("contenttypes", "0002_remove_content_type_name"),
|
||||||
("netbox_acls", "0001_initial"),
|
("netbox_acls", "0001_initial"),
|
||||||
|
|||||||
@ -5,7 +5,6 @@ from django.db import migrations, models
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
("netbox_acls", "0002_alter_accesslist_options_and_more"),
|
("netbox_acls", "0002_alter_accesslist_options_and_more"),
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
|||||||
@ -47,7 +47,11 @@ urlpatterns = (
|
|||||||
views.ACLInterfaceAssignmentEditView.as_view(),
|
views.ACLInterfaceAssignmentEditView.as_view(),
|
||||||
name="aclinterfaceassignment_add",
|
name="aclinterfaceassignment_add",
|
||||||
),
|
),
|
||||||
# path('interface-assignments/edit/', views.ACLInterfaceAssignmentBulkEditView.as_view(), name='aclinterfaceassignment_bulk_edit'),
|
# path(
|
||||||
|
# "interface-assignments/edit/",
|
||||||
|
# views.ACLInterfaceAssignmentBulkEditView.as_view(),
|
||||||
|
# name="aclinterfaceassignment_bulk_edit"
|
||||||
|
# ),
|
||||||
path(
|
path(
|
||||||
"interface-assignments/delete/",
|
"interface-assignments/delete/",
|
||||||
views.ACLInterfaceAssignmentBulkDeleteView.as_view(),
|
views.ACLInterfaceAssignmentBulkDeleteView.as_view(),
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
__version__ = "1.2.2"
|
__version__ = "1.3.0"
|
||||||
|
|||||||
@ -50,7 +50,8 @@ class AccessListView(generic.ObjectView):
|
|||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
"""
|
"""
|
||||||
Depending on the Access List type, the list view will return the required ACL Rule using the previous defined tables in tables.py.
|
Depending on the Access List type, the list view will return
|
||||||
|
the required ACL Rule using the previous defined tables in tables.py.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if instance.type == choices.ACLTypeChoices.TYPE_EXTENDED:
|
if instance.type == choices.ACLTypeChoices.TYPE_EXTENDED:
|
||||||
@ -227,8 +228,7 @@ class ACLInterfaceAssignmentEditView(generic.ObjectEditView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_list": request.GET.get("access_list")
|
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
|
||||||
or request.POST.get("access_list"),
|
|
||||||
"direction": request.GET.get("direction") or request.POST.get("direction"),
|
"direction": request.GET.get("direction") or request.POST.get("direction"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -360,8 +360,7 @@ class ACLStandardRuleEditView(generic.ObjectEditView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_list": request.GET.get("access_list")
|
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
|
||||||
or request.POST.get("access_list"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -443,8 +442,7 @@ class ACLExtendedRuleEditView(generic.ObjectEditView):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"access_list": request.GET.get("access_list")
|
"access_list": request.GET.get("access_list") or request.POST.get("access_list"),
|
||||||
or request.POST.get("access_list"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 140
|
||||||
|
|
||||||
|
[tool.isort]
|
||||||
|
profile = "black"
|
||||||
|
include_trailing_comma = true
|
||||||
|
multi_line_output = 3
|
||||||
|
|
||||||
|
[tool.pylint]
|
||||||
|
max-line-length = 140
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
include = ["netbox_secrets"]
|
||||||
|
exclude = [
|
||||||
|
"**/node_modules",
|
||||||
|
"**/__pycache__",
|
||||||
|
]
|
||||||
|
reportMissingImports = true
|
||||||
|
reportMissingTypeStubs = false
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 140
|
||||||
15
setup.py
15
setup.py
@ -9,7 +9,7 @@ from setuptools import find_packages, setup
|
|||||||
script_dir = os.path.abspath(os.path.dirname(__file__))
|
script_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
with open(os.path.join(script_dir, "README.md"), encoding="utf-8") as fh:
|
with open(os.path.join(script_dir, "README.md"), encoding="utf-8") as fh:
|
||||||
long_description = fh.read()
|
long_description = fh.read().replace("(docs/img/", "(https://raw.githubusercontent.com/ryanmerolle/netbox-acls/release/docs/img/")
|
||||||
|
|
||||||
|
|
||||||
def read(relative_path):
|
def read(relative_path):
|
||||||
@ -43,7 +43,18 @@ setup(
|
|||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
classifiers=[
|
classifiers=[
|
||||||
"Framework :: Django",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Natural Language :: English",
|
||||||
|
"Programming Language :: Python",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Intended Audience :: System Administrators",
|
||||||
|
"Intended Audience :: Telecommunications Industry",
|
||||||
|
"Framework :: Django",
|
||||||
|
"Topic :: System :: Networking",
|
||||||
|
"Topic :: Internet",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user