widgets.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605
  1. """
  2. Form Widget classes specific to the Django admin site.
  3. """
  4. import copy
  5. import json
  6. from django import forms
  7. from django.conf import settings
  8. from django.core.exceptions import ValidationError
  9. from django.core.validators import URLValidator
  10. from django.db.models import CASCADE, UUIDField
  11. from django.urls import reverse
  12. from django.urls.exceptions import NoReverseMatch
  13. from django.utils.html import smart_urlquote
  14. from django.utils.http import urlencode
  15. from django.utils.text import Truncator
  16. from django.utils.translation import get_language
  17. from django.utils.translation import gettext as _
  18. class FilteredSelectMultiple(forms.SelectMultiple):
  19. """
  20. A SelectMultiple with a JavaScript filter interface.
  21. Note that the resulting JavaScript assumes that the jsi18n
  22. catalog has been loaded in the page
  23. """
  24. class Media:
  25. js = [
  26. "admin/js/core.js",
  27. "admin/js/SelectBox.js",
  28. "admin/js/SelectFilter2.js",
  29. ]
  30. def __init__(self, verbose_name, is_stacked, attrs=None, choices=()):
  31. self.verbose_name = verbose_name
  32. self.is_stacked = is_stacked
  33. super().__init__(attrs, choices)
  34. def get_context(self, name, value, attrs):
  35. context = super().get_context(name, value, attrs)
  36. context["widget"]["attrs"]["class"] = "selectfilter"
  37. if self.is_stacked:
  38. context["widget"]["attrs"]["class"] += "stacked"
  39. context["widget"]["attrs"]["data-field-name"] = self.verbose_name
  40. context["widget"]["attrs"]["data-is-stacked"] = int(self.is_stacked)
  41. return context
  42. class BaseAdminDateWidget(forms.DateInput):
  43. class Media:
  44. js = [
  45. "admin/js/calendar.js",
  46. "admin/js/admin/DateTimeShortcuts.js",
  47. ]
  48. def __init__(self, attrs=None, format=None):
  49. attrs = {"class": "vDateField", "size": "10", **(attrs or {})}
  50. super().__init__(attrs=attrs, format=format)
  51. class AdminDateWidget(BaseAdminDateWidget):
  52. template_name = "admin/widgets/date.html"
  53. class BaseAdminTimeWidget(forms.TimeInput):
  54. class Media:
  55. js = [
  56. "admin/js/calendar.js",
  57. "admin/js/admin/DateTimeShortcuts.js",
  58. ]
  59. def __init__(self, attrs=None, format=None):
  60. attrs = {"class": "vTimeField", "size": "8", **(attrs or {})}
  61. super().__init__(attrs=attrs, format=format)
  62. class AdminTimeWidget(BaseAdminTimeWidget):
  63. template_name = "admin/widgets/time.html"
  64. class AdminSplitDateTime(forms.SplitDateTimeWidget):
  65. """
  66. A SplitDateTime Widget that has some admin-specific styling.
  67. """
  68. template_name = "admin/widgets/split_datetime.html"
  69. def __init__(self, attrs=None):
  70. widgets = [BaseAdminDateWidget, BaseAdminTimeWidget]
  71. # Note that we're calling MultiWidget, not SplitDateTimeWidget, because
  72. # we want to define widgets.
  73. forms.MultiWidget.__init__(self, widgets, attrs)
  74. def get_context(self, name, value, attrs):
  75. context = super().get_context(name, value, attrs)
  76. context["date_label"] = _("Date:")
  77. context["time_label"] = _("Time:")
  78. return context
  79. class AdminRadioSelect(forms.RadioSelect):
  80. template_name = "admin/widgets/radio.html"
  81. class AdminFileWidget(forms.ClearableFileInput):
  82. template_name = "admin/widgets/clearable_file_input.html"
  83. def url_params_from_lookup_dict(lookups):
  84. """
  85. Convert the type of lookups specified in a ForeignKey limit_choices_to
  86. attribute to a dictionary of query parameters
  87. """
  88. params = {}
  89. if lookups and hasattr(lookups, "items"):
  90. for k, v in lookups.items():
  91. if callable(v):
  92. v = v()
  93. if isinstance(v, (tuple, list)):
  94. v = ",".join(str(x) for x in v)
  95. elif isinstance(v, bool):
  96. v = ("0", "1")[v]
  97. else:
  98. v = str(v)
  99. params[k] = v
  100. return params
  101. class ForeignKeyRawIdWidget(forms.TextInput):
  102. """
  103. A Widget for displaying ForeignKeys in the "raw_id" interface rather than
  104. in a <select> box.
  105. """
  106. template_name = "admin/widgets/foreign_key_raw_id.html"
  107. def __init__(self, rel, admin_site, attrs=None, using=None):
  108. self.rel = rel
  109. self.admin_site = admin_site
  110. self.db = using
  111. super().__init__(attrs)
  112. def get_context(self, name, value, attrs):
  113. context = super().get_context(name, value, attrs)
  114. rel_to = self.rel.model
  115. if self.admin_site.is_registered(rel_to):
  116. # The related object is registered with the same AdminSite
  117. related_url = reverse(
  118. "admin:%s_%s_changelist"
  119. % (
  120. rel_to._meta.app_label,
  121. rel_to._meta.model_name,
  122. ),
  123. current_app=self.admin_site.name,
  124. )
  125. params = self.url_parameters()
  126. if params:
  127. related_url += "?" + urlencode(params)
  128. context["related_url"] = related_url
  129. context["link_title"] = _("Lookup")
  130. # The JavaScript code looks for this class.
  131. css_class = "vForeignKeyRawIdAdminField"
  132. if isinstance(self.rel.get_related_field(), UUIDField):
  133. css_class += " vUUIDField"
  134. context["widget"]["attrs"].setdefault("class", css_class)
  135. else:
  136. context["related_url"] = None
  137. if context["widget"]["value"]:
  138. context["link_label"], context["link_url"] = self.label_and_url_for_value(
  139. value
  140. )
  141. else:
  142. context["link_label"] = None
  143. return context
  144. def base_url_parameters(self):
  145. limit_choices_to = self.rel.limit_choices_to
  146. if callable(limit_choices_to):
  147. limit_choices_to = limit_choices_to()
  148. return url_params_from_lookup_dict(limit_choices_to)
  149. def url_parameters(self):
  150. from django.contrib.admin.views.main import TO_FIELD_VAR
  151. params = self.base_url_parameters()
  152. params.update({TO_FIELD_VAR: self.rel.get_related_field().name})
  153. return params
  154. def label_and_url_for_value(self, value):
  155. key = self.rel.get_related_field().name
  156. try:
  157. obj = self.rel.model._default_manager.using(self.db).get(**{key: value})
  158. except (ValueError, self.rel.model.DoesNotExist, ValidationError):
  159. return "", ""
  160. try:
  161. url = reverse(
  162. "%s:%s_%s_change"
  163. % (
  164. self.admin_site.name,
  165. obj._meta.app_label,
  166. obj._meta.model_name,
  167. ),
  168. args=(obj.pk,),
  169. )
  170. except NoReverseMatch:
  171. url = "" # Admin not registered for target model.
  172. return Truncator(obj).words(14), url
  173. class ManyToManyRawIdWidget(ForeignKeyRawIdWidget):
  174. """
  175. A Widget for displaying ManyToMany ids in the "raw_id" interface rather than
  176. in a <select multiple> box.
  177. """
  178. template_name = "admin/widgets/many_to_many_raw_id.html"
  179. def get_context(self, name, value, attrs):
  180. context = super().get_context(name, value, attrs)
  181. if self.admin_site.is_registered(self.rel.model):
  182. # The related object is registered with the same AdminSite
  183. context["widget"]["attrs"]["class"] = "vManyToManyRawIdAdminField"
  184. return context
  185. def url_parameters(self):
  186. return self.base_url_parameters()
  187. def label_and_url_for_value(self, value):
  188. return "", ""
  189. def value_from_datadict(self, data, files, name):
  190. value = data.get(name)
  191. if value:
  192. return value.split(",")
  193. def format_value(self, value):
  194. return ",".join(str(v) for v in value) if value else ""
  195. class RelatedFieldWidgetWrapper(forms.Widget):
  196. """
  197. This class is a wrapper to a given widget to add the add icon for the
  198. admin interface.
  199. """
  200. template_name = "admin/widgets/related_widget_wrapper.html"
  201. def __init__(
  202. self,
  203. widget,
  204. rel,
  205. admin_site,
  206. can_add_related=None,
  207. can_change_related=False,
  208. can_delete_related=False,
  209. can_view_related=False,
  210. ):
  211. self.needs_multipart_form = widget.needs_multipart_form
  212. self.attrs = widget.attrs
  213. self.widget = widget
  214. self.rel = rel
  215. # Backwards compatible check for whether a user can add related
  216. # objects.
  217. if can_add_related is None:
  218. can_add_related = admin_site.is_registered(rel.model)
  219. self.can_add_related = can_add_related
  220. # XXX: The UX does not support multiple selected values.
  221. multiple = getattr(widget, "allow_multiple_selected", False)
  222. if not isinstance(widget, AutocompleteMixin):
  223. self.attrs["data-context"] = "available-source"
  224. self.can_change_related = not multiple and can_change_related
  225. # XXX: The deletion UX can be confusing when dealing with cascading deletion.
  226. cascade = getattr(rel, "on_delete", None) is CASCADE
  227. self.can_delete_related = not multiple and not cascade and can_delete_related
  228. self.can_view_related = not multiple and can_view_related
  229. # so we can check if the related object is registered with this AdminSite
  230. self.admin_site = admin_site
  231. def __deepcopy__(self, memo):
  232. obj = copy.copy(self)
  233. obj.widget = copy.deepcopy(self.widget, memo)
  234. obj.attrs = self.widget.attrs
  235. memo[id(self)] = obj
  236. return obj
  237. @property
  238. def is_hidden(self):
  239. return self.widget.is_hidden
  240. @property
  241. def media(self):
  242. return self.widget.media
  243. @property
  244. def choices(self):
  245. return self.widget.choices
  246. @choices.setter
  247. def choices(self, value):
  248. self.widget.choices = value
  249. def get_related_url(self, info, action, *args):
  250. return reverse(
  251. "admin:%s_%s_%s" % (info + (action,)),
  252. current_app=self.admin_site.name,
  253. args=args,
  254. )
  255. def get_context(self, name, value, attrs):
  256. from django.contrib.admin.views.main import IS_POPUP_VAR, TO_FIELD_VAR
  257. rel_opts = self.rel.model._meta
  258. info = (rel_opts.app_label, rel_opts.model_name)
  259. related_field_name = self.rel.get_related_field().name
  260. url_params = "&".join(
  261. "%s=%s" % param
  262. for param in [
  263. (TO_FIELD_VAR, related_field_name),
  264. (IS_POPUP_VAR, 1),
  265. ]
  266. )
  267. context = {
  268. "rendered_widget": self.widget.render(name, value, attrs),
  269. "is_hidden": self.is_hidden,
  270. "name": name,
  271. "url_params": url_params,
  272. "model": rel_opts.verbose_name,
  273. "model_name": rel_opts.model_name,
  274. "can_add_related": self.can_add_related,
  275. "can_change_related": self.can_change_related,
  276. "can_delete_related": self.can_delete_related,
  277. "can_view_related": self.can_view_related,
  278. "model_has_limit_choices_to": self.rel.limit_choices_to,
  279. }
  280. if self.can_add_related:
  281. context["add_related_url"] = self.get_related_url(info, "add")
  282. if self.can_delete_related:
  283. context["delete_related_template_url"] = self.get_related_url(
  284. info, "delete", "__fk__"
  285. )
  286. if self.can_view_related or self.can_change_related:
  287. context["view_related_url_params"] = f"{TO_FIELD_VAR}={related_field_name}"
  288. context["change_related_template_url"] = self.get_related_url(
  289. info, "change", "__fk__"
  290. )
  291. return context
  292. def value_from_datadict(self, data, files, name):
  293. return self.widget.value_from_datadict(data, files, name)
  294. def value_omitted_from_data(self, data, files, name):
  295. return self.widget.value_omitted_from_data(data, files, name)
  296. def id_for_label(self, id_):
  297. return self.widget.id_for_label(id_)
  298. class AdminTextareaWidget(forms.Textarea):
  299. def __init__(self, attrs=None):
  300. super().__init__(attrs={"class": "vLargeTextField", **(attrs or {})})
  301. class AdminTextInputWidget(forms.TextInput):
  302. def __init__(self, attrs=None):
  303. super().__init__(attrs={"class": "vTextField", **(attrs or {})})
  304. class AdminEmailInputWidget(forms.EmailInput):
  305. def __init__(self, attrs=None):
  306. super().__init__(attrs={"class": "vTextField", **(attrs or {})})
  307. class AdminURLFieldWidget(forms.URLInput):
  308. template_name = "admin/widgets/url.html"
  309. def __init__(self, attrs=None, validator_class=URLValidator):
  310. super().__init__(attrs={"class": "vURLField", **(attrs or {})})
  311. self.validator = validator_class()
  312. def get_context(self, name, value, attrs):
  313. try:
  314. self.validator(value if value else "")
  315. url_valid = True
  316. except ValidationError:
  317. url_valid = False
  318. context = super().get_context(name, value, attrs)
  319. context["current_label"] = _("Currently:")
  320. context["change_label"] = _("Change:")
  321. context["widget"]["href"] = (
  322. smart_urlquote(context["widget"]["value"]) if url_valid else ""
  323. )
  324. context["url_valid"] = url_valid
  325. return context
  326. class AdminIntegerFieldWidget(forms.NumberInput):
  327. class_name = "vIntegerField"
  328. def __init__(self, attrs=None):
  329. super().__init__(attrs={"class": self.class_name, **(attrs or {})})
  330. class AdminBigIntegerFieldWidget(AdminIntegerFieldWidget):
  331. class_name = "vBigIntegerField"
  332. class AdminUUIDInputWidget(forms.TextInput):
  333. def __init__(self, attrs=None):
  334. super().__init__(attrs={"class": "vUUIDField", **(attrs or {})})
  335. # Mapping of lowercase language codes [returned by Django's get_language()] to
  336. # language codes supported by select2.
  337. # See django/contrib/admin/static/admin/js/vendor/select2/i18n/*
  338. SELECT2_TRANSLATIONS = {
  339. x.lower(): x
  340. for x in [
  341. "ar",
  342. "az",
  343. "bg",
  344. "ca",
  345. "cs",
  346. "da",
  347. "de",
  348. "el",
  349. "en",
  350. "es",
  351. "et",
  352. "eu",
  353. "fa",
  354. "fi",
  355. "fr",
  356. "gl",
  357. "he",
  358. "hi",
  359. "hr",
  360. "hu",
  361. "id",
  362. "is",
  363. "it",
  364. "ja",
  365. "km",
  366. "ko",
  367. "lt",
  368. "lv",
  369. "mk",
  370. "ms",
  371. "nb",
  372. "nl",
  373. "pl",
  374. "pt-BR",
  375. "pt",
  376. "ro",
  377. "ru",
  378. "sk",
  379. "sr-Cyrl",
  380. "sr",
  381. "sv",
  382. "th",
  383. "tr",
  384. "uk",
  385. "vi",
  386. ]
  387. }
  388. SELECT2_TRANSLATIONS.update({"zh-hans": "zh-CN", "zh-hant": "zh-TW"})
  389. def get_select2_language():
  390. lang_code = get_language()
  391. supported_code = SELECT2_TRANSLATIONS.get(lang_code)
  392. if supported_code is None and lang_code is not None:
  393. # If 'zh-hant-tw' is not supported, try subsequent language codes i.e.
  394. # 'zh-hant' and 'zh'.
  395. i = None
  396. while (i := lang_code.rfind("-", 0, i)) > -1:
  397. if supported_code := SELECT2_TRANSLATIONS.get(lang_code[:i]):
  398. return supported_code
  399. return supported_code
  400. class AutocompleteMixin:
  401. """
  402. Select widget mixin that loads options from AutocompleteJsonView via AJAX.
  403. Renders the necessary data attributes for select2 and adds the static form
  404. media.
  405. """
  406. url_name = "%s:autocomplete"
  407. def __init__(self, field, admin_site, attrs=None, choices=(), using=None):
  408. self.field = field
  409. self.admin_site = admin_site
  410. self.db = using
  411. self.choices = choices
  412. self.attrs = {} if attrs is None else attrs.copy()
  413. self.i18n_name = get_select2_language()
  414. def get_url(self):
  415. return reverse(self.url_name % self.admin_site.name)
  416. def build_attrs(self, base_attrs, extra_attrs=None):
  417. """
  418. Set select2's AJAX attributes.
  419. Attributes can be set using the html5 data attribute.
  420. Nested attributes require a double dash as per
  421. https://select2.org/configuration/data-attributes#nested-subkey-options
  422. """
  423. attrs = super().build_attrs(base_attrs, extra_attrs=extra_attrs)
  424. attrs.setdefault("class", "")
  425. attrs.update(
  426. {
  427. "data-ajax--cache": "true",
  428. "data-ajax--delay": 250,
  429. "data-ajax--type": "GET",
  430. "data-ajax--url": self.get_url(),
  431. "data-app-label": self.field.model._meta.app_label,
  432. "data-model-name": self.field.model._meta.model_name,
  433. "data-field-name": self.field.name,
  434. "data-theme": "admin-autocomplete",
  435. "data-allow-clear": json.dumps(not self.is_required),
  436. "data-placeholder": "", # Allows clearing of the input.
  437. "lang": self.i18n_name,
  438. "class": attrs["class"]
  439. + (" " if attrs["class"] else "")
  440. + "admin-autocomplete",
  441. }
  442. )
  443. return attrs
  444. def optgroups(self, name, value, attr=None):
  445. """Return selected options based on the ModelChoiceIterator."""
  446. default = (None, [], 0)
  447. groups = [default]
  448. has_selected = False
  449. selected_choices = {
  450. str(v) for v in value if str(v) not in self.choices.field.empty_values
  451. }
  452. if not self.is_required and not self.allow_multiple_selected:
  453. default[1].append(self.create_option(name, "", "", False, 0))
  454. remote_model_opts = self.field.remote_field.model._meta
  455. to_field_name = getattr(
  456. self.field.remote_field, "field_name", remote_model_opts.pk.attname
  457. )
  458. to_field_name = remote_model_opts.get_field(to_field_name).attname
  459. choices = (
  460. (getattr(obj, to_field_name), self.choices.field.label_from_instance(obj))
  461. for obj in self.choices.queryset.using(self.db).filter(
  462. **{"%s__in" % to_field_name: selected_choices}
  463. )
  464. )
  465. for option_value, option_label in choices:
  466. selected = str(option_value) in value and (
  467. has_selected is False or self.allow_multiple_selected
  468. )
  469. has_selected |= selected
  470. index = len(default[1])
  471. subgroup = default[1]
  472. subgroup.append(
  473. self.create_option(
  474. name, option_value, option_label, selected_choices, index
  475. )
  476. )
  477. return groups
  478. @property
  479. def media(self):
  480. extra = "" if settings.DEBUG else ".min"
  481. i18n_file = (
  482. ("admin/js/vendor/select2/i18n/%s.js" % self.i18n_name,)
  483. if self.i18n_name
  484. else ()
  485. )
  486. return forms.Media(
  487. js=(
  488. "admin/js/vendor/jquery/jquery%s.js" % extra,
  489. "admin/js/vendor/select2/select2.full%s.js" % extra,
  490. )
  491. + i18n_file
  492. + (
  493. "admin/js/jquery.init.js",
  494. "admin/js/autocomplete.js",
  495. ),
  496. css={
  497. "screen": (
  498. "admin/css/vendor/select2/select2%s.css" % extra,
  499. "admin/css/autocomplete.css",
  500. ),
  501. },
  502. )
  503. class AutocompleteSelect(AutocompleteMixin, forms.Select):
  504. pass
  505. class AutocompleteSelectMultiple(AutocompleteMixin, forms.SelectMultiple):
  506. pass