Repository: nautobot/nautobot

Issue:
Support unix socket Redis connections in parse_redis_connection
### As ...

Isabella - Infrastructure Owner

### I want ...

to be able to use Redis cache via a unix socket connection that than a TCP connection

### So that ...

I can share sockets between docker containers rather than exposing ports (which is tricky to corporate environment)

### I know this is done when...

- I am able to start and run Nautobot with configuration for a unix socket and the application fully functions

### Optional - Feature groups this request pertains to.

- [ ] Automation
- [ ] Circuits
- [ ] DCIM
- [ ] IPAM
- [ ] Misc (including Data Sources)
- [ ] Organization
- [ ] Plugins (and other Extensibility)
- [ ] Security (Secrets, etc)
- [ ] Image Management
- [ ] UI/UX
- [ ] Documentation
- [ ] Other (not directly a platform feature)

### Database Changes

None

### External Dependencies

None


Discussion:

Resolving patch:
diff --git a/nautobot/core/settings_funcs.py b/nautobot/core/settings_funcs.py
--- a/nautobot/core/settings_funcs.py
+++ b/nautobot/core/settings_funcs.py
@@ -72,7 +72,9 @@ def parse_redis_connection(redis_database):
     """
     # The following `_redis_*` variables are used to generate settings based on
     # environment variables.
-    redis_scheme = "rediss" if is_truthy(os.getenv("NAUTOBOT_REDIS_SSL", "false")) else "redis"
+    redis_scheme = os.getenv("NAUTOBOT_REDIS_SCHEME")
+    if redis_scheme is None:
+        redis_scheme = "rediss" if is_truthy(os.getenv("NAUTOBOT_REDIS_SSL", "false")) else "redis"
     redis_host = os.getenv("NAUTOBOT_REDIS_HOST", "localhost")
     redis_port = int(os.getenv("NAUTOBOT_REDIS_PORT", "6379"))
     redis_username = os.getenv("NAUTOBOT_REDIS_USERNAME", "")
@@ -85,4 +87,7 @@ def parse_redis_connection(redis_database):
     if redis_username or redis_password:
         redis_creds = f"{redis_username}:{redis_password}@"
 
-    return f"{redis_scheme}://{redis_creds}{redis_host}:{redis_port}/{redis_database}"
+    if redis_scheme == "unix":
+        return f"{redis_scheme}://{redis_creds}{redis_host}?db={redis_database}"
+    else:
+        return f"{redis_scheme}://{redis_creds}{redis_host}:{redis_port}/{redis_database}"
diff --git a/nautobot/extras/models/jobs.py b/nautobot/extras/models/jobs.py
--- a/nautobot/extras/models/jobs.py
+++ b/nautobot/extras/models/jobs.py
@@ -1015,7 +1015,7 @@ def to_cron(self):
         elif self.interval == JobExecutionType.TYPE_DAILY:
             return schedules.crontab(minute=t.minute, hour=t.hour)
         elif self.interval == JobExecutionType.TYPE_WEEKLY:
-            return schedules.crontab(minute=t.minute, hour=t.hour, day_of_week=t.weekday())
+            return schedules.crontab(minute=t.minute, hour=t.hour, day_of_week=t.strftime("%w"))
         elif self.interval == JobExecutionType.TYPE_CUSTOM:
             return self.get_crontab(self.crontab)
         raise ValueError(f"I do not know to convert {self.interval} to a Cronjob!")
diff --git a/tasks.py b/tasks.py
--- a/tasks.py
+++ b/tasks.py
@@ -22,6 +22,10 @@
 try:
     # Override built-in print function with rich's pretty-printer function, if available
     from rich import print  # pylint: disable=redefined-builtin
+    from rich.console import Console
+    from rich.markup import escape
+
+    console = Console()
 
     HAS_RICH = True
 except ModuleNotFoundError:
@@ -91,6 +95,41 @@ def task_wrapper(function=None):
     return task_wrapper
 
 
+def print_command(command, env=None):
+    r"""
+    >>> command = "docker buildx build . --platform linux/amd64 --target final --load -f ./docker/Dockerfile --build-arg PYTHON_VER=3.9 -t networktocode/nautobot-py3.9:local --no-cache"
+    >>> print_command(command)
+    docker buildx build . \
+        --platform linux/amd64 \
+        --target final \
+        --load \
+        -f ./docker/Dockerfile \
+        --build-arg PYTHON_VER=3.9 \
+        -t networktocode/nautobot-py3.9:local \
+        --no-cache
+    >>> env = {"PYTHON_VER": "3.9"}
+    >>> print_command(command, env=env)
+    PYTHON_VER=3.9 \
+    docker buildx build . \
+        --platform linux/amd64 \
+        --target final \
+        --load \
+        -f ./docker/Dockerfile \
+        --build-arg PYTHON_VER=3.9 \
+        -t networktocode/nautobot-py3.9:local \
+        --no-cache
+    """
+    # Everywhere we have a `--foo`, a `-f`, a `--foo bar`, or a `-f bar`, wrap to a new line
+    formatted_command = re.sub(r"\s+(--?\w+(\s+[^-]\S*)?)", r" \\\n    \1", command)
+    formatted_env = ""
+    if env:
+        formatted_env = " \\\n".join(f"{var}={value}" for var, value in env.items()) + " \\\n"
+    if HAS_RICH:
+        console.print(f"[dim]{escape(formatted_env)}{escape(formatted_command)}[/dim]", soft_wrap=True)
+    else:
+        print(f"{formatted_env}{formatted_command}")
+
+
 def docker_compose(context, command, **kwargs):
     """Helper function for running a specific docker-compose command with all appropriate parameters and environment.
 
