diff --git a/.travis.yml b/.travis.yml index 20b7afb..dfb9888 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,10 +12,11 @@ python: - 3.6 - 3.7 - 3.8 + - 3.9 env: matrix: - - "NAUTOBOT_VER=1.0.0" + - "NAUTOBOT_VER=1.0.2" services: - "docker" @@ -37,7 +38,7 @@ jobs: script: - "invoke black" - "invoke bandit" - - "invoke pydocstyle" + # - "invoke pydocstyle" - "invoke flake8" - "invoke yamllint" # - "invoke pylint" diff --git a/development/Dockerfile b/development/Dockerfile index ba1f142..41c90f0 100644 --- a/development/Dockerfile +++ b/development/Dockerfile @@ -1,32 +1,18 @@ -ARG python_ver=3.7 -FROM python:${python_ver} +ARG PYTHON_VER +ARG NAUTOBOT_VER +FROM ghcr.io/nautobot/nautobot-dev:${NAUTOBOT_VER}-py${PYTHON_VER} -ARG nautobot_ver=v1.0.0b1 -ENV PYTHONUNBUFFERED 1 - -RUN mkdir /prom_cache -ENV prometheus_multiproc_dir /prom_cache - -RUN mkdir -p /opt/nautobot - -RUN pip install --upgrade pip\ - && pip install poetry +WORKDIR /source -# ------------------------------------------------------------------------------------- -# Install Nautobot -# ------------------------------------------------------------------------------------- -COPY packages/nautobot-1.0.0b1-py3-none-any.whl /tmp -RUN pip install /tmp/nautobot-1.0.0b1-py3-none-any.whl +# Copy in only pyproject.toml/poetry.lock to help with caching this layer if no updates to dependencies +COPY poetry.lock pyproject.toml /source/ +# --no-root declares not to install the project package since we're wanting to take advantage of caching dependency installation +# and the project is copied in and installed after this step +RUN poetry install --no-interaction --no-ansi --no-root -# ------------------------------------------------------------------------------------- -# Install Nautobot Plugin -# ------------------------------------------------------------------------------------- -RUN mkdir -p /source -WORKDIR /source +# Copy in the rest of the source code and install local Nautobot plugin COPY . /source -RUN poetry config virtualenvs.create false \ - && poetry install --no-interaction --no-ansi +RUN poetry install --no-interaction --no-ansi -ENV NAUTOBOT_CONFIG /opt/nautobot/configuration.py -WORKDIR /opt/nautobot/ +COPY development/nautobot_config.py /opt/nautobot/nautobot_config.py diff --git a/development/dev.env b/development/dev.env index 0a66644..01e20a8 100644 --- a/development/dev.env +++ b/development/dev.env @@ -1,20 +1,24 @@ -ALLOWED_HOSTS=* -CHANGELOG_RETENTION=0 -DB_HOST=postgres -DB_NAME=nautobot -DB_PASSWORD=decinablesprewad -DB_USER=nautobot -HIDE_RESTRICTED_UI=True -MAX_PAGE_SIZE=0 -NAPALM_TIMEOUT=5 -NAUTOBOT_CONFIG=/etc/nautobot/nautobot_config.py +NAUTOBOT_ALLOWED_HOSTS=* +NAUTOBOT_CHANGELOG_RETENTION=0 +NAUTOBOT_CONFIG=/opt/nautobot/nautobot_config.py +NAUTOBOT_DB_HOST=postgres +NAUTOBOT_DB_NAME=nautobot +NAUTOBOT_DB_PASSWORD=decinablesprewad +NAUTOBOT_DB_USER=nautobot +NAUTOBOT_MAX_PAGE_SIZE=0 +NAUTOBOT_NAPALM_TIMEOUT=5 +NAUTOBOT_REDIS_HOST=redis +NAUTOBOT_REDIS_PASSWORD=decinablesprewad +NAUTOBOT_REDIS_PORT=6379 +# Uncomment REDIS_SSL if using SSL +# NAUTOBOT_REDIS_SSL=True +NAUTOBOT_SECRET_KEY=012345678901234567890123456789012345678901234567890123456789 + +# Needed for Postgres should match the values for Nautobot above PGPASSWORD=decinablesprewad POSTGRES_DB=nautobot POSTGRES_PASSWORD=decinablesprewad POSTGRES_USER=nautobot -REDIS_HOST=redis + +# Needed for Redis should match the values for Nautobot above REDIS_PASSWORD=decinablesprewad -REDIS_PORT=6379 -# Uncomment REDIS_SSL if using SSL -# REDIS_SSL=True -SECRET_KEY=012345678901234567890123456789012345678901234567890123456789 diff --git a/development/docker-compose.yml b/development/docker-compose.yml index 31f9b5d..b8606ef 100644 --- a/development/docker-compose.yml +++ b/development/docker-compose.yml @@ -1,53 +1,59 @@ + --- -version: "3" -services: - nautobot: + x-nautobot-build: &nautobot-build build: + args: + NAUTOBOT_VER: "${NAUTOBOT_VER}" + PYTHON_VER: "${PYTHON_VER}" context: "../" dockerfile: "development/Dockerfile" - image: "nautobot-data-validation-engine/nautobot:${NAUTOBOT_VER}-py${PYTHON_VER}" - command: > - sh -c "nautobot-server migrate && - nautobot-server runserver 0.0.0.0:8000" - ports: - - "8000:8000" - depends_on: - - "postgres" - - "redis" + x-nautobot-base: &nautobot-base + image: "nautobot_data_validation_engine/nautobot:${NAUTOBOT_VER}-py${PYTHON_VER}" env_file: - - "./dev.env" - volumes: - - "./nautobot_config.py:/etc/nautobot/nautobot_config.py" - - "../:/source" + - "dev.env" tty: true - worker: - build: - context: "../" - dockerfile: "development/Dockerfile" - image: "nautobot-data-validation-engine/nautobot:${NAUTOBOT_VER}-py${PYTHON_VER}" - command: > - sh -c "nautobot-server rqworker" - depends_on: - - "nautobot" - env_file: - - "./dev.env" - volumes: - - "./nautobot_config.py:/etc/nautobot/nautobot_config.py" - - "../nautobot_data_validation_engine:/source/nautobot_data_validation_engine" - tty: true - postgres: - image: "postgres:12" - env_file: - - "./dev.env" - volumes: - - "pgdata_nautobot_data_validation_engine:/var/lib/postgresql/data" - redis: - image: "redis:5-alpine" - command: - - "sh" - - "-c" # this is to evaluate the $REDIS_PASSWORD from the env - - "redis-server --appendonly yes --requirepass $$REDIS_PASSWORD" ## $$ because of docker-compose - env_file: - - "./dev.env" -volumes: - pgdata_nautobot_data_validation_engine: {} + + version: "3.4" + services: + nautobot: + command: "nautobot-server runserver 0.0.0.0:8080 --insecure" + volumes: + - "./nautobot_config.py:/opt/nautobot/nautobot_config.py" + - "../:/source" + ports: + - "8080:8080" + depends_on: + - "postgres" + - "redis" + <<: *nautobot-build + <<: *nautobot-base + worker: + entrypoint: "nautobot-server rqworker" + volumes: + - "./nautobot_config.py:/opt/nautobot/nautobot_config.py" + - "../:/source" + depends_on: + - "nautobot" + healthcheck: + disable: true + <<: *nautobot-base + postgres: + image: "postgres:13-alpine" + env_file: + - "dev.env" + volumes: + - "pgdata_nautobot_data_validation_engine:/var/lib/postgresql/data" + ports: + - "5432:5432" + redis: + image: "redis:6-alpine" + command: + - "sh" + - "-c" # this is to evaluate the $REDIS_PASSWORD from the env + - "redis-server --appendonly yes --requirepass $$REDIS_PASSWORD" + env_file: + - "dev.env" + ports: + - "6379:6379" + volumes: + pgdata_nautobot_data_validation_engine: # yamllint disable-line rule:empty-values diff --git a/development/nautobot_config.py b/development/nautobot_config.py index c6413a5..6e21909 100644 --- a/development/nautobot_config.py +++ b/development/nautobot_config.py @@ -1,16 +1,19 @@ -"""Nautobot configuration file.""" +"""Nautobot development configuration file.""" import os import sys -ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(" ") +from nautobot.core.settings import * +from nautobot.core.settings_funcs import is_truthy + +ALLOWED_HOSTS = os.environ.get("NAUTOBOT_ALLOWED_HOSTS", "").split(" ") DATABASES = { "default": { - "NAME": os.environ.get("DB_NAME", "nautobot"), - "USER": os.environ.get("DB_USER", ""), - "PASSWORD": os.environ.get("DB_PASSWORD", ""), - "HOST": os.environ.get("DB_HOST", "localhost"), - "PORT": os.environ.get("DB_PORT", ""), + "NAME": os.environ.get("NAUTOBOT_DB_NAME", "nautobot"), + "USER": os.environ.get("NAUTOBOT_DB_USER", ""), + "PASSWORD": os.environ.get("NAUTOBOT_DB_PASSWORD", ""), + "HOST": os.environ.get("NAUTOBOT_DB_HOST", "localhost"), + "PORT": os.environ.get("NAUTOBOT_DB_PORT", ""), "CONN_MAX_AGE": 300, "ENGINE": "django.db.backends.postgresql", } @@ -20,54 +23,94 @@ LOG_LEVEL = "DEBUG" if DEBUG else "INFO" -LOGGING = { - "version": 1, - "disable_existing_loggers": False, - "formatters": { - "normal": { - "format": "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s :\n %(message)s", - "datefmt": "%H:%M:%S", +TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" + +# Verbose logging during normal development operation, but quiet logging during unit test execution +if not TESTING: + LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "normal": { + "format": "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)s :\n %(message)s", + "datefmt": "%H:%M:%S", + }, + "verbose": { + "format": "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-20s %(filename)-15s %(funcName)30s() :\n %(message)s", + "datefmt": "%H:%M:%S", + }, }, - "verbose": { - "format": "%(asctime)s.%(msecs)03d %(levelname)-7s %(name)-20s %(filename)-15s %(funcName)30s() :\n %(message)s", - "datefmt": "%H:%M:%S", + "handlers": { + "normal_console": { + "level": "INFO", + "class": "rq.utils.ColorizingStreamHandler", + "formatter": "normal", + }, + "verbose_console": { + "level": "DEBUG", + "class": "rq.utils.ColorizingStreamHandler", + "formatter": "verbose", + }, }, - }, - "handlers": { - "normal_console": {"level": "INFO", "class": "rq.utils.ColorizingStreamHandler", "formatter": "normal"}, - "verbose_console": {"level": "DEBUG", "class": "rq.utils.ColorizingStreamHandler", "formatter": "verbose"}, - }, - "loggers": { - "django": {"handlers": ["normal_console"], "level": "INFO"}, - "nautobot": {"handlers": ["verbose_console" if DEBUG else "normal_console"], "level": LOG_LEVEL}, - "rq.worker": {"handlers": ["verbose_console" if DEBUG else "normal_console"], "level": LOG_LEVEL}, - }, -} + "loggers": { + "django": {"handlers": ["normal_console"], "level": "INFO"}, + "nautobot": { + "handlers": ["verbose_console" if DEBUG else "normal_console"], + "level": LOG_LEVEL, + }, + "rq.worker": { + "handlers": ["verbose_console" if DEBUG else "normal_console"], + "level": LOG_LEVEL, + }, + }, + } + + +# Redis variables +REDIS_HOST = os.getenv("NAUTOBOT_REDIS_HOST", "localhost") +REDIS_PORT = os.getenv("NAUTOBOT_REDIS_PORT", 6379) +REDIS_PASSWORD = os.getenv("NAUTOBOT_REDIS_PASSWORD", "") -REDIS = { - "caching": { - "HOST": os.environ.get("REDIS_HOST", "redis"), - "PORT": int(os.environ.get("REDIS_PORT", 6379)), - "PASSWORD": os.environ.get("REDIS_PASSWORD", ""), - "DATABASE": 1, - "SSL": bool(os.environ.get("REDIS_SSL", False)), - }, - "tasks": { - "HOST": os.environ.get("REDIS_HOST", "redis"), - "PORT": int(os.environ.get("REDIS_PORT", 6379)), - "PASSWORD": os.environ.get("REDIS_PASSWORD", ""), - "DATABASE": 0, - "SSL": bool(os.environ.get("REDIS_SSL", False)), - }, +# Check for Redis SSL +REDIS_SCHEME = "redis" +REDIS_SSL = is_truthy(os.environ.get("NAUTOBOT_REDIS_SSL", False)) +if REDIS_SSL: + REDIS_SCHEME = "rediss" + +# The django-redis cache is used to establish concurrent locks using Redis. The +# django-rq settings will use the same instance/database by default. +# +# This "default" server is now used by RQ_QUEUES. +# >> See: nautobot.core.settings.RQ_QUEUES +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": f"{REDIS_SCHEME}://{REDIS_HOST}:{REDIS_PORT}/0", + "TIMEOUT": 300, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "PASSWORD": REDIS_PASSWORD, + }, + } } -SECRET_KEY = os.environ.get("SECRET_KEY", "") +# RQ_QUEUES is not set here because it just uses the default that gets imported +# up top via `from nautobot.core.settings import *`. + +# REDIS CACHEOPS +CACHEOPS_REDIS = f"{REDIS_SCHEME}://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/1" + +HIDE_RESTRICTED_UI = os.environ.get("HIDE_RESTRICTED_UI", False) + +SECRET_KEY = os.environ.get("NAUTOBOT_SECRET_KEY", "") # Django Debug Toolbar -TESTING = len(sys.argv) > 1 and sys.argv[1] == "test" DEBUG_TOOLBAR_CONFIG = {"SHOW_TOOLBAR_CALLBACK": lambda _request: DEBUG and not TESTING} -HIDE_RESTRICTED_UI = os.environ.get("HIDE_RESTRICTED_UI", False) -PLUGINS = [ - "nautobot_data_validation_engine", -] +if "debug_toolbar" not in INSTALLED_APPS: + INSTALLED_APPS.append("debug_toolbar") +if "debug_toolbar.middleware.DebugToolbarMiddleware" not in MIDDLEWARE: + MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware") + +# Enable installed plugins. Add the name of each plugin to the list. +PLUGINS = ["nautobot_data_validation_engine"] diff --git a/nautobot_data_validation_engine/api/__init__.py b/nautobot_data_validation_engine/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nautobot_data_validation_engine/api/serializers.py b/nautobot_data_validation_engine/api/serializers.py new file mode 100644 index 0000000..53d7aa9 --- /dev/null +++ b/nautobot_data_validation_engine/api/serializers.py @@ -0,0 +1,68 @@ +""" +API serializers +""" +from django.contrib.contenttypes.models import ContentType +from rest_framework import serializers + +from nautobot.core.api import ( + ContentTypeField, + ValidatedModelSerializer, +) +from nautobot.extras.utils import FeatureQuery + +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class RegularExpressionValidationRuleSerializer(ValidatedModelSerializer): + """Serializer for `RegularExpressionValidationRule` objects.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_data_validation_engine-api:regularexpressionvalidationrule-detail" + ) + content_type = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()), + ) + + class Meta: + model = RegularExpressionValidationRule + fields = [ + "id", + "url", + "name", + "slug", + "content_type", + "field", + "regular_expression", + "enabled", + "error_message", + "created", + "last_updated", + ] + + +class MinMaxValidationRuleSerializer(ValidatedModelSerializer): + """Serializer for `MinMaxValidationRule` objects.""" + + url = serializers.HyperlinkedIdentityField( + view_name="plugins-api:nautobot_data_validation_engine-api:minmaxvalidationrule-detail" + ) + content_type = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()), + ) + + class Meta: + model = MinMaxValidationRule + fields = [ + "id", + "url", + "name", + "slug", + "content_type", + "field", + "min", + "max", + "enabled", + "error_message", + "created", + "last_updated", + ] diff --git a/nautobot_data_validation_engine/api/urls.py b/nautobot_data_validation_engine/api/urls.py new file mode 100644 index 0000000..efa09cf --- /dev/null +++ b/nautobot_data_validation_engine/api/urls.py @@ -0,0 +1,19 @@ +""" +API routes +""" +from nautobot.core.api import OrderedDefaultRouter + +from nautobot_data_validation_engine.api import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.DataValidationEngineRootView + +# Regular expression rules +router.register("rules/regex", views.RegularExpressionValidationRuleViewSet) + +# Min/max rules +router.register("rules/min-max", views.MinMaxValidationRuleViewSet) + + +urlpatterns = router.urls diff --git a/nautobot_data_validation_engine/api/views.py b/nautobot_data_validation_engine/api/views.py new file mode 100644 index 0000000..e54f435 --- /dev/null +++ b/nautobot_data_validation_engine/api/views.py @@ -0,0 +1,38 @@ +""" +API views +""" +from rest_framework.routers import APIRootView + +from nautobot.core.api.views import ModelViewSet + +from nautobot_data_validation_engine.api import serializers +from nautobot_data_validation_engine import models, filters + + +class DataValidationEngineRootView(APIRootView): + """ + Data Validation Engine API root view + """ + + def get_view_name(self): + return "Data Validation Engine" + + +class RegularExpressionValidationRuleViewSet(ModelViewSet): + """ + View to manage regular expression validation rules + """ + + queryset = models.RegularExpressionValidationRule.objects.all() + serializer_class = serializers.RegularExpressionValidationRuleSerializer + filterset_class = filters.RegularExpressionValidationRuleFilterSet + + +class MinMaxValidationRuleViewSet(ModelViewSet): + """ + View to manage min max expression validation rules + """ + + queryset = models.MinMaxValidationRule.objects.all() + serializer_class = serializers.MinMaxValidationRuleSerializer + filterset_class = filters.MinMaxValidationRuleFilterSet diff --git a/nautobot_data_validation_engine/custom_validators.py b/nautobot_data_validation_engine/custom_validators.py index f6860da..9a4ec7f 100644 --- a/nautobot_data_validation_engine/custom_validators.py +++ b/nautobot_data_validation_engine/custom_validators.py @@ -9,11 +9,12 @@ validation rules that have been defined for the given model. """ import re +from django.db.models.query_utils import Q from nautobot.extras.plugins import PluginCustomValidator from nautobot.extras.registry import registry -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule class BaseValidator(PluginCustomValidator): @@ -31,11 +32,44 @@ def clean(self): # Regex rules for rule in RegularExpressionValidationRule.objects.get_for_model(self.model): - if not re.match(rule.regular_expression, getattr(obj, rule.field)): + field_value = getattr(obj, rule.field) + if field_value is None: + # Coerce to a string for regex validation + field_value = "" + if not re.match(rule.regular_expression, field_value): self.validation_error( {rule.field: rule.error_message or f"Value does not conform to regex: {rule.regular_expression}"} ) + # Min/Max rules + for rule in MinMaxValidationRule.objects.get_for_model(self.model): + field_value = getattr(obj, rule.field) + + if field_value is None: + self.validation_error( + { + rule.field: rule.error_message + or f"Value does not conform to mix/max validation: min {rule.min}, max {rule.max}" + } + ) + + elif not isinstance(field_value, (int, float)): + self.validation_error( + { + rule.field: f"Unable to validate against min/max rule {rule} because the field value is not numeric." + } + ) + + elif rule.min is not None and field_value is not None and field_value < rule.min: + self.validation_error( + {rule.field: rule.error_message or f"Value is less than minimum value: {rule.min}"} + ) + + elif rule.max is not None and field_value is not None and field_value > rule.max: + self.validation_error( + {rule.field: rule.error_message or f"Value is more than maximum value: {rule.max}"} + ) + class CustomValidatorIterator: """ diff --git a/nautobot_data_validation_engine/filters.py b/nautobot_data_validation_engine/filters.py index d0f540e..b6e4625 100644 --- a/nautobot_data_validation_engine/filters.py +++ b/nautobot_data_validation_engine/filters.py @@ -5,9 +5,10 @@ from django.db.models import Q from nautobot.extras.filters import CreatedUpdatedFilterSet -from nautobot.utilities.filters import BaseFilterSet, ContentTypeFilter +from nautobot.extras.utils import FeatureQuery +from nautobot.utilities.filters import BaseFilterSet, ContentTypeMultipleChoiceFilter -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule class RegularExpressionValidationRuleFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): @@ -19,11 +20,13 @@ class RegularExpressionValidationRuleFilterSet(BaseFilterSet, CreatedUpdatedFilt method="search", label="Search", ) - content_type = ContentTypeFilter() + content_type = ContentTypeMultipleChoiceFilter( + choices=FeatureQuery("custom_validators").get_choices, conjoined=False # Make this an OR with multi-values + ) class Meta: model = RegularExpressionValidationRule - fields = ["id", "name", "regular_expression", "enabled", "content_type", "field", "error_message"] + fields = ["id", "name", "slug", "regular_expression", "enabled", "content_type", "field", "error_message"] def search(self, queryset, name, value): """ @@ -33,6 +36,7 @@ def search(self, queryset, name, value): return queryset qs_filter = ( Q(name__icontains=value) + | Q(slug__icontains=value) | Q(regular_expression__icontains=value) | Q(error_message__icontains=value) | Q(content_type__app_label=value) @@ -40,3 +44,37 @@ def search(self, queryset, name, value): | Q(field=value) ) return queryset.filter(qs_filter) + + +class MinMaxValidationRuleFilterSet(BaseFilterSet, CreatedUpdatedFilterSet): + """ + Base filterset for the MinMaxValidationRule model. + """ + + q = django_filters.CharFilter( + method="search", + label="Search", + ) + content_type = ContentTypeMultipleChoiceFilter( + choices=FeatureQuery("custom_validators").get_choices, conjoined=False # Make this an OR with multi-values + ) + + class Meta: + model = MinMaxValidationRule + fields = ["id", "name", "slug", "min", "max", "enabled", "content_type", "field", "error_message"] + + def search(self, queryset, name, value): + """ + Custom filter method which searches a string value across several fields attached to the `q` filter field. + """ + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) + | Q(slug__icontains=value) + | Q(error_message__icontains=value) + | Q(content_type__app_label=value) + | Q(content_type__model=value) + | Q(field=value) + ) + return queryset.filter(qs_filter) diff --git a/nautobot_data_validation_engine/forms.py b/nautobot_data_validation_engine/forms.py index 56ffbbe..0be056c 100644 --- a/nautobot_data_validation_engine/forms.py +++ b/nautobot_data_validation_engine/forms.py @@ -4,6 +4,8 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from nautobot.extras.forms import AddRemoveTagsForm +from nautobot.extras.models.tags import Tag from nautobot.extras.utils import FeatureQuery from nautobot.utilities.forms import ( BootstrapMixin, @@ -12,9 +14,11 @@ CSVContentTypeField, CSVMultipleContentTypeField, CSVModelForm, + DynamicModelMultipleChoiceField, + SlugField, ) -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule # @@ -27,6 +31,7 @@ class RegularExpressionValidationRuleForm(BootstrapMixin, forms.ModelForm): Base model form for the RegularExpressionValidationRule model. """ + slug = SlugField() content_type = forms.ModelChoiceField( queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()).order_by( "app_label", "model" @@ -35,7 +40,7 @@ class RegularExpressionValidationRuleForm(BootstrapMixin, forms.ModelForm): class Meta: model = RegularExpressionValidationRule - fields = ["name", "enabled", "content_type", "field", "regular_expression", "error_message"] + fields = ["name", "slug", "enabled", "content_type", "field", "regular_expression", "error_message"] class RegularExpressionValidationRuleCSVForm(CSVModelForm): @@ -43,6 +48,7 @@ class RegularExpressionValidationRuleCSVForm(CSVModelForm): Base csv form for the RegularExpressionValidationRule model. """ + slug = SlugField() content_type = CSVContentTypeField( queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()), help_text="The object type to which this regular expression rule applies.", @@ -88,3 +94,79 @@ class RegularExpressionValidationRuleFilterForm(BootstrapMixin, forms.Form): ), required=False, ) + + +# +# MinMaxValidationRules +# + + +class MinMaxValidationRuleForm(BootstrapMixin, forms.ModelForm): + """ + Base model form for the MinMaxValidationRule model. + """ + + slug = SlugField() + content_type = forms.ModelChoiceField( + queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()).order_by( + "app_label", "model" + ), + ) + + class Meta: + model = MinMaxValidationRule + fields = ["name", "slug", "enabled", "content_type", "field", "min", "max", "error_message"] + + +class MinMaxValidationRuleCSVForm(CSVModelForm): + """ + Base csv form for the MinMaxValidationRule model. + """ + + slug = SlugField() + content_type = CSVContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()), + help_text="The object type to which this regular expression rule applies.", + ) + + class Meta: + model = MinMaxValidationRule + fields = MinMaxValidationRule.csv_headers + + +class MinMaxValidationRuleBulkEditForm(BootstrapMixin, BulkEditForm): + """ + Base bulk edit form for the MinMaxValidationRule model. + """ + + pk = forms.ModelMultipleChoiceField(queryset=MinMaxValidationRule.objects.all(), widget=forms.MultipleHiddenInput) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + ) + min = forms.IntegerField(required=False) + max = forms.IntegerField(required=False) + error_message = forms.CharField(required=False) + + class Meta: + nullable_fields = ["error_message"] + + +class MinMaxValidationRuleFilterForm(BootstrapMixin, forms.Form): + """ + Base filter form for the MinMaxValidationRule model. + """ + + model = MinMaxValidationRule + field_order = ["q", "name", "enabled", "content_type", "field", "min", "max", "error_message"] + q = forms.CharField(required=False, label="Search") + # "CSV" field is being used here because it is using the slug-form input for + # content-types, which improves UX. + content_type = CSVMultipleContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery("custom_validators").get_query()).order_by( + "app_label", "model" + ), + required=False, + ) + min = forms.IntegerField(required=False) + max = forms.IntegerField(required=False) diff --git a/nautobot_data_validation_engine/migrations/0001_initial.py b/nautobot_data_validation_engine/migrations/0001_initial.py index c80ed78..3517089 100644 --- a/nautobot_data_validation_engine/migrations/0001_initial.py +++ b/nautobot_data_validation_engine/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.3 on 2021-02-22 23:21 +# Generated by Django 3.1.11 on 2021-05-26 06:04 from django.db import migrations, models import django.db.models.deletion @@ -28,13 +28,46 @@ class Migration(migrations.Migration): ("created", models.DateField(auto_now_add=True, null=True)), ("last_updated", models.DateTimeField(auto_now=True, null=True)), ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(max_length=100, unique=True)), + ("enabled", models.BooleanField(default=True)), + ("error_message", models.CharField(blank=True, max_length=255, null=True)), ("field", models.CharField(max_length=50)), ( "regular_expression", models.TextField(validators=[nautobot_data_validation_engine.models.validate_regex]), ), + ( + "content_type", + models.ForeignKey( + limit_choices_to=nautobot.extras.utils.FeatureQuery("custom_validators"), + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ], + options={ + "ordering": ("name",), + "unique_together": {("content_type", "field")}, + }, + ), + migrations.CreateModel( + name="MinMaxValidationRule", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True + ), + ), + ("created", models.DateField(auto_now_add=True, null=True)), + ("last_updated", models.DateTimeField(auto_now=True, null=True)), + ("name", models.CharField(max_length=100, unique=True)), + ("slug", models.SlugField(max_length=100, unique=True)), ("enabled", models.BooleanField(default=True)), ("error_message", models.CharField(blank=True, max_length=255, null=True)), + ("field", models.CharField(max_length=50)), + ("min", models.FloatField(blank=True, null=True)), + ("max", models.FloatField(blank=True, null=True)), ( "content_type", models.ForeignKey( diff --git a/nautobot_data_validation_engine/models.py b/nautobot_data_validation_engine/models.py index 3f8a6ae..d546a74 100644 --- a/nautobot_data_validation_engine/models.py +++ b/nautobot_data_validation_engine/models.py @@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models +from django.db.models.query_utils import Q from django.shortcuts import reverse from nautobot.extras.models import ChangeLoggedModel @@ -41,19 +42,16 @@ def get_for_model(self, content_type): return self.filter(enabled=True, content_type__app_label=app_label, content_type__model=model) -class RegularExpressionValidationRule(BaseModel, ChangeLoggedModel): +class ValidationRule(BaseModel, ChangeLoggedModel): """ - A type of validation rule that applies a regular expression to a given model field. + Base model for all validation engine rule models """ name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=100, unique=True) content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, limit_choices_to=FeatureQuery("custom_validators") ) - field = models.CharField( - max_length=50, - ) - regular_expression = models.TextField(validators=[validate_regex]) enabled = models.BooleanField(default=True) error_message = models.CharField( max_length=255, null=True, blank=True, help_text="Optional error message to display when validation fails." @@ -61,12 +59,8 @@ class RegularExpressionValidationRule(BaseModel, ChangeLoggedModel): objects = ValidationRuleManager.as_manager() - csv_headers = ["name", "enabled", "content_type", "field", "regular_expression", "error_message"] - clone_fields = ["enabled", "content_type", "regular_expression", "error_message"] - class Meta: - ordering = ("name",) - unique_together = [["content_type", "field"]] + abstract = True def __str__(self): """ @@ -74,11 +68,29 @@ def __str__(self): """ return self.name + +class RegularExpressionValidationRule(ValidationRule): + """ + A type of validation rule that applies a regular expression to a given model field. + """ + + field = models.CharField( + max_length=50, + ) + regular_expression = models.TextField(validators=[validate_regex]) + + csv_headers = ["name", "slug", "enabled", "content_type", "field", "regular_expression", "error_message"] + clone_fields = ["enabled", "content_type", "regular_expression", "error_message"] + + class Meta: + ordering = ("name",) + unique_together = [["content_type", "field"]] + def get_absolute_url(self): """ Absolute url for the instance. """ - return reverse("plugins:nautobot_data_validation_engine:regularexpressionvalidationrule", args=[self.pk]) + return reverse("plugins:nautobot_data_validation_engine:regularexpressionvalidationrule", args=[self.slug]) def to_csv(self): """ @@ -86,6 +98,7 @@ def to_csv(self): """ return ( self.name, + self.slug, self.enabled, f"{self.content_type.app_label}.{self.content_type.model}", self.field, @@ -113,9 +126,11 @@ def clean(self): models.ForeignKey, models.ImageField, models.JSONField, + models.Manager, models.ManyToManyField, models.NullBooleanField, models.OneToOneField, + models.fields.related.RelatedField, models.SmallAutoField, models.UUIDField, ) @@ -123,4 +138,88 @@ def clean(self): model_field = self.content_type.model_class()._meta.get_field(self.field) if self.field.startswith("_") or not model_field.editable or isinstance(model_field, blacklisted_field_types): - raise ValidationError({"field": "This field does not support regular expression validation."}) + raise ValidationError({"field": "This field's type does not support regular expression validation."}) + + +class MinMaxValidationRule(ValidationRule): + """ + A type of validation rule that applies min/max constraints to a given numeric model field. + """ + + field = models.CharField( + max_length=50, + ) + min = models.FloatField( + null=True, blank=True, help_text="When set, apply a minimum value contraint to the value of the model field." + ) + max = models.FloatField( + null=True, blank=True, help_text="When set, apply a maximum value contraint to the value of the model field." + ) + + csv_headers = ["name", "slug", "enabled", "content_type", "field", "min", "max", "error_message"] + clone_fields = ["enabled", "content_type", "min", "max", "error_message"] + + class Meta: + ordering = ("name",) + unique_together = [["content_type", "field"]] + + def get_absolute_url(self): + """ + Absolute url for the instance. + """ + return reverse("plugins:nautobot_data_validation_engine:minmaxvalidationrule", args=[self.slug]) + + def to_csv(self): + """ + Return tuple representing the instance, which this used for CSV export. + """ + return ( + self.name, + self.slug, + self.enabled, + f"{self.content_type.app_label}.{self.content_type.model}", + self.field, + self.min, + self.max, + self.error_message, + ) + + def clean(self): + """ + Ensure field is valid for the model and has not been blacklisted. + """ + if self.field not in [f.name for f in self.content_type.model_class()._meta.get_fields()]: + raise ValidationError( + { + "field": f"Not a valid field for content type {self.content_type.app_label}.{self.content_type.model}." + } + ) + + whitelisted_field_types = ( + models.DecimalField, + models.FloatField, + models.IntegerField, + ) + + blacklisted_field_types = ( + models.AutoField, + models.BigAutoField, + ) + + model_field = self.content_type.model_class()._meta.get_field(self.field) + + if not isinstance(model_field, whitelisted_field_types) or ( + self.field.startswith("_") or not model_field.editable or isinstance(model_field, blacklisted_field_types) + ): + raise ValidationError({"field": "This field's type does not support min/max validation."}) + + if self.min is None and self.max is None: + raise ValidationError("At least a minimum or maximum value must be specified.") + + if self.min is not None and self.max is not None and self.min > self.max: + raise ValidationError( + { + "min": "Minimum value cannot be more than the maximum value.", + "max": "Maximum value cannot be less than the minimum value.", + } + ) diff --git a/nautobot_data_validation_engine/navigation.py b/nautobot_data_validation_engine/navigation.py index e4a71fe..116facd 100644 --- a/nautobot_data_validation_engine/navigation.py +++ b/nautobot_data_validation_engine/navigation.py @@ -6,6 +6,27 @@ menu_items = ( + PluginMenuItem( + link="plugins:nautobot_data_validation_engine:minmaxvalidationrule_list", + link_text="Min/Max Rules", + permissions=["nautobot_data_validation_engine.view_minmaxvalidationrule"], + buttons=( + PluginMenuButton( + link="plugins:nautobot_data_validation_engine:minmaxvalidationrule_add", + title="Add", + icon_class="mdi mdi-plus-thick", + color=ButtonColorChoices.GREEN, + permissions=["nautobot_data_validation_engine.add_minmaxvalidationrule"], + ), + PluginMenuButton( + link="plugins:nautobot_data_validation_engine:minmaxvalidationrule_import", + title="Import", + icon_class="mdi mdi-database-import-outline", + color=ButtonColorChoices.BLUE, + permissions=["nautobot_data_validation_engine.add_minmaxvalidationrule"], + ), + ), + ), PluginMenuItem( link="plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_list", link_text="Regex Rules", diff --git a/nautobot_data_validation_engine/tables.py b/nautobot_data_validation_engine/tables.py index 76b22b8..455a970 100644 --- a/nautobot_data_validation_engine/tables.py +++ b/nautobot_data_validation_engine/tables.py @@ -5,7 +5,7 @@ from nautobot.utilities.tables import BaseTable, ToggleColumn -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule # @@ -25,3 +25,22 @@ class Meta(BaseTable.Meta): model = RegularExpressionValidationRule fields = ("pk", "name", "enabled", "content_type", "field", "regular_expression", "error_message") default_columns = ("pk", "name", "enabled", "content_type", "field", "regular_expression", "error_message") + + +# +# MinMaxValidationRules +# + + +class MinMaxValidationRuleTable(BaseTable): + """ + Base table for the MinMaxValidationRule model. + """ + + pk = ToggleColumn() + name = tables.LinkColumn(order_by=("name",)) + + class Meta(BaseTable.Meta): + model = MinMaxValidationRule + fields = ("pk", "name", "enabled", "content_type", "field", "min", "max", "error_message") + default_columns = ("pk", "name", "enabled", "content_type", "field", "min", "max", "error_message") diff --git a/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/minmaxvalidationrule.html b/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/minmaxvalidationrule.html new file mode 100644 index 0000000..efce5a8 --- /dev/null +++ b/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/minmaxvalidationrule.html @@ -0,0 +1,100 @@ +{% extends 'base.html' %} +{% load buttons %} +{% load static %} +{% load helpers %} + +{% block title %}{{ object }}{% endblock %} + +{% block header %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.nautobot_data_validation.add_minmaxvalidationrule %} + {% clone_button object %} + {% endif %} + {% if perms.nautobot_data_validation.change_minmaxvalidationrule %} + {% edit_button object %} + {% endif %} + {% if perms.nautobot_data_validation.delete_minmaxvalidationrule %} + {% delete_button object %} + {% endif %} +
+

