"""
LDAP ORM Field implementations.
This module provides Django ORM-like field classes for LDAP data modeling.
Each field type handles the conversion between Python data types and LDAP
attribute formats, providing a familiar interface for Django developers.
"""
import collections.abc
import datetime
import hashlib
import os
import warnings
from base64 import b64encode as encode
from collections.abc import Callable, Sequence
from functools import partialmethod, total_ordering
from typing import (
TYPE_CHECKING,
Any,
cast,
)
import pytz
from django import forms
from django.conf import settings
from django.core import checks, exceptions
from django.core import validators as dj_validators
from django.db.models.constants import LOOKUP_SEP
from django.db.models.fields import (
BLANK_CHOICE_DASH,
NOT_PROVIDED,
return_None, # type: ignore[attr-defined]
)
from django.utils import timezone
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.functional import cached_property
from django.utils.text import capfirst
from django.utils.translation import gettext_lazy as _
from . import forms as ldap_forms
from .validators import validate_email_forward
if TYPE_CHECKING:
from .models import Model
#: Type alias for field validators
Validator = Callable[[Any], None]
[docs]@total_ordering
class Field:
"""
Base field class for LDAP ORM models.
This class provides enough of a Django ORM Field implementation to allow
building Django ORM-like models and fool ModelForm into working with LDAP data.
It handles the conversion between Python data types and LDAP attribute formats.
Args:
verbose_name: The human-readable name of the field.
name: The name of the field
primary_key: If True, this field is the primary key for the model.
max_length: The maximum length of the field.
blank: If True, the field is allowed to be blank in forms.
null: If True, the field is allowed to be empty in the LDAP server.
default: The default value for the field.
editable: If False, the field will not be editable in the admin.
choices: A list of choices for the field.
help_text: Help text for the field.
validators: A list of validators for the field.
error_messages: A dictionary of error messages for the field.
db_column: The attribute name in the LDAP schema.
"""
#: Are empty strings allowed to be stored in this field?
empty_strings_allowed: bool = True
#: A list of values that should be considered as empty.
empty_values: list[Any] = list(dj_validators.EMPTY_VALUES) # noqa: RUF012
#: Counter for field creation order, used for sorting fields.
creation_counter: int = 0
#: Default set of validators for the field.
default_validators: list[
Validator
] = [] # Default set of validators # noqa: RUF012
#: Default error messages for the field.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid_choice": _("Value %(value)r is not a valid choice."), # type: ignore[dict-item]
"null": _("This field cannot be null."), # type: ignore[dict-item]
"blank": _("This field cannot be blank."), # type: ignore[dict-item]
}
# These are here to fool ModelForm into thinking we're a Django ORM Field. We
# don't actually use them.
#: Placeholder for Django ORM compatibility.
many_to_many: Any = None
#: Placeholder for Django ORM compatibility.
many_to_one: Any = None
#: Placeholder for Django ORM compatibility.
one_to_many: Any = None
#: Placeholder for Django ORM compatibility.
one_to_one: Any = None
#: Placeholder for Django ORM compatibility.
related_model: Any = None
#: Whether the field should be hidden in forms.
hidden: bool = False
def _description(self) -> str:
"""
Get a generic description of the field type.
Returns:
A string description of the field type.
"""
return _("Field of type: %(field_type)s") % {
"field_type": self.__class__.__name__
}
#: Property that returns a description of the field type.
description = property(_description)
[docs] def __init__( # noqa: PLR0913
self,
verbose_name: str | None = None,
name: str | None = None,
primary_key: bool = False,
max_length: int | None = None,
blank: bool = False,
null: bool = False,
default: Any = NOT_PROVIDED,
editable: bool = True,
choices: list[Any] | None = None,
help_text: str = "",
validators: Sequence[Validator] = (),
error_messages: dict[str, str] | None = None,
db_column: str | None = None,
) -> None:
self.name = name
self.verbose_name = verbose_name # May be set by set_attributes_from_name
self.primary_key = primary_key
self.max_length = max_length
self.blank, self.null = blank, null
self.default = default
self.editable = editable
if isinstance(choices, collections.abc.Iterator):
choices = list(choices)
self.choices: list[Any] = choices or []
self.help_text = help_text
self.db_column = db_column
self.model: type[Model] | None = None
self.creation_counter = Field.creation_counter
Field.creation_counter += 1
self._validators = list(validators) # Store for deconstruction later
messages: dict[str, str] = {}
for c in reversed(self.__class__.__mro__):
messages.update(getattr(c, "default_error_messages", {}))
messages.update(error_messages or {})
self.error_messages = messages
def __repr__(self) -> str:
"""
Display the module, class, and name of the field.
Returns:
A string representation of the field.
"""
path = f"{self.__class__.__module__}.{self.__class__.__qualname__}"
name = getattr(self, "name", None)
if name is not None:
return f"<{path}: {name}>"
return f"<{path}>"
def __lt__(self, other: "Field") -> bool:
"""
Compare fields by creation counter for sorting.
This is needed because django.forms.models.fields_from_model tries to sort
all the fields on a model before interrogating them for which form field
class they need.
Args:
other: The other field to compare to.
Returns:
True if this field is less than the other field, False otherwise.
Raises:
NotImplementedError: If the other object is not a Field.
"""
if isinstance(other, Field):
return self.creation_counter < other.creation_counter
raise NotImplementedError
def __eq__(self, other: object) -> bool:
"""
Check equality based on creation counter.
Equality is based on the creation_counter of the field. If the other
object has the same value for creation_counter, it is considered equal.
Args:
other: The other object to compare to.
Returns:
True if the other object is a Field and has the same creation_counter.
Raises:
NotImplementedError: If the other object is not a Field.
"""
if isinstance(other, Field):
return self.creation_counter == other.creation_counter
raise NotImplementedError
def __hash__(self) -> int:
"""
Get hash based on creation counter.
Returns:
The hash of the field based on its creation counter.
"""
return hash(self.creation_counter)
[docs] def has_default(self) -> bool:
"""
Check if this field has a default value.
Returns:
True if the field has a default value, False otherwise.
"""
return self.default is not NOT_PROVIDED
[docs] def get_default(self) -> Any:
"""
Get the default value for this field.
Returns:
The default value for the field.
"""
return self._get_default()
[docs] def check(self, **_) -> list[checks.Error | checks.Warning]:
"""
Run field validation checks.
Returns:
A list of validation errors and warnings.
"""
return [
*self._check_field_name(),
*self._check_choices(),
*self._check_null_allowed_for_primary_keys(),
*self._check_validators(),
]
def _check_field_name(self) -> list[checks.Error]:
"""
Check if field name is valid.
Validates that the field name:
1. Does not end with an underscore
2. Does not contain "__"
3. Is not "pk"
Returns:
A list of validation errors for the field name.
"""
if cast("str", self.name).endswith("_"):
return [
checks.Error(
"Field names must not end with an underscore.",
obj=self,
id="fields.E001",
)
]
if LOOKUP_SEP in cast("str", self.name):
return [
checks.Error(
f'Field names must not contain "{LOOKUP_SEP}".',
obj=self,
id="fields.E002",
)
]
if self.name == "pk":
return [
checks.Error(
"'pk' is a reserved word that cannot be used as a field name.",
obj=self,
id="fields.E003",
)
]
return []
def _check_choices(self) -> list[checks.Error]:
"""
Check if choices are properly formatted.
Returns:
A list of validation errors for the choices.
"""
if not self.choices:
return []
def is_value(value):
return isinstance(value, str)
if is_value(self.choices):
return [
checks.Error(
"'choices' must be an iterable (e.g., a list or tuple).",
obj=self,
id="fields.E004",
)
]
for choices_group in self.choices:
try:
group_name, group_choices = choices_group
except ValueError:
# Containing non-pairs
break
try:
if not all(
is_value(value) and is_value(human_name)
for value, human_name in group_choices
):
break
except (TypeError, ValueError):
# No groups, choices in the form [value, display]
value, human_name = group_name, group_choices
if not is_value(value) or not is_value(human_name):
break
# Special case: choices=['ab']
if isinstance(choices_group, str):
break
else:
return []
return [
checks.Error(
"'choices' must be an iterable containing (actual value, human "
"readable name) tuples.",
obj=self,
id="fields.E005",
)
]
def _check_null_allowed_for_primary_keys(self) -> list[checks.Error]:
"""
Check that primary keys don't allow null values.
Returns:
A list of validation errors for primary key null settings.
"""
if self.primary_key and self.null:
return [
checks.Error(
"Primary keys must not have null=True.",
hint=(
"Set null=False on the field, or remove primary_key=True "
"argument."
),
obj=self,
id="fields.E007",
)
]
return []
def _check_validators(self) -> list[checks.Error]:
"""
Check that all validators are callable.
Returns:
A list of validation errors for invalid validators.
"""
errors: list[checks.Error] = []
for i, validator in enumerate(self.validators):
if not callable(validator):
errors.append(
checks.Error(
"All 'validators' must be callable.",
hint=(
f"validators[{i}] ({validator!r}) isn't a function or "
"instance of a validator class."
),
obj=self,
id="fields.E008",
)
)
return errors
@property
def ldap_attribute(self) -> str:
"""
Get the LDAP attribute name for this field.
Returns:
The LDAP attribute name (db_column if set, otherwise field name).
"""
return cast("str", self.db_column or self.name)
@cached_property
def _get_default(self) -> Callable[[], Any]:
"""
Get a callable that returns the default value.
Returns:
A callable that returns the default value when called.
"""
if self.has_default():
if callable(self.default):
return self.default
return lambda: self.default
if not self.empty_strings_allowed or self.null:
return return_None
return str # return empty string
[docs] def to_python(self, value: Any) -> Any:
"""
Convert the value to Python format.
Args:
value: The value to convert.
Returns:
The converted value.
"""
return value
@cached_property
def validators(self) -> list[Validator]:
"""
Get all validators for this field.
Returns:
A list of all validators (default + custom).
"""
return [*self.default_validators, *self._validators]
[docs] def run_validators(self, value: Any) -> None:
"""
Run all validators on the given value.
Args:
value: The value to validate.
Raises:
ValidationError: If any validator fails.
"""
if value in self.empty_values:
return
errors = []
for v in self.validators:
try:
v(value)
except exceptions.ValidationError as e: # noqa: PERF203
if hasattr(e, "code") and e.code in self.error_messages:
e.message = self.error_messages[e.code]
errors.extend(e.error_list)
if errors:
raise exceptions.ValidationError(errors)
[docs] def validate(self, value: Any, model_instance: "Model") -> None: # noqa: ARG002
"""
Validate the value and raise ValidationError if necessary.
Subclasses should override this to provide validation logic.
Args:
value: The value to validate.
model_instance: The model instance being validated.
Raises:
ValidationError: If validation fails.
"""
if not self.editable:
# Skip validation for non-editable fields.
return
if self.choices and value not in self.empty_values:
for option_key, option_value in self.choices:
if isinstance(option_value, (list, tuple)):
# This is an optgroup, so look inside the group for
# options.
for optgroup_key, _ in option_value:
if value == optgroup_key:
return
elif value == option_key:
return
raise exceptions.ValidationError(
self.error_messages["invalid_choice"],
code="invalid_choice",
params={"value": value},
)
if value is None and not self.null:
raise exceptions.ValidationError(self.error_messages["null"], code="null")
if not self.blank and value in self.empty_values:
raise exceptions.ValidationError(self.error_messages["blank"], code="blank")
[docs] def pre_save(self, model_instance: "Model", add: bool) -> Any: # noqa: ARG002
"""
Get the field's value just before saving.
Args:
model_instance: The model instance being saved.
add: Whether this is a new instance being added.
Returns:
The field's value before saving.
"""
return getattr(model_instance, self.attname)
[docs] def clean(self, value: Any, model_instance: "Model") -> Any:
"""
Convert the value's type and run validation.
Validation errors from to_python() and validate() are propagated.
Return the correct value if no error is raised.
Args:
value: The value to clean.
model_instance: The model instance being cleaned.
Returns:
The cleaned value.
Raises:
ValidationError: If validation fails.
"""
value = self.to_python(value)
self.validate(value, model_instance)
self.run_validators(value)
return value
[docs] def set_attributes_from_name(self, name: str) -> None:
"""
Set field attributes from the field name.
Args:
name: The name of the field.
"""
self.name = self.name or name
self.attname = self.name
if self.verbose_name is None and self.name:
self.verbose_name = self.name.replace("_", " ")
[docs] def get_choices(
self,
include_blank: bool = True,
blank_choice: list[tuple[str, str]] | None = None,
limit_choices_to: Any = None, # noqa: ARG002
):
"""
Get choices with a default blank choice included.
Args:
include_blank: Whether to include a blank choice.
blank_choice: The blank choice to include.
limit_choices_to: Unused parameter for compatibility.
Returns:
A list of choices for use in select widgets.
"""
if not blank_choice:
blank_choice = BLANK_CHOICE_DASH
if self.choices:
choices = list(self.choices)
if include_blank:
blank_defined = any(
choice in ("", None) for choice, _ in self.flatchoices
)
if not blank_defined:
choices = blank_choice + choices # type: ignore[operator]
return choices
return blank_choice
[docs] def limit_choices_to(self):
"""
Limit choices to a subset.
Raises:
NotImplementedError: This method is not implemented.
"""
raise NotImplementedError
def _get_flatchoices(self) -> list[tuple[str, str]]:
"""
Get a flattened version of choices tuple.
Returns:
A flattened list of (value, display) tuples.
"""
flat: list[tuple[str, str]] = []
for choice, value in self.choices:
if isinstance(value, (list, tuple)):
flat.extend(value)
else:
flat.append((choice, value))
return flat
#: Property that returns flattened choices.
flatchoices = property(_get_flatchoices)
[docs] def value_from_object(self, obj: "Model") -> Any:
"""
Get the field's value from a model object.
Args:
obj: The model object.
Returns:
The field's value from the object.
"""
return getattr(obj, cast("str", self.name))
[docs] def from_db_value(self, value: list[bytes]) -> list[str] | None:
"""
Convert LDAP data to Python format.
Take data for one attribute from LDAP and convert it to our internal
Python format. The value will always be a list of byte strings.
Subclasses should implement the actual logic for this, but first call
super().from_db_value(value) to convert the byte strings in the list
to unicode strings.
Args:
value: A list of byte strings from LDAP.
Returns:
A list of decoded strings or None if empty.
"""
return [b.decode("utf-8") for b in value]
[docs] def to_db_value(self, value: Any) -> dict[str, list[bytes]]:
"""
Convert Python value to LDAP format.
Subclasses should implement this and do proper casting of the value
from our internal data type to the appropriate value to stuff into LDAP,
and then call super().to_db_value(value).
Args:
value: The Python value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
# Subclasses should implement this and do proper casting of the value
# from our internal data type to the appropriate value to stuff into LDAP
# and then call super().to_db_value(value)
if value is None:
value = []
if not isinstance(value, list):
value = [value] if value not in self.empty_values else []
# LDAP doesn't like unicode strings; it wants bytes.
cleaned = []
for item in value:
_item = item
if isinstance(item, str):
_item = item.encode("utf-8")
cleaned.append(_item)
return {self.ldap_attribute: cleaned}
[docs] def value_to_string(self, obj: "Model") -> str:
"""
Convert the field's value to a string.
Args:
obj: The model object.
Returns:
A string representation of the field's value.
"""
return str(self.value_from_object(obj))
[docs] def contribute_to_class(self, cls, name: str) -> None:
"""
Register the field with the model class it belongs to.
Args:
cls: The model class to register with.
name: The name of the field.
"""
self.set_attributes_from_name(name)
self.model = cls
self.check()
cls._meta.add_field(self)
if self.choices:
setattr(
cls,
f"get_{self.name}_display",
partialmethod(cls._get_FIELD_display, field=self),
)
[docs]class BooleanField(Field):
"""
A boolean field which stores data internally as bool() but stores the
strings 'true' and 'false' in LDAP.
This field handles the conversion between Python boolean values and LDAP
string representations, storing 'true' and 'false' in LDAP while working
with Python bool values in the application.
Args:
*args: Positional arguments passed to the parent class.
Keyword Args:
**kwargs: Keyword arguments passed to the parent class.
"""
#: Boolean fields don't allow empty strings.
empty_strings_allowed: bool = False
#: Error messages for boolean validation.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid": _("'%(value)s' value must be either True or False."), # type: ignore[dict-item]
"invalid_nullable": _("'%(value)s' value must be either True, False, or None."), # type: ignore[dict-item]
}
#: Human-readable description of the field type.
description: str = _("Boolean (Either True or False)") # type: ignore[assignment]
#: The string value used to represent True in LDAP.
LDAP_TRUE: str = "true"
#: The string value used to represent False in LDAP.
LDAP_FALSE: str = "false"
[docs] def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
[docs] def to_python(self, value: None | bool | str) -> bool | None:
"""
Convert the value to a Python boolean.
Args:
value: The value to convert. Can be None, bool, or string.
Returns:
The converted boolean value or None if null is allowed.
Raises:
ValidationError: If the value cannot be converted to a boolean.
"""
if self.null and value in self.empty_values:
return None
if value in (True, False):
# if value is 1 or 0 than it's equal to True or False, but we want
# to return a true bool for semantic reasons.
return bool(value)
if value in ("t", "True", "1"):
return True
if value in ("f", "False", "0"):
return False
raise exceptions.ValidationError(
self.error_messages["invalid_nullable" if self.null else "invalid"],
code="invalid",
params={"value": value},
)
[docs] def from_db_value(self, value: list[bytes]) -> bool | None: # type: ignore[override]
"""
Convert LDAP data to Python boolean.
Args:
value: A list of byte strings from LDAP.
Returns:
The boolean value or None if empty.
Raises:
ValueError: If the LDAP data contains unexpected values.
"""
db_value = cast("list[str]", super().from_db_value(value))
if db_value == []:
return None
if db_value[0].lower() == self.LDAP_TRUE:
return True
if db_value[0].lower() == self.LDAP_FALSE:
return False
if db_value == [self.LDAP_TRUE]:
return True
if db_value == [self.LDAP_FALSE]:
return False
msg = (
f'Field "{self.name}" (BooleanField) on model '
f"{self.model._meta.object_name}" # type: ignore[union-attr]
f" got got unexpected data from LDAP: {db_value}"
)
raise ValueError(msg)
[docs] def to_db_value(self, value: bool | None) -> dict[str, list[bytes]]:
"""
Convert Python boolean to LDAP format.
Args:
value: The boolean value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
db_value: str | None = None
if value is not None:
db_value = self.LDAP_TRUE if value else self.LDAP_FALSE
return super().to_db_value(db_value)
[docs]class AllCapsBooleanField(BooleanField):
"""
A boolean field that uses uppercase 'TRUE' and 'FALSE' in LDAP.
This field is similar to BooleanField but uses uppercase strings
for LDAP storage, which is common in some LDAP schemas.
"""
#: The uppercase string value used to represent True in LDAP.
LDAP_TRUE: str = "TRUE"
#: The uppercase string value used to represent False in LDAP.
LDAP_FALSE: str = "FALSE"
[docs]class CharField(Field):
"""
A field for storing character strings.
This field handles string data with optional maximum length validation.
It converts between Python strings and LDAP byte strings.
Keyword Args:
**kwargs: Keyword arguments passed to the parent class.
Args:
*args: Positional arguments passed to the parent class.
"""
#: Human-readable description of the field type.
description: str = _("String (up to %(max_length)s)") # type: ignore[assignment]
[docs] def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.validators.append(dj_validators.MaxLengthValidator(self.max_length)) # type: ignore[attr-defined]
[docs] def to_python(self, value: str | None) -> str | None:
"""
Convert the value to a Python string.
Args:
value: The value to convert.
Returns:
The converted string value or None.
"""
if isinstance(value, str) or value is None:
return value
return str(value)
[docs] def from_db_value(self, value: list[bytes]) -> str | None: # type: ignore[override]
"""
Convert LDAP data to Python string.
Args:
value: A list of byte strings from LDAP.
Returns:
The decoded string value or None if empty.
"""
db_value = cast("list[str]", super().from_db_value(value))
if db_value == []:
return None
return db_value[0]
[docs]class DateField(Field):
"""
A field for storing dates without time information.
This field handles date data, converting between Python date objects
and LDAP date string format (YYYYMMDD).
Keyword Args:
verbose_name: The human-readable name of the field.
name: The name of the field in the LDAP schema.
auto_now: If True, automatically set to current date on save.
auto_now_add: If True, automatically set to current date on creation.
**kwargs: Additional keyword arguments passed to the parent class.
"""
#: Date fields don't allow empty strings.
empty_strings_allowed: bool = False
#: Error messages for date validation.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid": _(
"'%(value)s' value has an invalid date format. It must be in YYYY-MM-DD format." # type: ignore[dict-item] # noqa: E501
),
"invalid_date": _(
"'%(value)s' value has the correct format (YYYY-MM-DD) but it is an invalid date." # type: ignore[dict-item] # noqa: E501
),
}
#: Human-readable description of the field type.
description: str = _("Date (without time)") # type: ignore[assignment]
#: The LDAP date format string.
LDAP_DATETIME_FORMAT: str = "%Y%m%d"
[docs] def __init__(
self,
verbose_name: str | None = None,
name: str | None = None,
auto_now: bool = False,
auto_now_add: bool = False,
**kwargs: Any,
) -> None:
self.auto_now, self.auto_now_add = auto_now, auto_now_add
if auto_now or auto_now_add:
kwargs["editable"] = False
kwargs["blank"] = True
super().__init__(verbose_name, name, **kwargs)
[docs] def check(self, **kwargs) -> list[checks.Error | checks.Warning]: # noqa: ARG002
"""
Run field validation checks.
Returns:
A list of validation errors and warnings.
"""
return [
*super().check(),
*self._check_fix_default_value(),
]
def _check_fix_default_value(self) -> list[checks.Warning]:
"""
Warn that using an actual date or datetime value is probably wrong.
This check warns developers that using a fixed date/datetime value as
default is probably not what they want, as it's only evaluated on
server startup.
Returns:
A list of warnings about fixed default values.
"""
if not self.has_default():
return []
now = timezone.now()
if not timezone.is_naive(now):
now = timezone.make_naive(now, datetime.timezone.utc)
value = self.default
if isinstance(value, datetime.datetime):
if not timezone.is_naive(value):
value = timezone.make_naive(value, datetime.timezone.utc)
value = value.date()
elif isinstance(value, datetime.date):
# Nothing to do, as dates don't have tz information
pass
else:
# No explicit date / datetime value -- no checks necessary
return []
offset = datetime.timedelta(days=1)
lower = (now - offset).date()
upper = (now + offset).date()
if lower <= value <= upper:
return [
checks.Warning(
"Fixed default value provided.",
hint="It seems you set a fixed date / time / datetime "
"value as default for this field. This may not be "
"what you want. If you want to have the current date "
"as default, use `django.utils.timezone.now`",
obj=self,
id="fields.W161",
)
]
return []
[docs] def to_python(
self, value: str | datetime.date | datetime.datetime | None
) -> datetime.date | None:
"""
Convert the value to a Python date.
Args:
value: The value to convert.
Returns:
The converted date value or None.
Raises:
ValidationError: If the value cannot be converted to a date.
"""
if value is None:
return value
if isinstance(value, datetime.datetime):
if settings.USE_TZ and timezone.is_aware(value):
# Convert aware datetimes to the default time zone
# before casting them to dates (#17742).
default_timezone = timezone.get_default_timezone()
value = timezone.make_naive(value, default_timezone)
return cast("datetime.datetime", value).date()
if isinstance(value, datetime.date):
return value
try:
parsed = parse_date(value)
if parsed is not None:
return parsed
except ValueError as e:
raise exceptions.ValidationError(
self.error_messages["invalid_date"],
code="invalid_date",
params={"value": value},
) from e
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
)
[docs] def pre_save(self, model_instance: "Model", add: bool) -> datetime.date | None:
"""
Get the field's value just before saving.
Args:
model_instance: The model instance being saved.
add: Whether this is a new instance being added.
Returns:
The field's value before saving.
"""
if self.auto_now or (self.auto_now_add and add):
value = datetime.date.today()
setattr(model_instance, self.attname, value)
return value
return super().pre_save(model_instance, add)
[docs] def contribute_to_class(self, cls, name: str, **kwargs) -> None:
"""
Register the field with the model class and add convenience methods.
Args:
cls: The model class to register with.
name: The name of the field.
**kwargs: Additional keyword arguments.
"""
super().contribute_to_class(cls, name, **kwargs)
if not self.null:
setattr(
cls,
f"get_next_by_{self.name}",
partialmethod(
cls._get_next_or_previous_by_FIELD, field=self, is_next=True
),
)
setattr(
cls,
f"get_previous_by_{self.name}",
partialmethod(
cls._get_next_or_previous_by_FIELD, field=self, is_next=False
),
)
[docs] def from_db_value(self, value: list[bytes]) -> datetime.date | None: # type: ignore[override]
"""
Convert LDAP data to Python date.
Args:
value: A list of byte strings from LDAP.
Returns:
The parsed date value or None if empty.
"""
db_value = super().from_db_value(value)
if not db_value:
return None
dt = db_value[0]
ts = datetime.datetime.strptime(dt, self.LDAP_DATETIME_FORMAT)
return datetime.date(year=ts.year, month=ts.month, day=ts.day)
[docs] def to_db_value(
self, value: datetime.date | datetime.datetime | None
) -> dict[str, list[bytes]]:
"""
Convert Python date to LDAP format.
Args:
value: The date value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
db_value = None
if value:
db_value = value.strftime(self.LDAP_DATETIME_FORMAT)
return super().to_db_value(db_value)
[docs] def value_to_string(self, obj: "Model") -> str:
"""
Convert the field's value to a string.
Args:
obj: The model object.
Returns:
An ISO format string representation of the date.
"""
val = self.value_from_object(obj)
return "" if val is None else val.isoformat()
[docs]class DateTimeField(DateField):
"""
A field for storing dates with time information.
This field handles datetime data, converting between Python datetime objects
and LDAP datetime string formats. It supports multiple LDAP datetime formats
and handles timezone conversion.
"""
#: List of supported LDAP datetime formats.
LDAP_DATETIME_FORMATS: list[str] = ["%Y%m%d%H%M%SZ", "%Y%m%d%H%M%S+0000"] # noqa: RUF012
#: The default LDAP datetime format for output.
LDAP_DATETIME_FORMAT: str = "%Y%m%d%H%M%S+0000"
#: DateTime fields don't allow empty strings.
empty_strings_allowed: bool = False
#: Error messages for datetime validation.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid": _(
"'%(value)s' value has an invalid format. It must be in "
"YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ] format."
), # type: ignore[dict-item]
"invalid_date": _(
"'%(value)s' value has the correct format (YYYY-MM-DD) but it is an "
"invalid date."
), # type: ignore[dict-item]
"invalid_datetime": _(
"'%(value)s' value has the correct format "
"(YYYY-MM-DD HH:MM[:ss[.uuuuuu]][TZ]) "
"but it is an invalid date/time."
), # type: ignore[dict-item]
"invalid_ldap_datetime": _(
"LDAP datetime '%(value)s' value is not in a supported format"
), # type: ignore[dict-item]
}
#: Human-readable description of the field type.
description: str = _("Date (with time)") # type: ignore[assignment]
def _check_fix_default_value(self) -> list[checks.Warning]:
"""
Warn that using an actual date or datetime value is probably wrong.
This check warns developers that using a fixed date/datetime value as
default is probably not what they want, as it's only evaluated on
server startup.
Returns:
A list of warnings about fixed default values.
"""
if not self.has_default():
return []
now = timezone.now()
if not timezone.is_naive(now):
now = timezone.make_naive(now, datetime.timezone.utc)
value = self.default
if isinstance(value, datetime.datetime):
second_offset = datetime.timedelta(seconds=10)
lower = now - second_offset
upper = now + second_offset
if timezone.is_aware(value):
value = timezone.make_naive(value, datetime.timezone.utc)
elif isinstance(value, datetime.date):
second_offset = datetime.timedelta(seconds=10)
lower = now - second_offset
lower = datetime.datetime(lower.year, lower.month, lower.day)
upper = now + second_offset
upper = datetime.datetime(upper.year, upper.month, upper.day)
value = datetime.datetime(value.year, value.month, value.day)
else:
# No explicit date / datetime value -- no checks necessary
return []
if lower <= value <= upper:
return [
checks.Warning(
"Fixed default value provided.",
hint="It seems you set a fixed date / time / datetime "
"value as default for this field. This may not be "
"what you want. If you want to have the current date "
"as default, use `django.utils.timezone.now`",
obj=self,
id="fields.W161",
)
]
return []
[docs] def to_python(
self, value: str | datetime.datetime | datetime.date | None
) -> datetime.datetime | None:
"""
Convert the value to a Python datetime.
Args:
value: The value to convert. Can be string, datetime, date, or None.
Returns:
The converted datetime value or None.
Raises:
ValidationError: If the value cannot be converted to a datetime.
"""
if value is None:
return value
if isinstance(value, datetime.datetime):
return value
if isinstance(value, datetime.date):
value = datetime.datetime(value.year, value.month, value.day)
if settings.USE_TZ:
# For backwards compatibility, interpret naive datetimes in
# local time. This won't work during DST change, but we can't
# do much about it, so we let the exceptions percolate up the
# call stack.
warnings.warn(
"DateTimeField {}.{} received a naive datetime ({}) while time zone support is active.".format( # noqa: E501
cast("type[Model]", self.model).__name__, self.name, value
),
RuntimeWarning,
stacklevel=2,
)
default_timezone = timezone.get_default_timezone()
value = timezone.make_aware(value, default_timezone)
return value # type: ignore[return-value]
try:
parsed = parse_datetime(value)
if parsed is not None:
return parsed
except ValueError as e:
raise exceptions.ValidationError(
self.error_messages["invalid_datetime"],
code="invalid_datetime",
params={"value": value},
) from e
try:
parsed_date = parse_date(value)
if parsed_date is not None:
return datetime.datetime(
parsed_date.year, parsed_date.month, parsed_date.day
)
except ValueError as e:
raise exceptions.ValidationError(
self.error_messages["invalid_date"],
code="invalid_date",
params={"value": value},
) from e
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
)
[docs] def from_db_value(self, value: list[bytes]) -> datetime.datetime | None: # type: ignore[override]
"""
Convert LDAP data to Python datetime.
Args:
value: A list of byte strings from LDAP.
Returns:
The parsed datetime value or None if empty.
Raises:
ValidationError: If the LDAP datetime format is not supported.
"""
db_value = Field.from_db_value(self, value)
if not db_value:
return None
dt_str = db_value[0]
dt: datetime.datetime | None = None
for fmt in self.LDAP_DATETIME_FORMATS:
try:
dt = datetime.datetime.strptime(dt_str, fmt)
except ValueError: # noqa: PERF203
pass
else:
break
if not isinstance(dt, datetime.datetime):
raise exceptions.ValidationError(
self.error_messages["invalid_ldap_datetime"],
code="invalid_ldap_datetime",
params={"value": value},
)
return pytz.utc.localize(dt)
[docs] def to_db_value(
self, value: datetime.datetime | datetime.date | None
) -> dict[str, list[bytes]]:
"""
Convert Python datetime to LDAP format.
Args:
value: The datetime value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
dt_str = None
if value:
utc = pytz.utc
dt_str = (
cast("datetime.datetime", value)
.astimezone(utc)
.strftime(self.LDAP_DATETIME_FORMAT)
)
return Field.to_db_value(self, dt_str)
[docs] def pre_save(self, model_instance: "Model", add: bool) -> Any:
"""
Get the field's value just before saving.
Args:
model_instance: The model instance being saved.
add: Whether this is a new instance being added.
Returns:
The field's value before saving.
"""
if self.auto_now or (self.auto_now_add and add):
value = timezone.now()
setattr(model_instance, self.attname, value)
return value
return super().pre_save(model_instance, add)
[docs]class EmailField(CharField):
"""
A field for storing email addresses.
This field extends CharField to add email validation. It uses Django's
built-in email validator and sets a default max_length of 254 to be
compliant with RFCs 3696 and 5321.
Keyword Args:
**kwargs: Keyword arguments passed to the parent class.
Args:
*args: Positional arguments passed to the parent class.
"""
#: List containing Django's email validator.
default_validators: list[Validator] = [dj_validators.validate_email] # noqa: RUF012
#: Human-readable description of the field type.
description: str = _("Email address") # type: ignore[assignment]
[docs] def __init__(self, *args, **kwargs):
# max_length=254 to be compliant with RFCs 3696 and 5321
kwargs.setdefault("max_length", 254)
super().__init__(*args, **kwargs)
class EmailForwardField(CharField):
"""
A field for storing email forwarding addresses.
This field extends CharField to add custom email forwarding validation.
It uses a custom validator that allows email forwarding syntax.
"""
#: List containing the email forward validator.
default_validators: list[Validator] = [validate_email_forward] # noqa: RUF012
#: Human-readable description of the field type.
description: str = _("Email address") # type: ignore[assignment]
[docs]class IntegerField(Field):
"""
A field for storing integer values.
This field handles integer data, converting between Python integers
and LDAP string representations.
"""
#: Integer fields don't allow empty strings.
empty_strings_allowed: bool = False
#: Error messages for integer validation.
default_error_messages: dict[str, str] = { # noqa: RUF012
"invalid": _("'%(value)s' value must be an integer."), # type: ignore[dict-item]
}
#: Human-readable description of the field type.
description: str = _("Integer") # type: ignore[assignment]
[docs] def check(self, **kwargs) -> list[checks.Error | checks.Warning]: # noqa: ARG002
"""
Run field validation checks.
Returns:
A list of validation errors and warnings.
"""
return [
*super().check(),
*self._check_max_length_warning(),
]
def _check_max_length_warning(self) -> list[checks.Warning]:
"""
Warn that max_length is ignored for IntegerField.
Returns:
A list of warnings about max_length usage.
"""
if self.max_length is not None:
return [
checks.Warning(
"'max_length' is ignored when used with IntegerField",
hint="Remove 'max_length' from field",
obj=self,
id="fields.W122",
)
]
return []
[docs] def from_db_value(self, value: list[bytes]) -> int | None: # type: ignore[override]
"""
Convert LDAP data to Python integer.
Args:
value: A list of byte strings from LDAP.
Returns:
The parsed integer value or None if empty.
"""
db_value = super().from_db_value(value)
if not db_value:
return None
return self.to_python(db_value[0])
[docs] def to_db_value(self, value: int | None) -> dict[str, list[bytes]]:
"""
Convert Python integer to LDAP format.
Args:
value: The integer value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
db_value: str | None = str(value) if value else None
return super().to_db_value(db_value)
[docs] def to_python(self, value: str) -> int:
"""
Convert the value to a Python integer.
Args:
value: The value to convert.
Returns:
The converted integer value.
Raises:
ValidationError: If the value cannot be converted to an integer.
"""
if value is None:
return value
try:
return int(value)
except (TypeError, ValueError) as e:
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
) from e
[docs]class CharListField(CharField):
"""
A field for storing lists of character strings.
This field handles lists of strings, converting between Python lists
and LDAP multi-valued attributes. It treats newlines as delimiters
when converting from strings to lists.
Keyword Args:
**kwargs: Keyword arguments passed to the parent class.
Args:
*args: Positional arguments passed to the parent class.
"""
description: str = _("List of strings (each up to %(max_length)s)") # type: ignore[assignment]
[docs] def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.empty_values += ("[]",)
self.validators.append(dj_validators.MaxLengthValidator(self.max_length)) # type: ignore[attr-defined]
[docs] def get_default(self) -> list[str]:
"""
Get the default value for this field.
Returns:
An empty list if no default is set, otherwise the default value.
"""
if self.default is None:
return []
return self._get_default() # type: ignore[return-value]
[docs] def from_db_value(self, value):
"""
Convert LDAP data to Python list.
This is important because we don't want CharField.from_db_value() to
execute; we only want Field.from_db_value(). CharField.from_db_value()
turns the list that Field.from_db_value() returns into a string. We
actually want the list.
Args:
value: A list of byte strings from LDAP.
Returns:
A list of decoded strings.
"""
return Field.from_db_value(self, value)
[docs] def to_python(self, value: str | list[str] | None) -> list[str]: # type: ignore[override]
"""
Convert the value to a Python list of strings.
Keyword Args:
value: The value to convert. Can be string, list, or None.
Returns:
A list of strings. If value is a string, it's split on newlines.
"""
if not value:
return []
if isinstance(value, list):
return value
return value.splitlines()
class CaseInsensitiveSHA1Field(CharField):
"""
A readonly field that stores its value as a lowercased SHA1 hash.
Use this when you want to store a secret value that you will only ever
compare to a similar hashed value. The field automatically hashes
values before storing them in LDAP.
"""
#: Human-readable description of the field type.
description: str = _("SHA1 Hash") # type: ignore[assignment]
@staticmethod
def hash_value(value: str | None) -> str | None:
"""
Hash a value using SHA1 with lowercase conversion.
Args:
value: The value to hash.
Returns:
The SHA1 hash of the lowercase value, or None if value is None.
"""
if value:
return hashlib.sha1(value.lower().encode("utf-8")).hexdigest() # noqa: S324
return None
def to_db_value(self, value: str) -> dict[str, list[bytes]]:
"""
Convert Python string to LDAP format with hashing.
Args:
value: The string value to convert and hash.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
return super().to_db_value(self.hash_value(value))
class PasswordField(CharField):
"""
Base class for password fields.
This field provides basic password handling functionality.
Subclasses should override hash_password() to implement specific
password hashing algorithms.
"""
#: Human-readable description of the field type.
description: str = _("Password") # type: ignore[assignment]
def hash_password(self, password: str) -> bytes:
"""
Hash a password using the default algorithm.
Args:
password: The plain text password to hash.
Returns:
The hashed password as bytes.
"""
return password.encode("utf-8")
def to_db_value(self, value: str) -> dict[str, list[bytes]]:
"""
Convert Python string to LDAP format with password hashing.
Args:
value: The plain text password to hash and store.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
hashed_password = self.hash_password(value)
return super().to_db_value(hashed_password)
class LDAPPasswordField(PasswordField):
"""
A password field that uses LDAP SSHA hashing.
This field implements the LDAP SSHA (Salted SHA1) password hashing
algorithm, which is commonly used in LDAP directories.
"""
#: Human-readable description of the field type.
description: str = _("LDAP SSHA Password") # type: ignore[assignment]
def hash_password(self, password: str) -> bytes:
"""
Hash a password using LDAP SSHA algorithm.
Args:
password: The plain text password to hash.
Returns:
The SSHA hashed password as bytes.
"""
salt = os.urandom(8)
h = hashlib.sha1(password.encode("utf-8")) # noqa: S324
h.update(salt)
return b"{SSHA}" + encode(h.digest() + salt)
class ADPasswordField(PasswordField):
"""
A password field for Active Directory.
This field implements the Active Directory password format,
which stores passwords as UTF-16LE encoded strings with quotes.
"""
#: Human-readable description of the field type.
description: str = _("Active Directory Password") # type: ignore[assignment]
def hash_password(self, password: str) -> bytes:
"""
Hash a password for Active Directory.
Args:
password: The plain text password to hash.
Returns:
The AD-formatted password as bytes.
"""
return f'"{password}"'.encode("utf-16-le")
[docs]class ActiveDirectoryTimestampField(DateTimeField):
"""
A field for storing Active Directory timestamp values as datetime objects.
This field handles Active Directory timestamps, which are stored as 18-digit
integers representing the number of 100-nanosecond intervals since January 1,
1601 UTC (also known as Windows NT time format). It converts between these
timestamps and Python datetime objects.
The Active Directory timestamp is the number of 100-nanosecond intervals
(1 nanosecond = one billionth of a second) since Jan 1, 1601 UTC.
"""
#: Human-readable description of the field type.
description: str = _("Active Directory DateTime") # type: ignore[assignment]
#: Error messages for Active Directory datetime validation.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid": _( # type: ignore[dict-item]
"'%(value)s' value has an invalid format. It must be an 18-digit integer."
),
"invalid_timestamp": _( # type: ignore[dict-item]
"'%(value)s' value is not a valid Active Directory timestamp."
),
"timestamp_out_of_range": _( # type: ignore[dict-item]
"'%(value)s' value represents a timestamp outside the supported range." # type: ignore[dict-item]
),
}
#: The Active Directory epoch (January 1, 1601 UTC).
AD_EPOCH: datetime.datetime = datetime.datetime(1601, 1, 1, tzinfo=pytz.UTC)
#: The number of 100-nanosecond intervals per second.
INTERVALS_PER_SECOND: int = 10_000_000
#: The number of 100-nanosecond intervals per day.
INTERVALS_PER_DAY: int = 864_000_000_000
[docs] def to_python(
self, value: str | datetime.datetime | datetime.date | None
) -> datetime.datetime | None:
"""
Convert the value to a Python datetime.
Args:
value: The value to convert. Can be string, integer, datetime, or None.
Returns:
The converted datetime value or None.
Raises:
ValidationError: If the value cannot be converted to a datetime.
"""
if value is None:
return value
if isinstance(value, datetime.datetime):
return value
# Handle string or integer Active Directory timestamp
if isinstance(value, (str, int)):
try:
# Convert to integer
timestamp = int(value)
# Validate it's an 18-digit number
if len(str(timestamp)) != 18: # noqa: PLR2004
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
)
# Convert to datetime
return self._ad_timestamp_to_datetime(timestamp)
except (ValueError, OverflowError) as e:
raise exceptions.ValidationError(
self.error_messages["invalid_timestamp"],
code="invalid_timestamp",
params={"value": value},
) from e
# For other types, use parent's to_python method
return super().to_python(value)
[docs] def from_db_value(self, value: list[bytes]) -> datetime.datetime | None: # type: ignore[override]
"""
Convert LDAP data to Python datetime.
Args:
value: A list of byte strings from LDAP.
Returns:
The parsed datetime value or None if empty.
Raises:
ValidationError: If the LDAP timestamp format is invalid.
"""
db_value = Field.from_db_value(self, value)
if not db_value:
return None
try:
# Convert the string timestamp to integer
timestamp = int(db_value[0])
return self._ad_timestamp_to_datetime(timestamp)
except (ValueError, OverflowError) as e:
raise exceptions.ValidationError(
self.error_messages["invalid_timestamp"],
code="invalid_timestamp",
params={"value": db_value[0]},
) from e
[docs] def to_db_value(
self, value: datetime.datetime | datetime.date | None
) -> dict[str, list[bytes]]:
"""
Convert Python datetime to Active Directory timestamp format.
Args:
value: The datetime value to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
if value is None:
return Field.to_db_value(self, None)
# Convert datetime to Active Directory timestamp
timestamp = self._datetime_to_ad_timestamp(value)
return Field.to_db_value(self, str(timestamp))
def _ad_timestamp_to_datetime(self, timestamp: int) -> datetime.datetime:
"""
Convert Active Directory timestamp to Python datetime.
Args:
timestamp: The Active Directory timestamp (18-digit integer).
Returns:
The corresponding datetime object.
Raises:
ValidationError: If the timestamp is out of range.
"""
try:
# Convert 100-nanosecond intervals to seconds
seconds = timestamp / self.INTERVALS_PER_SECOND
# Add to AD epoch
dt = self.AD_EPOCH + datetime.timedelta(seconds=seconds)
# Validate the result is reasonable (not too far in past)
if dt.year < 1601: # noqa: PLR2004
raise exceptions.ValidationError(
self.error_messages["timestamp_out_of_range"],
code="timestamp_out_of_range",
params={"value": timestamp},
)
except (OverflowError, OSError) as e:
raise exceptions.ValidationError(
self.error_messages["timestamp_out_of_range"],
code="timestamp_out_of_range",
params={"value": timestamp},
) from e
else:
return dt
def _datetime_to_ad_timestamp(self, dt: datetime.datetime | datetime.date) -> int:
"""
Convert Python datetime to Active Directory timestamp.
Args:
dt: The datetime object to convert.
Returns:
The Active Directory timestamp as an 18-digit integer.
"""
# Ensure we have a datetime object
if isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime):
dt = datetime.datetime.combine(dt, datetime.time.min)
# Ensure timezone awareness
dt = (
timezone.make_aware(dt, pytz.UTC)
if timezone.is_naive(dt)
else dt.astimezone(pytz.UTC)
)
# Calculate the difference from AD epoch
delta = dt - self.AD_EPOCH
# Convert to 100-nanosecond intervals
total_seconds = delta.total_seconds()
return int(total_seconds * self.INTERVALS_PER_SECOND)
[docs]class BinaryField(Field):
"""
A field for storing binary data.
This field handles binary data, converting between Python bytes objects
and LDAP binary attributes. It's commonly used for storing photos,
certificates, and other binary data in LDAP.
Keyword Args:
**kwargs: Keyword arguments passed to the parent class.
Args:
*args: Positional arguments passed to the parent class.
"""
#: Binary fields don't allow empty strings.
empty_strings_allowed: bool = False
#: Error messages for binary validation.
default_error_messages: dict[str, str] = { # type: ignore[assignment] # noqa: RUF012
"invalid": _("'%(value)s' value must be bytes or bytearray."), # type: ignore[dict-item]
}
#: Human-readable description of the field type.
description: str = _("Binary data") # type: ignore[assignment]
[docs] def check(self, **kwargs) -> list[checks.Error | checks.Warning]: # noqa: ARG002
"""
Run field validation checks.
Returns:
A list of validation errors and warnings.
"""
return [
*super().check(),
*self._check_max_length_warning(),
]
def _check_max_length_warning(self) -> list[checks.Warning]:
"""
Warn that max_length is ignored for BinaryField.
Returns:
A list of warnings about max_length usage.
"""
if self.max_length is not None:
return [
checks.Warning(
"'max_length' is ignored when used with BinaryField",
hint="Remove 'max_length' from field",
obj=self,
id="fields.W123",
)
]
return []
[docs] def to_python(self, value: bytes | bytearray | None) -> bytes | None:
"""
Convert the value to Python bytes.
Args:
value: The value to convert. Can be bytes, bytearray, or None.
Returns:
The converted bytes value or None.
Raises:
ValidationError: If the value cannot be converted to bytes.
"""
if value is None:
return value
if isinstance(value, bytes):
return value
if isinstance(value, bytearray):
return bytes(value)
raise exceptions.ValidationError(
self.error_messages["invalid"],
code="invalid",
params={"value": value},
)
[docs] def from_db_value(self, value: list[bytes]) -> bytes | None: # type: ignore[override]
"""
Convert LDAP data to Python bytes.
Args:
value: A list of byte strings from LDAP.
Returns:
The binary data as bytes or None if empty.
"""
if not value:
return None
return value[0]
[docs] def to_db_value(self, value: bytes | bytearray | None) -> dict[str, list[bytes]]:
"""
Convert Python bytes to LDAP format.
Args:
value: The binary data to convert.
Returns:
A dictionary mapping LDAP attribute name to list of bytes.
"""
if value is None:
return {self.ldap_attribute: []}
if isinstance(value, bytearray):
value = bytes(value)
return {self.ldap_attribute: [value]}