"""Utility functions for django-app-parameter."""
from __future__ import annotations
from importlib import import_module
from typing import Any
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.core.validators import (
EmailValidator,
FileExtensionValidator,
MaxLengthValidator,
MaxValueValidator,
MinLengthValidator,
MinValueValidator,
RegexValidator,
URLValidator,
validate_ipv4_address,
validate_ipv6_address,
validate_slug,
)
from django.utils.text import slugify
try:
from cryptography.fernet import Fernet
except ImportError:
Fernet = None # type: ignore[assignment, misc]
HAS_CRYPTOGRAPHY = Fernet is not None
# Cache for imported validators to avoid repeated imports
_VALIDATOR_CACHE: dict[str, Any] = {}
[docs]
def parameter_slugify(content: str) -> str:
"""
Transform content :
* slugify (with django's function)
* upperise
* replace dash (-) with underscore (_)
"""
return slugify(content).upper().replace("-", "_")
# Built-in Django validators that are available by default
BUILTIN_VALIDATORS: dict[str, Any] = {
"MinValueValidator": MinValueValidator,
"MaxValueValidator": MaxValueValidator,
"MinLengthValidator": MinLengthValidator,
"MaxLengthValidator": MaxLengthValidator,
"RegexValidator": RegexValidator,
"EmailValidator": EmailValidator,
"URLValidator": URLValidator,
"validate_slug": validate_slug,
"validate_ipv4_address": validate_ipv4_address,
"validate_ipv6_address": validate_ipv6_address,
"FileExtensionValidator": FileExtensionValidator,
}
[docs]
def get_setting(key: str, default: Any = None) -> Any:
"""
Get a value from DJANGO_APP_PARAMETER settings dictionary.
Args:
key: The setting key to retrieve
default: Default value if key is not found
Returns:
The setting value or default
Example:
>>> get_setting("validators", {})
{'even_number': 'myapp.validators.validate_even_number'}
"""
app_settings = getattr(settings, "DJANGO_APP_PARAMETER", {})
return app_settings.get(key, default)
[docs]
def import_validator(validator_path: str) -> Any:
"""
Import a validator from a dotted path string.
Args:
validator_path: Dotted path to the validator
(e.g., 'myapp.validators.validate_even_number')
Returns:
The imported validator function or class
Raises:
ImportError: If the module or validator cannot be imported
AttributeError: If the validator doesn't exist in the module
Example:
>>> validator = import_validator("myapp.validators.validate_even_number")
>>> validator(4) # Should not raise
>>> validator(3) # Should raise ValidationError
"""
try:
module_path, validator_name = validator_path.rsplit(".", 1)
except ValueError as e:
raise ImportError(
f"Invalid validator path '{validator_path}'. "
f"Expected format: 'module.path.validator_name'"
) from e
try:
module = import_module(module_path)
except ImportError as e:
raise ImportError(
f"Cannot import module '{module_path}'"
f" from validator path '{validator_path}'"
) from e
try:
validator = getattr(module, validator_name)
except AttributeError as e:
raise AttributeError(
f"Module '{module_path}' does not have attribute '{validator_name}'"
) from e
return validator
[docs]
def get_validator_from_registry(
validator_type: str, use_cache: bool = True
) -> Any | None:
"""
Get a validator by type from built-in or custom validators.
This function looks up validators in the following order:
1. Built-in Django validators (from BUILTIN_VALIDATORS)
2. Custom validators from settings (DJANGO_APP_PARAMETER['validators'])
Args:
validator_type: The validator type/key to look up
use_cache: Whether to use cached imports (default: True)
Returns:
The validator class/function, or None if not found
Example:
>>> # Built-in validator
>>> validator = get_validator_from_registry("MinValueValidator")
>>> validator is MinValueValidator
True
>>> # Custom validator (from settings)
>>> validator = get_validator_from_registry("even_number")
>>> validator.__name__
'validate_even_number'
"""
# Check built-in validators first
if validator_type in BUILTIN_VALIDATORS:
return BUILTIN_VALIDATORS[validator_type]
# Check cache for custom validators
if use_cache and validator_type in _VALIDATOR_CACHE:
return _VALIDATOR_CACHE[validator_type]
# Check custom validators from settings
custom_validators = get_setting("validators", {})
if validator_type in custom_validators:
validator_path = custom_validators[validator_type]
try:
validator = import_validator(validator_path)
if use_cache:
_VALIDATOR_CACHE[validator_type] = validator
return validator
except (ImportError, AttributeError):
# Let the caller handle the error
raise
return None
[docs]
def get_available_validators() -> dict[str, str]:
"""
Get all available validators (built-in + custom) with their display names.
Returns a dictionary mapping validator keys to human-readable names.
Built-in validators use their class/function names as display names.
Custom validators use their keys as display names.
Returns:
Dictionary of {validator_key: display_name}
Example:
>>> validators = get_available_validators()
>>> "MinValueValidator" in validators
True
>>> "even_number" in validators # If defined in settings
True
"""
validators: dict[str, str] = {}
# Add built-in validators with friendly names
builtin_display_names = {
"MinValueValidator": "Valeur minimale",
"MaxValueValidator": "Valeur maximale",
"MinLengthValidator": "Longueur minimale",
"MaxLengthValidator": "Longueur maximale",
"RegexValidator": "Expression régulière",
"EmailValidator": "Validation email",
"URLValidator": "Validation URL",
"validate_slug": "Validation slug",
"validate_ipv4_address": "Adresse IPv4",
"validate_ipv6_address": "Adresse IPv6",
"FileExtensionValidator": "Extensions de fichier autorisées",
}
for key in BUILTIN_VALIDATORS.keys():
validators[key] = builtin_display_names.get(key, key)
# Add custom validators from settings
custom_validators = get_setting("validators", {})
for key in custom_validators.keys():
# Use a more friendly display name for custom validators
display_name = key.replace("_", " ").title()
validators[key] = f"{display_name} (custom)"
return validators
[docs]
def clear_validator_cache() -> None:
"""
Clear the validator import cache.
Useful for testing or when validators are dynamically modified.
"""
_VALIDATOR_CACHE.clear()
# ===== Encryption utilities =====
[docs]
def get_encryption_key(key: str | bytes | None = None) -> bytes:
"""
Get the encryption key from Django settings or use provided key.
The key should be stored in settings.DJANGO_APP_PARAMETER['encryption_key']
and must be a Fernet-compatible key (32 url-safe base64-encoded bytes).
Args:
key: Optional encryption key to use. If provided, this key is used
instead of the one from settings. Can be str or bytes.
Returns:
The encryption key as bytes
Raises:
ImproperlyConfigured: If cryptography is not installed, or if the
encryption key is not configured and no key
parameter is provided
"""
if not HAS_CRYPTOGRAPHY:
raise ImproperlyConfigured(
"Encryption requires the 'cryptography' package. "
"Install it with: pip install django-app-parameter[cryptography]"
)
# Use provided key if given
if key is not None:
if isinstance(key, str):
return key.encode("utf-8")
return key
# Otherwise get from settings
settings_key = get_setting("encryption_key")
if not settings_key:
raise ImproperlyConfigured(
"No encryption key configured. "
"Set DJANGO_APP_PARAMETER['encryption_key'] in settings. "
"Generate one with: from cryptography.fernet import Fernet; "
"Fernet.generate_key()"
)
# Convert to bytes if string
if isinstance(settings_key, str):
return settings_key.encode("utf-8")
return settings_key
[docs]
def encrypt_value(value: str, encryption_key: str | bytes | None = None) -> str:
"""
Encrypt a string value using Fernet symmetric encryption.
Args:
value: The plaintext string to encrypt
encryption_key: Optional encryption key to use. If not provided,
uses key from settings.
Returns:
The encrypted value as a string (base64-encoded)
Raises:
ImproperlyConfigured: If cryptography is not installed or if
encryption key is not configured
"""
key = get_encryption_key(encryption_key)
fernet = Fernet(key) # type: ignore[misc]
encrypted_bytes = fernet.encrypt(value.encode("utf-8"))
return encrypted_bytes.decode("utf-8")
[docs]
def decrypt_value(
encrypted_value: str, encryption_key: str | bytes | None = None
) -> str:
"""
Decrypt a string value using Fernet symmetric encryption.
Args:
encrypted_value: The encrypted value as a string (base64-encoded)
encryption_key: Optional encryption key to use. If not provided,
uses key from settings.
Returns:
The decrypted plaintext string
Raises:
ImproperlyConfigured: If cryptography is not installed or if
encryption key is not configured
cryptography.fernet.InvalidToken: If decryption fails
(wrong key or corrupted data)
"""
key = get_encryption_key(encryption_key)
fernet = Fernet(key) # type: ignore[misc]
decrypted_bytes = fernet.decrypt(encrypted_value.encode("utf-8"))
return decrypted_bytes.decode("utf-8")