{{ object }}

+ {% include 'inc/created_updated.html' %} + +{% endblock %} + +{% block content %} +
+
+
+
+ Min/Max Rule +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Enabled + {% if object.enabled %} + + {% else %} + + {% endif %} +
Content type{{ object.content_type.app_label }}.{{ object.content_type.model }}
Field{{ object.field }}
Min{{ object.min|placeholder }}
Max{{ object.max|placeholder }}
Error message{{ object.error_message|placeholder }}
+
+
+
+{% endblock %} diff --git a/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule.html b/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule.html index 0b79fb5..4e6b2f7 100644 --- a/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule.html +++ b/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule.html @@ -15,9 +15,9 @@
-
+
- + @@ -28,13 +28,13 @@
{% if perms.nautobot_data_validation.add_regularexpressionvalidationrule %} - {% include 'buttons/clone.html' with url=clone_url %} + {% clone_button object %} {% endif %} {% if perms.nautobot_data_validation.change_regularexpressionvalidationrule %} - {% include 'buttons/edit.html' with url=edit_url %} + {% edit_button object %} {% endif %} {% if perms.nautobot_data_validation.delete_regularexpressionvalidationrule %} - {% include 'buttons/delete.html' with url=delete_url %} + {% delete_button object %} {% endif %}

