Skip to content

Commit

Permalink
add EXTEND_INSTALLED_APPS and EXTEND_URL_PATTERNS settings, deprecate…
Browse files Browse the repository at this point in the history
… CA_CUSTOM_APPS
  • Loading branch information
mathiasertl committed Feb 2, 2025
1 parent a59cb5c commit af35ba3
Show file tree
Hide file tree
Showing 12 changed files with 444 additions and 32 deletions.
40 changes: 38 additions & 2 deletions ca/ca/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,16 @@

"""Default settings for the django-ca Django project."""

import json
import os
import warnings
from pathlib import Path
from typing import Any

from django.core.exceptions import ImproperlyConfigured

from ca.settings_utils import (
UrlPatternsModel,
load_secret_key,
load_settings_from_environment,
load_settings_from_files,
Expand Down Expand Up @@ -147,6 +153,9 @@
CA_URL_PATH = "django_ca/"
CA_ENABLE_REST_API = False

EXTEND_INSTALLED_APPS: list[str] = []
_EXTEND_URL_PATTERNS: list[dict[str, Any]] = []

# Setting to allow us to disable clickjacking projection if header is already set by the webserver
CA_ENABLE_CLICKJACKING_PROTECTION = True

Expand Down Expand Up @@ -216,11 +225,21 @@

# Load settings from files
for _setting, _value in load_settings_from_files(BASE_DIR):
if _setting == "EXTEND_URL_PATTERNS":
_EXTEND_URL_PATTERNS += _value
if _setting == "EXTEND_INSTALLED_APPS":
EXTEND_INSTALLED_APPS += _value

globals()[_setting] = _value

# Load settings from environment variables
for _setting, _value in load_settings_from_environment():
globals()[_setting] = _value
if _setting == "EXTEND_URL_PATTERNS":
_EXTEND_URL_PATTERNS += json.loads(_value)
elif _setting == "EXTEND_INSTALLED_APPS":
EXTEND_INSTALLED_APPS += json.loads(_value)
else:
globals()[_setting] = _value

# Try to use POSTGRES_* and MYSQL_* environment variables to determine database access credentials.
# These are the variables set by the standard PostgreSQL/MySQL Docker containers.
Expand All @@ -243,10 +262,27 @@
if ENABLE_ADMIN is not True and "django.contrib.admin" in INSTALLED_APPS:
INSTALLED_APPS.remove("django.contrib.admin")

INSTALLED_APPS = INSTALLED_APPS + CA_CUSTOM_APPS
if CA_CUSTOM_APPS:
warnings.warn(
"CA_CUSTOM_APPS is deprecated and will be removed in django-ca==2.5.0, "
"use EXTEND_INSTALLED_APPS instead.",
DeprecationWarning,
stacklevel=1,
)
INSTALLED_APPS = INSTALLED_APPS + CA_CUSTOM_APPS

if CA_ENABLE_REST_API and "ninja" not in INSTALLED_APPS:
INSTALLED_APPS.append("ninja")

# Add additional applications to INSTALLED_APPS
INSTALLED_APPS += EXTEND_INSTALLED_APPS

# Add additional URL configurations
try:
EXTEND_URL_PATTERNS = UrlPatternsModel.model_validate(_EXTEND_URL_PATTERNS)
except ValueError as ex:
raise ImproperlyConfigured(ex) from ex

if STORAGES is None:
# Set the default storages argument
STORAGES = {
Expand Down
101 changes: 92 additions & 9 deletions ca/ca/settings_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,88 @@

"""Utility functions for loading settings."""

import importlib
import logging
import os
from collections.abc import Iterator
from collections.abc import Callable, Iterator
from inspect import isclass
from pathlib import Path
from typing import Any, Optional
from typing import Annotated, Any, Optional, Union

from pydantic import BaseModel, BeforeValidator, Field, RootModel

from django.core.exceptions import ImproperlyConfigured
from django.urls import URLPattern, URLResolver, include, path, re_path
from django.views import View

try:
import yaml
except ImportError: # pragma: no cover
yaml = False # type: ignore[assignment]


def url_pattern_type_validator(value: Any) -> Any:
"""Validator for url pattern type."""
if value == "path":
return path
elif value == "re_path":
return re_path
return value # pragma: no cover # might even be actual function, in theory


class ViewModel(BaseModel):
"""Model for using path() or re_path()."""

view: str
initkwargs: dict[str, Any] = Field(default_factory=dict)


class IncludeModel(BaseModel):
"""Model for using include()."""

module: str
namespace: Optional[str] = None


# TYPEHINT NOTE: mypy complains about kwargs. See https://github.com/pydantic/pydantic/issues/3125
class UrlPatternModel(BaseModel): # type: ignore[no-redef]
"""Model used vor validating elements in EXTEND_URL_PATTERNS."""

func: Annotated[
Callable[..., Union[URLPattern, URLResolver]], BeforeValidator(url_pattern_type_validator)
] = path
route: str
view: Union[ViewModel, IncludeModel]
kwargs: dict[str, Any] = Field(default_factory=dict)
name: Optional[str] = None

@property
def parsed_view(self) -> Any:
if isinstance(self.view, IncludeModel):
return include(self.view.module, namespace=self.view.namespace)

module_name, view_name = self.view.view.rsplit(".", 1)
module = importlib.import_module(module_name)
view = getattr(module, view_name)
if isclass(view) and issubclass(view, View):
return view.as_view(**self.view.initkwargs)

return view

@property
def pattern(self) -> Union[URLResolver, URLPattern]:
return self.func(self.route, self.parsed_view, kwargs=self.kwargs, name=self.name)


class UrlPatternsModel(RootModel[list[UrlPatternModel]]):
"""Root model used for validating the EXTEND_URL_PATTERNS setting."""

root: list[UrlPatternModel]

def __iter__(self) -> Iterator[UrlPatternModel]: # type: ignore[override]
return iter(self.root)


def load_secret_key(secret_key: Optional[str], secret_key_file: Optional[str]) -> str:
"""Load SECRET_KEY from file if not set elsewhere."""
if secret_key:
Expand All @@ -40,17 +108,21 @@ def load_secret_key(secret_key: Optional[str], secret_key_file: Optional[str]) -

def get_settings_files(base_dir: Path, paths: str) -> Iterator[Path]:
"""Get relevant settings files."""
for path in [base_dir / p for p in paths.split(":")]:
if not path.exists():
raise ImproperlyConfigured(f"{path}: No such file or directory.")
for settings_path in [base_dir / p for p in paths.split(":")]:
if not settings_path.exists():
raise ImproperlyConfigured(f"{settings_path}: No such file or directory.")

if path.is_dir():
if settings_path.is_dir():
# exclude files that don't end with '.yaml' and any directories
yield from sorted(
[path / _f.name for _f in path.iterdir() if _f.suffix == ".yaml" and not _f.is_dir()]
[
settings_path / _f.name
for _f in settings_path.iterdir()
if _f.suffix == ".yaml" and not _f.is_dir()
]
)
else:
yield path
yield settings_path

settings_yaml = base_dir / "ca" / "settings.yaml"
if settings_yaml.exists():
Expand All @@ -67,6 +139,8 @@ def load_settings_from_files(base_dir: Path) -> Iterator[tuple[str, Any]]:
settings_paths = os.environ.get("DJANGO_CA_SETTINGS", os.environ.get("CONFIGURATION_DIRECTORY", ""))

settings_files = []
extend_installed_apps = []
extend_url_patterns = []

for full_path in get_settings_files(base_dir, settings_paths):
with open(full_path, encoding="utf-8") as stream:
Expand All @@ -82,7 +156,16 @@ def load_settings_from_files(base_dir: Path) -> Iterator[tuple[str, Any]]:
raise ImproperlyConfigured(f"{full_path}: File is not a key/value mapping.")
else:
settings_files.append(full_path)
yield from data.items()
for setting_name, setting_value in data.items():
if setting_name == "EXTEND_URL_PATTERNS":
extend_url_patterns += setting_value
elif setting_name == "EXTEND_INSTALLED_APPS":
extend_installed_apps += setting_value
else:
yield setting_name, setting_value

yield "EXTEND_INSTALLED_APPS", extend_installed_apps
yield "EXTEND_URL_PATTERNS", extend_url_patterns

# ALSO yield the SETTINGS_FILES setting with the loaded files.
yield "SETTINGS_FILES", tuple(settings_files)
Expand Down
5 changes: 5 additions & 0 deletions ca/ca/test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

from django.utils.crypto import get_random_string

from ca.settings_utils import UrlPatternsModel

# Base paths in this project
BASE_DIR = Path(__file__).resolve().parent.parent # ca/

Expand Down Expand Up @@ -295,3 +297,6 @@
CA_PASSWORDS = {
_fixture_data["certs"]["pwd"]["serial"]: _fixture_data["certs"]["pwd"]["password"].encode("utf-8"),
}

EXTEND_URL_PATTERNS = UrlPatternsModel([])
EXTEND_INCLUDED_APPS: list[str] = []
9 changes: 7 additions & 2 deletions ca/ca/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,21 @@
# <http://www.gnu.org/licenses/>.

"""Root URL configuration for the django-ca Django project."""
from typing import Union

from django.conf import settings
from django.contrib import admin
from django.urls import include, path
from django.urls import URLPattern, URLResolver, include, path

admin.autodiscover()

urlpatterns = [
urlpatterns: list[Union[URLPattern, URLResolver]] = [
path(getattr(settings, "CA_URL_PATH", "django_ca/"), include("django_ca.urls")),
]

if getattr(settings, "ENABLE_ADMIN", True):
urlpatterns.append(path("admin/", admin.site.urls))

# Append additional URL patterns
for pattern in settings.EXTEND_URL_PATTERNS:
urlpatterns.append(pattern.pattern)
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
SETTINGS_DIR_ONE: True

EXTEND_URL_PATTERNS:
- route: /path1
view:
- view: yourapp1.views.YourView

EXTEND_INSTALLED_APPS:
- yourapp1
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
SETTINGS_DIR_TWO: True

EXTEND_URL_PATTERNS:
- route: /path2
view:
- view: yourapp2.views.YourView


EXTEND_INSTALLED_APPS:
- yourapp2
Loading

0 comments on commit af35ba3

Please sign in to comment.