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.
- django_spire/ai/chat/router.py +10 -20
- django_spire/ai/chat/templates/django_spire/ai/chat/dropdown/ellipsis_dropdown.html +5 -3
- django_spire/ai/chat/templates/django_spire/ai/chat/message/default_message.html +6 -2
- django_spire/ai/chat/templatetags/spire_ai_chat_tags.py +19 -0
- django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +20 -88
- django_spire/auth/templates/django_spire/auth/element/android_and_chrome_app_install_element.html +33 -0
- django_spire/auth/templates/django_spire/auth/element/ios_app_install_element.html +48 -0
- django_spire/auth/templates/django_spire/auth/page/auth_page.html +2 -1
- django_spire/auth/templates/django_spire/auth/page/login_page.html +2 -0
- django_spire/comment/mixins.py +3 -3
- django_spire/comment/templates/django_spire/comment/card/comment_list_card.html +8 -5
- django_spire/comment/templates/django_spire/comment/form/comment_form.html +3 -3
- django_spire/comment/templates/django_spire/comment/form/content/comment_form_content.html +1 -0
- django_spire/comment/templates/django_spire/comment/item/comment_item_ellipsis.html +12 -8
- django_spire/comment/views.py +8 -8
- django_spire/consts.py +1 -1
- django_spire/contrib/form/utils.py +3 -3
- django_spire/contrib/progress/session.py +1 -1
- django_spire/contrib/queryset/filter_tools.py +56 -14
- django_spire/contrib/queryset/mixins.py +24 -3
- django_spire/contrib/service/django_model_service.py +5 -6
- django_spire/core/management/commands/spire_startapp.py +42 -25
- django_spire/core/management/commands/spire_startapp_pkg/exceptions.py +5 -0
- django_spire/core/management/commands/spire_startapp_pkg/maps.py +64 -32
- django_spire/core/management/commands/spire_startapp_pkg/template/app/constants.py.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py.template +4 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py.template +2 -1
- django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py.template +15 -6
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py.template +6 -6
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/template_urls.py.template +12 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py.template +10 -11
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py.template +17 -3
- django_spire/core/management/commands/spire_startapp_pkg/template/app/views/template_views.py.template +40 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${form_card_template_name}.html.template +1 -1
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_base_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_items_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_table_card_template_name}.html.template +16 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/container/${list_container_template_name}.html.template +1 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/form/${list_filter_form_template_name}.html.template +30 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${item_template_name}.html.template +32 -20
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/item/${list_items_template_name}.html.template +3 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${detail_page_template_name}.html.template +3 -3
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${form_page_template_name}.html.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/page/${list_page_template_name}.html.template +2 -2
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_row_template_name}.html.template +6 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_rows_template_name}.html.template +3 -0
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/table/${table_template_name}.html.template +6 -0
- django_spire/core/management/commands/spire_startapp_pkg/user_input.py +82 -9
- django_spire/core/management/commands/spire_startapp_pkg/validator.py +19 -6
- django_spire/core/middleware.py +2 -3
- django_spire/core/querysets.py +19 -0
- django_spire/core/static/django_spire/js/theme.js +10 -7
- django_spire/core/templates/django_spire/badge/base_badge.html +2 -3
- django_spire/core/templates/django_spire/base/base.html +1 -0
- django_spire/core/templates/django_spire/button/base_button.html +2 -1
- django_spire/core/templates/django_spire/card/title_card.html +13 -10
- django_spire/core/templates/django_spire/container/container.html +1 -1
- django_spire/core/templates/django_spire/filtering/form/base_session_filter_form.html +1 -1
- django_spire/core/templates/django_spire/form/field/_base_file_field.html +216 -0
- django_spire/core/templates/django_spire/form/field/_multi_checkbox_field.html +52 -0
- django_spire/core/templates/django_spire/form/field/base_field.html +128 -0
- django_spire/core/templates/django_spire/form/field/char_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/color_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/date_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/datetime_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/decimal_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/element/select_checkmark_element.html +4 -0
- django_spire/core/templates/django_spire/form/field/element/select_down_arrow_element.html +5 -0
- django_spire/core/templates/django_spire/form/field/email_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/input_field.html +13 -0
- django_spire/core/templates/django_spire/form/field/item/select_choice_item.html +15 -0
- django_spire/core/templates/django_spire/form/field/item/selected_choice_item.html +5 -0
- django_spire/core/templates/django_spire/form/field/list_field.html +112 -0
- django_spire/core/templates/django_spire/form/field/multi_file_field.html +90 -0
- django_spire/core/templates/django_spire/form/field/multi_select_field.html +155 -0
- django_spire/core/templates/django_spire/form/field/number_field.html +11 -0
- django_spire/core/templates/django_spire/form/field/password_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/radio_field.html +24 -0
- django_spire/core/templates/django_spire/form/field/range_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/search_and_select_field.html +119 -0
- django_spire/core/templates/django_spire/form/field/search_field.html +5 -0
- django_spire/core/templates/django_spire/form/field/select_field.html +78 -0
- django_spire/core/templates/django_spire/form/field/single_checkbox_field.html +27 -0
- django_spire/core/templates/django_spire/form/field/single_file_field.html +90 -0
- django_spire/core/templates/django_spire/form/field/telephone_field.html +1 -0
- django_spire/core/templates/django_spire/form/field/text_field.html +11 -0
- django_spire/core/templates/django_spire/form/field/time_field.html +1 -0
- django_spire/core/templates/django_spire/infinite_scroll/base.html +2 -1
- django_spire/core/templatetags/model_tags.py +34 -0
- django_spire/knowledge/entry/forms.py +1 -1
- django_spire/knowledge/entry/models.py +18 -0
- django_spire/knowledge/entry/querysets.py +8 -6
- django_spire/knowledge/entry/services/processor_service.py +1 -0
- django_spire/knowledge/entry/services/search_index_service.py +61 -0
- django_spire/knowledge/entry/services/search_service.py +99 -0
- django_spire/knowledge/entry/services/service.py +6 -0
- django_spire/knowledge/entry/version/services/processor_service.py +2 -0
- django_spire/knowledge/entry/version/tests/factories.py +9 -4
- django_spire/knowledge/entry/version/tests/test_services.py +7 -16
- django_spire/knowledge/intelligence/bots/knowledge_answer_bot.py +40 -6
- django_spire/knowledge/intelligence/bots/knowledge_entries_bot.py +4 -2
- django_spire/knowledge/intelligence/bots/search_preprocessing_bot.py +32 -0
- django_spire/knowledge/intelligence/intel/entry_intel.py +12 -0
- django_spire/knowledge/intelligence/router.py +47 -4
- django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +24 -42
- django_spire/knowledge/intelligence/workflows/search_preprocessing_workflow.py +78 -0
- django_spire/knowledge/management/__init__.py +0 -0
- django_spire/knowledge/management/commands/__init__.py +0 -0
- django_spire/knowledge/management/commands/rebuild_knowledge_search_index.py +16 -0
- django_spire/knowledge/migrations/0010_entry__search_text_entry__search_vector_and_more.py +40 -0
- django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html +31 -23
- django_spire/metric/report/enums.py +11 -5
- django_spire/metric/report/report.py +24 -12
- django_spire/metric/report/tools.py +14 -4
- django_spire/testing/playwright/fixtures.py +4 -5
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/METADATA +1 -1
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/RECORD +123 -69
- django_spire/core/management/commands/spire_startapp_pkg/template/templates/card/${list_card_template_name}.html.template +0 -18
- /django_spire/{core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py.template → ai/chat/templatetags/__init__.py} +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/WHEEL +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.25.1.dist-info → django_spire-0.26.0.dist-info}/top_level.txt +0 -0
django_spire/ai/chat/router.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
{%
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
5
|
-
{
|
|
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
|
|
26
|
+
def test_default_chat_callable_returns_default_without_permission(self) -> None:
|
|
27
27
|
router = SpireChatRouter()
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
django_spire/auth/templates/django_spire/auth/element/android_and_chrome_app_install_element.html
ADDED
|
@@ -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.
|
|
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>
|
django_spire/comment/mixins.py
CHANGED
|
@@ -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(
|
|
14
|
+
class CommentModelMixin(ActivityMixin):
|
|
15
15
|
comments = GenericRelation(
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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 %}
|
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
{%
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
{%
|
|
17
|
-
{%
|
|
18
|
-
|
|
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 %}#}
|
django_spire/comment/views.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
@@ -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
|
|
9
|
+
from django.forms import BaseForm
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def form_errors_as_list(form:
|
|
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:
|
|
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
|
|
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
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from typing import Any
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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)
|