Repository: nautobot/nautobot

Issue:
Multiple ConfigContext can be created with the same name
<!--
    NOTE: IF YOUR ISSUE DOES NOT FOLLOW THIS TEMPLATE, IT WILL BE CLOSED.

    This form is only for reporting reproducible bugs. If you need assistance
    with Nautobot installation, or if you have a general question, please start a
    discussion instead: https://github.com/nautobot/nautobot/discussions

    Please describe the environment in which you are running Nautobot. Be sure
    that you are running an unmodified instance of the latest stable release
    before submitting a bug report, and that any plugins have been disabled.
-->
### Environment
* Python version: any
* Nautobot version: 1.0.1

<!--
    Describe in detail the exact steps that someone else can take to reproduce
    this bug using the current stable release of Nautobot. Begin with the
    creation of any necessary database objects and call out every operation
    being performed explicitly. If reporting a bug in the REST API, be sure to
    reconstruct the raw HTTP request(s) being made: Don't rely on a client
    library such as pynautobot.
-->
### Steps to Reproduce
1. Create a ConfigContext.
2. Create a second config context with the same `name` string.

<!-- What did you expect to happen? -->
### Expected Behavior

Creation of the second same-named ConfigContext should fail or be blocked by the UI due to the duplicate name. This is the behavior shown in NetBox 2.10.x.

<!-- What happened instead? -->
### Observed Behavior

Multiple same-named config contexts can coexist. This is problematic because, in the case of multiple contexts having the same weight, ties are broken based on the context name.

This appears to be another case where a `unique_together` constraint is not working as one might expect due to `NULL!=NULL` behavior of the database, as ConfigContext defines `unique_together = [["name", "owner_content_type", "owner_object_id"]]` and in common usage `owner_content_type` and `owner_object_id` will be NULL values.



Discussion:

Resolving patch:
diff --git a/nautobot/core/api/views.py b/nautobot/core/api/views.py
--- a/nautobot/core/api/views.py
+++ b/nautobot/core/api/views.py
@@ -9,7 +9,7 @@
 from django.http.response import HttpResponseBadRequest
 from django.db import transaction
 from django.db.models import ProtectedError
-from django_rq.queues import get_connection
+from django_rq.queues import get_connection as get_rq_connection
 from rest_framework import status
 from rest_framework.response import Response
 from rest_framework.reverse import reverse
@@ -19,7 +19,7 @@
 from rest_framework.exceptions import PermissionDenied, ParseError
 from drf_yasg.openapi import Schema, TYPE_OBJECT, TYPE_ARRAY
 from drf_yasg.utils import swagger_auto_schema
-from rq.worker import Worker
+from rq.worker import Worker as RQWorker
 
 from graphql import get_default_backend
 from graphql.execution import ExecutionResult
@@ -28,6 +28,7 @@
 from graphene_django.settings import graphene_settings
 from graphene_django.views import GraphQLView, instantiate_middleware, HttpError
 
+from nautobot.core.celery import app as celery_app
 from nautobot.core.api import BulkOperationSerializer
 from nautobot.core.api.exceptions import SerializerNotFound
 from nautobot.utilities.api import get_serializer_for_model
@@ -340,6 +341,10 @@ def get(self, request):
             plugins[plugin_name] = getattr(plugin_config, "version", None)
         plugins = {k: v for k, v in sorted(plugins.items())}
 
+        # Gather Celery workers
+        workers = celery_app.control.inspect().active()  # list or None
+        worker_count = len(workers) if workers is not None else 0
+
         return Response(
             {
                 "django-version": DJANGO_VERSION,
@@ -347,7 +352,8 @@ def get(self, request):
                 "nautobot-version": settings.VERSION,
                 "plugins": plugins,
                 "python-version": platform.python_version(),
-                "rq-workers-running": Worker.count(get_connection("default")),
+                "rq-workers-running": RQWorker.count(get_rq_connection("default")),
+                "celery-workers-running": worker_count,
             }
         )
 
diff --git a/nautobot/extras/utils.py b/nautobot/extras/utils.py
--- a/nautobot/extras/utils.py
+++ b/nautobot/extras/utils.py
@@ -2,6 +2,7 @@
 import hashlib
 import hmac
 
+from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 from django.utils.deconstruct import deconstructible
@@ -97,3 +98,26 @@ def generate_signature(request_body, secret):
     """
     hmac_prep = hmac.new(key=secret.encode("utf8"), msg=request_body, digestmod=hashlib.sha512)
     return hmac_prep.hexdigest()
+
+
+def get_worker_count(request):
+    """
+    Return a count of the active Celery workers.
+    """
+    # Inner imports so we don't risk circular imports
+    from nautobot.core.celery import app  # noqa
+    from rq.worker import Worker  # noqa
+    from django_rq.queues import get_connection  # noqa
+
+    # Try RQ first since, it's faster.
+    rq_count = Worker.count(get_connection("default"))
+
+    # FIXME(jathan): If both RQ/Celery workers are running, this warning is
+    # displayed but barely seen because of the redirect after task execution.
+    if rq_count:
+        messages.warning(request, "RQ workers are deprecated. Please migrate your worker to Celery.")
+
+    # Celery next, since it's slower.
+    inspect = app.control.inspect()
+    active = inspect.active()  # None if no active workers
+    return len(active) if active is not None else 0
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -10,11 +10,11 @@
 from django.views.generic import View
 from django_tables2 import RequestConfig
 from jsonschema.validators import Draft7Validator
-from rq import Worker
 
 from nautobot.core.views import generic
 from nautobot.dcim.models import Device
 from nautobot.dcim.tables import DeviceTable
+from nautobot.extras.utils import get_worker_count
 from nautobot.utilities.paginator import EnhancedPaginator, get_paginate_count
 from nautobot.utilities.utils import (
     copy_safe_request,
@@ -543,7 +543,11 @@ def post(self, request, slug):
 
         repository = get_object_or_404(GitRepository.objects.all(), slug=slug)
 
-        enqueue_pull_git_repository_and_refresh_data(repository, request)
+        # Allow execution only if a worker process is running.
+        if not get_worker_count(request):
+            messages.error(request, "Unable to run job: Celery worker process not running.")
+        else:
+            enqueue_pull_git_repository_and_refresh_data(repository, request)
 
         return redirect("extras:gitrepository_result", slug=slug)
 
@@ -690,7 +694,10 @@ def post(self, request, class_path):
         grouping, module, class_name = class_path.split("/", 2)
         form = job.as_form(request.POST, request.FILES)
 
-        if form.is_valid():
+        # Allow execution only if a worker process is running.
+        if not get_worker_count(request):
+            messages.error(request, "Unable to run job: Celery worker process not running.")
+        elif form.is_valid():
             # Run the job. A new JobResult is created.
             commit = form.cleaned_data.pop("_commit")
 


Test patch: