codex-django-cli 0.2.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 (212) hide show
  1. codex_django_cli/__init__.py +12 -0
  2. codex_django_cli/blueprints/apps/default/__init__.py +0 -0
  3. codex_django_cli/blueprints/apps/default/admin/__init__.py +0 -0
  4. codex_django_cli/blueprints/apps/default/apps.py.j2 +0 -0
  5. codex_django_cli/blueprints/apps/default/forms/__init__.py +0 -0
  6. codex_django_cli/blueprints/apps/default/models/__init__.py +0 -0
  7. codex_django_cli/blueprints/apps/default/modules/__init__.py +0 -0
  8. codex_django_cli/blueprints/apps/default/selector/__init__.py +0 -0
  9. codex_django_cli/blueprints/apps/default/services/__init__.py +0 -0
  10. codex_django_cli/blueprints/apps/default/tests/__init__.py +0 -0
  11. codex_django_cli/blueprints/apps/default/translations.py.j2 +0 -0
  12. codex_django_cli/blueprints/apps/default/urls.py.j2 +0 -0
  13. codex_django_cli/blueprints/apps/default/views/__init__.py +0 -0
  14. codex_django_cli/blueprints/deploy/shared/.dockerignore.j2 +24 -0
  15. codex_django_cli/blueprints/deploy/shared/.env.example.j2 +64 -0
  16. codex_django_cli/blueprints/deploy/shared/backend/Dockerfile.j2 +36 -0
  17. codex_django_cli/blueprints/deploy/shared/backend/entrypoint.sh.j2 +20 -0
  18. codex_django_cli/blueprints/deploy/shared/nginx/Dockerfile.local.j2 +14 -0
  19. codex_django_cli/blueprints/deploy/shared/nginx/nginx-main.conf.j2 +67 -0
  20. codex_django_cli/blueprints/deploy/shared/nginx/site-local.conf.j2 +66 -0
  21. codex_django_cli/blueprints/deploy/shared/worker/Dockerfile.j2 +25 -0
  22. codex_django_cli/blueprints/deploy/stack/docker/base.Dockerfile.j2 +38 -0
  23. codex_django_cli/blueprints/deploy/stack/docker/django.Dockerfile.j2 +27 -0
  24. codex_django_cli/blueprints/deploy/stack/docker/entrypoint.sh.j2 +19 -0
  25. codex_django_cli/blueprints/deploy/stack/docker-compose.apps.yml.j2 +41 -0
  26. codex_django_cli/blueprints/deploy/stack/docker-compose.infra.yml.j2 +80 -0
  27. codex_django_cli/blueprints/deploy/stack/docker-compose.local.apps.yml.j2 +41 -0
  28. codex_django_cli/blueprints/deploy/stack/docker-compose.local.infra.yml.j2 +94 -0
  29. codex_django_cli/blueprints/deploy/stack/docker-compose.test.yml.j2 +63 -0
  30. codex_django_cli/blueprints/deploy/stack/nginx/Dockerfile.j2 +12 -0
  31. codex_django_cli/blueprints/deploy/stack/nginx/conf.d/default.conf.j2 +68 -0
  32. codex_django_cli/blueprints/deploy/stack/nginx/site.conf.template.j2 +81 -0
  33. codex_django_cli/blueprints/deploy/stack_workflows/ci-develop.yml.j2 +42 -0
  34. codex_django_cli/blueprints/deploy/stack_workflows/ci-main.yml.j2 +165 -0
  35. codex_django_cli/blueprints/deploy/stack_workflows/deploy-cluster.yml.j2 +112 -0
  36. codex_django_cli/blueprints/deploy/standalone/docker-compose.prod.yml.j2 +118 -0
  37. codex_django_cli/blueprints/deploy/standalone/docker-compose.test.yml.j2 +86 -0
  38. codex_django_cli/blueprints/deploy/standalone/docker-compose.yml.j2 +151 -0
  39. codex_django_cli/blueprints/deploy/standalone/nginx/Dockerfile.j2 +18 -0
  40. codex_django_cli/blueprints/deploy/standalone/nginx/site.conf.template.j2 +168 -0
  41. codex_django_cli/blueprints/deploy/standalone_workflows/ci-develop.yml.j2 +42 -0
  42. codex_django_cli/blueprints/deploy/standalone_workflows/ci-main.yml.j2 +113 -0
  43. codex_django_cli/blueprints/deploy/standalone_workflows/deploy-production-tag.yml.j2 +211 -0
  44. codex_django_cli/blueprints/features/booking/booking/__init__.py.j2 +0 -0
  45. codex_django_cli/blueprints/features/booking/booking/admin.py.j2 +52 -0
  46. codex_django_cli/blueprints/features/booking/booking/apps.py.j2 +8 -0
  47. codex_django_cli/blueprints/features/booking/booking/models.py.j2 +142 -0
  48. codex_django_cli/blueprints/features/booking/booking/selectors.py.j2 +102 -0
  49. codex_django_cli/blueprints/features/booking/booking/urls.py.j2 +12 -0
  50. codex_django_cli/blueprints/features/booking/booking/views.py.j2 +174 -0
  51. codex_django_cli/blueprints/features/booking/booking/wiki.md.j2 +142 -0
  52. codex_django_cli/blueprints/features/booking/cabinet/templates/cabinet/booking/my_bookings.html +249 -0
  53. codex_django_cli/blueprints/features/booking/cabinet/views/booking.py.j2 +70 -0
  54. codex_django_cli/blueprints/features/booking/system/admin/booking_settings.py.j2 +31 -0
  55. codex_django_cli/blueprints/features/booking/system/models/booking_settings.py.j2 +7 -0
  56. codex_django_cli/blueprints/features/booking/templates/booking/booking_page.html +137 -0
  57. codex_django_cli/blueprints/features/booking/templates/booking/partials/step_confirm.html +143 -0
  58. codex_django_cli/blueprints/features/booking/templates/booking/partials/step_date.html +184 -0
  59. codex_django_cli/blueprints/features/booking/templates/booking/partials/step_service.html +78 -0
  60. codex_django_cli/blueprints/features/booking/templates/booking/partials/step_time.html +89 -0
  61. codex_django_cli/blueprints/features/client_cabinet/cabinet/adapters.py.j2 +21 -0
  62. codex_django_cli/blueprints/features/client_cabinet/cabinet/templates/cabinet/client/my_appointments.html +58 -0
  63. codex_django_cli/blueprints/features/client_cabinet/cabinet/templates/cabinet/client/profile.html +107 -0
  64. codex_django_cli/blueprints/features/client_cabinet/cabinet/templates/cabinet/client/settings.html +106 -0
  65. codex_django_cli/blueprints/features/client_cabinet/cabinet/templates/cabinet/client/settings_notifications.html +106 -0
  66. codex_django_cli/blueprints/features/client_cabinet/cabinet/templates/cabinet/client/settings_privacy.html +135 -0
  67. codex_django_cli/blueprints/features/client_cabinet/cabinet/views/client.py.j2 +49 -0
  68. codex_django_cli/blueprints/features/client_cabinet/system/models/user_profile.py.j2 +22 -0
  69. codex_django_cli/blueprints/features/notifications/arq/client.j2 +22 -0
  70. codex_django_cli/blueprints/features/notifications/feature/models/email_content.j2 +15 -0
  71. codex_django_cli/blueprints/features/notifications/feature/selectors/email_content.j2 +23 -0
  72. codex_django_cli/blueprints/features/notifications/feature/services/notification.j2 +66 -0
  73. codex_django_cli/blueprints/project/cabinet/__init__.py +0 -0
  74. codex_django_cli/blueprints/project/cabinet/admin/__init__.py +0 -0
  75. codex_django_cli/blueprints/project/cabinet/apps.py.j2 +11 -0
  76. codex_django_cli/blueprints/project/cabinet/cabinet.py.j2 +33 -0
  77. codex_django_cli/blueprints/project/cabinet/forms/__init__.py +0 -0
  78. codex_django_cli/blueprints/project/cabinet/mock.py.j2 +110 -0
  79. codex_django_cli/blueprints/project/cabinet/models/__init__.py +0 -0
  80. codex_django_cli/blueprints/project/cabinet/modules/__init__.py +0 -0
  81. codex_django_cli/blueprints/project/cabinet/selector/__init__.py +3 -0
  82. codex_django_cli/blueprints/project/cabinet/selector/users.py.j2 +25 -0
  83. codex_django_cli/blueprints/project/cabinet/services/__init__.py +0 -0
  84. codex_django_cli/blueprints/project/cabinet/static/cabinet/css/base.css +11 -0
  85. codex_django_cli/blueprints/project/cabinet/static/cabinet/css/compiler_config.json +5 -0
  86. codex_django_cli/blueprints/project/cabinet/static/cabinet/css/theme/tokens.css +30 -0
  87. codex_django_cli/blueprints/project/cabinet/static/cabinet/js/app/cabinet.js +37 -0
  88. codex_django_cli/blueprints/project/cabinet/static/cabinet/js/compiler_config.json +7 -0
  89. codex_django_cli/blueprints/project/cabinet/templates/cabinet/users/detail.html +91 -0
  90. codex_django_cli/blueprints/project/cabinet/templates/cabinet/users/index.html +97 -0
  91. codex_django_cli/blueprints/project/cabinet/tests/__init__.py +0 -0
  92. codex_django_cli/blueprints/project/cabinet/translations.py.j2 +2 -0
  93. codex_django_cli/blueprints/project/cabinet/urls.py.j2 +22 -0
  94. codex_django_cli/blueprints/project/cabinet/views/__init__.py +3 -0
  95. codex_django_cli/blueprints/project/cabinet/views/users.py.j2 +17 -0
  96. codex_django_cli/blueprints/project/core/__init__.py.j2 +1 -0
  97. codex_django_cli/blueprints/project/core/apps.py.j2 +15 -0
  98. codex_django_cli/blueprints/project/core/asgi.py.j2 +7 -0
  99. codex_django_cli/blueprints/project/core/logger.py.j2 +57 -0
  100. codex_django_cli/blueprints/project/core/redis.py.j2 +4 -0
  101. codex_django_cli/blueprints/project/core/settings/__init__.py.j2 +4 -0
  102. codex_django_cli/blueprints/project/core/settings/base.py.j2 +67 -0
  103. codex_django_cli/blueprints/project/core/settings/dev.py.j2 +56 -0
  104. codex_django_cli/blueprints/project/core/settings/modules/__init__.py.j2 +1 -0
  105. codex_django_cli/blueprints/project/core/settings/modules/admin.py.j2 +72 -0
  106. codex_django_cli/blueprints/project/core/settings/modules/apps.py.j2 +64 -0
  107. codex_django_cli/blueprints/project/core/settings/modules/cache.py.j2 +49 -0
  108. codex_django_cli/blueprints/project/core/settings/modules/codex.py.j2 +39 -0
  109. codex_django_cli/blueprints/project/core/settings/modules/database.py.j2 +49 -0
  110. codex_django_cli/blueprints/project/core/settings/modules/internationalization.py.j2 +43 -0
  111. codex_django_cli/blueprints/project/core/settings/modules/logging.py.j2 +45 -0
  112. codex_django_cli/blueprints/project/core/settings/modules/middleware.py.j2 +17 -0
  113. codex_django_cli/blueprints/project/core/settings/modules/security.py.j2 +53 -0
  114. codex_django_cli/blueprints/project/core/settings/modules/sitemap.py.j2 +14 -0
  115. codex_django_cli/blueprints/project/core/settings/modules/static.py.j2 +36 -0
  116. codex_django_cli/blueprints/project/core/settings/modules/templates.py.j2 +29 -0
  117. codex_django_cli/blueprints/project/core/settings/prod.py.j2 +77 -0
  118. codex_django_cli/blueprints/project/core/settings/test.py.j2 +40 -0
  119. codex_django_cli/blueprints/project/core/sitemaps.py.j2 +26 -0
  120. codex_django_cli/blueprints/project/core/urls.py.j2 +71 -0
  121. codex_django_cli/blueprints/project/core/wsgi.py.j2 +7 -0
  122. codex_django_cli/blueprints/project/features/__init__.py.j2 +0 -0
  123. codex_django_cli/blueprints/project/features/main/admin/__init__.py +0 -0
  124. codex_django_cli/blueprints/project/features/main/apps.py.j2 +8 -0
  125. codex_django_cli/blueprints/project/features/main/forms/__init__.py +0 -0
  126. codex_django_cli/blueprints/project/features/main/models/__init__.py +0 -0
  127. codex_django_cli/blueprints/project/features/main/sitemaps.py.j2 +23 -0
  128. codex_django_cli/blueprints/project/features/main/tests/__init__.py +0 -0
  129. codex_django_cli/blueprints/project/features/main/translations.py.j2 +2 -0
  130. codex_django_cli/blueprints/project/features/main/urls.py.j2 +8 -0
  131. codex_django_cli/blueprints/project/features/main/views/__init__.py.j2 +9 -0
  132. codex_django_cli/blueprints/project/manage.py.j2 +39 -0
  133. codex_django_cli/blueprints/project/static/css/base/components.css +88 -0
  134. codex_django_cli/blueprints/project/static/css/base/footer.css +43 -0
  135. codex_django_cli/blueprints/project/static/css/base/header.css +76 -0
  136. codex_django_cli/blueprints/project/static/css/base/layout.css +58 -0
  137. codex_django_cli/blueprints/project/static/css/base/reset.css +65 -0
  138. codex_django_cli/blueprints/project/static/css/base/tokens.css +45 -0
  139. codex_django_cli/blueprints/project/static/css/base.css +31 -0
  140. codex_django_cli/blueprints/project/static/css/compiler_config.json +10 -0
  141. codex_django_cli/blueprints/project/static/css/pages/contacts.css +42 -0
  142. codex_django_cli/blueprints/project/static/css/pages/errors.css +11 -0
  143. codex_django_cli/blueprints/project/static/css/pages/home.css +120 -0
  144. codex_django_cli/blueprints/project/static/js/app/main.js +8 -0
  145. codex_django_cli/blueprints/project/static/js/vendor/alpine.min.js +9 -0
  146. codex_django_cli/blueprints/project/static/js/vendor/htmx.min.js +9 -0
  147. codex_django_cli/blueprints/project/static/manifest.json +15 -0
  148. codex_django_cli/blueprints/project/static/robots.txt +4 -0
  149. codex_django_cli/blueprints/project/system/__init__.py.j2 +1 -0
  150. codex_django_cli/blueprints/project/system/admin/__init__.py.j2 +15 -0
  151. codex_django_cli/blueprints/project/system/admin/seo.py.j2 +28 -0
  152. codex_django_cli/blueprints/project/system/admin/settings.py.j2 +65 -0
  153. codex_django_cli/blueprints/project/system/admin/static.py.j2 +18 -0
  154. codex_django_cli/blueprints/project/system/apps.py.j2 +9 -0
  155. codex_django_cli/blueprints/project/system/forms/__init__.py.j2 +1 -0
  156. codex_django_cli/blueprints/project/system/management/__init__.py +0 -0
  157. codex_django_cli/blueprints/project/system/management/commands/__init__.py +0 -0
  158. codex_django_cli/blueprints/project/system/management/commands/dev.py.j2 +5 -0
  159. codex_django_cli/blueprints/project/system/management/commands/menu.py.j2 +81 -0
  160. codex_django_cli/blueprints/project/system/management/commands/runserver_plus.py.j2 +46 -0
  161. codex_django_cli/blueprints/project/system/management/commands/update_all_content.py.j2 +17 -0
  162. codex_django_cli/blueprints/project/system/management/commands/update_site_settings.py.j2 +80 -0
  163. codex_django_cli/blueprints/project/system/models/__init__.py.j2 +15 -0
  164. codex_django_cli/blueprints/project/system/models/seo.py.j2 +37 -0
  165. codex_django_cli/blueprints/project/system/models/settings.py.j2 +31 -0
  166. codex_django_cli/blueprints/project/system/models/static.py.j2 +13 -0
  167. codex_django_cli/blueprints/project/system/services/__init__.py.j2 +1 -0
  168. codex_django_cli/blueprints/project/system/urls.py.j2 +7 -0
  169. codex_django_cli/blueprints/project/system/views/__init__.py.j2 +1 -0
  170. codex_django_cli/blueprints/project/system/views/errors.py.j2 +13 -0
  171. codex_django_cli/blueprints/project/templates/base.html.j2 +53 -0
  172. codex_django_cli/blueprints/project/templates/errors/400.html +9 -0
  173. codex_django_cli/blueprints/project/templates/errors/403.html +9 -0
  174. codex_django_cli/blueprints/project/templates/errors/404.html +16 -0
  175. codex_django_cli/blueprints/project/templates/errors/500.html +16 -0
  176. codex_django_cli/blueprints/project/templates/includes/_analytics_body.html +2 -0
  177. codex_django_cli/blueprints/project/templates/includes/_analytics_head.html +2 -0
  178. codex_django_cli/blueprints/project/templates/includes/_cookie_consent.html +2 -0
  179. codex_django_cli/blueprints/project/templates/includes/_critical_css.html +38 -0
  180. codex_django_cli/blueprints/project/templates/includes/_footer.html +57 -0
  181. codex_django_cli/blueprints/project/templates/includes/_header.html.j2 +75 -0
  182. codex_django_cli/blueprints/project/templates/includes/_hreflang_tags.html.j2 +11 -0
  183. codex_django_cli/blueprints/project/templates/includes/_meta.html +44 -0
  184. codex_django_cli/blueprints/project/templates/main/contacts.html +38 -0
  185. codex_django_cli/blueprints/project/templates/main/home.html +128 -0
  186. codex_django_cli/blueprints/project/templates/main/home.html.j2 +129 -0
  187. codex_django_cli/blueprints/project/templates/sitemap.xml +16 -0
  188. codex_django_cli/blueprints/repo/.env.example.j2 +15 -0
  189. codex_django_cli/blueprints/repo/.github/workflows/.gitkeep +1 -0
  190. codex_django_cli/blueprints/repo/.gitignore +38 -0
  191. codex_django_cli/blueprints/repo/README.md.j2 +32 -0
  192. codex_django_cli/blueprints/repo/docs/README.md +5 -0
  193. codex_django_cli/blueprints/repo/pyproject.toml.j2 +46 -0
  194. codex_django_cli/blueprints/repo/tools/dev/check.py.j2 +23 -0
  195. codex_django_cli/blueprints/repo/tools/dev/generate_project_tree.py +15 -0
  196. codex_django_cli/commands/__init__.py +1 -0
  197. codex_django_cli/commands/add_app.py +56 -0
  198. codex_django_cli/commands/booking.py +92 -0
  199. codex_django_cli/commands/client_cabinet.py +66 -0
  200. codex_django_cli/commands/deploy.py +96 -0
  201. codex_django_cli/commands/init.py +216 -0
  202. codex_django_cli/commands/notifications.py +59 -0
  203. codex_django_cli/commands/quality.py +116 -0
  204. codex_django_cli/engine.py +113 -0
  205. codex_django_cli/main.py +470 -0
  206. codex_django_cli/prompts.py +258 -0
  207. codex_django_cli/py.typed +0 -0
  208. codex_django_cli/utils.py +25 -0
  209. codex_django_cli-0.2.0.dist-info/METADATA +66 -0
  210. codex_django_cli-0.2.0.dist-info/RECORD +212 -0
  211. codex_django_cli-0.2.0.dist-info/WHEEL +4 -0
  212. codex_django_cli-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,174 @@
1
+ """
2
+ Booking views — public booking wizard with HTMX + Alpine.js.
3
+
4
+ Solo mode (single service):
5
+ POST /confirm/ with service_id=<int>
6
+
7
+ Multi-service mode:
8
+ POST /confirm/ with service_ids=<int>&service_ids=<int>&...
9
+ Requires a BookingPersistenceHook implementation — see wiki.md for details.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from datetime import date, datetime
16
+
17
+ from django.http import HttpResponse
18
+ from django.shortcuts import render
19
+ from django.views.decorators.http import require_GET, require_POST
20
+
21
+ from .models import Service, ServiceCategory
22
+ from .selectors import create_booking, get_available_slots, get_calendar_data
23
+
24
+
25
+ @require_GET
26
+ def booking_page_view(request):
27
+ """Main booking page — renders the wizard shell with step 1 (services)."""
28
+ categories = ServiceCategory.objects.prefetch_related("services").all()
29
+ return render(request, "booking/booking_page.html", {
30
+ "categories": categories,
31
+ })
32
+
33
+
34
+ @require_GET
35
+ def get_calendar_view(request):
36
+ """HTMX endpoint — returns the calendar partial for a given month."""
37
+ service_id = request.GET.get("service_id")
38
+ year = int(request.GET.get("year", date.today().year))
39
+ month = int(request.GET.get("month", date.today().month))
40
+ selected = request.GET.get("selected_date")
41
+
42
+ selected_date = None
43
+ if selected:
44
+ selected_date = datetime.strptime(selected, "%Y-%m-%d").date()
45
+
46
+ calendar_data = get_calendar_data(
47
+ year=year,
48
+ month=month,
49
+ today=date.today(),
50
+ selected_date=selected_date,
51
+ )
52
+
53
+ return render(request, "booking/partials/step_date.html", {
54
+ "calendar_data": calendar_data,
55
+ "year": year,
56
+ "month": month,
57
+ "service_id": service_id,
58
+ "selected_date": selected_date,
59
+ })
60
+
61
+
62
+ @require_GET
63
+ def get_slots_view(request):
64
+ """HTMX endpoint — returns available time slots for a date + service.
65
+
66
+ For multi-service, pass the first (primary) service_id — the engine
67
+ will compute intersected availability during confirm_booking_view.
68
+ """
69
+ service_id = request.GET.get("service_id")
70
+ target = request.GET.get("date")
71
+
72
+ if not service_id or not target:
73
+ return HttpResponse("Missing parameters", status=400)
74
+
75
+ target_date = datetime.strptime(target, "%Y-%m-%d").date()
76
+
77
+ result = get_available_slots(service_ids=[int(service_id)], target_date=target_date)
78
+ slots = result.get_unique_start_times() if result.solutions else []
79
+
80
+ return render(request, "booking/partials/step_time.html", {
81
+ "slots": slots,
82
+ "service_id": service_id,
83
+ "target_date": target,
84
+ })
85
+
86
+
87
+ @require_POST
88
+ def confirm_booking_view(request):
89
+ """Final step — creates the booking.
90
+
91
+ Solo mode (single service_id):
92
+ Creates one Appointment, no persistence hook needed.
93
+
94
+ Multi-service mode (multiple service_ids):
95
+ Requires BookingPersistenceHook — implement it in this project
96
+ and pass it as persistence_hook=. See wiki.md for details.
97
+
98
+ POST parameters:
99
+ service_id — single service (solo mode, legacy)
100
+ service_ids — one or more service ids (multi-service mode preferred)
101
+ date — YYYY-MM-DD
102
+ time — time string as returned by get_available_slots()
103
+ master_id — optional, leave blank for "any master"
104
+ master_selections — optional JSON: {"<service_id>": <master_id>}
105
+ """
106
+ target = request.POST.get("date")
107
+ selected_time = request.POST.get("time")
108
+
109
+ # Resolve service_ids: prefer multi-value list, fall back to single service_id
110
+ raw_ids = request.POST.getlist("service_ids") or [request.POST.get("service_id")]
111
+ service_ids = [int(s) for s in raw_ids if s]
112
+
113
+ if not service_ids or not target or not selected_time:
114
+ return HttpResponse("Missing parameters", status=400)
115
+
116
+ if not request.user.is_authenticated:
117
+ return HttpResponse("Authentication required", status=401)
118
+
119
+ target_date = datetime.strptime(target, "%Y-%m-%d").date()
120
+
121
+ # master_id (solo) or master_selections JSON (multi)
122
+ master_id_raw = request.POST.get("master_id")
123
+ master_id = int(master_id_raw) if master_id_raw else None
124
+
125
+ master_selections = None
126
+ master_selections_raw = request.POST.get("master_selections")
127
+ if master_selections_raw:
128
+ try:
129
+ master_selections = {
130
+ int(k): (int(v) if v else None)
131
+ for k, v in json.loads(master_selections_raw).items()
132
+ }
133
+ except (json.JSONDecodeError, ValueError):
134
+ return HttpResponse("Invalid master_selections format", status=400)
135
+
136
+ persistence_hook = None
137
+ if len(service_ids) > 1:
138
+ # TODO: Implement BookingPersistenceHook for multi-service support.
139
+ # Example:
140
+ # from .hooks import MyBookingPersistenceHook
141
+ # persistence_hook = MyBookingPersistenceHook()
142
+ # See wiki.md — "Multi-service bookings" section for full details.
143
+ return HttpResponse(
144
+ "Multi-service booking requires BookingPersistenceHook. "
145
+ "See booking/wiki.md for implementation guide.",
146
+ status=501,
147
+ )
148
+
149
+ try:
150
+ result = create_booking(
151
+ service_ids=service_ids,
152
+ target_date=target_date,
153
+ selected_time=selected_time,
154
+ master_id=master_id,
155
+ master_selections=master_selections,
156
+ client=request.user,
157
+ extra_fields={"service_id": service_ids[0]},
158
+ persistence_hook=persistence_hook,
159
+ )
160
+ except Exception as e:
161
+ return render(request, "booking/partials/step_confirm.html", {
162
+ "error": str(e),
163
+ "service_ids": service_ids,
164
+ "date": target,
165
+ "time": selected_time,
166
+ })
167
+
168
+ # Solo mode returns a single Appointment; multi mode returns a list via hook.
169
+ appointments = result if isinstance(result, list) else [result]
170
+
171
+ return render(request, "booking/partials/step_confirm.html", {
172
+ "appointments": appointments,
173
+ "success": True,
174
+ })
@@ -0,0 +1,142 @@
1
+ # Booking Module
2
+
3
+ ## Quick Start
4
+
5
+ 1. Add `"booking"` to `INSTALLED_APPS`
6
+ 2. Run migrations: `python manage.py makemigrations booking && python manage.py migrate`
7
+ 3. Add URLs to root `urls.py`:
8
+ ```python
9
+ path("booking/", include("booking.urls")),
10
+ ```
11
+ 4. Add cabinet URL to `cabinet/urls.py`:
12
+ ```python
13
+ from cabinet.views.booking import my_bookings_view
14
+ path("my/bookings/", my_bookings_view, name="my_bookings"),
15
+ ```
16
+
17
+ ## Admin Setup
18
+
19
+ 1. Create a `BookingSettings` model in `system/models/` extending `AbstractBookingSettings`
20
+ 2. Register it in admin — configure `step_minutes`, `default_buffer_between_minutes`, etc.
21
+ 3. Add masters via the Master admin (each master needs a linked User)
22
+ 4. Set up working days for each master (Mon-Sun schedule)
23
+ 5. Add services with durations and link them to masters
24
+
25
+ ## How It Works
26
+
27
+ The booking wizard is a multi-step form:
28
+
29
+ 1. **Select Service** — client picks a service
30
+ 2. **Select Date** — calendar view, HTMX loads available dates
31
+ 3. **Select Time** — available slots rendered as buttons
32
+ 4. **Confirm** — summary and final submission
33
+
34
+ All slot computation is handled by `codex_django.booking` engine via
35
+ the adapter pattern — no booking logic lives in this app.
36
+
37
+ ## Customizing Templates
38
+
39
+ Override any template by placing your version in your project's `templates/` dir:
40
+
41
+ - `booking/booking_page.html` — main wrapper
42
+ - `booking/partials/step_service.html` — service selection step
43
+ - `booking/partials/step_date.html` — calendar step
44
+ - `booking/partials/step_time.html` — time slots step
45
+ - `booking/partials/step_confirm.html` — confirmation step
46
+ - `cabinet/booking/my_bookings.html` — cabinet bookings list
47
+
48
+ ## CSS Variables
49
+
50
+ The public booking templates use CSS custom properties for theming:
51
+
52
+ ```css
53
+ :root {
54
+ --booking-primary: #2563eb;
55
+ --booking-primary-hover: #1d4ed8;
56
+ --booking-bg: #ffffff;
57
+ --booking-bg-secondary: #f8fafc;
58
+ --booking-text: #1e293b;
59
+ --booking-text-muted: #64748b;
60
+ --booking-border: #e2e8f0;
61
+ --booking-radius: 8px;
62
+ --booking-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
63
+ }
64
+ ```
65
+
66
+ Override these in your site CSS to match your brand.
67
+
68
+ ## URL Configuration
69
+
70
+ Public booking page:
71
+ - `GET /booking/` — wizard page
72
+ - `GET /booking/calendar/?service_id=1&year=2026&month=3` — calendar partial (HTMX)
73
+ - `GET /booking/slots/?service_id=1&date=2026-03-25` — time slots partial (HTMX)
74
+ - `POST /booking/confirm/` — create booking
75
+
76
+ Cabinet:
77
+ - `GET /cabinet/my/bookings/` — client's bookings list
78
+
79
+ ## Multi-service Bookings
80
+
81
+ By default the booking wizard runs in **solo mode** (one service per booking).
82
+ To support booking multiple services in a single session, implement `BookingPersistenceHook`.
83
+
84
+ ### 1. Implement the hook
85
+
86
+ Create `booking/hooks.py` in your project:
87
+
88
+ ```python
89
+ from __future__ import annotations
90
+ from typing import Any
91
+ from .models import Appointment
92
+
93
+
94
+ class MyBookingPersistenceHook:
95
+ """Persists a multi-service chain as individual Appointment rows."""
96
+
97
+ def persist_chain(
98
+ self,
99
+ solution: Any,
100
+ service_ids: list[int],
101
+ client: Any,
102
+ extra_fields: dict[str, Any] | None = None,
103
+ ) -> list[Appointment]:
104
+ appointments = []
105
+ for item in solution.items:
106
+ appointments.append(
107
+ Appointment.objects.create(
108
+ master_id=item.master_id,
109
+ service_id=item.service_id,
110
+ start_time=item.start_time,
111
+ end_time=item.end_time,
112
+ client=client,
113
+ **(extra_fields or {}),
114
+ )
115
+ )
116
+ return appointments
117
+ ```
118
+
119
+ ### 2. Wire it into the confirm view
120
+
121
+ In `booking/views.py`, find the TODO comment and replace it:
122
+
123
+ ```python
124
+ from .hooks import MyBookingPersistenceHook
125
+
126
+ # inside confirm_booking_view, replace the TODO block:
127
+ if len(service_ids) > 1:
128
+ persistence_hook = MyBookingPersistenceHook()
129
+ ```
130
+
131
+ ### 3. Update the confirm form
132
+
133
+ Your form should send multiple `service_ids` values:
134
+
135
+ ```html
136
+ <input type="hidden" name="service_ids" value="3">
137
+ <input type="hidden" name="service_ids" value="7">
138
+ <!-- optional per-service master selection as JSON -->
139
+ <input type="hidden" name="master_selections" value='{"3": 10, "7": null}'>
140
+ ```
141
+
142
+ A `null` value for a service means "any available master".
@@ -0,0 +1,249 @@
1
+ {% extends "cabinet/base_client.html" %}
2
+ {% load i18n %}
3
+
4
+ {% block title %}{% trans "My Bookings" %}{% endblock %}
5
+ {% block page_title %}{% trans "My Bookings" %}{% endblock %}
6
+
7
+ {% block sidebar_nav %}
8
+ <li>
9
+ <a href="{% url 'cabinet:my_appointments' %}"
10
+ class="cab-nav__item d-flex align-items-center px-3 py-2 rounded mx-2
11
+ {% if active_section == 'my' %}cab-nav__item--active{% endif %}"
12
+ hx-get="{% url 'cabinet:my_appointments' %}"
13
+ hx-target="#cab-content"
14
+ hx-push-url="true"
15
+ style="color: var(--cab-sidebar-text); transition: background 0.15s;">
16
+ <span class="bi bi-calendar-check fs-5 flex-shrink-0"></span>
17
+ <span class="cab-nav__label ms-3" style="white-space: nowrap;">{% trans "My Appointments" %}</span>
18
+ </a>
19
+ </li>
20
+ <li>
21
+ <a href="{% url 'cabinet:my_bookings' %}"
22
+ class="cab-nav__item d-flex align-items-center px-3 py-2 rounded mx-2
23
+ {% if active_section == 'my_bookings' %}cab-nav__item--active{% endif %}"
24
+ hx-get="{% url 'cabinet:my_bookings' %}"
25
+ hx-target="#cab-content"
26
+ hx-push-url="true"
27
+ style="color: var(--cab-sidebar-text); transition: background 0.15s;">
28
+ <span class="bi bi-journal-bookmark fs-5 flex-shrink-0"></span>
29
+ <span class="cab-nav__label ms-3" style="white-space: nowrap;">{% trans "My Bookings" %}</span>
30
+ </a>
31
+ </li>
32
+ <li>
33
+ <a href="{% url 'cabinet:profile' %}"
34
+ class="cab-nav__item d-flex align-items-center px-3 py-2 rounded mx-2
35
+ {% if active_section == 'profile' %}cab-nav__item--active{% endif %}"
36
+ hx-get="{% url 'cabinet:profile' %}"
37
+ hx-target="#cab-content"
38
+ hx-push-url="true"
39
+ style="color: var(--cab-sidebar-text); transition: background 0.15s;">
40
+ <span class="bi bi-person fs-5 flex-shrink-0"></span>
41
+ <span class="cab-nav__label ms-3" style="white-space: nowrap;">{% trans "Profile" %}</span>
42
+ </a>
43
+ </li>
44
+ {% endblock %}
45
+
46
+ {% block sidebar_settings_link %}
47
+ <a href="{% url 'cabinet:settings' %}"
48
+ class="cab-nav__item d-flex align-items-center px-3 py-2 mx-2 rounded"
49
+ style="color: var(--cab-sidebar-text); transition: background 0.15s;">
50
+ <span class="bi bi-gear fs-5 flex-shrink-0"></span>
51
+ <span class="cab-nav__label ms-3" style="white-space: nowrap; font-size: 0.875rem;">{% trans "Settings" %}</span>
52
+ </a>
53
+ {% endblock %}
54
+
55
+ {% block cabinet_content %}
56
+
57
+ {# ─── Stats row ─── #}
58
+ <div class="row g-3 mb-4">
59
+ <div class="col-md-4">
60
+ <div class="card border-0 shadow-sm h-100">
61
+ <div class="card-body d-flex align-items-center gap-3">
62
+ <div class="rounded-circle d-flex align-items-center justify-content-center"
63
+ style="width:48px;height:48px;background:#ede9fe;flex-shrink:0;">
64
+ <span class="bi bi-calendar-check fs-5" style="color:#7c3aed;"></span>
65
+ </div>
66
+ <div>
67
+ <div class="text-muted" style="font-size:.75rem;">{% trans "Upcoming" %}</div>
68
+ <div class="fw-bold fs-4">{{ stats.upcoming }}</div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <div class="col-md-4">
74
+ <div class="card border-0 shadow-sm h-100">
75
+ <div class="card-body d-flex align-items-center gap-3">
76
+ <div class="rounded-circle d-flex align-items-center justify-content-center"
77
+ style="width:48px;height:48px;background:#dcfce7;flex-shrink:0;">
78
+ <span class="bi bi-check-circle fs-5" style="color:#16a34a;"></span>
79
+ </div>
80
+ <div>
81
+ <div class="text-muted" style="font-size:.75rem;">{% trans "Completed" %}</div>
82
+ <div class="fw-bold fs-4">{{ stats.completed }}</div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ <div class="col-md-4">
88
+ <div class="card border-0 shadow-sm h-100">
89
+ <div class="card-body d-flex align-items-center gap-3">
90
+ <div class="rounded-circle d-flex align-items-center justify-content-center"
91
+ style="width:48px;height:48px;background:#fee2e2;flex-shrink:0;">
92
+ <span class="bi bi-x-circle fs-5" style="color:#dc2626;"></span>
93
+ </div>
94
+ <div>
95
+ <div class="text-muted" style="font-size:.75rem;">{% trans "Cancelled" %}</div>
96
+ <div class="fw-bold fs-4">{{ stats.cancelled }}</div>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+
103
+ {# ─── Status filter tabs ─── #}
104
+ <div class="d-flex gap-2 mb-4 flex-wrap">
105
+ <a href="{% url 'cabinet:my_bookings' %}"
106
+ class="btn btn-sm {% if not status_filter %}btn-dark{% else %}btn-outline-secondary{% endif %}"
107
+ style="font-size:.8rem;border-radius:20px;">
108
+ {% trans "All" %}
109
+ </a>
110
+ <a href="{% url 'cabinet:my_bookings' %}?status=pending"
111
+ class="btn btn-sm {% if status_filter == 'pending' %}btn-dark{% else %}btn-outline-secondary{% endif %}"
112
+ style="font-size:.8rem;border-radius:20px;">
113
+ {% trans "Pending" %}
114
+ </a>
115
+ <a href="{% url 'cabinet:my_bookings' %}?status=confirmed"
116
+ class="btn btn-sm {% if status_filter == 'confirmed' %}btn-dark{% else %}btn-outline-secondary{% endif %}"
117
+ style="font-size:.8rem;border-radius:20px;">
118
+ {% trans "Confirmed" %}
119
+ </a>
120
+ <a href="{% url 'cabinet:my_bookings' %}?status=completed"
121
+ class="btn btn-sm {% if status_filter == 'completed' %}btn-dark{% else %}btn-outline-secondary{% endif %}"
122
+ style="font-size:.8rem;border-radius:20px;">
123
+ {% trans "Completed" %}
124
+ </a>
125
+ <a href="{% url 'cabinet:my_bookings' %}?status=cancelled"
126
+ class="btn btn-sm {% if status_filter == 'cancelled' %}btn-dark{% else %}btn-outline-secondary{% endif %}"
127
+ style="font-size:.8rem;border-radius:20px;">
128
+ {% trans "Cancelled" %}
129
+ </a>
130
+ </div>
131
+
132
+ {# ─── Bookings table ─── #}
133
+ <div class="card border-0 shadow-sm">
134
+ <div class="card-header bg-white border-0 pt-3 pb-0 d-flex align-items-center justify-content-between">
135
+ <h6 class="fw-semibold mb-0">{% trans "My Bookings" %}</h6>
136
+ <a href="{% url 'booking:booking_page' %}" class="btn btn-sm btn-primary">
137
+ + {% trans "Book Now" %}
138
+ </a>
139
+ </div>
140
+ <div class="card-body p-0">
141
+ <table class="table table-hover mb-0">
142
+ <thead class="table-light">
143
+ <tr>
144
+ <th class="ps-4" style="font-size:.75rem;font-weight:600;color:#64748b;">{% trans "DATE AND TIME" %}</th>
145
+ <th style="font-size:.75rem;font-weight:600;color:#64748b;">{% trans "SERVICE" %}</th>
146
+ <th style="font-size:.75rem;font-weight:600;color:#64748b;">{% trans "SPECIALIST" %}</th>
147
+ <th style="font-size:.75rem;font-weight:600;color:#64748b;">{% trans "STATUS" %}</th>
148
+ <th></th>
149
+ </tr>
150
+ </thead>
151
+ <tbody>
152
+ {% for appt in page_obj %}
153
+ <tr>
154
+ <td class="ps-4">
155
+ <div class="fw-semibold">{{ appt.datetime_start|date:"j F Y" }}</div>
156
+ <div class="text-muted" style="font-size:.8rem;">{{ appt.datetime_start|time:"H:i" }}</div>
157
+ </td>
158
+ <td>{{ appt.service.name }}</td>
159
+ <td>
160
+ <div class="d-flex align-items-center gap-2">
161
+ <span class="rounded-circle d-flex align-items-center justify-content-center fw-bold text-white"
162
+ style="width:28px;height:28px;background:var(--cab-primary);font-size:.7rem;">
163
+ {{ appt.master.name|make_list|first }}
164
+ </span>
165
+ {{ appt.master.name }}
166
+ </div>
167
+ </td>
168
+ <td>
169
+ {% if appt.status == 'confirmed' %}
170
+ <span class="badge" style="background:#dcfce7;color:#16a34a;">{% trans "Confirmed" %}</span>
171
+ {% elif appt.status == 'pending' %}
172
+ <span class="badge" style="background:#fef9c3;color:#ca8a04;">{% trans "Pending" %}</span>
173
+ {% elif appt.status == 'completed' %}
174
+ <span class="badge" style="background:#dcfce7;color:#16a34a;">{% trans "Completed" %}</span>
175
+ {% elif appt.status == 'cancelled' %}
176
+ <span class="badge" style="background:#fee2e2;color:#dc2626;">{% trans "Cancelled" %}</span>
177
+ {% elif appt.status == 'no_show' %}
178
+ <span class="badge" style="background:#f1f5f9;color:#64748b;">{% trans "No Show" %}</span>
179
+ {% else %}
180
+ <span class="badge" style="background:#f1f5f9;color:#64748b;">{{ appt.get_status_display }}</span>
181
+ {% endif %}
182
+ </td>
183
+ <td class="text-end pe-3">
184
+ {% if appt.status == 'pending' or appt.status == 'confirmed' %}
185
+ <button class="btn btn-sm btn-outline-danger"
186
+ hx-post="{% url 'cabinet:cancel_booking' appt.pk %}"
187
+ hx-target="#cab-content"
188
+ hx-confirm="{% trans 'Cancel this booking?' %}">
189
+ {% trans "Cancel" %}
190
+ </button>
191
+ {% endif %}
192
+ </td>
193
+ </tr>
194
+ {% empty %}
195
+ <tr>
196
+ <td colspan="5" class="text-center py-5">
197
+ <span class="bi bi-calendar-x fs-1 text-muted d-block mb-2"></span>
198
+ <p class="text-muted mb-0">{% trans "No bookings found" %}</p>
199
+ </td>
200
+ </tr>
201
+ {% endfor %}
202
+ </tbody>
203
+ </table>
204
+ </div>
205
+
206
+ {% if page_obj.has_other_pages %}
207
+ <div class="card-footer bg-white border-0 d-flex align-items-center justify-content-between py-3 px-4">
208
+ <span class="text-muted" style="font-size:.8rem;">
209
+ {% blocktrans with start=page_obj.start_index end=page_obj.end_index total=page_obj.paginator.count %}
210
+ Showing {{ start }}–{{ end }} of {{ total }}
211
+ {% endblocktrans %}
212
+ </span>
213
+ <nav>
214
+ <ul class="pagination pagination-sm mb-0">
215
+ {% if page_obj.has_previous %}
216
+ <li class="page-item">
217
+ <a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}">«</a>
218
+ </li>
219
+ {% else %}
220
+ <li class="page-item disabled">
221
+ <a class="page-link" href="#">«</a>
222
+ </li>
223
+ {% endif %}
224
+
225
+ {% for num in page_obj.paginator.page_range %}
226
+ <li class="page-item {% if page_obj.number == num %}active{% endif %}">
227
+ <a class="page-link" href="?page={{ num }}{% if status_filter %}&status={{ status_filter }}{% endif %}"
228
+ {% if page_obj.number == num %}style="background:var(--cab-primary);border-color:var(--cab-primary);"{% endif %}>
229
+ {{ num }}
230
+ </a>
231
+ </li>
232
+ {% endfor %}
233
+
234
+ {% if page_obj.has_next %}
235
+ <li class="page-item">
236
+ <a class="page-link" href="?page={{ page_obj.next_page_number }}{% if status_filter %}&status={{ status_filter }}{% endif %}">»</a>
237
+ </li>
238
+ {% else %}
239
+ <li class="page-item disabled">
240
+ <a class="page-link" href="#">»</a>
241
+ </li>
242
+ {% endif %}
243
+ </ul>
244
+ </nav>
245
+ </div>
246
+ {% endif %}
247
+ </div>
248
+
249
+ {% endblock %}
@@ -0,0 +1,70 @@
1
+ """
2
+ Cabinet booking views — client-facing "My Bookings" page.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from django.contrib.auth.decorators import login_required
8
+ from django.core.paginator import Paginator
9
+ from django.http import HttpResponse
10
+ from django.shortcuts import render
11
+ from django.views.decorators.http import require_GET, require_POST
12
+
13
+ from booking.models import Appointment
14
+
15
+
16
+ @login_required
17
+ @require_GET
18
+ def my_bookings_view(request):
19
+ """List the current user's appointments with status filter and pagination."""
20
+ status_filter = request.GET.get("status", "")
21
+ qs = Appointment.objects.filter(client=request.user).select_related("master", "service")
22
+
23
+ if status_filter and status_filter in dict(Appointment.STATUS_CHOICES if hasattr(Appointment, "STATUS_CHOICES") else []):
24
+ qs = qs.filter(status=status_filter)
25
+
26
+ paginator = Paginator(qs, 10)
27
+ page = paginator.get_page(request.GET.get("page", 1))
28
+
29
+ # Count stats
30
+ user_appointments = Appointment.objects.filter(client=request.user)
31
+ stats = {
32
+ "upcoming": user_appointments.filter(status__in=["pending", "confirmed"]).count(),
33
+ "completed": user_appointments.filter(status="completed").count(),
34
+ "cancelled": user_appointments.filter(status="cancelled").count(),
35
+ }
36
+
37
+ return render(request, "cabinet/booking/my_bookings.html", {
38
+ "page_obj": page,
39
+ "status_filter": status_filter,
40
+ "stats": stats,
41
+ "active_section": "my_bookings",
42
+ })
43
+
44
+
45
+ @login_required
46
+ @require_POST
47
+ def cancel_booking_view(request, pk):
48
+ """Cancel an appointment via HTMX."""
49
+ try:
50
+ appointment = Appointment.objects.get(pk=pk, client=request.user)
51
+ except Appointment.DoesNotExist:
52
+ return HttpResponse("Not found", status=404)
53
+
54
+ if appointment.status in ("pending", "confirmed"):
55
+ appointment.status = "cancelled"
56
+ appointment.save(update_fields=["status"])
57
+
58
+ return render(request, "cabinet/booking/my_bookings.html", {
59
+ "page_obj": Paginator(
60
+ Appointment.objects.filter(client=request.user).select_related("master", "service"),
61
+ 10,
62
+ ).get_page(1),
63
+ "status_filter": "",
64
+ "stats": {
65
+ "upcoming": Appointment.objects.filter(client=request.user, status__in=["pending", "confirmed"]).count(),
66
+ "completed": Appointment.objects.filter(client=request.user, status="completed").count(),
67
+ "cancelled": Appointment.objects.filter(client=request.user, status="cancelled").count(),
68
+ },
69
+ "active_section": "my_bookings",
70
+ })
@@ -0,0 +1,31 @@
1
+ from django.contrib import admin
2
+
3
+ from system.models import BookingSettings
4
+
5
+
6
+ @admin.register(BookingSettings)
7
+ class BookingSettingsAdmin(admin.ModelAdmin):
8
+ fieldsets = [
9
+ (
10
+ "Time Grid",
11
+ {
12
+ "fields": [
13
+ "step_minutes",
14
+ "default_buffer_between_minutes",
15
+ "min_advance_minutes",
16
+ "max_advance_days",
17
+ ]
18
+ },
19
+ ),
20
+ (
21
+ "Default Working Hours",
22
+ {
23
+ "fields": [
24
+ "work_start_weekdays",
25
+ "work_end_weekdays",
26
+ "work_start_saturday",
27
+ "work_end_saturday",
28
+ ]
29
+ },
30
+ ),
31
+ ]
@@ -0,0 +1,7 @@
1
+ from codex_django.booking.mixins import AbstractBookingSettings
2
+
3
+
4
+ class BookingSettings(AbstractBookingSettings):
5
+ class Meta:
6
+ verbose_name = "Booking Settings"
7
+ verbose_name_plural = "Booking Settings"