@@ -117,23 +156,21 @@ def docker_compose(context, command, **kwargs):
         compose_command_tokens.append(service)
 
     print(f'Running docker-compose command "{command}"')
-    compose_command = " \\\n    ".join(compose_command_tokens)
+    compose_command = " ".join(compose_command_tokens)
     env = kwargs.pop("env", {})
     env.update({"PYTHON_VER": context.nautobot.python_ver})
     if "hide" not in kwargs:
-        env_str = " \\\n    ".join(f"{var}={value}" for var, value in env.items())
-        if HAS_RICH:
-            print(f"[dim]{env_str} \\\n    {compose_command}[/dim]")
-        else:
-            print(f"{env_str} \\\n    {compose_command}")
+        print_command(compose_command, env=env)
     return context.run(compose_command, env=env, **kwargs)
 
 
 def run_command(context, command, **kwargs):
     """Wrapper to run a command locally or inside the nautobot container."""
     if is_truthy(context.nautobot.local):
-        print(f'Running command "{command}"')
-        context.run(command, pty=True, **kwargs)
+        env = kwargs.pop("env", {})
+        if "hide" not in kwargs:
+            print_command(command, env=env)
+        context.run(command, pty=True, env=env, **kwargs)
     else:
         # Check if Nautobot is running; no need to start another Nautobot container to run a command
         docker_compose_status = "ps --services --filter status=running"
