df_site 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. df_site/__init__.py +1 -0
  2. df_site/__main__.py +37 -0
  3. df_site/admin.py +130 -0
  4. df_site/apps.py +57 -0
  5. df_site/components/__init__.py +1 -0
  6. df_site/components/base.py +82 -0
  7. df_site/components/detail.py +191 -0
  8. df_site/components/list.py +446 -0
  9. df_site/components/list_filters.py +74 -0
  10. df_site/components/registry.py +55 -0
  11. df_site/constants.py +71 -0
  12. df_site/context_processors.py +61 -0
  13. df_site/defaults.py +319 -0
  14. df_site/dynamic_settings.py +37 -0
  15. df_site/form_fields.py +138 -0
  16. df_site/management/__init__.py +1 -0
  17. df_site/management/commands/__init__.py +1 -0
  18. df_site/management/commands/add_image.py +104 -0
  19. df_site/management/commands/generate_favicon.py +47 -0
  20. df_site/middleware.py +20 -0
  21. df_site/migrations/0001_initial.py +220 -0
  22. df_site/migrations/0002_alter_alertribbon_message_alter_alertribbon_summary.py +23 -0
  23. df_site/migrations/__init__.py +0 -0
  24. df_site/model_fields.py +35 -0
  25. df_site/models.py +130 -0
  26. df_site/postman/__init__.py +1 -0
  27. df_site/postman/forms.py +38 -0
  28. df_site/postman/urls.py +75 -0
  29. df_site/postman/views.py +65 -0
  30. df_site/static/css/app.css +0 -0
  31. df_site/static/css/base.css +22208 -0
  32. df_site/static/css/ckeditor5.css +422 -0
  33. df_site/static/favicon/android-chrome-192x192.png +0 -0
  34. df_site/static/favicon/android-chrome-512x512.png +0 -0
  35. df_site/static/favicon/apple-touch-icon.png +0 -0
  36. df_site/static/favicon/favicon-16x16.png +0 -0
  37. df_site/static/favicon/favicon-32x32.png +0 -0
  38. df_site/static/favicon/favicon.ico +0 -0
  39. df_site/static/favicon/mstile-150x150.png +0 -0
  40. df_site/static/favicon/safari-pinned-tab.svg +46 -0
  41. df_site/static/images/accessibility.svg +1 -0
  42. df_site/static/images/align-bottom.svg +1 -0
  43. df_site/static/images/align-center.svg +1 -0
  44. df_site/static/images/align-justify.svg +1 -0
  45. df_site/static/images/align-left.svg +1 -0
  46. df_site/static/images/align-middle.svg +1 -0
  47. df_site/static/images/align-right.svg +1 -0
  48. df_site/static/images/align-top.svg +1 -0
  49. df_site/static/images/bold.svg +1 -0
  50. df_site/static/images/browse-files.svg +1 -0
  51. df_site/static/images/bulletedlist.svg +1 -0
  52. df_site/static/images/cancel.svg +1 -0
  53. df_site/static/images/caption.svg +1 -0
  54. df_site/static/images/check.svg +1 -0
  55. df_site/static/images/code.svg +1 -0
  56. df_site/static/images/codeblock.svg +1 -0
  57. df_site/static/images/cog.svg +1 -0
  58. df_site/static/images/color-palette.svg +1 -0
  59. df_site/static/images/color-tile-check.svg +1 -0
  60. df_site/static/images/drag-handle.svg +1 -0
  61. df_site/static/images/drag-indicator.svg +1 -0
  62. df_site/static/images/dropdown-arrow.svg +1 -0
  63. df_site/static/images/eraser.svg +1 -0
  64. df_site/static/images/file-arrow-up-solid.svg +1 -0
  65. df_site/static/images/find-replace.svg +1 -0
  66. df_site/static/images/font-background.svg +1 -0
  67. df_site/static/images/font-color.svg +1 -0
  68. df_site/static/images/font-family.svg +1 -0
  69. df_site/static/images/font-size.svg +1 -0
  70. df_site/static/images/heading1.svg +1 -0
  71. df_site/static/images/heading2.svg +1 -0
  72. df_site/static/images/heading3.svg +1 -0
  73. df_site/static/images/heading4.svg +1 -0
  74. df_site/static/images/heading5.svg +1 -0
  75. df_site/static/images/heading6.svg +1 -0
  76. df_site/static/images/history.svg +1 -0
  77. df_site/static/images/horizontalline.svg +1 -0
  78. df_site/static/images/html.svg +1 -0
  79. df_site/static/images/image-asset-manager.svg +1 -0
  80. df_site/static/images/image-upload.svg +1 -0
  81. df_site/static/images/image-url.svg +1 -0
  82. df_site/static/images/image.svg +1 -0
  83. df_site/static/images/importexport.svg +1 -0
  84. df_site/static/images/indent.svg +1 -0
  85. df_site/static/images/italic.svg +1 -0
  86. df_site/static/images/link.svg +1 -0
  87. df_site/static/images/liststylecircle.svg +1 -0
  88. df_site/static/images/liststyledecimal.svg +1 -0
  89. df_site/static/images/liststyledecimalleadingzero.svg +1 -0
  90. df_site/static/images/liststyledisc.svg +1 -0
  91. df_site/static/images/liststylelowerlatin.svg +1 -0
  92. df_site/static/images/liststylelowerroman.svg +1 -0
  93. df_site/static/images/liststylesquare.svg +1 -0
  94. df_site/static/images/liststyleupperlatin.svg +1 -0
  95. df_site/static/images/liststyleupperroman.svg +1 -0
  96. df_site/static/images/loupe.svg +1 -0
  97. df_site/static/images/low-vision.svg +1 -0
  98. df_site/static/images/marker.svg +1 -0
  99. df_site/static/images/media-placeholder.svg +1 -0
  100. df_site/static/images/media.svg +1 -0
  101. df_site/static/images/next-arrow.svg +1 -0
  102. df_site/static/images/numberedlist.svg +1 -0
  103. df_site/static/images/object-center.svg +1 -0
  104. df_site/static/images/object-full-width.svg +1 -0
  105. df_site/static/images/object-inline-left.svg +1 -0
  106. df_site/static/images/object-inline-right.svg +1 -0
  107. df_site/static/images/object-inline.svg +1 -0
  108. df_site/static/images/object-left.svg +1 -0
  109. df_site/static/images/object-right.svg +1 -0
  110. df_site/static/images/object-size-custom.svg +1 -0
  111. df_site/static/images/object-size-full.svg +1 -0
  112. df_site/static/images/object-size-large.svg +1 -0
  113. df_site/static/images/object-size-medium.svg +1 -0
  114. df_site/static/images/object-size-small.svg +1 -0
  115. df_site/static/images/outdent.svg +1 -0
  116. df_site/static/images/paragraph.svg +1 -0
  117. df_site/static/images/pen.svg +1 -0
  118. df_site/static/images/pencil.svg +1 -0
  119. df_site/static/images/pilcrow.svg +1 -0
  120. df_site/static/images/plus.svg +1 -0
  121. df_site/static/images/previous-arrow.svg +1 -0
  122. df_site/static/images/project-logo.svg +1 -0
  123. df_site/static/images/quote.svg +1 -0
  124. df_site/static/images/redo.svg +1 -0
  125. df_site/static/images/remove-format.svg +1 -0
  126. df_site/static/images/return-arrow.svg +1 -0
  127. df_site/static/images/select-all.svg +1 -0
  128. df_site/static/images/show-blocks.svg +1 -0
  129. df_site/static/images/source-editing.svg +1 -0
  130. df_site/static/images/specialcharacters.svg +1 -0
  131. df_site/static/images/strikethrough.svg +1 -0
  132. df_site/static/images/subscript.svg +1 -0
  133. df_site/static/images/superscript.svg +1 -0
  134. df_site/static/images/table-cell-properties.svg +1 -0
  135. df_site/static/images/table-column.svg +1 -0
  136. df_site/static/images/table-merge-cell.svg +1 -0
  137. df_site/static/images/table-properties.svg +1 -0
  138. df_site/static/images/table-row.svg +1 -0
  139. df_site/static/images/table.svg +1 -0
  140. df_site/static/images/text-alternative.svg +1 -0
  141. df_site/static/images/text.svg +1 -0
  142. df_site/static/images/three-vertical-dots.svg +1 -0
  143. df_site/static/images/todolist.svg +1 -0
  144. df_site/static/images/underline.svg +1 -0
  145. df_site/static/images/undo.svg +1 -0
  146. df_site/static/images/unlink.svg +1 -0
  147. df_site/static/js/app.js +98 -0
  148. df_site/static/js/app.js.map +1 -0
  149. df_site/static/js/base.js +161181 -0
  150. df_site/static/js/base.js.map +1 -0
  151. df_site/static/translations/af.js +1 -0
  152. df_site/static/translations/ar.js +1 -0
  153. df_site/static/translations/ast.js +1 -0
  154. df_site/static/translations/az.js +1 -0
  155. df_site/static/translations/bg.js +1 -0
  156. df_site/static/translations/bn.js +1 -0
  157. df_site/static/translations/bs.js +1 -0
  158. df_site/static/translations/ca.js +1 -0
  159. df_site/static/translations/cs.js +1 -0
  160. df_site/static/translations/da.js +1 -0
  161. df_site/static/translations/de-ch.js +1 -0
  162. df_site/static/translations/de.js +1 -0
  163. df_site/static/translations/el.js +1 -0
  164. df_site/static/translations/en-au.js +1 -0
  165. df_site/static/translations/en-gb.js +1 -0
  166. df_site/static/translations/en.js +1 -0
  167. df_site/static/translations/eo.js +1 -0
  168. df_site/static/translations/es-co.js +1 -0
  169. df_site/static/translations/es.js +1 -0
  170. df_site/static/translations/et.js +1 -0
  171. df_site/static/translations/eu.js +1 -0
  172. df_site/static/translations/fa.js +1 -0
  173. df_site/static/translations/fi.js +1 -0
  174. df_site/static/translations/gl.js +1 -0
  175. df_site/static/translations/gu.js +1 -0
  176. df_site/static/translations/he.js +1 -0
  177. df_site/static/translations/hi.js +1 -0
  178. df_site/static/translations/hr.js +1 -0
  179. df_site/static/translations/hu.js +1 -0
  180. df_site/static/translations/hy.js +1 -0
  181. df_site/static/translations/id.js +1 -0
  182. df_site/static/translations/it.js +1 -0
  183. df_site/static/translations/ja.js +1 -0
  184. df_site/static/translations/jv.js +1 -0
  185. df_site/static/translations/kk.js +1 -0
  186. df_site/static/translations/km.js +1 -0
  187. df_site/static/translations/kn.js +1 -0
  188. df_site/static/translations/ko.js +1 -0
  189. df_site/static/translations/ku.js +1 -0
  190. df_site/static/translations/lt.js +1 -0
  191. df_site/static/translations/lv.js +1 -0
  192. df_site/static/translations/ms.js +1 -0
  193. df_site/static/translations/nb.js +1 -0
  194. df_site/static/translations/ne.js +1 -0
  195. df_site/static/translations/nl.js +1 -0
  196. df_site/static/translations/no.js +1 -0
  197. df_site/static/translations/oc.js +1 -0
  198. df_site/static/translations/pl.js +1 -0
  199. df_site/static/translations/pt-br.js +1 -0
  200. df_site/static/translations/pt.js +1 -0
  201. df_site/static/translations/ro.js +1 -0
  202. df_site/static/translations/ru.js +1 -0
  203. df_site/static/translations/si.js +1 -0
  204. df_site/static/translations/sk.js +1 -0
  205. df_site/static/translations/sl.js +1 -0
  206. df_site/static/translations/sq.js +1 -0
  207. df_site/static/translations/sr-latn.js +1 -0
  208. df_site/static/translations/sr.js +1 -0
  209. df_site/static/translations/sv.js +1 -0
  210. df_site/static/translations/th.js +1 -0
  211. df_site/static/translations/ti.js +1 -0
  212. df_site/static/translations/tk.js +1 -0
  213. df_site/static/translations/tr.js +1 -0
  214. df_site/static/translations/tt.js +1 -0
  215. df_site/static/translations/ug.js +1 -0
  216. df_site/static/translations/uk.js +1 -0
  217. df_site/static/translations/ur.js +1 -0
  218. df_site/static/translations/uz.js +1 -0
  219. df_site/static/translations/vi.js +1 -0
  220. df_site/static/translations/zh-cn.js +1 -0
  221. df_site/static/translations/zh.js +1 -0
  222. df_site/static/webfonts/fa-brands-400.ttf +0 -0
  223. df_site/static/webfonts/fa-brands-400.woff2 +0 -0
  224. df_site/static/webfonts/fa-regular-400.ttf +0 -0
  225. df_site/static/webfonts/fa-regular-400.woff2 +0 -0
  226. df_site/static/webfonts/fa-solid-900.ttf +0 -0
  227. df_site/static/webfonts/fa-solid-900.woff2 +0 -0
  228. df_site/static/webfonts/fa-v4compatibility.ttf +0 -0
  229. df_site/static/webfonts/fa-v4compatibility.woff2 +0 -0
  230. df_site/templates/account/email.html +78 -0
  231. df_site/templates/account/password_change.html +28 -0
  232. df_site/templates/account/snippets/warn_no_email.html +6 -0
  233. df_site/templates/allauth/elements/alert.html +6 -0
  234. df_site/templates/allauth/elements/badge.html +4 -0
  235. df_site/templates/allauth/elements/button.html +14 -0
  236. df_site/templates/allauth/elements/button_group.html +5 -0
  237. df_site/templates/allauth/elements/field.html +72 -0
  238. df_site/templates/allauth/elements/fields.html +3 -0
  239. df_site/templates/allauth/elements/form.html +10 -0
  240. df_site/templates/allauth/elements/h1.html +1 -0
  241. df_site/templates/allauth/elements/h2.html +1 -0
  242. df_site/templates/allauth/elements/img.html +4 -0
  243. df_site/templates/allauth/elements/p.html +1 -0
  244. df_site/templates/allauth/elements/panel.html +14 -0
  245. df_site/templates/allauth/elements/provider.html +6 -0
  246. df_site/templates/allauth/elements/provider_list.html +5 -0
  247. df_site/templates/allauth/elements/table.html +5 -0
  248. df_site/templates/allauth/layouts/base.html +14 -0
  249. df_site/templates/allauth/layouts/entrance.html +20 -0
  250. df_site/templates/allauth/layouts/manage.html +1 -0
  251. df_site/templates/cookie_consent/_cookie_group.html +64 -0
  252. df_site/templates/cookie_consent/cookiegroup_list.html +23 -0
  253. df_site/templates/df_components/base.html +0 -0
  254. df_site/templates/df_components/detail.html +12 -0
  255. df_site/templates/df_components/detail_fieldset.html +46 -0
  256. df_site/templates/df_components/list.html +42 -0
  257. df_site/templates/df_components/list_filter.html +13 -0
  258. df_site/templates/df_components/list_filters.html +36 -0
  259. df_site/templates/df_components/list_hierarchy.html +25 -0
  260. df_site/templates/df_components/list_pagination.html +39 -0
  261. df_site/templates/df_components/list_search_form.html +38 -0
  262. df_site/templates/df_components/list_table.html +35 -0
  263. df_site/templates/df_site/app.html +1 -0
  264. df_site/templates/df_site/base.html +221 -0
  265. df_site/templates/df_site/detail.html +8 -0
  266. df_site/templates/df_site/humans.txt +11 -0
  267. df_site/templates/df_site/manage_base.html +51 -0
  268. df_site/templates/df_site/popup_app.html +1 -0
  269. df_site/templates/df_site/popup_base.html +29 -0
  270. df_site/templates/df_site/security.txt +5 -0
  271. df_site/templates/django_bootstrap5/breadcrumb.html +17 -0
  272. df_site/templates/django_bootstrap5/pagination.html +40 -0
  273. df_site/templates/django_ckeditor_5/widget.html +13 -0
  274. df_site/templates/favicon/browserconfig.xml +9 -0
  275. df_site/templates/mfa/index.html +115 -0
  276. df_site/templates/mfa/recovery_codes/index.html +33 -0
  277. df_site/templates/mfa/webauthn/authenticator_list.html +74 -0
  278. df_site/templates/pipeline/css.html +1 -0
  279. df_site/templates/pipeline/js.html +1 -0
  280. df_site/templates/postman/archives.html +8 -0
  281. df_site/templates/postman/base.html +20 -0
  282. df_site/templates/postman/base_folder.html +71 -0
  283. df_site/templates/postman/base_write.html +26 -0
  284. df_site/templates/postman/inbox.html +7 -0
  285. df_site/templates/postman/inc_subject_ex.html +21 -0
  286. df_site/templates/postman/trash.html +12 -0
  287. df_site/templates/postman/view.html +64 -0
  288. df_site/templates/users/settings.html +26 -0
  289. df_site/templates/usersessions/usersession_list.html +70 -0
  290. df_site/templatetags/__init__.py +1 -0
  291. df_site/templatetags/df_site.py +241 -0
  292. df_site/templatetags/images.py +515 -0
  293. df_site/templatetags/pipeline_sri.py +97 -0
  294. df_site/testing/__init__.py +1 -0
  295. df_site/testing/multiple_views.py +369 -0
  296. df_site/testing/requests.py +299 -0
  297. df_site/urls.py +41 -0
  298. df_site/user_settings.py +69 -0
  299. df_site/users/__init__.py +1 -0
  300. df_site/users/forms.py +35 -0
  301. df_site/users/notifications.py +14 -0
  302. df_site/users/urls.py +17 -0
  303. df_site/users/views.py +75 -0
  304. df_site/views.py +122 -0
  305. df_site-0.1.0.dist-info/LICENSE +519 -0
  306. df_site-0.1.0.dist-info/METADATA +217 -0
  307. df_site-0.1.0.dist-info/RECORD +309 -0
  308. df_site-0.1.0.dist-info/WHEEL +4 -0
  309. df_site-0.1.0.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,369 @@
