django-pudding 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 (68) hide show
  1. django_pudding-0.1.0.dist-info/METADATA +24 -0
  2. django_pudding-0.1.0.dist-info/RECORD +68 -0
  3. django_pudding-0.1.0.dist-info/WHEEL +4 -0
  4. pudding/__init__.py +0 -0
  5. pudding/actions/__init__.py +9 -0
  6. pudding/actions/base.py +119 -0
  7. pudding/actions/contracts.py +28 -0
  8. pudding/actions/defaults/__init__.py +3 -0
  9. pudding/actions/defaults/add.py +145 -0
  10. pudding/actions/defaults/delete.py +91 -0
  11. pudding/actions/defaults/edit.py +82 -0
  12. pudding/actions/defaults/utils.py +100 -0
  13. pudding/actions/defaults/view.py +76 -0
  14. pudding/actions/registry.py +63 -0
  15. pudding/actions/utils.py +2 -0
  16. pudding/apps.py +7 -0
  17. pudding/domains/__init__.py +4 -0
  18. pudding/domains/base.py +62 -0
  19. pudding/domains/registry.py +56 -0
  20. pudding/exceptions.py +10 -0
  21. pudding/forms.py +30 -0
  22. pudding/py.typed +0 -0
  23. pudding/static/pudding/css/styles.css +2105 -0
  24. pudding/static/pudding/js/bundle.min.js +1813 -0
  25. pudding/static/pudding/js/bundle.min.js.map +7 -0
  26. pudding/templates/pudding/base.html +56 -0
  27. pudding/templates/pudding/components/actions/dropdown.html +17 -0
  28. pudding/templates/pudding/components/form.html +30 -0
  29. pudding/templates/pudding/components/messages.html +41 -0
  30. pudding/templates/pudding/components/overlays/modal.html +35 -0
  31. pudding/templates/pudding/components/sidebar.html +21 -0
  32. pudding/templates/pudding/components/table/_table_empty_state.html +29 -0
  33. pudding/templates/pudding/components/table/header_checkbox.html +20 -0
  34. pudding/templates/pudding/components/table/pagination.html +47 -0
  35. pudding/templates/pudding/components/table/table.html +55 -0
  36. pudding/templates/pudding/components/table/table_cell.html +5 -0
  37. pudding/templates/pudding/components/table/table_controls.html +97 -0
  38. pudding/templates/pudding/components/table/table_row.html +54 -0
  39. pudding/templates/pudding/frames/modal_frame.html +62 -0
  40. pudding/templates/pudding/frames/view_frame.html +36 -0
  41. pudding/templates/pudding/table.html +15 -0
  42. pudding/templates/pudding/table_columns/profile.html +11 -0
  43. pudding/ui/__init__.py +52 -0
  44. pudding/ui/_types.py +14 -0
  45. pudding/ui/breadcrumbs.py +83 -0
  46. pudding/ui/buttons/__init__.py +18 -0
  47. pudding/ui/buttons/base.py +165 -0
  48. pudding/ui/buttons/looks.py +117 -0
  49. pudding/ui/buttons/wrappers/__init__.py +0 -0
  50. pudding/ui/buttons/wrappers/base.py +73 -0
  51. pudding/ui/buttons/wrappers/factories.py +90 -0
  52. pudding/ui/contracts.py +47 -0
  53. pudding/ui/frames/__init__.py +20 -0
  54. pudding/ui/frames/base.py +150 -0
  55. pudding/ui/frames/frame_response.py +77 -0
  56. pudding/ui/frames/modal.py +175 -0
  57. pudding/ui/hooks/__init__.py +3 -0
  58. pudding/ui/hooks/modal.py +17 -0
  59. pudding/ui/sidebar/sidebar.py +23 -0
  60. pudding/ui/tables/__init__.py +3 -0
  61. pudding/ui/tables/base.py +142 -0
  62. pudding/ui/tables/columns/base.py +73 -0
  63. pudding/ui/tables/columns/column_formatters.py +112 -0
  64. pudding/ui/tables/columns/column_renderers.py +114 -0
  65. pudding/ui/tables/table_row.py +61 -0
  66. pudding/ui/utils.py +86 -0
  67. pudding/urls.py +13 -0
  68. pudding/views.py +33 -0
