import datetime import warnings from collections import OrderedDict import django from django.conf import settings from django.core.exceptions import FieldDoesNotExist, FieldError from django.db import models from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import Expression from django.db.models.fields.related import ForeignObjectRel, RelatedField from django.utils import timezone from django.utils.encoding import force_str from django.utils.text import capfirst from django.utils.translation import gettext as _ from .exceptions import FieldLookupError def deprecate(msg, level_modifier=0): warnings.warn(msg, MigrationNotice, stacklevel=3 + level_modifier) class MigrationNotice(DeprecationWarning): url = "https://django-filter.readthedocs.io/en/main/guide/migration.html" def __init__(self, message): super().__init__("%s See: %s" % (message, self.url)) class RenameAttributesBase(type): """ Handles the deprecation paths when renaming an attribute. It does the following: - Defines accessors that redirect to the renamed attributes. - Complain whenever an old attribute is accessed. This is conceptually based on `django.utils.deprecation.RenameMethodsBase`. """ renamed_attributes = () def __new__(metacls, name, bases, attrs): # remove old attributes before creating class old_names = [r[0] for r in metacls.renamed_attributes] old_names = [name for name in old_names if name in attrs] old_attrs = {name: attrs.pop(name) for name in old_names} # get a handle to any accessors defined on the class cls_getattr = attrs.pop("__getattr__", None) cls_setattr = attrs.pop("__setattr__", None) new_class = super().__new__(metacls, name, bases, attrs) def __getattr__(self, name): name = type(self).get_name(name) if cls_getattr is not None: return cls_getattr(self, name) elif hasattr(super(new_class, self), "__getattr__"): return super(new_class, self).__getattr__(name) return self.__getattribute__(name) def __setattr__(self, name, value): name = type(self).get_name(name) if cls_setattr is not None: return cls_setattr(self, name, value) return super(new_class, self).__setattr__(name, value) new_class.__getattr__ = __getattr__ new_class.__setattr__ = __setattr__ # set renamed attributes for name, value in old_attrs.items(): setattr(new_class, name, value) return new_class def get_name(metacls, name): """ Get the real attribute name. If the attribute has been renamed, the new name will be returned and a deprecation warning issued. """ for renamed_attribute in metacls.renamed_attributes: old_name, new_name, deprecation_warning = renamed_attribute if old_name == name: warnings.warn( "`%s.%s` attribute should be renamed `%s`." % (metacls.__name__, old_name, new_name), deprecation_warning, 3, ) return new_name return name def __getattr__(metacls, name): return super().__getattribute__(metacls.get_name(name)) def __setattr__(metacls, name, value): return super().__setattr__(metacls.get_name(name), value) def try_dbfield(fn, field_class): """ Try ``fn`` with the DB ``field_class`` by walking its MRO until a result is found. ex:: _try_dbfield(field_dict.get, models.CharField) """ # walk the mro, as field_class could be a derived model field. for cls in field_class.mro(): # skip if cls is models.Field if cls is models.Field: continue data = fn(cls) if data: return data def get_all_model_fields(model): opts = model._meta return [ f.name for f in sorted(opts.fields + opts.many_to_many) if not isinstance(f, models.AutoField) and not (getattr(f.remote_field, "parent_link", False)) ] def get_model_field(model, field_name): """ Get a ``model`` field, traversing relationships in the ``field_name``. ex:: f = get_model_field(Book, 'author__first_name') """ fields = get_field_parts(model, field_name) return fields[-1] if fields else None def get_field_parts(model, field_name): """ Get the field parts that represent the traversable relationships from the base ``model`` to the final field, described by ``field_name``. ex:: >>> parts = get_field_parts(Book, 'author__first_name') >>> [p.verbose_name for p in parts] ['author', 'first name'] """ parts = field_name.split(LOOKUP_SEP) opts = model._meta fields = [] # walk relationships for name in parts: try: field = opts.get_field(name) except FieldDoesNotExist: return None fields.append(field) try: if isinstance(field, RelatedField): opts = field.remote_field.model._meta elif isinstance(field, ForeignObjectRel): opts = field.related_model._meta except AttributeError: # Lazy relationships are not resolved until registry is populated. raise RuntimeError( "Unable to resolve relationship `%s` for `%s`. Django is most " "likely not initialized, and its apps registry not populated. " "Ensure Django has finished setup before loading `FilterSet`s." % (field_name, model._meta.label) ) return fields def resolve_field(model_field, lookup_expr): """ Resolves a ``lookup_expr`` into its final output field, given the initial ``model_field``. The lookup expression should only contain transforms and lookups, not intermediary model field parts. Note: This method is based on django.db.models.sql.query.Query.build_lookup For more info on the lookup API: https://docs.djangoproject.com/en/stable/ref/models/lookups/ """ query = model_field.model._default_manager.all().query lhs = Expression(model_field) lookups = lookup_expr.split(LOOKUP_SEP) assert len(lookups) > 0 try: while lookups: name = lookups[0] args = (lhs, name) # If there is just one part left, try first get_lookup() so # that if the lhs supports both transform and lookup for the # name, then lookup will be picked. if len(lookups) == 1: final_lookup = lhs.get_lookup(name) if not final_lookup: # We didn't find a lookup. We are going to interpret # the name as transform, and do an Exact lookup against # it. lhs = query.try_transform(*args) final_lookup = lhs.get_lookup("exact") return lhs.output_field, final_lookup.lookup_name lhs = query.try_transform(*args) lookups = lookups[1:] except FieldError as e: raise FieldLookupError(model_field, lookup_expr) from e def handle_timezone(value, is_dst=None): if settings.USE_TZ and timezone.is_naive(value): # On pre-5.x versions, the default is to use zoneinfo, but pytz # is still available under USE_DEPRECATED_PYTZ, and is_dst is # meaningful there. Under those versions we should only use is_dst # if USE_DEPRECATED_PYTZ is present and True; otherwise, we will cause # deprecation warnings, and we should not. See #1580. # # This can be removed once 4.2 is no longer supported upstream. if django.VERSION < (5, 0) and settings.USE_DEPRECATED_PYTZ: return timezone.make_aware(value, timezone.get_current_timezone(), is_dst) return timezone.make_aware(value, timezone.get_current_timezone()) elif not settings.USE_TZ and timezone.is_aware(value): return timezone.make_naive(value, datetime.timezone.utc) return value def verbose_field_name(model, field_name): """ Get the verbose name for a given ``field_name``. The ``field_name`` will be traversed across relationships. Returns '[invalid name]' for any field name that cannot be traversed. ex:: >>> verbose_field_name(Article, 'author__name') 'author name' """ if field_name is None: return "[invalid name]" parts = get_field_parts(model, field_name) if not parts: return "[invalid name]" names = [] for part in parts: if isinstance(part, ForeignObjectRel): if part.related_name: names.append(part.related_name.replace("_", " ")) else: return "[invalid name]" else: names.append(force_str(part.verbose_name)) return " ".join(names) def verbose_lookup_expr(lookup_expr): """ Get a verbose, more humanized expression for a given ``lookup_expr``. Each part in the expression is looked up in the ``FILTERS_VERBOSE_LOOKUPS`` dictionary. Missing keys will simply default to itself. ex:: >>> verbose_lookup_expr('year__lt') 'year is less than' # with `FILTERS_VERBOSE_LOOKUPS = {}` >>> verbose_lookup_expr('year__lt') 'year lt' """ from .conf import settings as app_settings VERBOSE_LOOKUPS = app_settings.VERBOSE_LOOKUPS or {} lookups = [ force_str(VERBOSE_LOOKUPS.get(lookup, _(lookup))) for lookup in lookup_expr.split(LOOKUP_SEP) ] return " ".join(lookups) def label_for_filter(model, field_name, lookup_expr, exclude=False): """ Create a generic label suitable for a filter. ex:: >>> label_for_filter(Article, 'author__name', 'in') 'auther name is in' """ name = verbose_field_name(model, field_name) verbose_expression = [_("exclude"), name] if exclude else [name] # iterable lookups indicate a LookupTypeField, which should not be verbose if isinstance(lookup_expr, str): verbose_expression += [verbose_lookup_expr(lookup_expr)] verbose_expression = [force_str(part) for part in verbose_expression if part] verbose_expression = capfirst(" ".join(verbose_expression)) return verbose_expression def translate_validation(error_dict): """ Translate a Django ErrorDict into its DRF ValidationError. """ # it's necessary to lazily import the exception, as it can otherwise create # an import loop when importing django_filters inside the project settings. from rest_framework.exceptions import ErrorDetail, ValidationError exc = OrderedDict( ( key, [ ErrorDetail(e.message % (e.params or ()), code=e.code) for e in error_list ], ) for key, error_list in error_dict.as_data().items() ) return ValidationError(exc)