1
+ """A generic class testing several views with different users.
2
+
3
+ The expected response can by defined by the response code, a HttpResponse, an exception or a callable.
4
+ In these case, a RequestTester will be built with this value as first argument.
5
+ Otherwise, you can also provide a RequestTester (especially to provide GET or POST arguments).
6
+ """
7
+
8
+ import logging
9
+ from typing import Dict, List, Optional, Tuple, Type, TypeAlias, Union
10
+ from urllib.parse import urlencode
11
+
12
+ from django.conf import settings
13
+ from django.contrib import admin
14
+ from django.contrib.admin.sites import site as default_site
15
+ from django.contrib.auth import get_user_model
16
+ from django.contrib.auth.models import AbstractUser, AnonymousUser
17
+ from django.contrib.messages.middleware import MessageMiddleware
18
+ from django.contrib.sessions.middleware import SessionMiddleware
19
+ from django.core.exceptions import PermissionDenied
20
+ from django.db import models
21
+ from django.http import Http404, HttpResponse, SimpleCookie
22
+ from django.middleware.csrf import get_token
23
+ from django.test import RequestFactory, TestCase
24
+ from django.urls import URLPattern
25
+ from django.urls.resolvers import RoutePattern
26
+ from django.utils.encoding import iri_to_uri
27
+
28
+ from df_site.testing.requests import RequestTester
29
+
30
+ logger = logging.getLogger(__name__)
31
+ ExpectedResponse: TypeAlias = Union[int, Exception, Type[Exception], HttpResponse, callable, RequestTester]
32
+ ExpectedResponses = Union[List[ExpectedResponse], ExpectedResponse]
33
+
34
+
35
+ class TestMultipleViews(TestCase):
36
+ """Define some methods for testing a set of views with different users (identified by its username).
37
+
38
+ `expected_responses` is a dict such that:
39
+ - the key is the view name
40
+ - the value is a dict such that:
41
+ - the key is a USER_KEY (can be anything, like its privilege level)
42
+ - the value is any of:
43
+ - an integer (the expected HTTP status code — then this is the only checked thing)
44
+ - an exception (only the type of the raised exception is checked)
45
+ - a HttpResponse (only status_codes are checked)
46
+ - a callable that will be given the TestCase and the HttpResponse as arguments
47
+ - a RequestTester to deeply customize the test (including the request args/kwargs)
48
+ - a list of any of the previous values
49
+
50
+ You must override `get_user_keys` to provide a list of values to create different users.
51
+ (`str(value)` is used to get the username)
52
+ Your test_* methods must call
53
+ check_url_patterns(url_patterns, common_kwargs, expected_responses, display_prefix=display_prefix)
54
+ """
55
+
56
+ views_test_counter = 0
57
+ call_test_counter = 0
58
+
59
+ @classmethod
60
+ def setUpClass(cls):
61
+ """Create some users and a request factory for testing."""
62
+ super().setUpClass()
63
+ # Every test needs access to the request factory.
64
+ cls.request_factory = RequestFactory()
65
+ cls.created_users: List[AbstractUser] = cls.create_users()
66
+
67
+ def setUp(self):
68
+ """Counts the number of run tests."""
69
+ TestMultipleViews.call_test_counter += 1
70
+ return super().setUp()
71
+
72
+ @classmethod
73
+ def get_users(cls) -> List[AbstractUser]:
74
+ """Teturn a list of values to identify different users.
75
+
76
+ The special value `None` is dedicated to AnonymousUser.
77
+ """
78
+ return cls.created_users
79
+
80
+ @classmethod
81
+ def create_users(cls):
82
+ """Create some users for testing."""
83
+ return [
84
+ AnonymousUser(),
85
+ cls.create_user("staff", is_staff=True),
86
+ cls.create_user("admin", is_staff=True, is_superuser=True),
87
+ ]
88
+
89
+ @classmethod
90
+ def create_user(cls, name, is_active=True, **kwargs):
91
+ """Create a user with the provided name."""
92
+ user = get_user_model().objects.filter(username=name).first()
93
+ if user is not None:
94
+ return user
95
+ user_kwargs = {
96
+ "email": f"{name}@test.com",
97
+ "is_active": is_active,
98
+ }
99
+ user_kwargs.update(kwargs)
100
+ return get_user_model().objects.create_user(username=name, **user_kwargs)
101
+
102
+ def create_request(
103
+ self,
104
+ url,
105
+ *args,
106
+ method=None,
107
+ user=None,
108
+ form_data=None,
109
+ get_data=None,
110
+ post_data=None,
111
+ **kwargs,
112
+ ):
113
+ """Return a request object for the provided URL."""
114
+ if form_data:
115
+ kwargs["data"] = urlencode(form_data)
116
+ kwargs["content_type"] = "application/x-www-form-urlencoded"
117
+ if method:
118
+ method = method.lower()
119
+ if method and method in ("get", "head") and get_data:
120
+ kwargs["data"] = get_data
121
+ elif get_data:
122
+ kwargs["QUERY_STRING"] = urlencode(get_data, doseq=True)
123
+ if post_data:
124
+ kwargs["data"] = post_data
125
+ method = method or "post"
126
+ request = self.request_factory.generic(method.upper(), url, *args, **kwargs)
127
+ else:
128
+ method = method or "get"
129
+ request = getattr(self.request_factory, method)(url, *args, **kwargs)
130
+ request.user = user or AnonymousUser()
131
+ request.cookies = SimpleCookie()
132
+ setattr(request, "_dont_enforce_csrf_checks", True)
133
+ request.cookies["csrftoken"] = get_token(request)
134
+
135
+ def get_response():
136
+ pass
137
+
138
+ SessionMiddleware(get_response).process_request(request)
139
+ MessageMiddleware(get_response).process_request(request)
140
+ return request
141
+
142
+ def check_url_patterns(
143
+ self,
144
+ url_patterns: List[Tuple[str, URLPattern]],
145
+ common_kwargs: Dict[str, str],
146
+ expected_responses: Dict[str, Dict[Optional[str], ExpectedResponses]],
147
+ display_prefix: str = "",
148
+ ):
149
+ """Check all provided URL patterns.
150
+
151
+ :param url_patterns: list of ("namespace:", URLPattern) (leave "" if no namespace)
152
+ :param common_kwargs: kwargs common to all URL patterns
153
+ (use `expected_responses` to provided kwargs specific to a URL pattern)
154
+ :param expected_responses:
155
+ :param display_prefix: prefix added to displayed error messages
156
+ :return:
157
+ """
158
+ failures = [] # list of (url_pattern, ps, exception)
159
+ for namespace, url_pattern in url_patterns:
160
+ if url_pattern.name:
161
+ view_name = f"{namespace}{url_pattern.name}"
162
+ else:
163
+ view_name = None
164
+ url_name = self.format_url_pattern(url_pattern)
165
+ view = url_pattern.callback
166
+ provided_view_kwargs = url_pattern.default_args
167
+ # "kwargs" argument provided to path()
168
+ if isinstance(url_pattern.pattern, RoutePattern):
169
+ arg_names = list(url_pattern.pattern.converters.keys())
170
+ else:
171
+ arg_names = list(url_pattern.pattern.regex.groupindex)
172
+ reverse_kwargs = {k: common_kwargs[k] for k in arg_names if k in common_kwargs}
173
+ for user in self.get_users():
174
+ username = None if user.is_anonymous else user.username
175
+ request_testers_: ExpectedResponses = expected_responses.get(url_name, {}).get(username, Http404)
176
+ if not isinstance(request_testers_, list):
177
+ request_testers: List[ExpectedResponse] = [request_testers_]
178
+ else:
179
+ request_testers: List[ExpectedResponse] = request_testers_
180
+ for request_tester in request_testers:
181
+ if not isinstance(request_tester, RequestTester):
182
+ request_tester = RequestTester(request_tester)
183
+ # actual view kwargs are the kwargs provided in the get_urls()
184
+ self.__class__.views_test_counter += 1
185
+ exc = request_tester.evaluate(
186
+ self,
187
+ view_name,
188
+ view,
189
+ reverse_kwargs,
190
+ provided_view_kwargs,
191
+ user,
192
+ )
193
+ if exc is not None:
194
+ failed_url = request_tester.request.path + (
195
+ "?" + iri_to_uri(request_tester.request.META.get("QUERY_STRING", ""))
196
+ if request_tester.request.META.get("QUERY_STRING", "")
197
+ else ""
198
+ )
199
+ failure = (
200
+ url_pattern,
201
+ username,
202
+ exc,
203
+ failed_url,
204
+ )
205
+ failures.append(failure)
206
+ for url_pattern, username, e, url in failures:
207
+ pattern = self.format_url_pattern(url_pattern)
208
+ if username is None:
209
+ username = "AnonymousUser"
210
+ else:
211
+ username = f"user {username}"
212
+ base_url = settings.SERVER_BASE_URL[:-1]
213
+ msg = f"{display_prefix}Exception in {pattern} with {username}: {e} ({base_url}{url})."
214
+ print(msg)
215
+ self.assertEqual(0, len(failures))
216
+
217
+ @staticmethod
218
+ def format_url_pattern(url_pattern) -> str:
219
+ """Return a string representation of the provided URL pattern."""
220
+ if url_pattern.name:
221
+ return url_pattern.name
222
+ if isinstance(url_pattern.pattern, RoutePattern):
223
+ return url_pattern.pattern._route
224
+ return url_pattern.pattern._regex
225
+
226
+
227
+ class TestModelAdmin(TestMultipleViews):
228
+ """Define some methods for testing *all* views defined in the get_urls() of a ModelAdmin.
229
+
230
+ Each view is tested with different users.
231
+ . A RequestTester define more precisely how to test a given view with a given user.
232
+
233
+ Many of these views require some kwargs for the `reverse` function, but most of them have always the same meaning.
234
+ So, we always reuse the same kwargs for all views (but we can override them in a RequestTester).
235
+
236
+ `expected_responses` is a dict such that:
237
+ - the key is the view name
238
+ - the value is a dict such that:
239
+ - the key is a username (or None for AnonymousUser)
240
+ - the value is any of:
241
+ - an integer (the expected HTTP status code — then this is the only checked thing)
242
+ - an exception (only the type of the raised exception is checked)
243
+ - a HttpResponse (only status_codes are checked)
244
+ - a callable that will be given the TestCase and the HttpResponse as arguments
245
+ - a RequestTester to deeply customize the test (including the request args/kwargs)
246
+ - a list of any of the previous values
247
+
248
+ """
249
+
250
+ checked_model_admins = set()
251
+ placeholders = "%(app_label)s_%(model_name)s_"
252
+ expected_responses: Dict[str, Dict[Optional[str], ExpectedResponses]] = {
253
+ f"{placeholders}changelist": {
254
+ None: 302,
255
+ "staff": PermissionDenied,
256
+ "admin": 200,
257
+ },
258
+ f"{placeholders}add": {
259
+ None: 302,
260
+ "staff": PermissionDenied,
261
+ "admin": 200,
262
+ },
263
+ f"{placeholders}history": {
264
+ None: 302,
265
+ "staff": PermissionDenied,
266
+ "admin": 200,
267
+ },
268
+ f"{placeholders}delete": {
269
+ None: 302,
270
+ "staff": PermissionDenied,
271
+ "admin": 200,
272
+ },
273
+ f"{placeholders}change": {
274
+ None: 302,
275
+ "staff": PermissionDenied,
276
+ "admin": 200,
277
+ },
278
+ "<path:object_id>/": {
279
+ None: 302,
280
+ "staff": 302,
281
+ "admin": 302,
282
+ },
283
+ }
284
+
285
+ # expected_responses["view_name"]["staff"] = 200
286
+ # %(app_label)s and %(model_name)s can
287
+ # be used in the view names to avoid repeating the same values.
288
+ def check_model_admin(
289
+ self,
290
+ model_admin: Union[Type[admin.ModelAdmin], Type[models.Model]],
291
+ common_kwargs: Dict[str, str],
292
+ display_prefix: str = "",
293
+ expected_responses: Dict[str, Dict[Optional[str], ExpectedResponses]] = None,
294
+ ):
295
+ """Check all admin views.
296
+
297
+ :param model_admin: the models.Model or admin.ModelAdmin to check
298
+ :param common_kwargs: dict of values for reversing URLs
299
+ :param display_prefix: prefix to display in front of each error
300
+ :param expected_responses: extra responses, only for this ModelAdmin
301
+ (same format as self.expected_responses)
302
+ :return:
303
+ """
304
+ if isinstance(model_admin, type) and issubclass(model_admin, models.Model):
305
+ model_admin = default_site.get_model_admin(model_admin)
306
+
307
+ TestModelAdmin.checked_model_admins.add(model_admin)
308
+ base_expected_responses = self.get_expected_responses(model_admin, raw_expected_responses=expected_responses)
309
+ # with app_label="app", model_name="model", if we have both
310
+ # "%(app_label)s_%(model_name)s_viewname" and "app_model_viewname" keys in raw_expected_responses
311
+ # we do not want to override the initial values.
312
+ url_patterns = self.get_admin_urls(model_admin)
313
+ self.check_url_patterns(
314
+ url_patterns,
315
+ common_kwargs,
316
+ base_expected_responses,
317
+ display_prefix=display_prefix,
318
+ )
319
+
320
+ def get_expected_responses(
321
+ self,
322
+ model_admin: admin.ModelAdmin,
323
+ raw_expected_responses: Dict[str, Dict[Optional[str], ExpectedResponses]] = None,
324
+ ) -> Dict[str, Dict[Optional[str], ExpectedResponses]]:
325
+ """Return the expected responses for this model_admin.
326
+
327
+ Replace %(app_label)s and %(model_name)s placeholders in the view names and use
328
+ them when these view names are missing from the expected_responses.
329
+ """
330
+ base_expected_responses = {}
331
+ base_expected_responses.update(self.expected_responses)
332
+ if raw_expected_responses is not None:
333
+ base_expected_responses.update(raw_expected_responses)
334
+
335
+ opts = {
336
+ "app_label": model_admin.model._meta.app_label,
337
+ "model_name": model_admin.model._meta.model_name,
338
+ }
339
+ expected_responses = {url_name % opts: v for (url_name, v) in base_expected_responses.items() if url_name}
340
+ expected_responses.update(base_expected_responses)
341
+ return expected_responses
342
+
343
+ # noinspection PyMethodMayBeStatic
344
+ def get_admin_urls(self, model_admin: admin.ModelAdmin) -> List[Tuple[str, URLPattern]]:
345
+ """Return the list of all URLs in this model_admin."""
346
+ return [("admin:", x) for x in model_admin.get_urls()]
347
+
348
+
349
+ class TestModel(TestModelAdmin):
350
+ """Define some methods for testing all views defined in the get_urls() of a ModelAdmin."""
351
+
352
+ def get_object(self):
353
+ """Return an object of the model to test."""
354
+ raise NotImplementedError("You must override this method to return an object of the model to test.")
355
+
356
+ def test_model_admin(self):
357
+ """Test all views of the model admin correspoding to the provided object."""
358
+ try:
359
+ obj = self.get_object()
360
+ except NotImplementedError as e:
361
+ self.skipTest(str(e))
362
+ if obj._state.adding:
363
+ obj.save()
364
+ common_kwargs = self.get_common_kwargs_for_object(obj)
365
+ self.check_model_admin(obj.__class__, common_kwargs=common_kwargs, display_prefix="")
366
+
367
+ def get_common_kwargs_for_object(self, obj) -> Dict[str, str]:
368
+ """Return the common kwargs for all views of the provided object."""
369
+ return {"object_id": str(obj.pk)}
@@ -0,0 +1,299 @@
1
+ """Check if a response from a Django view meets the expectations.
2
+
3
+ These expectations can be as low as a simple HTTP code.
4
+ """
5
+
6
+ import json
7
+ import traceback
8
+ from html.parser import HTMLParser
9
+ from typing import Callable, Dict, List, Optional, Set, Tuple, Type, Union
10
+ from unittest import TestCase
11
+
12
+ from django.contrib.auth.models import AbstractUser
13
+ from django.core.exceptions import PermissionDenied
14
+ from django.core.files.uploadedfile import SimpleUploadedFile
15
+ from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
16
+ from django.template.response import TemplateResponse
17
+ from django.urls import reverse
18
+
19
+
20
+ class FormHTMLParser(HTMLParser):
21
+ """Fetch all field names in a HTML document."""
22
+
23
+ def __init__(
24
+ self,
25
+ form_id: Optional[str] = None,
26
+ convert_charrefs=True,
27
+ ignored_fields: Optional[Set[str]] = None,
28
+ ignored_types: Optional[Set[str]] = None,
29
+ ):
30
+ """Initialize the parser."""
31
+ super().__init__(convert_charrefs=convert_charrefs)
32
+ self.form_id = form_id
33
+ self.form_fields: Set[str] = set()
34
+ self.is_in_selected_form = False
35
+ if ignored_fields is None:
36
+ ignored_fields = {"csrfmiddlewaretoken"}
37
+ if ignored_types is None:
38
+ ignored_types = {"submit", "reset", "button", "image"}
39
+ self.ignored_fields = ignored_fields
40
+ self.ignored_types = ignored_types
41
+
42
+ def reset(self, form_id=None):
43
+ """Reset the parser."""
44
+ super().reset()
45
+ self.form_fields: Set[str] = set()
46
+ self.is_in_selected_form = False
47
+
48
+ def handle_starttag(self, tag: str, attrs: List[Tuple[str, str]]):
49
+ """Check if we are in the form and if we look at a field."""
50
+ attrs = dict(attrs)
51
+ if tag == "form" and (self.form_id is None or attrs.get("id") == self.form_id):
52
+ self.is_in_selected_form = True
53
+ if self.is_in_selected_form and tag in {"input", "select", "textarea"} and "name" in attrs:
54
+ if attrs["name"] in self.ignored_fields:
55
+ return
56
+ elif tag == "input" and attrs.get("type") in self.ignored_types:
57
+ return
58
+ self.form_fields.add(attrs["name"])
59
+
60
+ def handle_endtag(self, tag):
61
+ """Check if we are still in the form."""
62
+ if tag == "form":
63
+ self.is_in_selected_form = False
64
+
65
+ def error(self, message):
66
+ """Does nothing when there is an error."""
67
+ pass
68
+
69
+
70
+ class RequestTester:
71
+ """Check if the response of a Django view meets the expectation."""
72
+
73
+ def __init__(
74
+ self,
75
+ expected_response: Union[int, Exception, Type[Exception], HttpResponse, callable] = 200,
76
+ method="get",
77
+ form_data: Union[Dict[str, str], Callable] = None,
78
+ get_data: Union[Dict[str, str], Callable] = None,
79
+ post_data: Union[bytes, Dict[str, str], Callable] = None,
80
+ files_data: Dict[str, SimpleUploadedFile] = None,
81
+ raise_error: bool = False,
82
+ reverse_kwargs: Union[Dict, Callable] = None,
83
+ request_kwargs: Dict = None,
84
+ view_kwargs: Union[Dict, Callable] = None,
85
+ validators=None,
86
+ headers: Optional[Dict[str, str]] = None,
87
+ **response_attr,
88
+ ):
89
+ """Initialize the object."""
90
+ self.method = method.lower()
91
+ self.form_data = form_data
92
+ self.headers = headers or {}
93
+ self.post_data = post_data
94
+ self.files_data = files_data or {}
95
+ self.expected_response = expected_response
96
+ self.raise_error = raise_error
97
+ self.request_kwargs = request_kwargs or {} # extra kwargs for building the HttpRequest
98
+ self.reverse_kwargs = (
99
+ reverse_kwargs or {}
100
+ ) # extra kwargs provided to the callable and to the `reverse` function
101
+ self.view_kwargs = view_kwargs or {} # extra kwargs provided to the callabble
102
+ self.get_data = get_data or {} # provide the GET QueryDict of the created HttpRequest
103
+ self.validators = validators or [] # list of callable(testCase, HttpResponse)
104
+ self.response_attr = response_attr # extra response attrs
105
+ self.request = None
106
+
107
+ @staticmethod
108
+ def callable_value(value, test_case: TestCase, complete_url_name: str, user: AbstractUser):
109
+ """Call the callable if possible, otherwise return the value."""
110
+ if callable(value):
111
+ return value(test_case, complete_url_name, user)
112
+ return value
113
+
114
+ # noinspection PyUnusedLocal
115
+ def get_request_kwargs(self, test_case: TestCase, complete_url_name: str, user: AbstractUser) -> Dict:
116
+ """Build all kwargs provided to the request factory."""
117
+ merged_request_kwargs = {}
118
+ merged_request_kwargs.update(self.request_kwargs)
119
+ if self.form_data:
120
+ merged_request_kwargs["form_data"] = self.callable_value(self.form_data, test_case, complete_url_name, user)
121
+ if self.get_data:
122
+ merged_request_kwargs["get_data"] = self.callable_value(self.get_data, test_case, complete_url_name, user)
123
+ if self.post_data:
124
+ merged_request_kwargs["post_data"] = self.callable_value(self.post_data, test_case, complete_url_name, user)
125
+ if self.headers:
126
+ merged_request_kwargs["headers"] = self.headers
127
+ return merged_request_kwargs
128
+
129
+ def get_request_method(self, test_case: TestCase, complete_url_name: str, user: AbstractUser) -> str:
130
+ """Return the HTTP method."""
131
+ return self.callable_value(self.method, test_case, complete_url_name, user)
132
+
133
+ def get_view_kwargs(self, test_case: TestCase, complete_url_name: str, user: AbstractUser) -> Dict:
134
+ """Return extra kwargs provided to the view."""
135
+ return self.callable_value(self.view_kwargs, test_case, complete_url_name, user)
136
+
137
+ def get_reverse_kwargs(self, test_case: TestCase, complete_url_name: str, user: AbstractUser) -> Dict:
138
+ """Return the kwargs used for reversing the URL."""
139
+ return self.callable_value(self.reverse_kwargs, test_case, complete_url_name, user)
140
+
141
+ def get_http_request(self, test_case, view_name: str, reverse_kwargs: Dict, user: AbstractUser) -> HttpRequest:
142
+ """Return the HttpRequest."""
143
+ if view_name:
144
+ url = reverse(view_name, kwargs=reverse_kwargs)
145
+ else:
146
+ url = "/unnamed/view"
147
+ method = self.get_request_method(test_case, view_name, user)
148
+ request_kwargs = self.get_request_kwargs(test_case, view_name, user)
149
+ request = test_case.create_request(url, method=method, **request_kwargs)
150
+ if self.files_data:
151
+ request.FILES.update(self.files_data)
152
+ request.user = user
153
+ return request
154
+
155
+ def evaluate(
156
+ self,
157
+ test_case,
158
+ view_name: str,
159
+ view: callable,
160
+ reverse_kwargs: Dict,
161
+ view_kwargs: Dict,
162
+ user: AbstractUser,
163
+ ) -> Optional[Exception]:
164
+ """Evaluate a single view with the provided reverse kwargs and view kwargs.
165
+
166
+ :param test_case: the test case evaluating this function (maybe some attributes are required)
167
+ :param view_name: the name of the tested view
168
+ :param view: the tested view itself
169
+ :param reverse_kwargs: kwargs used for the `reverse` function (updated by `get_reverse_kwargs`)
170
+ :param view_kwargs: extra args passed to the view (updated by `get_view_kwargs`)
171
+ :param user: the tested permission set
172
+ """
173
+ merged_reverse_kwargs = {}
174
+ merged_reverse_kwargs.update(reverse_kwargs)
175
+ merged_reverse_kwargs.update(self.get_reverse_kwargs(test_case, view_name, user))
176
+
177
+ request = self.get_http_request(test_case, view_name, merged_reverse_kwargs, user)
178
+ self.request = request
179
+
180
+ merged_view_kwargs = merged_reverse_kwargs
181
+ merged_view_kwargs.update(view_kwargs)
182
+ merged_view_kwargs.update(self.get_view_kwargs(test_case, view_name, user))
183
+ if self.raise_error:
184
+ self.check_response(test_case, request, view, merged_view_kwargs, user)
185
+ else:
186
+ try:
187
+ self.check_response(test_case, request, view, merged_view_kwargs, user)
188
+ except Exception as e:
189
+ if not isinstance(e, PermissionDenied) and not isinstance(e, Http404):
190
+ traceback.print_exc()
191
+ return e
192
+
193
+ def check_response(
194
+ self,
195
+ test_case: TestCase,
196
+ request: HttpRequest,
197
+ view: callable,
198
+ view_kwargs: Dict,
199
+ user: AbstractUser,
200
+ ):
201
+ """Check if the response meets the expectation."""
202
+ expected_response = self.expected_response
203
+ # noinspection PyUnusedLocal
204
+ user = user
205
+ if isinstance(expected_response, type) and issubclass(expected_response, Exception):
206
+ test_case.assertRaises(expected_response, lambda: view(request, **view_kwargs))
207
+ return
208
+ http_response = view(request, **view_kwargs)
209
+ if isinstance(http_response, TemplateResponse):
210
+ http_response.render()
211
+ for validator in self.validators:
212
+ validator(test_case, http_response)
213
+ if isinstance(expected_response, int):
214
+ if expected_response != http_response.status_code:
215
+ # do not use test_case.assertEqual to have a more explicit error
216
+ msg = f"HTTP {http_response.status_code} (while expecting {expected_response})"
217
+ msg += self.get_description(request)
218
+ raise AssertionError(msg)
219
+ elif isinstance(expected_response, HttpResponse):
220
+ if expected_response.status_code != http_response.status_code:
221
+ # do not use test_case.assertEqual to have a more explicit error
222
+ msg = f"HTTP {http_response.status_code} (while expecting {expected_response.status_code})"
223
+ msg += self.get_description(request)
224
+ raise AssertionError(msg)
225
+ test_case.assertIsInstance(http_response, expected_response.__class__)
226
+ elif callable(expected_response):
227
+ expected_response(test_case, http_response)
228
+ else:
229
+ raise ValueError(f"unknown type of expected response: {expected_response!r}")
230
+ for attr_name, attr_value in self.response_attr.items():
231
+ if isinstance(attr_value, type):
232
+ test_case.assertIsInstance(attr_value, getattr(http_response, attr_name))
233
+ else:
234
+ test_case.assertEqual(attr_value, getattr(http_response, attr_name))
235
+
236
+ def get_description(self, request: HttpRequest) -> str:
237
+ """Return a string representation of a RequestTester."""
238
+ msg = f" url = {request.path}"
239
+ if self.method != "get":
240
+ msg += f" method={self.method}"
241
+ if self.headers:
242
+ msg += f" headers={self.headers}"
243
+ if self.post_data:
244
+ post_data = str(self.post_data)[:30]
245
+ msg += f" post_data={post_data}"
246
+ if self.form_data or self.get_data:
247
+ post_data = str(self.form_data)[:30]
248
+ msg += f" form_data={post_data}"
249
+ if self.get_data:
250
+ post_data = str(self.get_data)[:30]
251
+ msg += f" get_data={post_data}"
252
+ return msg
253
+
254
+
255
+ class HTMLFormRequestTester(RequestTester):
256
+ """Validate a form in the HTML response, for example to check if required fields are present."""
257
+
258
+ def __init__(
259
+ self,
260
+ required_fields: Set[str],
261
+ *args,
262
+ form_id: Optional[str] = None,
263
+ ignored_fields: Optional[Set[str]] = None,
264
+ ignored_types: Optional[Set[str]] = None,
265
+ **kwargs,
266
+ ):
267
+ """Initialize the object with expected values."""
268
+ self.required_fields = required_fields
269
+ kwargs.setdefault("validators", [])
270
+ kwargs["validators"].append(self.check_required_fields)
271
+ self.form_id = form_id
272
+ self.ignored_fields = ignored_fields
273
+ self.ignored_types = ignored_types
274
+ super().__init__(*args, **kwargs)
275
+
276
+ def check_required_fields(self, test_case: TestCase, response: HttpResponse):
277
+ """Check if the response contains the expected form values."""
278
+ parser = FormHTMLParser(
279
+ form_id=self.form_id,
280
+ ignored_types=self.ignored_types,
281
+ ignored_fields=self.ignored_fields,
282
+ )
283
+ parser.feed(response.content.decode())
284
+ test_case.assertEqual(self.required_fields, parser.form_fields)
285
+
286
+
287
+ class JsonValidator:
288
+ """Validate a JSONResponse."""
289
+
290
+ def __init__(self, value):
291
+ """Initialize the object with the expected JSON."""
292
+ self.value = value
293
+
294
+ def __call__(self, test_case: TestCase, response: JsonResponse):
295
+ """Check the content of a JsonResponse."""
296
+ content = json.loads(response.content)
297
+ if self.value != content:
298
+ print(content)
299
+ test_case.assertEqual(self.value, content)