@@ -0,0 +1,24 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-pudding
3
+ Version: 0.1.0
4
+ Summary: Declarative Python framework for Django to build dashboards and internal apps fast.
5
+ Author: Zishan Kadri
6
+ Author-email: Zishan Kadri <zishankadri01@gmail.com>
7
+ License-Expression: MIT
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Framework :: Django
10
+ Classifier: Framework :: Django :: 5.2
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
21
+ Requires-Dist: django>=5.2
22
+ Requires-Python: >=3.12
23
+ Description-Content-Type: text/markdown
24
+
@@ -0,0 +1,68 @@
1
+ pudding/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pudding/actions/__init__.py,sha256=sfLGmJPUltuj05bfWi3u9ZhWSSWerfMTSF373dtKFyU,409
3
+ pudding/actions/base.py,sha256=jNcT5dHMf9CX4f2pxaNAsTXWZFIKZCJFFYvJQk5Idjc,3463
4
+ pudding/actions/contracts.py,sha256=6Ez-Pep-UQpQUONE5bWfM05DiZ4K_Kf1BZf-17epM1Q,686
5
+ pudding/actions/defaults/__init__.py,sha256=8uWeFCnvcH9D6eDv1iKbbnYr3CTaha6g2vfvmBxIp9A,83
6
+ pudding/actions/defaults/add.py,sha256=Ldl53Cm03LhkhuGdWmpLVtGB5pIxjZghcw9ehsvA2bI,4695
7
+ pudding/actions/defaults/delete.py,sha256=sEzvq6hIDYMrafxsoZ5bTISZQJGVY-DqYeSS-TCTDAU,2779
8
+ pudding/actions/defaults/edit.py,sha256=qbqaP1iSsJGN42mREcfrpLNKVIK5jK4jtp--bU1_-Lg,2640
9
+ pudding/actions/defaults/utils.py,sha256=o8KhUB2UFLkUzdsMxEbjtOt338GDANiDawk6NpvVL5s,2958
10
+ pudding/actions/defaults/view.py,sha256=pxCp6o5N-tUfKvRT1T-clDqyDXFcN9DbWydes-hzrxU,2686
11
+ pudding/actions/registry.py,sha256=IGhx6IAZGlu0LbePTlhF_HtO_nDQ8nU7pWwW-MLFMeA,2114
12
+ pudding/actions/utils.py,sha256=YR3wEIl4b3t2wVtaffFgoJtbmrmCTNgHL5RSiNzRkO8,79
13
+ pudding/apps.py,sha256=00yy-yWffkc-3Ytb9dRVwG2ReoZ6-t_pmanWFKHPlaQ,168
14
+ pudding/domains/__init__.py,sha256=bn3AIzXmDLx-mhc551kG0eVYGsHja99xpyr0UFGByMM,152
15
+ pudding/domains/base.py,sha256=w-sUw8whYOx6aFKSRUtM3Jfx2-ziE7Hb0IT2QJ_wyi0,2022
16
+ pudding/domains/registry.py,sha256=vKfFNJgGVGmm0CXQ7Ow-OLvBsiJO-RzLZ7QV2dM-yg0,1743
17
+ pudding/exceptions.py,sha256=SIWNQ3V5uBz_gqbh7fYL-xgI0KoBSaU4VdN-XY4nl6Q,325
18
+ pudding/forms.py,sha256=UmAjuQqhAUMGTWw4nYVsEzsNaQ5yiniiaO-hbb0LotI,929
19
+ pudding/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ pudding/static/pudding/css/styles.css,sha256=cbos25luLxGCJwfqXbjWqiU04wieyrRYLH72fHGOJUU,60293
21
+ pudding/static/pudding/js/bundle.min.js,sha256=WxJEjnQZhLX-wAhZlqIxbLx7upQi5eF04HzWnlZfpzA,640704
22
+ pudding/static/pudding/js/bundle.min.js.map,sha256=SLgK7HacKuH9LQhXBTNzE_raPZ6O70hOZ3PLHfajbBM,3487076
23
+ pudding/templates/pudding/base.html,sha256=YgkCrglHBM1XRjWPRqNWpEdafhLzZwvl7pNOXUsXCzI,1697
24
+ pudding/templates/pudding/components/actions/dropdown.html,sha256=XWVUZqPvsFRju16nLpBJFVD6JP_lHjj5N-QQ64w2Ro8,544
25
+ pudding/templates/pudding/components/form.html,sha256=9A7LruNND2PnxquIPWyhxhXwMf-xv3PaRKfV-RoFdjY,1105
26
+ pudding/templates/pudding/components/messages.html,sha256=6649SPa-O4wTdIKBpn3Vaa0vCwu2c4Ks862Z-seg-OU,2162
27
+ pudding/templates/pudding/components/overlays/modal.html,sha256=2ooQRGc5udxxqxRDnM7ty5HiLXdaTzYm1ZRjiC8fAew,1142
28
+ pudding/templates/pudding/components/sidebar.html,sha256=T-UGUlW2JgWEOI8SG1aWJ8zHHdujeEk0TxtNUB4Olns,542
29
+ pudding/templates/pudding/components/table/_table_empty_state.html,sha256=b0j4hQeGUwamms1kJX7GU-koWFC24KG1r1I5sr2kCFM,1038
30
+ pudding/templates/pudding/components/table/header_checkbox.html,sha256=zH-UAPJLHD9s-Ib7Pb27udUzZjI_D8BZjEcTCGxgBLA,1255
31
+ pudding/templates/pudding/components/table/pagination.html,sha256=wv_McMLC2CJiRMWOzwB3sFZftjdQB8yrXuYX4_6nCxM,2473
32
+ pudding/templates/pudding/components/table/table.html,sha256=CA6Xm_FTGiz1c8LLB_KssaT4gJomPOrTHgI-0LmWOKQ,2246
33
+ pudding/templates/pudding/components/table/table_cell.html,sha256=4EAkA0HXoutzUyxoe0HnKX8q6JCESv9LJY0YNpS6lc0,202
34
+ pudding/templates/pudding/components/table/table_controls.html,sha256=aR2nqDw3PxR_O1XQ8xlC_iMDfLNev2vPLXgwlVCM8Vs,4199
35
+ pudding/templates/pudding/components/table/table_row.html,sha256=1s7NkjmzwRuXJAdh8ttRz5UXMWpDRKuynTfBss_VQ7o,2556
36
+ pudding/templates/pudding/frames/modal_frame.html,sha256=GuW_XhKK7iK9OKeMEdLu4nweWsJqEwHKc7hxWBxyNVw,2464
37
+ pudding/templates/pudding/frames/view_frame.html,sha256=NjSX7RMem6tAEYBUkS2RZgFRPszWzxSyBOzn4J4HrWI,1265
38
+ pudding/templates/pudding/table.html,sha256=KVzKWnjVWc63eP9nwK1HOZ9CwJbwdPekQ6uPzc3yBZ8,381
39
+ pudding/templates/pudding/table_columns/profile.html,sha256=iSKm2Cgk5BKW92pFWRnAMSJknFK3GbMdzQXkfNdX37Y,421
40
+ pudding/ui/__init__.py,sha256=e4yvBvm_31KKxluynb5yYrhmvF3ovbjCnAFu8b1GbEc,998
41
+ pudding/ui/_types.py,sha256=6Sju3bz4-RI71gqBEQLFWCbDTA3XU0to61VGqsoqVTk,252
42
+ pudding/ui/breadcrumbs.py,sha256=MZBb0liF0vY-lVh6YylXU-SGHWKaee3qwfII4d1btOc,2581
43
+ pudding/ui/buttons/__init__.py,sha256=qaSO0dzmk4yIuaKdaNtyNhMzj0OczPDkQLjTvZHBmFM,441
44
+ pudding/ui/buttons/base.py,sha256=5rjASdBOsLi-4d9FJ1adGsx2Em_MllODM2RobcNZGow,4671
45
+ pudding/ui/buttons/looks.py,sha256=1joQwgx0EG64E-uS7DTO2SWB-5vKn-WhBmc6k_Zb76Y,3598
46
+ pudding/ui/buttons/wrappers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
+ pudding/ui/buttons/wrappers/base.py,sha256=-k3PGO4qva7wG3FhB28aQ82BWIE8X3UeXi3Ap8dDq2o,2224
48
+ pudding/ui/buttons/wrappers/factories.py,sha256=_uD1O3HxyYIfd0zDIjSlW239eTGQJGAwYWTywjTsR88,2753
49
+ pudding/ui/contracts.py,sha256=gQNz0tJjvpAIiwh4Itt_-Wr_5joFBaYFao7_wyBdR8k,1223
50
+ pudding/ui/frames/__init__.py,sha256=Bpsj_5o0PwWh7mcf-qkiPQ2t1xZiy1RHCxmHyHyO7qw,399
51
+ pudding/ui/frames/base.py,sha256=_KonO2-nbAbBCeNm0hSzdCCbM8KmJpvLt6rBee3rIJ8,4928
52
+ pudding/ui/frames/frame_response.py,sha256=EoG73Dx1nOr8IfPuVMiJEtPaPuaJaBHhq759jnA1ZYM,2308
53
+ pudding/ui/frames/modal.py,sha256=fEHx0LcHbU_Z6ZiDY7nGyCuiTms9bGxpvsWuDWt7LoY,5998
54
+ pudding/ui/hooks/__init__.py,sha256=HnhAx2DmoeiXFsdKHllVQF5lBH3na0Ia8JwgHL8lVPE,72
55
+ pudding/ui/hooks/modal.py,sha256=ga1wnKL4dmQdKQs8-rjh_b99zEuVxAIsjIEa7XJnypY,471
56
+ pudding/ui/sidebar/sidebar.py,sha256=eYJa-PRLNExOI38PyBBY00Mq4rvbBXJ8bbdOwoDO6fs,644
57
+ pudding/ui/tables/__init__.py,sha256=cwcQjyrR448HL-5XNcH8e7azB_gAIegzNkECtv5_3iE,62
58
+ pudding/ui/tables/base.py,sha256=kd4Lr7oTpJp3GABdRAjtznpNHIH_2KL-nlbAOpw0LZ4,4673
59
+ pudding/ui/tables/columns/base.py,sha256=radRlAlR0l6QqR8pRfgKhZbcpWGVNJd4SUhuJdLTCRo,2120
60
+ pudding/ui/tables/columns/column_formatters.py,sha256=_lQKpg_JiaqU4KYZ8chIGdDSeIgmv1zofcDUdf6xeV8,3142
61
+ pudding/ui/tables/columns/column_renderers.py,sha256=vmsv-Z4rJiajdiu9eoJN35DxeFgVyoZt2M3wzJSmvLM,3297
62
+ pudding/ui/tables/table_row.py,sha256=7reENpnh3utH8lWXOBc4XBxuRiriesRjpfV1kwob6jE,1662
63
+ pudding/ui/utils.py,sha256=JOxaoKQyNGm2EMSkkCXPWiwObUNMY6PzSbPSbptN0Co,2409
64
+ pudding/urls.py,sha256=mx8zYFZ4B52wP2d5yDMV9YXcf_6FcQao1vlF3TzGitg,209
65
+ pudding/views.py,sha256=WPbdcx3eMMQ_BE7bFx3mp6wax-kOH61VpP82OjwAsds,1055
66
+ django_pudding-0.1.0.dist-info/WHEEL,sha256=EmLkUISDECbcUx3FMCYOqokNOJqNp2r0d4mJzjErvvs,80
67
+ django_pudding-0.1.0.dist-info/METADATA,sha256=VMt8qBzEphZxrUwWOtCqYki8Qpdmw3x5oYpNuqlCgoo,949
68
+ django_pudding-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
pudding/__init__.py ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ from pudding.actions.base import action_handler
2
+ from pudding.actions.contracts import Action
3
+ from pudding.actions.defaults.add import Add
4
+ from pudding.actions.defaults.delete import Delete
5
+ from pudding.actions.defaults.edit import Edit
6
+ from pudding.actions.defaults.view import View
7
+ from pudding.actions.utils import is_htmx
8
+
9
+ __all__ = ["Action", "Add", "Delete", "Edit", "View", "action_handler", "is_htmx"]
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ from django.http import HttpRequest, HttpResponse, HttpResponseNotAllowed
9
+
10
+ from pudding import ui
11
+ from pudding.actions.contracts import ActionHook, ActionStep
12
+
13
+
14
+ @dataclass(kw_only=True)
15
+ class InteractiveAction(ABC):
16
+ """
17
+ Base class for actions requiring user interaction (like confirmation or forms)
18
+ before the backend logic runs.
19
+ """
20
+
21
+ slug: str
22
+ icon: str | None = None
23
+ permission: Callable[[HttpRequest], bool] | None = None
24
+
25
+ def handle(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
26
+ match request.method:
27
+ case "GET":
28
+ return self.before(request, ctx)
29
+ case "POST":
30
+ return self.execute(request, ctx)
31
+ case _:
32
+ return HttpResponseNotAllowed(["GET", "POST"])
33
+
34
+ @abstractmethod
35
+ def before(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse: ...
36
+
37
+ @abstractmethod
38
+ def execute(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse: ...
39
+
40
+
41
+ @dataclass(kw_only=True)
42
+ class FunctionalAction:
43
+ slug: str
44
+ trigger: ui.Trigger
45
+ icon: str | None = None
46
+ permission: Callable[[HttpRequest], bool] | None = None
47
+
48
+ execute: ActionStep | None = None
49
+ before: ActionStep | None = None
50
+
51
+ allowed_methods: list[str] = field(init=False, repr=False)
52
+
53
+ def __post_init__(self):
54
+ allowed = []
55
+ if self.before:
56
+ allowed.append("GET")
57
+ if self.execute:
58
+ allowed.append("POST")
59
+
60
+ if not allowed:
61
+ raise ValueError("At least one of `execute` or `before` must be provided.")
62
+
63
+ self.allowed_methods = allowed
64
+
65
+ def handle(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
66
+ if request.method == "GET" and self.before:
67
+ return self.before(self, request, ctx)
68
+
69
+ if request.method == "POST" and self.execute:
70
+ return self.execute(self, request, ctx)
71
+
72
+ return HttpResponseNotAllowed(self.allowed_methods)
73
+
74
+ def __repr__(self):
75
+ return (
76
+ f"FunctionalAction("
77
+ f"slug={self.slug!r}, "
78
+ f"execute={getattr(self.execute, '__name__', None)!r}, "
79
+ f"before={getattr(self.before, '__name__', None)!r})"
80
+ )
81
+
82
+
83
+ def action_handler(
84
+ *,
85
+ slug: str,
86
+ label: str | None = None,
87
+ icon: str | None = None,
88
+ before: ActionHook | Callable | None = None,
89
+ permission: Callable[[HttpRequest], bool] | None = None,
90
+ ) -> Callable[[Callable], FunctionalAction]:
91
+ def decorator(func: Callable) -> FunctionalAction:
92
+ display_label = label or slug.replace("_", " ").replace("-", " ").capitalize()
93
+ trigger = ui.action(
94
+ slug,
95
+ label=display_label,
96
+ icon=icon,
97
+ )
98
+
99
+ before_func = None
100
+
101
+ if isinstance(before, ActionHook):
102
+ frame = before.frame
103
+ before_func = before.before
104
+ trigger = trigger(**frame.loading_mod)
105
+ elif callable(before):
106
+ before_func = before
107
+
108
+ action = FunctionalAction(
109
+ slug=slug,
110
+ icon=icon,
111
+ trigger=trigger,
112
+ execute=func,
113
+ before=before_func,
114
+ permission=permission,
115
+ )
116
+
117
+ return action
118
+
119
+ return decorator
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
6
+
7
+ if TYPE_CHECKING:
8
+ from pudding.ui.frames.base import Frame
9
+
10
+ from django.http import HttpRequest, HttpResponse
11
+
12
+
13
+ @runtime_checkable
14
+ class Action(Protocol):
15
+ slug: str
16
+ icon: str | None
17
+ permission: Callable[[HttpRequest], bool] | None
18
+
19
+ def handle(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse: ...
20
+
21
+
22
+ type ActionStep = Callable[[Action, HttpRequest, dict[str, Any]], HttpResponse]
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class ActionHook:
27
+ frame: Frame
28
+ before: ActionStep
@@ -0,0 +1,3 @@
1
+ from . import add, delete, edit, view
2
+
3
+ __all__ = ["add", "delete", "edit", "view"]
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from django.contrib import messages
7
+ from django.http import HttpRequest, HttpResponse
8
+ from django.shortcuts import render
9
+ from django.template.loader import render_to_string
10
+ from django.urls import reverse
11
+ from django.utils.html import format_html
12
+ from django.utils.translation import gettext_lazy as _
13
+
14
+ from pudding import actions, ui
15
+ from pudding.actions.base import InteractiveAction
16
+ from pudding.actions.defaults.utils import (
17
+ build_sidebar_from_domains,
18
+ get_form_class,
19
+ )
20
+
21
+
22
+ def get_content(self, form, request, ctx):
23
+ submit_btn = ui.action(
24
+ self.slug,
25
+ label=_("Save"),
26
+ method="post",
27
+ look=ui.looks.button(variant="solid"),
28
+ )
29
+
30
+ form_id = "modal-form"
31
+ validity_guard = (
32
+ f"const f = document.getElementById('{form_id}');"
33
+ " if (!f.checkValidity()) { event.preventDefault(); f.reportValidity(); }"
34
+ )
35
+ submit_btn = submit_btn(
36
+ attrs={
37
+ "form": form_id,
38
+ "hx-on::before-request": validity_guard,
39
+ "hx-encoding": "multipart/form-data",
40
+ }
41
+ )
42
+ form_html = render_to_string(
43
+ "pudding/components/form.html",
44
+ {"form": form, "form_id": form_id},
45
+ request=request,
46
+ )
47
+
48
+ submit_btn = submit_btn.render(ctx=ctx)
49
+
50
+ return form_html, submit_btn
51
+
52
+
53
+ class Add(InteractiveAction):
54
+ def __init__(
55
+ self,
56
+ slug: str = "add",
57
+ icon: str | None = "plus",
58
+ modal_frame: ui.Frame = ui.modal_frame,
59
+ **kwargs,
60
+ ):
61
+ self.modal_frame = modal_frame
62
+ super().__init__(slug=slug, icon=icon, **kwargs)
63
+
64
+ def before(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
65
+ domain = ctx["domain"]
66
+ model = domain.model
67
+
68
+ pks = request.POST.getlist("pks[]") or request.GET.getlist("pks[]")
69
+
70
+ FormClass = get_form_class(model=model)
71
+
72
+ base_label = _(model._meta.verbose_name_plural).title()
73
+ breadcrumbs = ui.Breadcrumbs(
74
+ children=tuple(
75
+ [
76
+ ui.action("view", label=base_label, icon=domain.icon),
77
+ ui.BreadcrumbLabel(label=_("Add"), icon="plus"),
78
+ ]
79
+ )
80
+ ).render(request=request, ctx=ctx)
81
+
82
+ form_html, submit_btn = get_content(
83
+ self, form=FormClass(), request=request, ctx=ctx
84
+ )
85
+ header_triggers = format_html(
86
+ '<div hx-vals="{ids}" class="flex gap-2 items-center"> {submit}</div>',
87
+ ids=json.dumps({"pks[]": pks}),
88
+ submit=submit_btn,
89
+ )
90
+
91
+ if actions.is_htmx(request):
92
+ return ui.ship(
93
+ ui.view_frame.fill(form_html),
94
+ ui.breadcrumb_frame.fill(breadcrumbs),
95
+ ui.header_triggers_frame.fill(header_triggers),
96
+ push_action_url={"action_slug": "add", "domain_slug": domain.slug},
97
+ )
98
+
99
+ context = ctx | {
100
+ "sidebar": build_sidebar_from_domains(ctx=ctx),
101
+ "breadcrumbs": ui.breadcrumb_frame(default_content=breadcrumbs).render(
102
+ request=request
103
+ ),
104
+ "view_area": ui.view_frame(default_content=form_html).render(
105
+ request=request
106
+ ),
107
+ "modal_frame": self.modal_frame.render(request=request),
108
+ "header_actions": ui.header_triggers_frame(
109
+ default_content=header_triggers
110
+ ).render(request=request),
111
+ }
112
+ response = render(request, "pudding/table.html", context)
113
+ return response
114
+
115
+ def execute(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
116
+ domain = ctx["domain"]
117
+ model = domain.model
118
+
119
+ FormClass = get_form_class(model=model)
120
+
121
+ form = FormClass(request.POST, request.FILES)
122
+ if form.is_valid():
123
+ form.save()
124
+ messages.success(
125
+ request,
126
+ _("{name} created successfully!").format(
127
+ name=_(model._meta.verbose_name.capitalize())
128
+ ),
129
+ )
130
+ response = HttpResponse(status=204)
131
+ response["HX-Redirect"] = reverse(
132
+ "pudding:run_action",
133
+ kwargs={
134
+ "domain_slug": domain.slug,
135
+ "action_slug": "view",
136
+ },
137
+ )
138
+ return response
139
+ else:
140
+ form_html, submit_btn = get_content(
141
+ self, form=form, request=request, ctx=ctx
142
+ )
143
+ return ui.ship(
144
+ ui.view_frame.fill(form_html), ui.header_triggers_frame.fill(submit_btn)
145
+ )
@@ -0,0 +1,91 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.contrib import messages
6
+ from django.http import Http404, HttpRequest, HttpResponse
7
+ from django.urls import reverse
8
+ from django.utils.translation import gettext_lazy as _
9
+ from django.utils.translation import ngettext
10
+
11
+ from pudding import ui
12
+ from pudding.actions.base import InteractiveAction
13
+ from pudding.ui.frames.base import Frame
14
+
15
+
16
+ class Delete(InteractiveAction):
17
+ def __init__(
18
+ self,
19
+ slug: str = "delete",
20
+ icon: str | None = "trash-2",
21
+ modal_frame: Frame = ui.modal_frame,
22
+ *args,
23
+ **kwargs,
24
+ ):
25
+ self.modal_frame = modal_frame
26
+ super().__init__(slug=slug, icon=icon, *args, **kwargs)
27
+
28
+ def before(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
29
+ domain = ctx["domain"]
30
+ model = domain.model
31
+
32
+ opts = model._meta
33
+ queryset = ctx["queryset"]
34
+ count = queryset.count()
35
+ if count == 0:
36
+ raise Http404(_("No items selected for deletion."))
37
+
38
+ if count == 1:
39
+ title = str(queryset.first())
40
+ else:
41
+ title = ngettext(
42
+ "{count} {model_name}", "{count} {model_name_plural}", count
43
+ ).format(
44
+ count=count,
45
+ model_name=opts.verbose_name,
46
+ model_name_plural=opts.verbose_name_plural,
47
+ )
48
+ return ui.ship(
49
+ self.modal_frame.fill(
50
+ ui.ActionModal(
51
+ action=self,
52
+ eyebrow=_("Delete"),
53
+ title=title,
54
+ button=lambda btn: btn(look=ui.looks.button(variant="danger")),
55
+ icon_color="danger",
56
+ ).render(ctx=ctx)
57
+ )
58
+ )
59
+
60
+ def execute(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
61
+ model = ctx["domain"].model
62
+
63
+ opts = model._meta
64
+ queryset = ctx["queryset"]
65
+ count = queryset.count()
66
+ if count == 0:
67
+ raise Http404(_("No items selected for deletion."))
68
+
69
+ queryset.delete()
70
+
71
+ success_message = ngettext(
72
+ "Deleted {count} {model_name}.",
73
+ "Deleted {count} {model_name_plural}.",
74
+ count,
75
+ ).format(
76
+ count=count,
77
+ model_name=opts.verbose_name,
78
+ model_name_plural=opts.verbose_name_plural,
79
+ )
80
+
81
+ messages.success(request, success_message)
82
+ response = HttpResponse()
83
+ response["HX-Redirect"] = reverse(
84
+ "pudding:run_action",
85
+ kwargs={
86
+ "domain_slug": ctx["domain"].slug,
87
+ "action_slug": "view",
88
+ "step": "init",
89
+ },
90
+ )
91
+ return response
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from django.contrib import messages
6
+ from django.http import HttpRequest, HttpResponse
7
+ from django.urls import reverse
8
+ from django.utils.translation import gettext_lazy as _
9
+
10
+ from pudding import ui
11
+ from pudding.actions.base import InteractiveAction
12
+ from pudding.actions.defaults.utils import get_form_class
13
+
14
+
15
+ class Edit(InteractiveAction):
16
+ def __init__(
17
+ self,
18
+ slug: str = "edit",
19
+ icon: str | None = "pen-line",
20
+ fields: list[str] | None = None,
21
+ exclude: list[str] | None = None,
22
+ dock_actions: list[ui.Trigger] | None = None,
23
+ modal_frame: ui.Frame = ui.modal_frame,
24
+ **kwargs,
25
+ ):
26
+ self.fields = fields
27
+ self.exclude = exclude
28
+ self.dock_actions: list[ui.Trigger] = dock_actions or []
29
+ self.modal_frame = modal_frame
30
+
31
+ super().__init__(slug=slug, icon=icon, **kwargs)
32
+
33
+ def before(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
34
+ domain = ctx["domain"]
35
+ model = domain.model
36
+
37
+ FormClass = get_form_class(model=model)
38
+
39
+ instance = ctx["queryset"][0] if ctx["queryset"] else None
40
+ form = FormClass(instance=instance)
41
+
42
+ return ui.ship(
43
+ self.modal_frame.fill(
44
+ ui.ActionModal(
45
+ action=self, form=form, icon=None, title=f"Edit {instance}"
46
+ ).render(ctx=ctx)
47
+ )
48
+ )
49
+
50
+ def execute(self, request: HttpRequest, ctx: dict[str, Any]) -> HttpResponse:
51
+ domain = ctx["domain"]
52
+ model = domain.model
53
+
54
+ FormClass = get_form_class(model=model)
55
+
56
+ instance = ctx["queryset"][0] if ctx["queryset"] else None
57
+ form = FormClass(request.POST, request.FILES, instance=instance)
58
+ if form.is_valid():
59
+ form.save()
60
+ messages.success(
61
+ request,
62
+ _("{name} edited successfully!").format(
63
+ name=_(model._meta.verbose_name.capitalize())
64
+ ),
65
+ )
66
+ response = HttpResponse(status=204)
67
+ response["HX-Redirect"] = reverse(
68
+ "pudding:run_action",
69
+ kwargs={
70
+ "domain_slug": domain.slug,
71
+ "action_slug": "view",
72
+ },
73
+ )
74
+ return response
75
+ else:
76
+ return ui.ship(
77
+ self.modal_frame.fill(
78
+ ui.ActionModal(
79
+ action=self, form=form, icon=None, title=f"Edit {instance}"
80
+ ).render(ctx=ctx)
81
+ )
82
+ )
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any
4
+
5
+ if TYPE_CHECKING:
6
+ from django.db import models
7
+ from django.http import HttpRequest
8
+
9
+ from pudding.ui import Trigger
10
+
11
+ from django import forms
12
+ from django.db import models
13
+ from django.forms import modelform_factory
14
+ from django.utils.html import format_html_join
15
+
16
+ from pudding import domains, ui
17
+ from pudding.forms import StyledModelForm
18
+
19
+
20
+ def formfield_for_dbfield(db_field, **kwargs):
21
+ """Return a form field with widgets based on the model field type"""
22
+ # Make a copy of kwargs because modelform_factory passes extra args
23
+ formfield_kwargs = kwargs.copy()
24
+
25
+ if isinstance(db_field, models.DateTimeField):
26
+ formfield_kwargs["widget"] = forms.DateTimeInput(
27
+ attrs={"type": "datetime-local"},
28
+ format="%Y-%m-%dT%H:%M",
29
+ )
30
+ elif isinstance(db_field, models.DateField):
31
+ formfield_kwargs["widget"] = forms.DateInput(
32
+ attrs={"type": "date"},
33
+ format="%Y-%m-%d",
34
+ )
35
+ elif isinstance(db_field, models.TextField):
36
+ formfield_kwargs["widget"] = forms.Textarea()
37
+
38
+ formfield = db_field.formfield(**formfield_kwargs)
39
+
40
+ # Ensure initial value formatting works
41
+ if isinstance(db_field, (models.DateTimeField, models.DateField)):
42
+ formfield.input_formats = [formfield.widget.format]
43
+
44
+ return db_field.formfield(**formfield_kwargs)
45
+
46
+
47
+ def get_editable_fields(model, fields=None):
48
+ """Return fields suitable for ModelForm, excluding non-editable fields"""
49
+ all_fields = fields if fields else [f.name for f in model._meta.fields]
50
+ editable_fields = [f for f in all_fields if model._meta.get_field(f).editable]
51
+
52
+ return editable_fields
53
+
54
+
55
+ def get_form_class(
56
+ *,
57
+ model: type[models.Model],
58
+ fields=None,
59
+ exclude=None,
60
+ form_class: type[forms.ModelForm] = StyledModelForm,
61
+ widgets=None,
62
+ ) -> type[forms.ModelForm]:
63
+ fields = get_editable_fields(model, fields)
64
+
65
+ return modelform_factory(
66
+ model,
67
+ form=form_class,
68
+ fields=fields,
69
+ exclude=exclude,
70
+ widgets=widgets,
71
+ formfield_callback=formfield_for_dbfield,
72
+ )
73
+
74
+
75
+ def render_actions(
76
+ actions: list[Trigger], request: HttpRequest | None, ctx: dict
77
+ ) -> str:
78
+ actions_html = format_html_join(
79
+ "",
80
+ "{}",
81
+ ((action.render(request=request, ctx=ctx),) for action in actions),
82
+ )
83
+ return actions_html
84
+
85
+
86
+ def build_sidebar_from_domains(ctx: dict[str, Any]):
87
+ sidebar_items = []
88
+ for slug, domain_class in domains.get_all().items():
89
+ item = ui.action(
90
+ "view",
91
+ label=slug,
92
+ icon=getattr(domain_class, "icon", None),
93
+ look=ui.looks.sidebar_item(activation_id=slug),
94
+ ).with_local_context(domain=domain_class)
95
+
96
+ sidebar_items.append(item)
97
+
98
+ return ui.sidebar(
99
+ ctx=ctx, sidebar_items=sidebar_items, active_id=ctx["domain"].slug
100
+ )