"""Namespaced settings for the django-tenant-options app.
Here is what it should look like in the settings.py file of the project:
.. code-block:: python
DJANGO_TENANT_OPTIONS = {
"TENANT_MODEL": "example.Tenant",
# ...
}
"""
import importlib
import logging
from typing import cast
from django.core.exceptions import ImproperlyConfigured
from django_tenant_options.form_fields import OptionsModelMultipleChoiceField # noqa: F401
try:
import_error = None
from django.conf import settings
from django.db import models
except ImproperlyConfigured as e:
import_error = "Settings could not be imported: %s", e
settings = None # pylint: disable=C0103
models = None # type: ignore[assignment]
except ImportError as e:
import_error = "Django could not be imported. Settings cannot be loaded: %s", e
settings = None # pylint: disable=C0103
models = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
if import_error:
logger.error(import_error)
[docs]
def import_string(dotted_path):
"""Import a dotted module path and return the attribute/class designated by the last name."""
try:
module_path, class_name = dotted_path.rsplit(".", 1)
except ValueError as err:
logger.exception("Failed to parse dotted_path '%s': %s", dotted_path, err)
raise ImportError(f"{dotted_path} doesn't look like a module path") from err
try:
module = importlib.import_module(module_path)
logger.debug("Successfully imported module '%s' for dotted_path '%s'", module_path, dotted_path)
except ImportError as err:
logger.exception("Failed to import module '%s': %s", module_path, err)
raise ImportError(f"Module {module_path} does not exist") from err
try:
return getattr(module, class_name)
except AttributeError as err:
logger.exception("Module '%s' does not define '%s': %s", module_path, class_name, err)
raise ImportError(f"Module {module_path} does not define {class_name}") from err
[docs]
class ModelClassConfig:
"""Configuration class for model base classes."""
[docs]
def __init__(self):
"""Initialize with lazy-loaded default class references."""
self._model_class = None
self._manager_class = None
self._queryset_class = None
self._foreignkey_class = None
self._onetoonefield_class = None
self._initialized = False
def _ensure_initialized(self):
"""Ensure the configuration is initialized."""
if not self._initialized:
self._model_class = models.Model
self._manager_class = models.Manager
self._queryset_class = models.QuerySet
self._foreignkey_class = models.ForeignKey
self._onetoonefield_class = models.OneToOneField
self._initialized = True
def _import_string(self, dotted_path):
"""Import a dotted module path and return the attribute/class designated by the last name."""
try:
module_path, class_name = dotted_path.rsplit(".", 1)
except ValueError as err:
raise ImportError(f"{dotted_path} doesn't look like a module path") from err
try:
module = importlib.import_module(module_path)
except ImportError as err:
raise ImportError(f"Module {module_path} does not exist") from err
try:
return getattr(module, class_name)
except AttributeError as err:
raise ImportError(f"Module {module_path} does not define {class_name}") from err
def _resolve_class(self, value):
"""Resolve a class from either a string path or direct class reference."""
if isinstance(value, str):
return self._import_string(value)
return value
@property
def model_class(self) -> type:
"""The base class to use for all django-tenant-options models."""
self._ensure_initialized()
return cast(type, self._model_class)
@model_class.setter
def model_class(self, value):
"""Set the base class to use for all django-tenant-options models."""
self._model_class = self._resolve_class(value)
self._initialized = True
@property
def manager_class(self) -> type:
"""The base class to use for all django-tenant-options model managers."""
self._ensure_initialized()
return cast(type, self._manager_class)
@manager_class.setter
def manager_class(self, value):
"""Set the base class to use for all django-tenant-options model managers."""
self._manager_class = self._resolve_class(value)
self._initialized = True
@property
def queryset_class(self) -> type:
"""The base class to use for all django-tenant-options model querysets."""
self._ensure_initialized()
return cast(type, self._queryset_class)
@queryset_class.setter
def queryset_class(self, value):
"""Set the base class to use for all django-tenant-options model querysets."""
self._queryset_class = self._resolve_class(value)
self._initialized = True
@property
def foreignkey_class(self) -> type:
"""The base class to use for all django-tenant-options foreign keys."""
self._ensure_initialized()
return cast(type, self._foreignkey_class)
@foreignkey_class.setter
def foreignkey_class(self, value):
"""Set the base class to use for all django-tenant-options foreign keys."""
self._foreignkey_class = self._resolve_class(value)
self._initialized = True
@property
def onetoonefield_class(self) -> type:
"""The base class to use for all django-tenant-options one-to-one fields."""
self._ensure_initialized()
return cast(type, self._onetoonefield_class)
@onetoonefield_class.setter
def onetoonefield_class(self, value):
"""Set the base class to use for all django-tenant-options one-to-one fields."""
self._onetoonefield_class = self._resolve_class(value)
self._initialized = True
# Global config instance for django-tenant-options models
model_config = ModelClassConfig()
_DJANGO_TENANT_OPTIONS = getattr(settings, "DJANGO_TENANT_OPTIONS", {})
"""dict: The settings for the django-tenant-options app."""
# Base class settings
MODEL_CLASS = _DJANGO_TENANT_OPTIONS.get("MODEL_CLASS", models.Model)
"""The base Model class to use. Defaults to django.db.models.Model."""
MANAGER_CLASS = _DJANGO_TENANT_OPTIONS.get("MANAGER_CLASS", models.Manager)
"""The base Manager class to use. Defaults to django.db.models.Manager."""
QUERYSET_CLASS = _DJANGO_TENANT_OPTIONS.get("QUERYSET_CLASS", models.QuerySet)
"""The base QuerySet class to use. Defaults to django.db.models.QuerySet."""
FOREIGNKEY_CLASS = _DJANGO_TENANT_OPTIONS.get("FOREIGNKEY_CLASS", models.ForeignKey)
"""The ForeignKey field class to use. Defaults to django.db.models.ForeignKey."""
ONETOONEFIELD_CLASS = _DJANGO_TENANT_OPTIONS.get("ONETOONEFIELD_CLASS", models.OneToOneField)
"""The OneToOneField field class to use. Defaults to django.db.models.OneToOneField."""
# Convert string references to actual classes
if isinstance(MODEL_CLASS, str):
MODEL_CLASS = import_string(MODEL_CLASS)
if isinstance(MANAGER_CLASS, str):
MANAGER_CLASS = import_string(MANAGER_CLASS)
if isinstance(QUERYSET_CLASS, str):
QUERYSET_CLASS = import_string(QUERYSET_CLASS)
if isinstance(FOREIGNKEY_CLASS, str):
FOREIGNKEY_CLASS = import_string(FOREIGNKEY_CLASS)
if isinstance(ONETOONEFIELD_CLASS, str):
ONETOONEFIELD_CLASS = import_string(ONETOONEFIELD_CLASS)
model_config.model_class = MODEL_CLASS
model_config.manager_class = MANAGER_CLASS
model_config.queryset_class = QUERYSET_CLASS
model_config.foreignkey_class = FOREIGNKEY_CLASS
model_config.onetoonefield_class = ONETOONEFIELD_CLASS
TENANT_MODEL = _DJANGO_TENANT_OPTIONS.get("TENANT_MODEL", "django_tenant_options.Tenant")
"""str: The model to use for the tenant."""
TENANT_ON_DELETE = _DJANGO_TENANT_OPTIONS.get("TENANT_ON_DELETE", models.CASCADE)
"""What should happen to Options and Selections when a related Tenant is deleted.
This sets the on_delete option for the `tenant` ForeignKey field on these models, and should use one of [django's
standard `on_delete` arguments](https://docs.djangoproject.com/en/dev/ref/models/fields/#arguments)
(e.g. models.CASCADE, models.PROTECT, models.SET_NULL, etc).
"""
OPTION_ON_DELETE = _DJANGO_TENANT_OPTIONS.get("OPTION_ON_DELETE", models.CASCADE)
"""What should happen to Selections when a related Option is deleted.
By default, Options are soft-deleted, so this setting is not used.
This sets the on_delete option for the `option` ForeignKey field on Selection models, and should use one of [django's
standard `on_delete` arguments](https://docs.djangoproject.com/en/dev/ref/models/fields/#arguments)
(e.g. models.CASCADE, models.PROTECT, models.SET_NULL, etc).
"""
TENANT_MODEL_RELATED_NAME = _DJANGO_TENANT_OPTIONS.get("TENANT_MODEL_RELATED_NAME", "%(app_label)s_%(class)s_related")
"""str: The related name template for the tenant model."""
TENANT_MODEL_RELATED_QUERY_NAME = _DJANGO_TENANT_OPTIONS.get(
"TENANT_MODEL_RELATED_QUERY_NAME", "%(app_label)s_%(class)ss"
)
"""str: The related query name template for the tenant model."""
ASSOCIATED_TENANTS_RELATED_NAME = _DJANGO_TENANT_OPTIONS.get(
"ASSOCIATED_TENANTS_RELATED_NAME", "%(app_label)s_%(class)s_selections"
)
"""str: The related name template for the associated tenants model.
This is used for the ManyToManyField from an Option model to a Tenant model.
"""
ASSOCIATED_TENANTS_RELATED_QUERY_NAME = _DJANGO_TENANT_OPTIONS.get(
"ASSOCIATED_TENANTS_RELATED_QUERY_NAME", "%(app_label)s_%(class)ss_selected"
)
"""str: The related query name template for the associated tenants model.
This is used for the ManyToManyField from an Option model to a Tenant model.
"""
OPTION_MODEL_RELATED_NAME = _DJANGO_TENANT_OPTIONS.get("OPTION_MODEL_RELATED_NAME", "%(app_label)s_%(class)s_related")
"""str: The related name template to use for all Option models."""
OPTION_MODEL_RELATED_QUERY_NAME = _DJANGO_TENANT_OPTIONS.get(
"OPTION_MODEL_RELATED_QUERY_NAME", "%(app_label)s_%(class)ss"
)
"""str: The related query name template to use for all Option models."""
DB_VENDOR_OVERRIDE = _DJANGO_TENANT_OPTIONS.get("DB_VENDOR_OVERRIDE", None)
"""str: The database vendor to use for generating triggers if the default is not one of the supported vendors.
In some cases, you may use a custom database backend, but the underlying database is still one of the supported
vendors. In this case, you can specify that supported database vendor here.
An example of this is if you are using Django's Postgis backend, but the underlying database is still PostgreSQL.
Allowed values are 'postgresql', 'mysql', 'sqlite', 'oracle'.
"""
DEFAULT_MULTIPLE_CHOICE_FIELD = _DJANGO_TENANT_OPTIONS.get(
"DEFAULT_MULTIPLE_CHOICE_FIELD", OptionsModelMultipleChoiceField
)
"""The default form field to use for multiple choice fields. This can also be overridden per form."""
DISABLE_FIELD_FOR_DELETED_SELECTION = _DJANGO_TENANT_OPTIONS.get("DISABLE_FIELD_FOR_DELETED_SELECTION", False)
"""bool: The behavior to use in user-facing forms when a selection was deleted by the tenant.
By default, if a selection was deleted, the user must select a new option when updating a form. If this setting is
True, the deleted selection will be displayed in the form, but disabled so it cannot be changed.
In both cases, the deleted selection cannot be used in new forms.
"""
CACHE_OPTIONS = _DJANGO_TENANT_OPTIONS.get("CACHE_OPTIONS", False)
"""bool: Master switch for per-tenant option caching. Defaults to False (opt-in).
When True, `OptionQuerySet.options_for_tenant` and `selected_options_for_tenant` cache their
results per tenant. Cached entries are invalidated automatically via post_save/post_delete signals
on Option and Selection models. When False, query behavior is identical to the uncached logic.
"""
CACHE_TIMEOUT = _DJANGO_TENANT_OPTIONS.get("CACHE_TIMEOUT", 300)
"""int: Time-to-live in seconds for cached per-tenant option lists. Defaults to 300."""
CACHE_KEY_PREFIX = _DJANGO_TENANT_OPTIONS.get("CACHE_KEY_PREFIX", "dto")
"""str: Prefix applied to every cache key written by this package. Defaults to 'dto'."""
CACHE_ALIAS = _DJANGO_TENANT_OPTIONS.get("CACHE_ALIAS", "default")
"""str: Which Django cache (from settings.CACHES) to use. Defaults to 'default'."""