django-spire 0.25.1__py3-none-any.whl → 0.26.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 (124) hide show
  1. django_spire/ai/chat/router.py +10 -20
  2. django_spire/ai/chat/templates/django_spire/ai/chat/dropdown/ellipsis_dropdown.html +5 -3
  3. django_spire/ai/chat/templates/django_spire/ai/chat/message/default_message.html +6 -2
  4. django_spire/ai/chat/templatetags/spire_ai_chat_tags.py +19 -0
  5. django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +20 -88
  6. django_spire/auth/templates/django_spire/auth/element/android_and_chrome_app_install_element.html +33 -0
  7. django_spire/auth/templates/django_spire/auth/element/ios_app_install_element.html +48 -0
  8. django_spire/auth/templates/django_spire/auth/page/auth_page.html +2 -1
  9. django_spire/auth/templates/django_spire/auth/page/login_page.html +2 -0
  10. django_spire/comment/mixins.py +3 -3
  11. django_spire/comment/templates/django_spire/comment/card/comment_list_card.html +8 -5
  12. django_spire/comment/templates/django_spire/comment/form/comment_form.html +3 -3
  13. django_spire/comment/templates/django_spire/comment/form/content/comment_form_content.html +1 -0
  14. django_spire/comment/templates/django_spire/comment/item/comment_item_ellipsis.html +12 -8
  15. django_spire/comment/views.py +8 -8
  16. django_spire/consts.py +1 -1
  17. django_spire/contrib/form/utils.py +3 -3
  18. django_spire/contrib/progress/session.py +1 -1
  19. django_spire/contrib/queryset/filter_tools.py +56 -14
  20. django_spire/contrib/queryset/mixins.py +24 -3
  21. django_spire/contrib/service/django_model_service.py +5 -6
  22. django_spire/core/management/commands/spire_startapp.py +42 -25
  23. django_spire/core/management/commands/spire_startapp_pkg/exceptions.py +5 -0
  24. django_spire/core/management/commands/spire_startapp_pkg/maps.py +64 -32
  25. django_spire/core/management/commands/spire_startapp_pkg/template/app/constants.py.template +1 -0
  26. django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py.template +4 -0
  27. django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py.template +2 -1
  28. django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py.template +15 -6
  29. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py.template +1 -0
  30. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py.template +6 -6
  31. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py.template +2 -2
  32. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/template_urls.py.template +12 -0
  33. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py.template +10 -11
  34. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py.template +17 -3
  35. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/template_views.py.template +40 -0
  36. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${form_card_template_name}.html.template +1 -1
  37. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_base_card_template_name}.html.template +16 -0
  38. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_items_card_template_name}.html.template +16 -0
  39. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_table_card_template_name}.html.template +16 -0
  40. django_spire/core/management/commands/spire_startapp_pkg/template/templates/container/${list_container_template_name}.html.template +1 -0
  41. django_spire/core/management/commands/spire_startapp_pkg/template/templates/form/${list_filter_form_template_name}.html.template +30 -0
  42. django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${item_template_name}.html.template +32 -20
  43. django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${list_items_template_name}.html.template +3 -0
  44. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${detail_page_template_name}.html.template +3 -3
  45. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${form_page_template_name}.html.template +2 -2
  46. django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${list_page_template_name}.html.template +2 -2
  47. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_row_template_name}.html.template +6 -0
  48. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_rows_template_name}.html.template +3 -0
  49. django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_template_name}.html.template +6 -0
  50. django_spire/core/management/commands/spire_startapp_pkg/user_input.py +82 -9
  51. django_spire/core/management/commands/spire_startapp_pkg/validator.py +19 -6
  52. django_spire/core/middleware.py +2 -3
  53. django_spire/core/querysets.py +19 -0
  54. django_spire/core/static/django_spire/js/theme.js +10 -7
  55. django_spire/core/templates/django_spire/badge/base_badge.html +2 -3
  56. django_spire/core/templates/django_spire/base/base.html +1 -0
  57. django_spire/core/templates/django_spire/button/base_button.html +2 -1
  58. django_spire/core/templates/django_spire/card/title_card.html +13 -10
  59. django_spire/core/templates/django_spire/container/container.html +1 -1
  60. django_spire/core/templates/django_spire/filtering/form/base_session_filter_form.html +1 -1
  61. django_spire/core/templates/django_spire/form/field/_base_file_field.html +216 -0
  62. django_spire/core/templates/django_spire/form/field/_multi_checkbox_field.html +52 -0
  63. django_spire/core/templates/django_spire/form/field/base_field.html +128 -0
  64. django_spire/core/templates/django_spire/form/field/char_field.html +1 -0
  65. django_spire/core/templates/django_spire/form/field/color_field.html +1 -0
  66. django_spire/core/templates/django_spire/form/field/date_field.html +1 -0
  67. django_spire/core/templates/django_spire/form/field/datetime_field.html +1 -0
  68. django_spire/core/templates/django_spire/form/field/decimal_field.html +1 -0
  69. django_spire/core/templates/django_spire/form/field/element/select_checkmark_element.html +4 -0
  70. django_spire/core/templates/django_spire/form/field/element/select_down_arrow_element.html +5 -0
  71. django_spire/core/templates/django_spire/form/field/email_field.html +1 -0
  72. django_spire/core/templates/django_spire/form/field/input_field.html +13 -0
  73. django_spire/core/templates/django_spire/form/field/item/select_choice_item.html +15 -0
  74. django_spire/core/templates/django_spire/form/field/item/selected_choice_item.html +5 -0
  75. django_spire/core/templates/django_spire/form/field/list_field.html +112 -0
  76. django_spire/core/templates/django_spire/form/field/multi_file_field.html +90 -0
  77. django_spire/core/templates/django_spire/form/field/multi_select_field.html +155 -0
  78. django_spire/core/templates/django_spire/form/field/number_field.html +11 -0
  79. django_spire/core/templates/django_spire/form/field/password_field.html +1 -0
  80. django_spire/core/templates/django_spire/form/field/radio_field.html +24 -0
  81. django_spire/core/templates/django_spire/form/field/range_field.html +1 -0
  82. django_spire/core/templates/django_spire/form/field/search_and_select_field.html +119 -0
  83. django_spire/core/templates/django_spire/form/field/search_field.html +5 -0
  84. django_spire/core/templates/django_spire/form/field/select_field.html +78 -0
  85. django_spire/core/templates/django_spire/form/field/single_checkbox_field.html +27 -0
  86. django_spire/core/templates/django_spire/form/field/single_file_field.html +90 -0
  87. django_spire/core/templates/django_spire/form/field/telephone_field.html +1 -0
  88. django_spire/core/templates/django_spire/form/field/text_field.html +11 -0
  89. django_spire/core/templates/django_spire/form/field/time_field.html +1 -0
  90. django_spire/core/templates/django_spire/infinite_scroll/base.html +2 -1
  91. django_spire/core/templatetags/model_tags.py +34 -0
  92. django_spire/knowledge/entry/forms.py +1 -1
  93. django_spire/knowledge/entry/models.py +18 -0
  94. django_spire/knowledge/entry/querysets.py +8 -6
  95. django_spire/knowledge/entry/services/processor_service.py +1 -0
  96. django_spire/knowledge/entry/services/search_index_service.py +61 -0
  97. django_spire/knowledge/entry/services/search_service.py +99 -0
  98. django_spire/knowledge/entry/services/service.py +6 -0
  99. django_spire/knowledge/entry/version/services/processor_service.py +2 -0
  100. django_spire/knowledge/entry/version/tests/factories.py +9 -4
  101. django_spire/knowledge/entry/version/tests/test_services.py +7 -16
  102. django_spire/knowledge/intelligence/bots/knowledge_answer_bot.py +40 -6
  103. django_spire/knowledge/intelligence/bots/knowledge_entries_bot.py +4 -2
  104. django_spire/knowledge/intelligence/bots/search_preprocessing_bot.py +32 -0
  105. django_spire/knowledge/intelligence/intel/entry_intel.py +12 -0
  106. django_spire/knowledge/intelligence/router.py +47 -4
  107. django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +24 -42
  108. django_spire/knowledge/intelligence/workflows/search_preprocessing_workflow.py +78 -0
  109. django_spire/knowledge/management/__init__.py +0 -0
  110. django_spire/knowledge/management/commands/__init__.py +0 -0
  111. django_spire/knowledge/management/commands/rebuild_knowledge_search_index.py +16 -0
  112. django_spire/knowledge/migrations/0010_entry__search_text_entry__search_vector_and_more.py +40 -0
  113. django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html +31 -23
  114. django_spire/metric/report/enums.py +11 -5
  115. django_spire/metric/report/report.py +24 -12
  116. django_spire/metric/report/tools.py +14 -4
  117. django_spire/testing/playwright/fixtures.py +4 -5
  118. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/METADATA +1 -1
  119. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/RECORD +123 -69
  120. django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_card_template_name}.html.template +0 -18
  121. /django_spire/{core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py.template → ai/chat/templatetags/__init__.py} +0 -0
  122. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/WHEEL +0 -0
  123. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/licenses/LICENSE.md +0 -0
  124. {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/top_level.txt +0 -0
@@ -3,13 +3,11 @@ from __future__ import annotations
3
3
  from abc import ABC, abstractmethod
4
4
  from typing import TYPE_CHECKING
5
5
 
6
- from dandy import Bot, Prompt
7
6
  from dandy.recorder import recorder_to_html_file
8
7
 
9
8
  from django_spire.ai.chat.intelligence.decoders.intent_decoder import generate_intent_decoder
10
9
  from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
11
10
  from django_spire.ai.decorators import log_ai_interaction_from_recorder
12
- from django_spire.conf import settings
13
11
 
14
12
  if TYPE_CHECKING:
15
13
  from dandy.llm.request.message import MessageHistory
@@ -62,27 +60,19 @@ class SpireChatRouter(BaseChatRouter):
62
60
  user_input: str,
63
61
  message_history: MessageHistory | None = None
64
62
  ) -> BaseMessageIntel:
