Funktionierender Prototyp des Serious Games zur Vermittlung von Wissen zu Software-Engineering-Arbeitsmodellen.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

widgets.py 19KB

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