@@ -208,9 +245,9 @@ def build_dependencies(context, poetry_parallel=True):
 @task(
     help={
         "cache": "Whether to use Docker's cache when building the image. (Default: enabled)",
-        "cache_dir": "Directory to use for caching buildx output. (Default: /home/travis/.cache/docker)",
+        "cache_dir": "Directory to use for caching buildx output. (Default: current directory)",
         "platforms": "Comma-separated list of strings for which to build. (Default: linux/amd64)",
-        "tag": "Tags to be applied to the built image. (Default: networktocode/nautobot-dev:local)",
+        "tag": "Tags to be applied to the built image. (Default: depends on the --target)",
         "target": "Build target from the Dockerfile. (Default: dev)",
         "poetry_parallel": "Enable/disable poetry to install packages in parallel. (Default: False)",
     }
@@ -220,28 +257,44 @@ def buildx(
     cache=False,
     cache_dir="",
     platforms="linux/amd64",
-    tag="networktocode/nautobot-dev-py3.7:local",
+    tag=None,
     target="dev",
     poetry_parallel=False,
 ):
-    """Build Nautobot docker image using the experimental buildx docker functionality (multi-arch capablility)."""
-    print(f"Building Nautobot with Python {context.nautobot.python_ver} for {platforms}...")
-    command = (
-        f"docker buildx build --platform {platforms} -t {tag} --target {target} --load -f ./docker/Dockerfile"
-        f" --build-arg PYTHON_VER={context.nautobot.python_ver}"
-        " ."
-    )
+    """Build Nautobot docker image using the experimental buildx docker functionality (multi-arch capability)."""
+    print(f"Building Nautobot {target} target with Python {context.nautobot.python_ver} for {platforms}...")
+    if tag is None:
+        if target == "dev":
+            tag = f"networktocode/nautobot-dev-py{context.nautobot.python_ver}:local"
+        elif target == "final":
+            tag = f"networktocode/nautobot-py{context.nautobot.python_ver}:local"
+        else:
+            print(f"Not sure what should be the standard tag for target {target}, will not tag.")
+    command_tokens = [
+        "docker buildx build .",
+        f"--platform {platforms}",
+        f"--target {target}",
+        "--load",
+        "-f ./docker/Dockerfile",
+        f"--build-arg PYTHON_VER={context.nautobot.python_ver}",
+    ]
+    if tag is not None:
+        command_tokens.append(f"-t {tag}")
     if not cache:
-        command += " --no-cache"
+        command_tokens.append("--no-cache")
     else:
-        command += (
-            f" --cache-to type=local,dest={cache_dir}/{context.nautobot.python_ver}"
-            f" --cache-from type=local,src={cache_dir}/{context.nautobot.python_ver}"
-        )
+        command_tokens += [
+            f"--cache-to type=local,dest={cache_dir}/{context.nautobot.python_ver}",
+            f"--cache-from type=local,src={cache_dir}/{context.nautobot.python_ver}",
+        ]
     if poetry_parallel:
-        command += " --build-arg POETRY_PARALLEL=true"
+        command_tokens.append("--build-arg POETRY_PARALLEL=true")
 
-    context.run(command, env={"PYTHON_VER": context.nautobot.python_ver})
+    command = " ".join(command_tokens)
+    env = {"PYTHON_VER": context.nautobot.python_ver}
+
+    print_command(command, env=env)
+    context.run(command, env=env)
 
 
 def get_nautobot_version():
@@ -476,7 +529,7 @@ def build_example_plugin_docs(context):
     command = "mkdocs build --no-directory-urls --strict"
     if is_truthy(context.nautobot.local):
         local_command = f"cd examples/example_plugin && {command}"
-        print(f'Running command "{local_command}"')
+        print_command(local_command)
         context.run(local_command, pty=True)
     else:
         docker_command = f"run --workdir='/source/examples/example_plugin' --entrypoint '{command}' nautobot"


Test patch:
diff --git a/nautobot/core/tests/nautobot_config.py b/nautobot/core/tests/nautobot_config.py
--- a/nautobot/core/tests/nautobot_config.py
+++ b/nautobot/core/tests/nautobot_config.py
@@ -48,3 +48,6 @@
 TEST_FACTORY_SEED = "Nautobot"
 # File in which all performance-specifc test baselines are stored
 TEST_PERFORMANCE_BASELINE_FILE = "nautobot/core/tests/performance_baselines.yml"
+
+# Disable logging for tests
+LOGGING = {}
diff --git a/nautobot/extras/tests/test_jobs.py b/nautobot/extras/tests/test_jobs.py
--- a/nautobot/extras/tests/test_jobs.py
+++ b/nautobot/extras/tests/test_jobs.py
@@ -786,3 +786,28 @@ def test_remove_stale_scheduled_jobs(self):
         self.assertTrue(ScheduledJob.objects.filter(name="test1").exists())
         for i in range(2, 7):
             self.assertFalse(ScheduledJob.objects.filter(name=f"test{i}").exists())
+
+
+class ScheduledJobIntervalTestCase(TestCase):
+    """Test scheduled job intervals"""
+
+    # cron schedule day_of_week starts on Sunday (Sunday = 0)
+    cron_days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
+    # datetime weekday starts on Monday (Sunday = 6)
+    datetime_days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]
+
+    def test_weekly_interval(self):
+        start_time = timezone.now() + datetime.timedelta(days=6)
+        scheduled_job = ScheduledJob.objects.create(
+            name="weekly_interval",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class="local/test_pass/TestPass",
+            interval=JobExecutionType.TYPE_WEEKLY,
+            user=self.user,
+            start_time=start_time,
+        )
+
+        requested_weekday = self.datetime_days[start_time.weekday()]
+        schedule_day_of_week = list(scheduled_job.schedule.day_of_week)[0]
+        scheduled_job_weekday = self.cron_days[schedule_day_of_week]
+        self.assertEqual(scheduled_job_weekday, requested_weekday)
diff --git a/nautobot/utilities/testing/api.py b/nautobot/utilities/testing/api.py
--- a/nautobot/utilities/testing/api.py
+++ b/nautobot/utilities/testing/api.py
@@ -229,7 +229,12 @@ def test_list_objects_brief(self):
             self.assertIsInstance(response.data, dict)
             self.assertIn("results", response.data)
             self.assertEqual(len(response.data["results"]), self._get_queryset().count())
-            self.assertEqual(sorted(response.data["results"][0]), self.brief_fields)
+            self.assertEqual(
+                sorted(response.data["results"][0]),
+                self.brief_fields,
+                "In order to test the brief API parameter the brief fields need to be manually added to "
+                "self.brief_fields. If this is already the case, perhaps the serializer is implemented incorrectly?",
+            )
 
         @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
         def test_list_objects_without_permission(self):
@@ -602,7 +607,12 @@ def test_options_returns_expected_choices(self):
             else:
                 self.fail(f"Neither PUT nor POST are available actions in: {data['actions']}")
 
-            self.assertEqual(set(self.choices_fields), field_choices)
+            self.assertEqual(
+                set(self.choices_fields),
+                field_choices,
+                "All field names of choice fields for a given model serializer need to be manually added to "
+                "self.choices_fields. If this is already the case, perhaps the serializer is implemented incorrectly?",
+            )
 
     class DeleteObjectViewTestCase(APITestCase):
         def get_deletable_object(self):