65
- persona_name = getattr(settings, 'DJANGO_SPIRE_AI_PERSONA_NAME', 'AI Assistant')
66
-
67
- system_prompt = (
68
- Prompt()
69
- .text(f'You are {persona_name}, a helpful AI assistant.')
70
- .line_break()
71
- .text('Important rules:')
72
- .list([
73
- f'You should always identify yourself as {persona_name}.',
74
- 'Please never mention being Qwen, Claude, GPT, or any other model name.',
75
- 'Be helpful, friendly, and professional.',
76
- ])
63
+ from django_spire.knowledge.intelligence.workflows.knowledge_workflow import (
64
+ knowledge_search_workflow,
77
65
  )
78
66
 
79
- bot = Bot()
80
- bot.llm_role = system_prompt
67
+ if request.user.has_perm('django_spire_knowledge.view_collection'):
68
+ return knowledge_search_workflow(
69
+ request=request,
70
+ user_input=user_input,
71
+ message_history=message_history
72
+ )
81
73
 
82
- return bot.llm.prompt_to_intel(
83
- prompt=user_input,
84
- intel_class=DefaultMessageIntel,
85
- message_history=message_history,
74
+ return DefaultMessageIntel(
75
+ text='Sorry, I could not find any information on that.'
86
76
  )
87
77
 
88
78
  def workflow(
@@ -11,8 +11,10 @@
11
11
  {% endblock %}
12
12
 
13
13
  {% block dropdown_content %}
14
- {% include 'django_spire/dropdown/element/dropdown_link_element.html' with x_click="enable_rename_chat(); toggle_dropdown()" link_text='Rename' link_css='text-start' %}
14
+ {% if recent_chat %}
15
+ {% include 'django_spire/dropdown/element/dropdown_link_element.html' with x_click="enable_rename_chat(); toggle_dropdown()" link_text='Rename' link_css='text-start' %}
15
16
 
16
- {% url 'django_spire:ai:chat:template:confirm_delete' pk=recent_chat.id as delete_url %}
17
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=delete_url link_text='Delete' link_css='text-app-danger text-start' %}
17
+ {% url 'django_spire:ai:chat:template:confirm_delete' pk=recent_chat.id as delete_url %}
18
+ {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=delete_url link_text='Delete' link_css='text-app-danger text-start' %}
19
+ {% endif %}
18
20
  {% endblock %}
@@ -1,5 +1,9 @@
1
1
  {% extends 'django_spire/ai/chat/message/message.html' %}
2
2
 
3
+ {% load spire_ai_chat_tags %}
4
+
3
5
  {% block message_content %}
4
- {{ message_intel.text|linebreaksbr }}
5
- {% endblock %}
6
+ <div class="ai-message-content">
7
+ {{ message_intel.text|render_markdown }}
8
+ </div>
9
+ {% endblock %}
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ import marko
4
+
5
+ from django import template
6
+ from django.utils.safestring import mark_safe
7
+
8
+
9
+ register = template.Library()
10
+
11
+
12
+ @register.filter
13
+ def render_markdown(value: str) -> str:
14
+ if not value:
15
+ return ''
16
+
17
+ html = marko.convert(value)
18
+
19
+ return mark_safe(html)
@@ -23,13 +23,26 @@ class TestSpireChatRouter(BaseTestCase):
23
23
 
24
24
  assert isinstance(router, SpireChatRouter)
25
25
 
26
- def test_default_chat_callable_returns_message_intel(self) -> None:
26
+ def test_default_chat_callable_returns_default_without_permission(self) -> None:
27
27
  router = SpireChatRouter()
28
28
 
29
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
30
- mock_bot_instance = Mock()
31
- MockBot.return_value = mock_bot_instance
32
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
29
+ self.request.user = Mock()
30
+ self.request.user.has_perm.return_value = False
31
+
32
+ result = router._default_chat_callable(
33
+ request=self.request,
34
+ user_input='Hello',
35
+ message_history=None
36
+ )
37
+
38
+ assert isinstance(result, DefaultMessageIntel)
39
+ assert result.text == 'Sorry, I could not find any information on that.'
40
+
41
+ def test_default_chat_callable_calls_knowledge_workflow_with_permission(self) -> None:
42
+ router = SpireChatRouter()
43
+
44
+ with patch('django_spire.knowledge.intelligence.workflows.knowledge_workflow.knowledge_search_workflow') as mock_workflow:
45
+ mock_workflow.return_value = DefaultMessageIntel(text='Knowledge response')
33
46
 
34
47
  result = router._default_chat_callable(
35
48
  request=self.request,
@@ -37,25 +50,13 @@ class TestSpireChatRouter(BaseTestCase):
37
50
  message_history=None
38
51
  )
39
52
 
40
- assert isinstance(result, DefaultMessageIntel)
41
- mock_bot_instance.llm.prompt_to_intel.assert_called_once()
42
-
43
- @override_settings(DJANGO_SPIRE_AI_PERSONA_NAME='Test Bot')
44
- def test_default_callable_uses_persona_name(self) -> None:
45
- router = SpireChatRouter()
46
-
47
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
48
- mock_bot_instance = Mock()
49
- MockBot.return_value = mock_bot_instance
50
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
51
-
52
- router._default_chat_callable(
53
+ mock_workflow.assert_called_once_with(
53
54
  request=self.request,
54
55
  user_input='Hello',
55
56
  message_history=None
56
57
  )
57
58
 
58
- assert MockBot.called
59
+ assert isinstance(result, DefaultMessageIntel)
59
60
 
60
61
  def test_workflow_uses_intent_decoder(self) -> None:
61
62
  router = SpireChatRouter()
@@ -131,72 +132,3 @@ class TestSpireChatRouter(BaseTestCase):
131
132
 
132
133
  assert isinstance(result, DefaultMessageIntel)
133
134
  assert result.text == 'Test Response'
134
-
135
- @override_settings(DJANGO_SPIRE_AI_PERSONA_NAME='Custom Persona')
136
- def test_default_callable_with_custom_persona(self) -> None:
137
- router = SpireChatRouter()
138
-
139
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
140
- mock_bot_instance = Mock()
141
- MockBot.return_value = mock_bot_instance
142
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
143
-
144
- router._default_chat_callable(
145
- request=self.request,
146
- user_input='Hello',
147
- message_history=None
148
- )
149
-
150
- assert mock_bot_instance.llm_role is not None
151
-
152
- def test_default_callable_passes_message_history(self) -> None:
153
- router = SpireChatRouter()
154
- message_history = MessageHistory()
155
-
156
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
157
- mock_bot_instance = Mock()
158
- MockBot.return_value = mock_bot_instance
159
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
160
-
161
- router._default_chat_callable(
162
- request=self.request,
163
- user_input='Hello',
164
- message_history=message_history
165
- )
166
-
167
- call_kwargs = mock_bot_instance.llm.prompt_to_intel.call_args[1]
168
- assert call_kwargs['message_history'] == message_history
169
-
170
- def test_default_callable_passes_user_input(self) -> None:
171
- router = SpireChatRouter()
172
-
173
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
174
- mock_bot_instance = Mock()
175
- MockBot.return_value = mock_bot_instance
176
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
177
-
178
- router._default_chat_callable(
179
- request=self.request,
180
- user_input='Test input',
181
- message_history=None
182
- )
183
-
184
- call_kwargs = mock_bot_instance.llm.prompt_to_intel.call_args[1]
185
- assert call_kwargs['prompt'] == 'Test input'
186
-
187
- def test_default_callable_uses_default_message_intel(self) -> None:
188
- router = SpireChatRouter()
189
-
190
- with patch('django_spire.ai.chat.router.Bot') as MockBot:
191
- mock_bot_instance = Mock()
192
- MockBot.return_value = mock_bot_instance
193
- mock_bot_instance.llm.prompt_to_intel.return_value = DefaultMessageIntel(text='Response')
194
-
195
- router._default_chat_callable(
196
- request=self.request,
197
- user_input='Hello',
198
- message_history=None
199
- )
200
-
201
- call_kwargs = mock_bot_instance.llm.prompt_to_intel.call_args[1]
202
- assert call_kwargs['intel_class'] == DefaultMessageIntel
@@ -0,0 +1,33 @@
1
+ <div class="text-center">
2
+ <button id="android-install-button" class="btn btn-app-primary fs-4 mt-3" hidden>
3
+ Install App <i class="bi bi-file-arrow-down"></i>
4
+ </button>
5
+ </div>
6
+
7
+ <script type="application/javascript">
8
+ let android_install_prompt = null;
9
+ let android_install_button = document.querySelector("#android-install-button");
10
+
11
+ window.addEventListener("beforeinstallprompt", (event) => {
12
+ event.preventDefault();
13
+ android_install_prompt = event;
14
+ android_install_button.removeAttribute("hidden");
15
+ });
16
+
17
+ android_install_button.addEventListener("click", async () => {
18
+ if (!android_install_prompt) {
19
+ return;
20
+ }
21
+ const result = await android_install_prompt.prompt();
22
+ disableInAppInstallPrompt();
23
+ });
24
+
25
+ window.addEventListener("appinstalled", () => {
26
+ disableInAppInstallPrompt();
27
+ });
28
+
29
+ function disableInAppInstallPrompt() {
30
+ android_install_prompt = null;
31
+ android_install_button.setAttribute("hidden", "");
32
+ }
33
+ </script>
@@ -0,0 +1,48 @@
1
+ {% load static %}
2
+
3
+ <div class="text-center">
4
+ <button id="ios-install-button" class="btn btn-app-primary fs-4 mt-3" data-bs-toggle="modal" data-bs-target="#ios-install-app-modal" hidden>
5
+ Install App <i class="bi bi-file-arrow-down"></i>
6
+ </button>
7
+ </div>
8
+
9
+ <div class="modal fade" id="ios-install-app-modal" tabindex="-1" aria-labelledby="exampleModalLabel" aria-hidden="true" data-bs-theme="{{ theme.mode }}">
10
+ <div class="modal-dialog">
11
+ <div class="modal-content bg-app-layer-one">
12
+ <div class="modal-header">
13
+ <img
14
+ src="{% block app_icon %}{% endblock %}"
15
+ alt="No Image"
16
+ class="border rounded-3 me-3 shadow"
17
+ style=" height: 3.0rem; "
18
+ >
19
+ <span class="fs-5 fw-semibold">{% block app_title %}{% endblock %}</span>
20
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
21
+ </div>
22
+ <div class="modal-body">
23
+ <p>Install the app on your Apple device to easily access it anytime by following the instructions below in the Safari Web Browser.</p>
24
+ <ol>
25
+ <li class="p-2">Tap on the <span class="border border-2 p-1 rounded-3"><i class="bi bi-box-arrow-up"></i></span> in your browser bar</li>
26
+ <li class="p-2">Select <span class="border border-2 p-1 rounded-3">Add to Home Screen</span></li>
27
+ <li class="p-2">Click <span class="border border-2 p-1 rounded-3">Add</span> When Prompted</li>
28
+ </ol>
29
+ <p class="m-0">You will need to close this popup to complete the installation process</p>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </div>
34
+
35
+ <script type="application/javascript">
36
+ let ios_install_button = document.querySelector("#ios-install-button");
37
+
38
+ const isIos = () => {
39
+ const userAgent = window.navigator.userAgent.toLowerCase();
40
+ return /iphone|ipad|ipod/.test(userAgent);
41
+ }
42
+
43
+ const isInStandaloneMode = () => ('standalone' in window.navigator) && (window.navigator.standalone);
44
+
45
+ if (isIos() && !isInStandaloneMode()) {
46
+ ios_install_button.removeAttribute("hidden");
47
+ }
48
+ </script>
@@ -10,7 +10,8 @@
10
10
  <div class="text-center pb-5">
11
11
  <a href="/">
12
12
  {% block authentication_page_img %}
13
- <img height="120px" class="ms-1" src="{% static 'img/django_spire.png' %}" alt="">
13
+ <img height="120px" class="ms-1" src="{% static 'django_spire/img/django_spire.svg' %}"
14
+ alt="">
14
15
  {% endblock %}
15
16
  </a>
16
17
  </div>
@@ -9,4 +9,6 @@
9
9
  Forgot my password
10
10
  </a>
11
11
  </div>
12
+ {% include 'django_spire/auth/element/android_and_chrome_app_install_element.html' %}
13
+ {% include 'django_spire/auth/element/ios_app_install_element.html' %}
12
14
  {% endblock %}
@@ -3,17 +3,17 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from django.contrib.contenttypes.fields import GenericRelation
6
- from django.db import models
7
6
 
8
7
  from django_spire.comment.models import Comment
8
+ from django_spire.history.activity.mixins import ActivityMixin
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from django.contrib.auth.models import User
12
12
 
13
13
 
14
- class CommentModelMixin(models.Model):
14
+ class CommentModelMixin(ActivityMixin):
15
15
  comments = GenericRelation(
16
- 'comment.Comment',
16
+ Comment,
17
17
  related_query_name='comment',
18
18
  editable=False
19
19
  )
@@ -1,30 +1,33 @@
1
1
  {% extends 'django_spire/card/title_card.html' %}
2
2
 
3
3
  {% load permission_tags %}
4
+ {% load model_tags %}
4
5
 
5
6
  {% block card_title %}
6
7
  Comments
7
8
  {% endblock %}
8
9
 
9
10
  {% block card_button %}
11
+ {% with app_label=obj|model_app_label model_name=obj|model_name %}
10
12
  {% check_permission user app_label model_name 'change' as has_access %}
11
13
  {% if has_access %}
12
14
  <span
13
- @click="dispatch_modal_view('{% url "comment:form_content" obj_pk=obj.pk comment_pk=0 app_label=app_label model_name=model_name %}')"
15
+ @click="dispatch_modal_view('{% url "django_spire:comment:form_content" obj_pk=obj.pk comment_pk=0 app_label=app_label model_name=model_name %}')"
14
16
  >
15
- {% include 'button/primary_dark_button.html' with button_logo_only='bi bi-plus' %}
16
- </span>
17
+ {% include 'django_spire/button/primary_button.html' with button_text='Add' button_icon='bi bi-plus' %}
18
+ </span>
17
19
  {% endif %}
20
+ {% endwith %}
18
21
  {% endblock %}
19
22
 
20
23
  {% block card_title_content %}
21
24
  {% with obj.comments.active.prefetch_user as comments %}
22
25
  {% if comments %}
23
26
  {% for comment in comments %}
24
- {% include 'comment/item/comment_item.html' %}
27
+ {% include 'django_spire/comment/item/comment_item.html' %}
25
28
  {% endfor %}
26
29
  {% else %}
27
- {% include 'item/no_data_item.html' %}
30
+ {% include 'django_spire/item/no_data_item.html' %}
28
31
  {% endif %}
29
32
  {% endwith %}
30
33
  {% endblock %}
@@ -1,18 +1,18 @@
1
1
  {% extends 'django_spire/modal/content/modal_title_content.html' %}
2
2
 
3
3
  {% block modal_content_title %}
4
- Comment
4
+ {{ model_name }} Comment
5
5
  {% endblock %}
6
6
 
7
7
  {% block modal_content_content %}
8
8
  <form
9
- action="{% url 'comment:form' comment_pk=comment.pk|default:0 obj_pk=obj_pk app_label=app_label model_name=model_name %}"
9
+ action="{% url 'django_spire:comment:form' comment_pk=comment.pk|default:0 obj_pk=obj_pk app_label=app_label model_name=model_name %}"
10
10
  method="post"
11
11
  >
12
12
  {% csrf_token %}
13
13
  {% include 'django_spire/comment/form/content/comment_form_content.html' %}
14
14
  <div class="pt-3">
15
- {% include 'django_spire/contrib/form/form_submit_button.html' %}
15
+ {% include 'django_spire/contrib/form/button/form_submit_button.html' %}
16
16
  </div>
17
17
  </form>
18
18
  {% endblock %}
@@ -4,6 +4,7 @@
4
4
  comment: new ModelObjectGlue('comment'),
5
5
  async init() {
6
6
  await this.comment.get()
7
+ this.comment.glue_fields.information.label = 'Message'
7
8
  }
8
9
  }"
9
10
  >
@@ -1,5 +1,6 @@
1
1
  {% extends 'django_spire/dropdown/ellipsis_dropdown.html' %}
2
2
  {% load spire_core_tags %}
3
+ {% load model_tags %}
3
4
 
4
5
  {% block dropdown_content %}
5
6
  {# {% include 'django_spire/comment/element/comment_edit_link.html' %}#}
@@ -8,14 +9,17 @@
8
9
  {# {% endif %}#}
9
10
 
10
11
  {# {% url 'comment:delete_form' pk=comment.pk as delete_url %}#}
11
- {% if comment.user_id == user.id %}
12
- {% url 'comment:form_content' comment_pk=comment.pk obj_pk=obj.pk app_label=app_label model_name=model_name as edit_url %}
13
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with link_text='Edit' view_url=edit_url %}
14
- {% endif %}
15
- {% if comment.user_id == user.id %}
16
- {% url 'comment:delete_form' comment_pk=comment.pk obj_pk=obj.pk app_label=app_label model_name=model_name as delete_url %}
17
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with link_text='Delete' link_css='text-danger' view_url=delete_url %}
18
- {% endif %}
12
+
13
+ {% with app_label=obj|model_app_label model_name=obj|model_name %}
14
+ {% if comment.user_id == user.id %}
15
+ {% url 'django_spire:comment:form_content' comment_pk=comment.pk obj_pk=obj.pk app_label=app_label model_name=model_name as edit_url %}
16
+ {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with link_text='Edit' view_url=edit_url %}
17
+ {% endif %}
18
+ {% if comment.user_id == user.id %}
19
+ {% url 'django_spire:comment:delete_form' comment_pk=comment.pk obj_pk=obj.pk app_label=app_label model_name=model_name as delete_url %}
20
+ {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with link_text='Delete' link_css='text-danger' view_url=delete_url %}
21
+ {% endif %}
22
+ {% endwith %}
19
23
  {# {% if comment.user_id == user.id %}#}
20
24
  {# {% include 'django_spire/dropdown/element/dropdown_link_element.html' with link_text='Delete' link_css='text-danger' link_href=delete_url %}#}
21
25
  {# {% endif %}#}
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ import django_glue as dg
5
6
  from django.contrib import messages
6
7
  from django.contrib.auth.decorators import login_required
7
8
  from django.http import HttpResponseRedirect
@@ -9,15 +10,14 @@ from django.shortcuts import get_object_or_404
9
10
  from django.template.response import TemplateResponse
10
11
  from django.urls import reverse
11
12
 
13
+ from django_spire.auth.group.utils import has_app_permission_or_404
12
14
  from django_spire.comment import models
13
15
  from django_spire.comment.forms import CommentForm
14
- from django_spire.core.redirect import safe_redirect_url
15
- from django_spire.core.shortcuts import get_object_or_null_obj, model_object_from_app_label
16
16
  from django_spire.contrib.form.utils import show_form_errors
17
- from django_spire.auth.group.utils import has_app_permission_or_404
18
17
  from django_spire.contrib.generic_views import dispatch_modal_delete_form_content
19
-
20
- import django_glue as dg
18
+ from django_spire.core.redirect import safe_redirect_url
19
+ from django_spire.core.shortcuts import get_object_or_null_obj, \
20
+ model_object_from_app_label
21
21
 
22
22
  if TYPE_CHECKING:
23
23
  from django.core.handlers.wsgi import WSGIRequest
@@ -112,7 +112,7 @@ def comment_modal_delete_form_view(
112
112
  messages.warning(request, 'You can only delete your comments.')
113
113
  return HttpResponseRedirect(return_url)
114
114
 
115
- form_action = reverse('comment:delete_form', kwargs={
115
+ form_action = reverse('django_spire:comment:delete_form', kwargs={
116
116
  'comment_pk': comment_pk,
117
117
  'obj_pk': obj_pk,
118
118
  'app_label': app_label,
@@ -123,8 +123,7 @@ def comment_modal_delete_form_view(
123
123
  obj.add_activity(
124
124
  user=request.user,
125
125
  verb='deleted',
126
- device=request.device,
127
- information=f'{request.user.get_full_name()} deleted a comment on "{obj}".'
126
+ information=f'{request.user.get_full_name()} deleted a comment on "{obj}".',
128
127
  )
129
128
 
130
129
  return dispatch_modal_delete_form_content(
@@ -133,4 +132,5 @@ def comment_modal_delete_form_view(
133
132
  form_action=form_action,
134
133
  activity_func=add_activity,
135
134
  return_url=return_url,
135
+ show_success_message=True
136
136
  )
django_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.25.1'
1
+ __VERSION__ = '0.26.0'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
@@ -6,10 +6,10 @@ from django.contrib import messages
6
6
 
7
7
  if TYPE_CHECKING:
8
8
  from django.core.handlers.wsgi import WSGIRequest
9
- from django.forms import Form
9
+ from django.forms import BaseForm
10
10
 
11
11
 
12
- def form_errors_as_list(form: Form) -> list[str]:
12
+ def form_errors_as_list(form: BaseForm) -> list[str]:
13
13
  form_errors = []
14
14
 
15
15
  for field_name, error_list in form.errors.items():
@@ -34,7 +34,7 @@ def form_errors_as_list(form: Form) -> list[str]:
34
34
  return form_errors
35
35
 
36
36
 
37
- def show_form_errors(request: WSGIRequest, *forms: Form) -> None:
37
+ def show_form_errors(request: WSGIRequest, *forms: BaseForm) -> None:
38
38
  for form in forms:
39
39
  for error in form_errors_as_list(form):
40
40
  messages.error(request=request, message=error)
@@ -13,7 +13,7 @@ from django_spire.contrib.progress.enums import ProgressStatus
13
13
  from django_spire.contrib.progress.tasks import ParallelTask, SequentialTask
14
14
 
15
15
  if TYPE_CHECKING:
16
- from typing_extensions import Any, Callable, Generator
16
+ from typing import Any, Callable, Generator
17
17
 
18
18
 
19
19
  SIMULATION_MESSAGES = [
@@ -1,9 +1,10 @@
1
1
  from __future__ import annotations
2
2
 
3
- from typing import TYPE_CHECKING
3
+ from collections.abc import Sequence
4
+ from typing import Any
4
5
 
5
- if TYPE_CHECKING:
6
- from django.db.models import QuerySet
6
+ from django.contrib.contenttypes.fields import GenericForeignKey
7
+ from django.db.models import QuerySet, Field, ForeignObjectRel, ForeignKey
7
8
 
8
9
 
9
10
  def filter_by_lookup_map(
@@ -16,19 +17,15 @@ def filter_by_lookup_map(
16
17
  Filters a given queryset based on a lookup map and provided data. Additional filters
17
18
  can also be applied if provided.
18
19
 
19
- Parameters:
20
- queryset (QuerySet): The queryset to be filtered.
21
- lookup_map (dict): A dictionary mapping input data keys to filtering fields in
22
- the queryset.
23
- data (dict): A dictionary containing key-value pairs to filter the queryset.
24
- extra_filters (list | None): Optional. A list of extra positional filter
25
- arguments to be applied. Defaults to None.
26
-
27
- Returns:
28
- QuerySet: The filtered queryset.
20
+ :param queryset (QuerySet): The queryset to be filtered.
21
+ :param lookup_map (dict): A dictionary mapping input data keys to filtering fields in
22
+ the queryset.
23
+ :param data (dict): A dictionary containing key-value pairs to filter the queryset.
24
+ :param extra_filters (list | None): Optional. A list of extra positional filter
25
+ arguments to be applied. Defaults to None.
29
26
 
27
+ :returns: QuerySet: The filtered queryset.
30
28
  """
31
-
32
29
  if extra_filters is None:
33
30
  extra_filters = {}
34
31
 
@@ -39,3 +36,48 @@ def filter_by_lookup_map(
39
36
  } | extra_filters
40
37
 
41
38
  return queryset.filter(**lookup_kwargs)
39
+
40
+
41
+ def _get_kwarg_name_for_filter_field(
42
+ field: Field[Any, Any] | ForeignObjectRel | GenericForeignKey,
43
+ val: Any
44
+ ) -> str:
45
+ """
46
+ Determines the appropriate keyword argument name for filtering based on the field type
47
+ and value type.
48
+
49
+ :param field (Field | ForeignObjectRel | GenericForeignKey): The model field to generate
50
+ the filter keyword argument for.
51
+ :param val (Any): The value to be used for filtering.
52
+
53
+ :returns: str: The keyword argument string suitable for filtering.
54
+ """
55
+ if isinstance(val, Sequence):
56
+ if isinstance(field, ForeignKey):
57
+ return f'{field.name}_id__in'
58
+ return f'{field.name}__in'
59
+ else:
60
+ return field.name
61
+
62
+
63
+ def filter_by_model_fields(queryset: QuerySet, data: dict) -> QuerySet:
64
+ """
65
+ Filters a given queryset based on the queryset's model fields and provided data.
66
+
67
+ :param queryset (QuerySet): The queryset to be filtered.
68
+ :param data (dict): A dictionary containing key-value pairs to filter the queryset.
69
+
70
+ :returns: QuerySet: The filtered queryset.
71
+ """
72
+ model_fields = [f for f in queryset.model._meta.get_fields()]
73
+
74
+ lookup_kwargs = {
75
+ _get_kwarg_name_for_filter_field(
76
+ field=model_field,
77
+ val=data[model_field.name]
78
+ ): data[model_field.name]
79
+ for model_field in model_fields
80
+ if model_field.name in data and data[model_field.name] not in (None, "", [])
81
+ }
82
+
83
+ return queryset.filter(**lookup_kwargs)