{{ object }}

@@ -45,7 +45,7 @@

{{ object }}

{% if perms.extras.view_objectchange %} {% endif %} diff --git a/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule_list.html b/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule_list.html deleted file mode 100644 index c7b3de2..0000000 --- a/nautobot_data_validation_engine/templates/nautobot_data_validation_engine/regularexpressionvalidationrule_list.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'base.html' %} -{% load buttons %} - -{% block content %} -
- {% if permissions.add %} - {% add_button 'plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_add' %} - {% import_button 'plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_import' %} - {% export_button %} - {% endif %} -
-

{% block title %}Regular Expression Rules{% endblock %}

-
-
- {% include 'utilities/obj_table.html' with bulk_delete_url="plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_bulk_delete" bulk_edit_url="plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_bulk_edit" %} -
-
- {% include 'inc/search_panel.html' %} -
-
-{% endblock %} \ No newline at end of file diff --git a/nautobot_data_validation_engine/tests/__init__.py b/nautobot_data_validation_engine/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nautobot_data_validation_engine/tests/test_api.py b/nautobot_data_validation_engine/tests/test_api.py new file mode 100644 index 0000000..d7bd54c --- /dev/null +++ b/nautobot_data_validation_engine/tests/test_api.py @@ -0,0 +1,181 @@ +""" +API test cases +""" +from django.contrib.contenttypes.models import ContentType +from django.urls import reverse + +from nautobot.dcim.models import PowerFeed, Site +from nautobot.utilities.testing import APITestCase, APIViewTestCases + +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class AppTest(APITestCase): + """ + Test base path for app + """ + + def test_root(self): + """ + Test the root view + """ + url = reverse("plugins-api:nautobot_data_validation_engine-api:api-root") + response = self.client.get("{}?format=api".format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class RegularExpressionValidationRuleTest(APIViewTestCases.APIViewTestCase): + """ + API view test cases for the RegularExpressionValidationRule model + """ + + model = RegularExpressionValidationRule + brief_fields = [ + "content_type", + "created", + "display", + "enabled", + "error_message", + "field", + "id", + "last_updated", + "name", + "regular_expression", + "slug", + "url", + ] + + create_data = [ + { + "name": "Regex rule 4", + "slug": "regex-rule-4", + "content_type": "dcim.site", + "field": "contact_name", + "regular_expression": "^.*$", + }, + { + "name": "Regex rule 5", + "slug": "regex-rule-5", + "content_type": "dcim.site", + "field": "physical_address", + "regular_expression": "^.*$", + }, + { + "name": "Regex rule 6", + "slug": "regex-rule-6", + "content_type": "dcim.site", + "field": "shipping_address", + "regular_expression": "^.*$", + }, + ] + bulk_update_data = { + "enabled": False, + } + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="name", + regular_expression="^.*$", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 2", + slug="regex-rule-2", + content_type=ContentType.objects.get_for_model(Site), + field="description", + regular_expression="^.*$", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 3", + slug="regex-rule-3", + content_type=ContentType.objects.get_for_model(Site), + field="comments", + regular_expression="^.*$", + ) + + +class MinMaxValidationRuleTest(APIViewTestCases.APIViewTestCase): + """ + API view test cases for the MinMaxValidationRule model + """ + + model = MinMaxValidationRule + brief_fields = [ + "content_type", + "created", + "display", + "enabled", + "error_message", + "field", + "id", + "last_updated", + "max", + "min", + "name", + "slug", + "url", + ] + + create_data = [ + { + "name": "Min max rule 4", + "slug": "min-max-rule-4", + "content_type": "dcim.device", + "field": "vc_position", + "min": 0, + "max": 1, + }, + { + "name": "Min max rule 5", + "slug": "min-max-rule-5", + "content_type": "dcim.device", + "field": "vc_priority", + "min": -5.6, + "max": 0, + }, + { + "name": "Min max rule 6", + "slug": "min-max-rule-6", + "content_type": "dcim.device", + "field": "position", + "min": 5, + "max": 6, + }, + ] + bulk_update_data = { + "enabled": False, + } + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + min=1, + ) + MinMaxValidationRule.objects.create( + name="Min max rule 2", + slug="min-max-rule-2", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="max_utilization", + min=1, + ) + MinMaxValidationRule.objects.create( + name="Min max rule 3", + slug="min-max-rule-3", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="voltage", + min=1, + ) diff --git a/nautobot_data_validation_engine/tests/test_custom_validators.py b/nautobot_data_validation_engine/tests/test_custom_validators.py new file mode 100644 index 0000000..b6fa2ca --- /dev/null +++ b/nautobot_data_validation_engine/tests/test_custom_validators.py @@ -0,0 +1,209 @@ +""" +Model test cases +""" +from django.contrib.contenttypes.models import ContentType +from django.core.validators import ValidationError +from django.test import TestCase + +from nautobot.dcim.models import Site + +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class RegularExpressionValidationRuleModelTestCase(TestCase): + """ + Test cases related to the RegularExpressionValidationRule model + """ + + def test_invalid_regex_matches_raise_validation_error(self): + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="name", + regular_expression="^ABC$", + ) + + site = Site(name="does not match the regex", slug="site") + + with self.assertRaises(ValidationError): + site.clean() + + def test_valid_regex_matches_do_not_raise_validation_error(self): + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="name", + regular_expression="^ABC$", + ) + + site = Site(name="ABC", slug="site") + + try: + site.clean() + except ValidationError as e: + self.fail(f"rule.clean() failed validation: {e}") + + def test_empty_field_values_coerced_to_empty_string(self): + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="description", + regular_expression="^ABC$", + ) + + site = Site( + name="does not match the regex", + slug="site", + description=None, # empty value not allowed by the regex + ) + + with self.assertRaises(ValidationError): + site.clean() + + +class MinMaxValidationRuleModelTestCase(TestCase): + """ + Test cases related to the MinMaxValidationRule model + """ + + def test_empty_field_values_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=1, + max=1, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=None, # empty value not allowed by the rule + ) + + with self.assertRaises(ValidationError): + site.clean() + + def test_field_value_type_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=1, + max=1, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude="foobar", # wrong type + ) + + with self.assertRaises(ValidationError): + site.clean() + + def test_min_violation_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=5, + max=10, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=4, # less than min of 5 + ) + + with self.assertRaises(ValidationError): + site.clean() + + def test_max_violation_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=5, + max=10, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=11, # more than max of 10 + ) + + with self.assertRaises(ValidationError): + site.clean() + + def test_unbounded_min_does_not_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=None, # unbounded + max=10, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=-5, + ) + + try: + site.clean() + except ValidationError as e: + self.fail(f"rule.clean() failed validation: {e}") + + def test_unbounded_max_does_not_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=5, + max=None, # unbounded + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=30, + ) + + try: + site.clean() + except ValidationError as e: + self.fail(f"rule.clean() failed validation: {e}") + + def test_valid_bounded_value_does_not_raise_validation_error(self): + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="latitude", + min=5, + max=10, + ) + + site = Site( + name="does not match the regex", + slug="site", + latitude=8, # within bounds + ) + + try: + site.clean() + except ValidationError as e: + self.fail(f"rule.clean() failed validation: {e}") diff --git a/nautobot_data_validation_engine/tests/test_filters.py b/nautobot_data_validation_engine/tests/test_filters.py new file mode 100644 index 0000000..c3266e1 --- /dev/null +++ b/nautobot_data_validation_engine/tests/test_filters.py @@ -0,0 +1,148 @@ +""" +Filterset test cases +""" +from logging import error +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from nautobot.dcim.models import PowerFeed, Rack, Region, Site + +from nautobot_data_validation_engine.filters import ( + MinMaxValidationRuleFilterSet, + RegularExpressionValidationRuleFilterSet, +) +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class RegularExpressionValidationRuleFilterTestCase(TestCase): + """ + Filterset test cases for the RegularExpressionValidationRule model + """ + + queryset = RegularExpressionValidationRule.objects.all() + filterset = RegularExpressionValidationRuleFilterSet + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Rack), + field="name", + regular_expression="^ABC$", + error_message="A", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 2", + slug="regex-rule-2", + content_type=ContentType.objects.get_for_model(Region), + field="description", + regular_expression="DEF$", + error_message="B", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 3", + slug="regex-rule-3", + content_type=ContentType.objects.get_for_model(Site), + field="comments", + regular_expression="GHI", + error_message="C", + ) + + def test_id(self): + """Test ID lookups.""" + params = {"id": self.queryset.values_list("pk", flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + """Test name lookups.""" + params = {"name": ["Regex rule 1", "Regex rule 2"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + """Test content type lookups.""" + params = {"content_type": ["dcim.rack", "dcim.site"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_regular_expression(self): + """Test regex lookups.""" + # TODO(john): revisit this once this is sorted: https://github.com/nautobot/nautobot/issues/477 + params = {"regular_expression": "^ABC$"} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_error_message(self): + """Test error message lookups.""" + params = {"error_message": ["A", "B"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_field(self): + """Test field lookups.""" + params = {"field": ["name", "description"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class MinMaxValidationRuleFilterTestCase(TestCase): + """ + Filterset test cases for the MinMaxValidationRule model + """ + + queryset = MinMaxValidationRule.objects.all() + filterset = MinMaxValidationRuleFilterSet + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + min=1, + error_message="A", + ) + MinMaxValidationRule.objects.create( + name="Min max rule 2", + slug="min-max-rule-2", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="max_utilization", + min=1, + error_message="B", + ) + MinMaxValidationRule.objects.create( + name="Min max rule 3", + slug="min-max-rule-3", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="voltage", + min=1, + error_message="C", + ) + + def test_id(self): + """Test ID lookups.""" + params = {"id": self.queryset.values_list("pk", flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + """Test name lookups.""" + params = {"name": ["Min max rule 1", "Min max rule 2"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + """Test content type lookups.""" + params = {"content_type": ["dcim.powerfeed"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_error_message(self): + """Test error message lookups.""" + params = {"error_message": ["A", "B"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_field(self): + """Test field lookups.""" + params = {"field": ["voltage", "max_utilization"]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/nautobot_data_validation_engine/tests/test_models.py b/nautobot_data_validation_engine/tests/test_models.py new file mode 100644 index 0000000..56aef15 --- /dev/null +++ b/nautobot_data_validation_engine/tests/test_models.py @@ -0,0 +1,169 @@ +""" +Model test cases +""" +from django.contrib.contenttypes.models import ContentType +from django.core.validators import ValidationError +from django.test import TestCase + +from nautobot.dcim.models import Cable, Device, PowerFeed +from nautobot.extras.models import Job + +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class RegularExpressionValidationRuleModelTestCase(TestCase): + """ + Test cases related to the RegularExpressionValidationRule model + """ + + def test_invalid_field_name(self): + """Test that a non-existent model field is rejected.""" + rule = RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Device), + field="afieldthatdoesnotexist", + regular_expression="^.*$", + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_private_fields_cannot_be_used(self): + """Test that a private model field is rejected.""" + rule = RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Device), + field="_name", # _name is a private field + regular_expression="^.*$", + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_non_editable_fields_cannot_be_used(self): + """Test that a non-editable model field is rejected.""" + rule = RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Device), + field="created", # created has auto_now_add=True, making it editable=False + regular_expression="^.*$", + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_blacklisted_fields_cannot_be_used(self): + """Test that a blacklisted model field is rejected.""" + rule = RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Device), + field="id", # id is a uuid field which is blacklisted + regular_expression="^.*$", + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_invalid_regex_fails_validation(self): + """Test that an invalid regex string fails validation.""" + rule = RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Device), + field="name", + regular_expression="[", # this is an invalid regex pattern + ) + + with self.assertRaises(ValidationError): + rule.full_clean() + + +class MinMaxValidationRuleModelTestCase(TestCase): + """ + Test cases related to the MinMaxValidationRule model + """ + + def test_invalid_field_name(self): + """Test that a non-existent model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="afieldthatdoesnotexist", + min=1, + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_private_fields_cannot_be_used(self): + """Test that a private model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Cable), + field="_abs_length", # this is a private field used for caching a denormalized value + min=1, + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_blacklisted_fields_cannot_be_used(self): + """Test that a blacklisted model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(Job), + field="id", # Job.id is an AutoField which is blacklisted + min=1, + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_min_or_max_must_be_set(self): + """Test that a blacklisted model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_min_must_be_less_than_max(self): + """Test that a blacklisted model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + min=1, + max=0, + ) + + with self.assertRaises(ValidationError): + rule.clean() + + def test_min__and_max_can_be_equal(self): + """Test that a blacklisted model field is rejected.""" + rule = MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + min=1, + max=1, + ) + + try: + rule.clean() + except ValidationError as e: + self.fail(f"rule.clean() failed validation: {e}") diff --git a/nautobot_data_validation_engine/tests/test_views.py b/nautobot_data_validation_engine/tests/test_views.py new file mode 100644 index 0000000..d4c8912 --- /dev/null +++ b/nautobot_data_validation_engine/tests/test_views.py @@ -0,0 +1,123 @@ +""" +View test cases +""" +from django.contrib.contenttypes.models import ContentType + +from nautobot.dcim.models import Device, PowerFeed, Site +from nautobot.utilities.testing import ViewTestCases + +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule + + +class RegularExpressionValidationRuleTestCase(ViewTestCases.PrimaryObjectViewTestCase): + """ + View test cases for the RegularExpressionValidationRule model + """ + + model = RegularExpressionValidationRule + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + RegularExpressionValidationRule.objects.create( + name="Regex rule 1", + slug="regex-rule-1", + content_type=ContentType.objects.get_for_model(Site), + field="name", + regular_expression="^.*$", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 2", + slug="regex-rule-2", + content_type=ContentType.objects.get_for_model(Site), + field="description", + regular_expression="^.*$", + ) + RegularExpressionValidationRule.objects.create( + name="Regex rule 3", + slug="regex-rule-3", + content_type=ContentType.objects.get_for_model(Site), + field="comments", + regular_expression="^.*$", + ) + + cls.form_data = { + "name": "Regex rule x", + "slug": "regex-rule-x", + "content_type": ContentType.objects.get_for_model(Site).pk, + "field": "contact_name", + "regular_expression": "^.*$", + } + + cls.csv_data = ( + "name,slug,content_type,field,regular_expression", + "Regex rule 4,regex-rule-4,dcim.site,contact_phone,^.*$", + "Regex rule 5,regex-rule-5,dcim.site,physical_address,^.*$", + "Regex rule 6,regex-rule-6,dcim.site,shipping_address,^.*$", + ) + + cls.bulk_edit_data = { + "regular_expression": "^.*.*$", + "enabled": False, + "error_message": "no soup", + } + + +class MinMaxValidationRuleTestCase(ViewTestCases.PrimaryObjectViewTestCase): + """ + View test cases for the MinMaxValidationRule model + """ + + model = MinMaxValidationRule + + @classmethod + def setUpTestData(cls): + """ + Create test data + """ + MinMaxValidationRule.objects.create( + name="Min max rule 1", + slug="min-max-rule-1", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="amperage", + min=1, + ) + MinMaxValidationRule.objects.create( + name="Min max rule 2", + slug="min-max-rule-2", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="max_utilization", + min=1, + ) + MinMaxValidationRule.objects.create( + name="Min max rule 3", + slug="min-max-rule-3", + content_type=ContentType.objects.get_for_model(PowerFeed), + field="voltage", + min=1, + ) + + cls.form_data = { + "name": "Min max rule x", + "slug": "min-max-rule-x", + "content_type": ContentType.objects.get_for_model(Device).pk, + "field": "position", + "min": 5.0, + "max": 6.0, + } + + cls.csv_data = ( + "name,slug,content_type,field,min,max", + "Min max rule 4,min-max-rule-4,dcim.device,vc_position,5,6", + "Min max rule 5,min-max-rule-5,dcim.device,vc_priority,5,6", + "Min max rule 6,min-max-rule-6,dcim.site,longitude,5,6", + ) + + cls.bulk_edit_data = { + "min": 5.0, + "max": 6.0, + "enabled": False, + "error_message": "no soup", + } diff --git a/nautobot_data_validation_engine/urls.py b/nautobot_data_validation_engine/urls.py index 7b54cfb..4be0099 100644 --- a/nautobot_data_validation_engine/urls.py +++ b/nautobot_data_validation_engine/urls.py @@ -1,59 +1,109 @@ """ Django url patterns. """ -from django.urls import path +from django.urls import path, include from nautobot.extras.views import ObjectChangeLogView from nautobot_data_validation_engine import views -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule -urlpatterns = [ +rule_patterns = [ path( - "regex-rules/", + "regex/", views.RegularExpressionValidationRuleListView.as_view(), name="regularexpressionvalidationrule_list", ), path( - "regex-rules/add/", + "regex/add/", views.RegularExpressionValidationRuleEditView.as_view(), name="regularexpressionvalidationrule_add", ), path( - "regex-rules/import/", + "regex/import/", views.RegularExpressionValidationRuleBulkImportView.as_view(), name="regularexpressionvalidationrule_import", ), path( - "regex-rules/edit/", + "regex/edit/", views.RegularExpressionValidationRuleBulkEditView.as_view(), name="regularexpressionvalidationrule_bulk_edit", ), path( - "regex-rules/delete/", + "regex/delete/", views.RegularExpressionValidationRuleBulkDeleteView.as_view(), name="regularexpressionvalidationrule_bulk_delete", ), path( - "regex-rules//", + "regex//", views.RegularExpressionValidationRuleView.as_view(), name="regularexpressionvalidationrule", ), path( - "regex-rules//edit/", + "regex//edit/", views.RegularExpressionValidationRuleEditView.as_view(), name="regularexpressionvalidationrule_edit", ), path( - "regex-rules//delete/", + "regex//delete/", views.RegularExpressionValidationRuleDeleteView.as_view(), name="regularexpressionvalidationrule_delete", ), path( - "regex-rules//changelog/", + "regex//changelog/", ObjectChangeLogView.as_view(), name="regularexpressionvalidationrule_changelog", kwargs={"model": RegularExpressionValidationRule}, ), + path( + "min-max/", + views.MinMaxValidationRuleListView.as_view(), + name="minmaxvalidationrule_list", + ), + path( + "min-max/add/", + views.MinMaxValidationRuleEditView.as_view(), + name="minmaxvalidationrule_add", + ), + path( + "min-max/import/", + views.MinMaxValidationRuleBulkImportView.as_view(), + name="minmaxvalidationrule_import", + ), + path( + "min-max/edit/", + views.MinMaxValidationRuleBulkEditView.as_view(), + name="minmaxvalidationrule_bulk_edit", + ), + path( + "min-max/delete/", + views.MinMaxValidationRuleBulkDeleteView.as_view(), + name="minmaxvalidationrule_bulk_delete", + ), + path( + "min-max//", + views.MinMaxValidationRuleView.as_view(), + name="minmaxvalidationrule", + ), + path( + "min-max//edit/", + views.MinMaxValidationRuleEditView.as_view(), + name="minmaxvalidationrule_edit", + ), + path( + "min-max//delete/", + views.MinMaxValidationRuleDeleteView.as_view(), + name="minmaxvalidationrule_delete", + ), + path( + "min-max//changelog/", + ObjectChangeLogView.as_view(), + name="minmaxvalidationrule_changelog", + kwargs={"model": MinMaxValidationRule}, + ), +] + +urlpatterns = [ + path("rules/", include(rule_patterns)), ] diff --git a/nautobot_data_validation_engine/views.py b/nautobot_data_validation_engine/views.py index 0194eda..1eb4914 100644 --- a/nautobot_data_validation_engine/views.py +++ b/nautobot_data_validation_engine/views.py @@ -1,13 +1,10 @@ """ Django views. """ -from django.shortcuts import reverse - from nautobot.core.views import generic -from nautobot.utilities.utils import prepare_cloned_fields from nautobot_data_validation_engine import filters, forms, tables -from nautobot_data_validation_engine.models import RegularExpressionValidationRule +from nautobot_data_validation_engine.models import MinMaxValidationRule, RegularExpressionValidationRule # @@ -24,7 +21,6 @@ class RegularExpressionValidationRuleListView(generic.ObjectListView): filterset = filters.RegularExpressionValidationRuleFilterSet filterset_form = forms.RegularExpressionValidationRuleFilterForm table = tables.RegularExpressionValidationRuleTable - template_name = "nautobot_data_validation_engine/regularexpressionvalidationrule_list.html" class RegularExpressionValidationRuleView(generic.ObjectView): @@ -33,29 +29,6 @@ class RegularExpressionValidationRuleView(generic.ObjectView): """ queryset = RegularExpressionValidationRule.objects.all() - template_name = "nautobot_data_validation_engine/regularexpressionvalidationrule.html" - - def get_extra_context(self, request, instance): - """ - Generate the urls for the UI buttons since the core templatetags do not understand the plugins namespace. - """ - clone_url = reverse("plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_add") - cloned_param_string = prepare_cloned_fields(instance) - if cloned_param_string: - clone_url = f"{clone_url}?{cloned_param_string}" - - edit_url = reverse( - "plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_edit", args=[instance.pk] - ) - delete_url = reverse( - "plugins:nautobot_data_validation_engine:regularexpressionvalidationrule_delete", args=[instance.pk] - ) - - return { - "clone_url": clone_url, - "edit_url": edit_url, - "delete_url": delete_url, - } class RegularExpressionValidationRuleEditView(generic.ObjectEditView): @@ -104,3 +77,75 @@ class RegularExpressionValidationRuleBulkDeleteView(generic.BulkDeleteView): queryset = RegularExpressionValidationRule.objects.all() filterset = filters.RegularExpressionValidationRuleFilterSet table = tables.RegularExpressionValidationRuleTable + + +# +# MinMaxValidationRules +# + + +class MinMaxValidationRuleListView(generic.ObjectListView): + """ + Base list view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + filterset = filters.MinMaxValidationRuleFilterSet + filterset_form = forms.MinMaxValidationRuleFilterForm + table = tables.MinMaxValidationRuleTable + + +class MinMaxValidationRuleView(generic.ObjectView): + """ + Base detail view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + + +class MinMaxValidationRuleEditView(generic.ObjectEditView): + """ + Base edit view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + model_form = forms.MinMaxValidationRuleForm + + +class MinMaxValidationRuleDeleteView(generic.ObjectDeleteView): + """ + Base delete view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + + +class MinMaxValidationRuleBulkImportView(generic.BulkImportView): + """ + Base bulk import view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + model_form = forms.MinMaxValidationRuleCSVForm + table = tables.MinMaxValidationRuleTable + + +class MinMaxValidationRuleBulkEditView(generic.BulkEditView): + """ + Base bulk edit view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + filterset = filters.MinMaxValidationRuleFilterSet + table = tables.MinMaxValidationRuleTable + form = forms.MinMaxValidationRuleBulkEditForm + + +class MinMaxValidationRuleBulkDeleteView(generic.BulkDeleteView): + """ + Base bulk delete view for the MinMaxValidationRule model. + """ + + queryset = MinMaxValidationRule.objects.all() + filterset = filters.MinMaxValidationRuleFilterSet + table = tables.MinMaxValidationRuleTable diff --git a/poetry.lock b/poetry.lock index 70ca9dc..09916af 100644 --- a/poetry.lock +++ b/poetry.lock @@ -108,14 +108,15 @@ smmap = ">=3.0.1,<4" [[package]] name = "gitpython" -version = "3.1.13" +version = "3.1.17" description = "Python Git Library" category = "dev" optional = false -python-versions = ">=3.4" +python-versions = ">=3.5" [package.dependencies] gitdb = ">=4.0.1,<5" +typing-extensions = {version = ">=3.7.4.0", markers = "python_version < \"3.8\""} [[package]] name = "importlib-metadata" @@ -393,7 +394,6 @@ bandit = [ {file = "bandit-1.7.0.tar.gz", hash = "sha256:8a4c7415254d75df8ff3c3b15cfe9042ecee628a1e40b44c15a98890fbfc2608"}, ] black = [ - {file = "black-20.8b1-py3-none-any.whl", hash = "sha256:70b62ef1527c950db59062cda342ea224d772abdf6adc58b86a45421bab20a6b"}, {file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"}, ] click = [ @@ -417,8 +417,8 @@ gitdb = [ {file = "gitdb-4.0.5.tar.gz", hash = "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9"}, ] gitpython = [ - {file = "GitPython-3.1.13-py3-none-any.whl", hash = "sha256:c5347c81d232d9b8e7f47b68a83e5dc92e7952127133c5f2df9133f2c75a1b29"}, - {file = "GitPython-3.1.13.tar.gz", hash = "sha256:8621a7e777e276a5ec838b59280ba5272dd144a18169c36c903d8b38b99f750a"}, + {file = "GitPython-3.1.17-py3-none-any.whl", hash = "sha256:29fe82050709760081f588dd50ce83504feddbebdc4da6956d02351552b1c135"}, + {file = "GitPython-3.1.17.tar.gz", hash = "sha256:ee24bdc93dce357630764db659edaf6b8d664d4ff5447ccfeedd2dc5c253f41e"}, ] importlib-metadata = [ {file = "importlib_metadata-3.4.0-py3-none-any.whl", hash = "sha256:ace61d5fc652dc280e7b6b4ff732a9c2d40db2c0f92bc6cb74e07b73d53a1771"}, @@ -506,18 +506,26 @@ pyyaml = [ {file = "PyYAML-5.4.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:bb4191dfc9306777bc594117aee052446b3fa88737cd13b7188d0e7aa8162185"}, {file = "PyYAML-5.4.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:6c78645d400265a062508ae399b60b8c167bf003db364ecb26dcab2bda048253"}, {file = "PyYAML-5.4.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:4e0583d24c881e14342eaf4ec5fbc97f934b999a6828693a99157fde912540cc"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:72a01f726a9c7851ca9bfad6fd09ca4e090a023c00945ea05ba1638c09dc3347"}, + {file = "PyYAML-5.4.1-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:895f61ef02e8fed38159bb70f7e100e00f471eae2bc838cd0f4ebb21e28f8541"}, {file = "PyYAML-5.4.1-cp36-cp36m-win32.whl", hash = "sha256:3bd0e463264cf257d1ffd2e40223b197271046d09dadf73a0fe82b9c1fc385a5"}, {file = "PyYAML-5.4.1-cp36-cp36m-win_amd64.whl", hash = "sha256:e4fac90784481d221a8e4b1162afa7c47ed953be40d31ab4629ae917510051df"}, {file = "PyYAML-5.4.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5accb17103e43963b80e6f837831f38d314a0495500067cb25afab2e8d7a4018"}, {file = "PyYAML-5.4.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:e1d4970ea66be07ae37a3c2e48b5ec63f7ba6804bdddfdbd3cfd954d25a82e63"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:cb333c16912324fd5f769fff6bc5de372e9e7a202247b48870bc251ed40239aa"}, + {file = "PyYAML-5.4.1-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:fe69978f3f768926cfa37b867e3843918e012cf83f680806599ddce33c2c68b0"}, {file = "PyYAML-5.4.1-cp37-cp37m-win32.whl", hash = "sha256:dd5de0646207f053eb0d6c74ae45ba98c3395a571a2891858e87df7c9b9bd51b"}, {file = "PyYAML-5.4.1-cp37-cp37m-win_amd64.whl", hash = "sha256:08682f6b72c722394747bddaf0aa62277e02557c0fd1c42cb853016a38f8dedf"}, {file = "PyYAML-5.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d2d9808ea7b4af864f35ea216be506ecec180628aced0704e34aca0b040ffe46"}, {file = "PyYAML-5.4.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:8c1be557ee92a20f184922c7b6424e8ab6691788e6d86137c5d93c1a6ec1b8fb"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:fd7f6999a8070df521b6384004ef42833b9bd62cfee11a09bda1079b4b704247"}, + {file = "PyYAML-5.4.1-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bfb51918d4ff3d77c1c856a9699f8492c612cde32fd3bcd344af9be34999bfdc"}, {file = "PyYAML-5.4.1-cp38-cp38-win32.whl", hash = "sha256:fa5ae20527d8e831e8230cbffd9f8fe952815b2b7dae6ffec25318803a7528fc"}, {file = "PyYAML-5.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:0f5f5786c0e09baddcd8b4b45f20a7b5d61a7e7e99846e3c799b05c7c53fa696"}, {file = "PyYAML-5.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:294db365efa064d00b8d1ef65d8ea2c3426ac366c0c4368d930bf1c5fb497f77"}, {file = "PyYAML-5.4.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:74c1485f7707cf707a7aef42ef6322b8f97921bd89be2ab6317fd782c2d53183"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:d483ad4e639292c90170eb6f7783ad19490e7a8defb3e46f97dfe4bacae89122"}, + {file = "PyYAML-5.4.1-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:fdc842473cd33f45ff6bce46aea678a54e3d21f1b61a7750ce3c498eedfe25d6"}, {file = "PyYAML-5.4.1-cp39-cp39-win32.whl", hash = "sha256:49d4cdd9065b9b6e206d0595fee27a96b5dd22618e7520c33204a4a3239d5b10"}, {file = "PyYAML-5.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:c20cfa2d49991c8b4147af39859b167664f2ad4561704ee74c1de03318e898db"}, {file = "PyYAML-5.4.1.tar.gz", hash = "sha256:607774cbba28732bfa802b54baa7484215f530991055bb562efbed5b2f20a45e"}, diff --git a/tasks.py b/tasks.py index 32217fd..430ffe0 100644 --- a/tasks.py +++ b/tasks.py @@ -1,55 +1,49 @@ -"""Tasks for use with Invoke. - -(c) 2020-2021 Network To Code -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -""" +"""Tasks for use with Invoke.""" import os from invoke import task PYTHON_VER = os.getenv("PYTHON_VER", "3.7") -NAUTOBOT_VER = os.getenv("NAUTOBOT_VER", "master") +NAUTOBOT_VER = os.getenv("NAUTOBOT_VER", "1.0.2") +NAUTOBOT_SRC_URL = os.getenv("NAUTOBOT_SRC_URL", f"https://github.com/nautobot/nautobot/archive/{NAUTOBOT_VER}.tar.gz") # Name of the docker image/container NAME = os.getenv("IMAGE_NAME", "nautobot-data-validation-engine") PWD = os.getcwd() COMPOSE_FILE = "development/docker-compose.yml" +COMPOSE_OVERRIDE = "docker-compose.override.yml" BUILD_NAME = "nautobot_data_validation_engine" +DEFAULT_ENV = { + "NAUTOBOT_VER": NAUTOBOT_VER, + "PYTHON_VER": PYTHON_VER, + "NAUTOBOT_SRC_URL": NAUTOBOT_SRC_URL, +} +COMPOSE_APPEND = "" +if os.path.isfile(COMPOSE_OVERRIDE): + COMPOSE_APPEND = f"-f {COMPOSE_OVERRIDE}" +COMPOSE_COMMAND = f"docker-compose -f {COMPOSE_FILE} {COMPOSE_APPEND} -p {BUILD_NAME}" + +environment = DEFAULT_ENV # ------------------------------------------------------------------------------ # BUILD # ------------------------------------------------------------------------------ @task -def build(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER, nocache=False, forcerm=False): +def build(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Build all docker images. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from - nocache (bool): Do not use cache when building the image - forcerm (bool): Always remove intermediate containers """ - command = "build" - - if nocache: - command += " --no-cache" - if forcerm: - command += " --force-rm" + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} {command}", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} build", + env=DEFAULT_ENV, ) @@ -59,67 +53,75 @@ def build(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER, nocache=Fal @task def debug(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Start Nautobot and its dependencies in debug mode. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + print("Starting Nautobot .. ") context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} up", + env=DEFAULT_ENV, ) @task def start(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Start Nautobot and its dependencies in detached mode. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + print("Starting Nautobot in detached mode.. ") context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up -d", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} up -d", + env=DEFAULT_ENV, ) @task def stop(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Stop Nautobot and its dependencies. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + print("Stopping Nautobot .. ") context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} down", + env=DEFAULT_ENV, ) @task def destroy(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Destroy all containers and volumes. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} down", + env=DEFAULT_ENV, ) context.run( f"docker volume rm -f {BUILD_NAME}_pgdata_nautobot_data_validation_engine", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + env=DEFAULT_ENV, ) @@ -129,15 +131,17 @@ def destroy(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def nbshell(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Launch a nbshell session. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot nautobot-server nbshell", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} run nautobot nautobot-server nbshell", + env=DEFAULT_ENV, pty=True, ) @@ -145,15 +149,17 @@ def nbshell(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def cli(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Launch a bash shell inside the running Nautobot container. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot bash", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} run nautobot bash", + env=DEFAULT_ENV, pty=True, ) @@ -161,16 +167,18 @@ def cli(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def create_user(context, user="admin", nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Create a new user in django (default: admin), will prompt for password. - Args: context (obj): Used to run specific commands user (str): name of the superuser to create nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot nautobot-server createsuperuser --username {user}", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} run nautobot nautobot-server createsuperuser --username {user}", + env=DEFAULT_ENV, pty=True, ) @@ -178,32 +186,34 @@ def create_user(context, user="admin", nautobot_ver=NAUTOBOT_VER, python_ver=PYT @task def makemigrations(context, name="", nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run Make Migration in Django. - Args: context (obj): Used to run specific commands name (str): Name of the migration to be created nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} up -d postgres", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} up -d postgres", + env=DEFAULT_ENV, ) if name: context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot nautobot-server makemigrations --name {name}", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} run nautobot nautobot-server makemigrations nautobot_data_validation_engine --name {name}", + env=DEFAULT_ENV, ) else: context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot nautobot-server makemigrations", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} run nautobot nautobot-server makemigrations nautobot_data_validation_engine", + env=DEFAULT_ENV, ) context.run( - f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} down", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{COMPOSE_COMMAND} down", + env=DEFAULT_ENV, ) @@ -213,16 +223,18 @@ def makemigrations(context, name="", nautobot_ver=NAUTOBOT_VER, python_ver=PYTHO @task def unittest(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run Django unit tests for the plugin. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + + docker = f"{COMPOSE_COMMAND} run --entrypoint='' nautobot " context.run( f'{docker} sh -c "nautobot-server test nautobot_data_validation_engine"', - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + env=DEFAULT_ENV, pty=True, ) @@ -230,17 +242,20 @@ def unittest(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def pylint(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run pylint code analysis. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + + docker = f"{COMPOSE_COMMAND} run --entrypoint='' nautobot " # We exclude the /migrations/ directory since it is autogenerated code context.run( - f"{docker} sh -c \"cd /source && find . -name '*.py' -not -path '*/migrations/*' | xargs pylint\"", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f"{docker} sh -c \"cd /source && find . -name '*.py' -not -path '*/migrations/*' | " + 'PYTHONPATH=/source/development DJANGO_SETTINGS_MODULE=nautobot_config xargs pylint"', + env=DEFAULT_ENV, pty=True, ) @@ -248,33 +263,18 @@ def pylint(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def black(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run black to check that Python files adhere to its style standards. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" - context.run( - f'{docker} sh -c "cd /source && black --check --diff ."', - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, - pty=True, - ) - - -@task -def blacken(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): - """Run black to format Python files to adhere to its style standards. + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver - Args: - context (obj): Used to run specific commands - nautobot_ver (str): Nautobot version to use to build the container - python_ver (str): Will use the Python version docker image to build from - """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" + docker = f"{COMPOSE_COMMAND} run --entrypoint='' nautobot " context.run( - f'{docker} sh -c "cd /source && black ."', - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f'{docker} sh -c "cd /source && black --check --diff ."', + env=DEFAULT_ENV, pty=True, ) @@ -282,51 +282,19 @@ def blacken(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def pydocstyle(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run pydocstyle to validate docstring formatting adheres to NTC defined standards. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + + docker = f"{COMPOSE_COMMAND} run --entrypoint='' nautobot " # We exclude the /migrations/ directory since it is autogenerated code context.run( f"{docker} sh -c \"cd /source && find . -name '*.py' -not -path '*/migrations/*' | xargs pydocstyle\"", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, - pty=True, - ) - - -@task -def flake8(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): - """This will run flake8 for the specified name and Python version. - - Args: - context (obj): Used to run specific commands - nautobot_ver (str): Nautobot version to use to build the container - python_ver (str): Will use the Python version docker image to build from - """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" - context.run( - f"{docker} sh -c \"cd /source && find . -name '*.py' | xargs flake8\"", - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, - pty=True, - ) - - -@task -def yamllint(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): - """Run yamllint to validate formatting adheres to NTC defined YAML standards. - - Args: - context (obj): Used to run specific commands - nautobot_ver (str): Nautobot version to use to build the container - python_ver (str): Will use the Python version docker image to build from - """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" - context.run( - f'{docker} sh -c "cd /source && yamllint ."', - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + env=DEFAULT_ENV, pty=True, ) @@ -334,16 +302,18 @@ def yamllint(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def bandit(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run bandit to validate basic static code security analysis. - Args: context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ - docker = f"docker-compose -f {COMPOSE_FILE} -p {BUILD_NAME} run nautobot" + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + + docker = f"{COMPOSE_COMMAND} run --entrypoint='' nautobot " context.run( - f'{docker} sh -c "cd /source && bandit --configfile .bandit.yml --recursive ./"', - env={"NAUTOBOT_VER": nautobot_ver, "PYTHON_VER": python_ver}, + f'{docker} sh -c "cd /source && bandit --recursive ./ --configfile .bandit.yml"', + env=DEFAULT_ENV, pty=True, ) @@ -351,25 +321,24 @@ def bandit(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): @task def tests(context, nautobot_ver=NAUTOBOT_VER, python_ver=PYTHON_VER): """Run all tests for this plugin. - Args: - context (obj): Used to run specific commands + context (obj): Used to run specific commands nautobot_ver (str): Nautobot version to use to build the container python_ver (str): Will use the Python version docker image to build from """ + DEFAULT_ENV[NAUTOBOT_VER] = nautobot_ver + DEFAULT_ENV[PYTHON_VER] = python_ver + # Sorted loosely from fastest to slowest print("Running black...") black(context, nautobot_ver=nautobot_ver, python_ver=python_ver) - print("Running yamllint...") - yamllint(context, NAME, python_ver) print("Running bandit...") bandit(context, nautobot_ver=nautobot_ver, python_ver=python_ver) - # print("Running pydocstyle...") - # pydocstyle(context, nautobot_ver=nautobot_ver, python_ver=python_ver) - print("Running flake8...") - flake8(context, nautobot_ver=nautobot_ver, python_ver=python_ver) - # print("Running pylint...") - # pylint(context, nautobot_ver=nautobot_ver, python_ver=python_ver) + print("Running pydocstyle...") + pydocstyle(context, nautobot_ver=nautobot_ver, python_ver=python_ver) + print("Running pylint...") + pylint(context, nautobot_ver=nautobot_ver, python_ver=python_ver) print("Running unit tests...") unittest(context, nautobot_ver=nautobot_ver, python_ver=python_ver) + print("All tests have passed!")