django-spire 0.25.1__py3-none-any.whl → 0.25.2__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 (41) 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/__init__.py +0 -0
  5. django_spire/ai/chat/templatetags/spire_ai_chat_tags.py +19 -0
  6. django_spire/ai/chat/tests/test_router/test_spire_chat_router.py +20 -88
  7. django_spire/consts.py +1 -1
  8. django_spire/contrib/progress/session.py +1 -1
  9. django_spire/core/middleware.py +2 -3
  10. django_spire/core/static/django_spire/js/theme.js +10 -7
  11. django_spire/knowledge/entry/forms.py +1 -1
  12. django_spire/knowledge/entry/models.py +18 -0
  13. django_spire/knowledge/entry/querysets.py +8 -6
  14. django_spire/knowledge/entry/services/processor_service.py +1 -0
  15. django_spire/knowledge/entry/services/search_index_service.py +61 -0
  16. django_spire/knowledge/entry/services/search_service.py +99 -0
  17. django_spire/knowledge/entry/services/service.py +6 -0
  18. django_spire/knowledge/entry/version/services/processor_service.py +2 -0
  19. django_spire/knowledge/entry/version/tests/factories.py +9 -4
  20. django_spire/knowledge/entry/version/tests/test_services.py +7 -16
  21. django_spire/knowledge/intelligence/bots/knowledge_answer_bot.py +40 -6
  22. django_spire/knowledge/intelligence/bots/knowledge_entries_bot.py +4 -2
  23. django_spire/knowledge/intelligence/bots/search_preprocessing_bot.py +32 -0
  24. django_spire/knowledge/intelligence/intel/entry_intel.py +12 -0
  25. django_spire/knowledge/intelligence/router.py +47 -4
  26. django_spire/knowledge/intelligence/workflows/knowledge_workflow.py +24 -42
  27. django_spire/knowledge/intelligence/workflows/search_preprocessing_workflow.py +78 -0
  28. django_spire/knowledge/management/__init__.py +0 -0
  29. django_spire/knowledge/management/commands/__init__.py +0 -0
  30. django_spire/knowledge/management/commands/rebuild_knowledge_search_index.py +16 -0
  31. django_spire/knowledge/migrations/0010_entry__search_text_entry__search_vector_and_more.py +40 -0
  32. django_spire/knowledge/templates/django_spire/knowledge/message/knowledge_message_intel.html +31 -23
  33. django_spire/metric/report/enums.py +11 -5
  34. django_spire/metric/report/report.py +24 -12
  35. django_spire/metric/report/tools.py +14 -2
  36. django_spire/testing/playwright/fixtures.py +4 -5
  37. {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/METADATA +1 -1
  38. {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/RECORD +41 -31
  39. {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/WHEEL +0 -0
  40. {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/licenses/LICENSE.md +0 -0
  41. {django_spire-0.25.1.dist-info → django_spire-0.25.2.dist-info}/top_level.txt +0 -0
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from enum import StrEnum
3
4
  from typing import TYPE_CHECKING
4
5
 
5
6
  from dandy import Bot, Prompt
@@ -7,9 +8,28 @@ from dandy import Bot, Prompt
7
8
  from django_spire.knowledge.intelligence.intel.answer_intel import AnswerIntel
8
9
 
9
10
  if TYPE_CHECKING:
11
+ from dandy.llm.request.message import MessageHistory
12
+
10
13
  from django_spire.knowledge.entry.models import Entry
11
14
 
12
15
 
16
+ class BlockType(StrEnum):
17
+ HEADING = 'HEADING'
18
+ SUBHEADING = 'SUBHEADING'
19
+
20
+
21
+ class MarkerType(StrEnum):
22
+ ARTICLE = 'ARTICLE'
23
+ END_ARTICLE = 'END ARTICLE'
24
+
25
+
26
+ def format_marker(marker: MarkerType, label: str | None = None) -> str:
27
+ if label:
28
+ return f'[{marker}: {label}]'
29
+
30
+ return f'[{marker}]'
31
+
32
+
13
33
  class KnowledgeAnswerBot(Bot):
14
34
  llm_role = 'Knowledge Entry Search Assistant'
15
35
  llm_task = 'Read through the knowledge and answer the users request.'
@@ -18,13 +38,20 @@ class KnowledgeAnswerBot(Bot):
18
38
  .list([
19
39
  'Make sure the answer is relevant and reflects knowledge entries.',
20
40
  'Do not make up information use the provided knowledge entries as a source of truth.',
21
- 'Use line breaks to separate sections of the answer and use 2 if you need to separate the section from the previous.'
41
+ 'Use line breaks to separate sections of the answer and use 2 if you need to separate the section from the previous.',
42
+ 'Use the conversation history to understand context and references like "before that", "my last query", etc.',
43
+ 'When a user asks about an article or section by title, summarize the content of that article or section.',
44
+ f'Content under a [{BlockType.HEADING}] or [{BlockType.SUBHEADING}] belongs to that section.',
22
45
  ])
23
46
  )
24
47
  llm_intel_class = AnswerIntel
25
48
 
26
- def process(self, user_input: str, entries: list[Entry]) -> AnswerIntel:
27
-
49
+ def process(
50
+ self,
51
+ user_input: str,
52
+ entries: list[Entry],
53
+ message_history: MessageHistory | None = None
54
+ ) -> AnswerIntel:
28
55
  entry_prompt = Prompt()
29
56
  entry_prompt.sub_heading('User Request')
30
57
  entry_prompt.line_break()
@@ -34,11 +61,18 @@ class KnowledgeAnswerBot(Bot):
34
61
  entry_prompt.line_break()
35
62
 
36
63
  for entry in entries:
64
+ entry_prompt.text(format_marker(MarkerType.ARTICLE, entry.name))
65
+
37
66
  for version_block in entry.current_version.blocks.all():
38
- if version_block.render_to_text() != '\n':
39
- entry_prompt.text(f'{version_block.render_to_text()}')
67
+ text = version_block.render_to_text()
68
+
69
+ if text and text != '\n':
70
+ entry_prompt.text(f'[{version_block.type.upper()}] {text}')
71
+
72
+ entry_prompt.text(format_marker(MarkerType.END_ARTICLE))
73
+ entry_prompt.line_break()
40
74
 
41
75
  return self.llm.prompt_to_intel(
42
76
  prompt=entry_prompt,
77
+ message_history=message_history,
43
78
  )
44
-
@@ -25,7 +25,6 @@ class KnowledgeEntriesBot(Bot):
25
25
  llm_intel_class = EntriesIntel
26
26
 
27
27
  def process(self, user_input: str, entries: list[Entry]) -> EntriesIntel:
28
-
29
28
  entry_prompt = Prompt()
30
29
  entry_prompt.sub_heading('User Request')
31
30
  entry_prompt.line_break()
@@ -35,11 +34,14 @@ class KnowledgeEntriesBot(Bot):
35
34
  entry_prompt.line_break()
36
35
 
37
36
  for entry in entries:
37
+ entry_prompt.text(f'Entry: {entry.name}')
38
+
38
39
  for version_block in entry.current_version.blocks.all():
39
40
  if version_block.render_to_text() != '\n':
40
41
  entry_prompt.text(f'{version_block.id}: {version_block.render_to_text()}')
41
42
 
43
+ entry_prompt.line_break()
44
+
42
45
  return self.llm.prompt_to_intel(
43
46
  prompt=entry_prompt,
44
47
  )
45
-
@@ -0,0 +1,32 @@
1
+ from __future__ import annotations
2
+
3
+ from dandy import BaseIntel, Bot, Prompt
4
+
5
+
6
+ class PreprocessedQueryIntel(BaseIntel):
7
+ corrected_query: str
8
+ meaningful_words: list[str]
9
+ expanded_terms: list[str]
10
+ search_phrases: list[str]
11
+
12
+
13
+ class SearchPreprocessingBot(Bot):
14
+ llm_intel_class = PreprocessedQueryIntel
15
+ llm_role = 'Search Query Preprocessor'
16
+ llm_task = 'Process a user search query to optimize it for knowledge base retrieval.'
17
+ llm_guidelines = (
18
+ Prompt()
19
+ .list([
20
+ 'Correct any obvious spelling or grammar mistakes while preserving intent.',
21
+ 'Identify meaningful words by removing stop words (the, a, an, is, are, how, what, can, do, etc.).',
22
+ 'Keep technical terms, acronyms, nouns, verbs, and domain-specific vocabulary as meaningful words.',
23
+ 'Generate 3-5 synonyms or related terms for key concepts in expanded_terms.',
24
+ 'Provide 2-3 alternative search phrases in search_phrases.',
25
+ 'Do not change proper nouns or technical terminology.',
26
+ ])
27
+ )
28
+
29
+ def process(self, query: str) -> PreprocessedQueryIntel:
30
+ return self.llm.prompt_to_intel(
31
+ prompt=f'Process this search query: {query}'
32
+ )
@@ -23,3 +23,15 @@ class EntryIntel(BaseIntel):
23
23
 
24
24
  class EntriesIntel(BaseListIntel[EntryIntel]):
25
25
  entry_intel_list: list[EntryIntel]
26
+
27
+ @property
28
+ def unique(self) -> list[EntryIntel]:
29
+ seen = set()
30
+ result = []
31
+
32
+ for entry_intel in self:
33
+ if entry_intel.relevant_heading_text not in seen:
34
+ seen.add(entry_intel.relevant_heading_text)
35
+ result.append(entry_intel)
36
+
37
+ return result
@@ -2,25 +2,68 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
+ from dandy import Bot, Prompt
6
+
7
+ from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
5
8
  from django_spire.ai.chat.router import BaseChatRouter
6
- from django_spire.knowledge.intelligence.workflows.knowledge_workflow import knowledge_search_workflow
9
+ from django_spire.conf import settings
10
+ from django_spire.knowledge.intelligence.workflows.knowledge_workflow import (
11
+ NO_KNOWLEDGE_MESSAGE_INTEL,
12
+ knowledge_search_workflow,
13
+ )
7
14
 
8
15
  if TYPE_CHECKING:
9
16
  from dandy.llm.request.message import MessageHistory
10
17
  from django.core.handlers.wsgi import WSGIRequest
11
18
 
12
- from django_spire.ai.chat.message_intel import BaseMessageIntel
13
-
14
19
 
15
20
  class KnowledgeSearchRouter(BaseChatRouter):
21
+ def _default_chat_callable(
22
+ self,
23
+ request: WSGIRequest,
24
+ user_input: str,
25
+ message_history: MessageHistory | None = None
26
+ ) -> BaseMessageIntel:
27
+ persona_name = getattr(settings, 'DJANGO_SPIRE_AI_PERSONA_NAME', 'AI Assistant')
28
+
29
+ system_prompt = (
30
+ Prompt()
31
+ .text(f'You are {persona_name}, a helpful AI assistant.')
32
+ .line_break()
33
+ .text('Important rules:')
34
+ .list([
35
+ f'You should always identify yourself as {persona_name}.',
36
+ 'Please never mention being Qwen, Claude, GPT, or any other model name.',
37
+ 'Be helpful, friendly, and professional.',
38
+ ])
39
+ )
40
+
41
+ bot = Bot()
42
+ bot.llm_role = system_prompt
43
+
44
+ return bot.llm.prompt_to_intel(
45
+ prompt=user_input,
46
+ intel_class=DefaultMessageIntel,
47
+ message_history=message_history,
48
+ )
49
+
16
50
  def workflow(
17
51
  self,
18
52
  request: WSGIRequest,
19
53
  user_input: str,
20
54
  message_history: MessageHistory | None = None
21
55
  ) -> BaseMessageIntel:
22
- return knowledge_search_workflow(
56
+ knowledge_result = knowledge_search_workflow(
23
57
  request=request,
24
58
  user_input=user_input,
25
59
  message_history=message_history
26
60
  )
61
+
62
+ if knowledge_result == NO_KNOWLEDGE_MESSAGE_INTEL:
63
+ return self._default_chat_callable(
64
+ request=request,
65
+ user_input=user_input,
66
+ message_history=message_history
67
+ )
68
+
69
+ return knowledge_result
@@ -2,18 +2,17 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from django.core.handlers.wsgi import WSGIRequest
5
+ from django.db.models import Prefetch
6
6
 
7
- from django_spire.ai.chat.message_intel import DefaultMessageIntel, BaseMessageIntel
8
- from django_spire.core.tag.intelligence.tag_set_bot import TagSetBot
9
- from django_spire.knowledge.collection.models import Collection
7
+ from django_spire.ai.chat.message_intel import BaseMessageIntel, DefaultMessageIntel
8
+ from django_spire.knowledge.entry.models import Entry
10
9
  from django_spire.knowledge.intelligence.bots.knowledge_answer_bot import KnowledgeAnswerBot
11
10
  from django_spire.knowledge.intelligence.bots.knowledge_entries_bot import KnowledgeEntriesBot
12
11
  from django_spire.knowledge.intelligence.intel.message_intel import KnowledgeMessageIntel
13
12
 
14
13
  if TYPE_CHECKING:
15
- from django.core.handlers.wsgi import WSGIRequest
16
14
  from dandy.llm.request.message import MessageHistory
15
+ from django.core.handlers.wsgi import WSGIRequest
17
16
 
18
17
 
19
18
  NO_KNOWLEDGE_MESSAGE_INTEL = DefaultMessageIntel(
@@ -24,52 +23,35 @@ NO_KNOWLEDGE_MESSAGE_INTEL = DefaultMessageIntel(
24
23
  def knowledge_search_workflow(
25
24
  request: WSGIRequest,
26
25
  user_input: str,
27
- message_history: MessageHistory | None = None
26
+ message_history: MessageHistory | None = None,
27
+ max_results: int = 10,
28
+ use_llm_preprocessing: bool = True,
28
29
  ) -> BaseMessageIntel | None:
29
- user_tag_set = TagSetBot().process(user_input)
30
-
31
- def get_top_scored_from_dict_to_list(
32
- scored_dict: dict[str, float],
33
- score_floor: float = 0.05
34
- ) -> list:
35
- if not scored_dict:
36
- return []
37
-
38
- min_score = min(scored_dict.values())
39
- max_score = max(scored_dict.values())
40
-
41
- if min_score == 0 and max_score == 0:
42
- return []
43
-
44
- median_score = (max_score - min_score) / 2
30
+ from django_spire.knowledge.entry.version.block.models import EntryVersionBlock
45
31
 
46
- top_scored_list = []
47
-
48
- for key, value in scored_dict.items():
49
- if value >= score_floor and value >= median_score:
50
- top_scored_list.append(key)
51
-
52
- return top_scored_list
53
-
54
- collections = get_top_scored_from_dict_to_list({
55
- collection: collection.services.tag.get_score_percentage_from_aggregated_tag_set_weighted(
56
- user_tag_set
32
+ entries = list(
33
+ Entry.services.search
34
+ .search(
35
+ query=user_input,
36
+ use_llm_preprocessing=use_llm_preprocessing,
57
37
  )
58
- for collection in Collection.objects.all().annotate_entry_count()
59
- })
60
-
61
- entries = get_top_scored_from_dict_to_list({
62
- entry: entry.services.tag.get_score_percentage_from_tag_set_weighted(user_tag_set)
63
- for collection in collections
64
- for entry in collection.entries.all()
65
- })
38
+ .user_has_access(user=request.user)
39
+ .select_related('current_version', 'current_version__author', 'collection')
40
+ .prefetch_related(
41
+ Prefetch(
42
+ 'current_version__blocks',
43
+ queryset=EntryVersionBlock.objects.active().order_by('order')
44
+ )
45
+ )[:max_results]
46
+ )
66
47
 
67
48
  if not entries:
68
49
  return NO_KNOWLEDGE_MESSAGE_INTEL
69
50
 
70
51
  answer_intel_future = KnowledgeAnswerBot(llm_temperature=0.5).process_to_future(
71
52
  user_input=user_input,
72
- entries=entries
53
+ entries=entries,
54
+ message_history=message_history,
73
55
  )
74
56
 
75
57
  entries_intel_future = KnowledgeEntriesBot(llm_temperature=0.5).process_to_future(
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from django.core.cache import cache
8
+
9
+ from django_spire.knowledge.intelligence.bots.search_preprocessing_bot import (
10
+ PreprocessedQueryIntel,
11
+ SearchPreprocessingBot,
12
+ )
13
+
14
+
15
+ PREPROCESSING_CACHE_TIMEOUT = 3600
16
+
17
+
18
+ def _get_cache_key(query: str) -> str:
19
+ normalized = query.lower().strip()
20
+ query_hash = hashlib.sha256(normalized.encode()).hexdigest()
21
+ return f'knowledge_search_preprocess:{query_hash}'
22
+
23
+
24
+ @dataclass
25
+ class PreprocessedSearchQuery:
26
+ corrected_query: str
27
+ expanded_terms: list[str]
28
+ meaningful_words: list[str]
29
+ original_query: str
30
+ search_phrases: list[str]
31
+
32
+ @property
33
+ def all_search_terms(self) -> set[str]:
34
+ terms = set(self.meaningful_words)
35
+ terms.update(self.expanded_terms)
36
+
37
+ if not terms:
38
+ terms.add(self.corrected_query or self.original_query)
39
+
40
+ return terms
41
+
42
+ @property
43
+ def primary_search_query(self) -> str:
44
+ if self.meaningful_words:
45
+ return ' '.join(self.meaningful_words)
46
+
47
+ return self.corrected_query or self.original_query
48
+
49
+
50
+ def preprocess_search_query(
51
+ query: str,
52
+ use_cache: bool = True,
53
+ llm_temperature: float = 0.3
54
+ ) -> PreprocessedSearchQuery:
55
+ if use_cache:
56
+ cache_key = _get_cache_key(query)
57
+ cached = cache.get(cache_key)
58
+
59
+ if cached:
60
+ return cached
61
+
62
+ intel: PreprocessedQueryIntel = (
63
+ SearchPreprocessingBot(llm_temperature=llm_temperature)
64
+ .process(query=query)
65
+ )
66
+
67
+ result = PreprocessedSearchQuery(
68
+ corrected_query=intel.corrected_query,
69
+ expanded_terms=intel.expanded_terms,
70
+ meaningful_words=intel.meaningful_words,
71
+ original_query=query,
72
+ search_phrases=intel.search_phrases,
73
+ )
74
+
75
+ if use_cache:
76
+ cache.set(cache_key, result, PREPROCESSING_CACHE_TIMEOUT)
77
+
78
+ return result
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from django.core.management.base import BaseCommand
4
+
5
+ from django_spire.knowledge.entry.models import Entry
6
+
7
+
8
+ class Command(BaseCommand):
9
+ help = 'Rebuilds the search index for all knowledge base entries.'
10
+
11
+ def handle(self, *args, **options):
12
+ self.stdout.write('Rebuilding search indexes...')
13
+
14
+ Entry.services.search_index.rebuild_all_search_indexes()
15
+
16
+ self.stdout.write(self.style.SUCCESS('Search indexes rebuilt successfully.'))
@@ -0,0 +1,40 @@
1
+ # Generated by Django 5.1.15 on 2026-01-23 16:22
2
+
3
+ import django.contrib.postgres.indexes
4
+ import django.contrib.postgres.search
5
+ from django.contrib.postgres.operations import TrigramExtension
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+
11
+ dependencies = [
12
+ ('django_spire_core', '0001_initial'),
13
+ ('django_spire_knowledge', '0009_alter_collection_tags_alter_entry_tags'),
14
+ ]
15
+
16
+ operations = [
17
+ TrigramExtension(),
18
+ migrations.AddField(
19
+ model_name='entry',
20
+ name='_search_text',
21
+ field=models.TextField(blank=True, default=''),
22
+ ),
23
+ migrations.AddField(
24
+ model_name='entry',
25
+ name='_search_vector',
26
+ field=django.contrib.postgres.search.SearchVectorField(null=True),
27
+ ),
28
+ migrations.AddIndex(
29
+ model_name='entry',
30
+ index=django.contrib.postgres.indexes.GinIndex(fields=['_search_vector'], name='entry_search_vector_idx'),
31
+ ),
32
+ migrations.AddIndex(
33
+ model_name='entry',
34
+ index=django.contrib.postgres.indexes.GinIndex(fields=['name'], name='entry_name_trgm_idx', opclasses=['gin_trgm_ops']),
35
+ ),
36
+ migrations.AddIndex(
37
+ model_name='entry',
38
+ index=django.contrib.postgres.indexes.GinIndex(fields=['_search_text'], name='entry_search_text_trgm_idx', opclasses=['gin_trgm_ops']),
39
+ ),
40
+ ]
@@ -1,32 +1,40 @@
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
6
  <div class="row">
5
- <div class="col">
6
- {{ message_intel.answer_intel.answer|linebreaksbr }}
7
+ <div class="col ai-message-content">
8
+ {{ message_intel.answer_intel.answer|render_markdown }}
7
9
  </div>
8
10
  </div>
9
11
 
10
- <div class="row mt-2 p-1" x-data="{show_resources: false}">
11
- <div class="col-auto mx-2 ps-1 pe-2 py-1 fs-7 rounded-3 border border-primary-subtle">
12
- <span class="text-muted cursor-pointer" x-on:click="show_resources = !show_resources">
13
- <i class="bi bi-caret-right" x-show="!show_resources"></i>
14
- <i class="bi bi-caret-down" x-show="show_resources"></i>
15
- Sources
16
- </span>
17
-
18
- <ul class="my-0 me-3" x-show="show_resources">
19
- {% for entry_intel in message_intel.entries_intel %}
20
- <li class="pb-1">
21
- <a href="{% url 'django_spire:knowledge:entry:version:page:editor' pk=entry_intel.entry.id %}?show_sub_nav=false&block_id={{ entry_intel.relevant_block_id }}"
22
- target="_blank">
23
- {{ entry_intel.relevant_heading_text }}
24
- </a>
25
- </li>
26
- {% endfor %}
27
- </ul>
12
+ {% if message_intel.entries_intel %}
13
+ <div class="row mt-2 p-1" x-data="{show_resources: false}">
14
+ <div class="col-auto mx-2 ps-1 pe-2 py-1 fs-7 rounded-3 border border-primary-subtle">
15
+ <span class="text-muted cursor-pointer" x-on:click="show_resources = !show_resources">
16
+ <i class="bi bi-caret-right" x-show="!show_resources"></i>
17
+ <i class="bi bi-caret-down" x-show="show_resources"></i>
18
+ Sources
19
+ </span>
28
20
 
21
+ <ul class="my-0 me-3" x-show="show_resources">
22
+ {% for entry_intel in message_intel.entries_intel.unique %}
23
+ {% if entry_intel.entry and entry_intel.entry.id %}
24
+ <li class="pb-1">
25
+ <a href="{% url 'django_spire:knowledge:entry:version:page:editor' pk=entry_intel.entry.id %}?show_sub_nav=false&block_id={{ entry_intel.relevant_block_id }}"
26
+ target="_blank">
27
+ {{ entry_intel.relevant_heading_text }}
28
+ </a>
29
+ </li>
30
+ {% else %}
31
+ <li class="pb-1 text-muted">
32
+ {{ entry_intel.relevant_heading_text }} (source unavailable)
33
+ </li>
34
+ {% endif %}
35
+ {% endfor %}
36
+ </ul>
37
+ </div>
29
38
  </div>
30
- </div>
31
-
32
- {% endblock %}
39
+ {% endif %}
40
+ {% endblock %}
@@ -1,14 +1,20 @@
1
- from enum import Enum
1
+ from enum import StrEnum
2
2
 
3
3
 
4
- class ColumnType(str, Enum):
4
+ class ColumnType(StrEnum):
5
5
  TEXT = 'text'
6
6
  CHOICE = 'choice'
7
7
  NUMBER = 'number'
8
- DECIMAL_1 = 'decimal_1'
9
- DECIMAL_2 = 'decimal_2'
10
- DECIMAL_3 = 'decimal_3'
8
+ NUMBER_1 = 'decimal_1'
9
+ NUMBER_2 = 'decimal_2'
10
+ NUMBER_3 = 'decimal_3'
11
11
  DOLLAR = 'dollar'
12
+ DOLLAR_1 = 'dollar_1'
13
+ DOLLAR_2 = 'dollar_2'
14
+ DOLLAR_3 = 'dollar_3'
12
15
  PERCENT = 'percent'
16
+ PERCENT_1 = 'percent_1'
17
+ PERCENT_2 = 'percent_2'
18
+ PERCENT_3 = 'percent_3'
13
19
 
14
20
 
@@ -2,14 +2,12 @@ import inspect
2
2
  from abc import ABC, abstractmethod
3
3
  from dataclasses import dataclass
4
4
  from dataclasses import field
5
- from typing import Literal, Callable, Any
5
+ from typing import Callable, Any
6
6
 
7
7
  from django_spire.metric.report.enums import ColumnType
8
8
  from django_spire.metric.report.helper import Helper
9
9
  from django_spire.metric.report.tools import get_text_alignment_css_class
10
10
 
11
- ColumnLiteralType = Literal['text', 'choice', 'number', 'dollar', 'percent']
12
-
13
11
 
14
12
  @dataclass
15
13
  class ReportColumn:
@@ -35,17 +33,31 @@ class ReportCell:
35
33
  @staticmethod
36
34
  def cell_value_verbose(value, cell_type):
37
35
  if cell_type == ColumnType.DOLLAR:
38
- return f"${float(value):,.2f}"
36
+ return f"${float(value):,.0f}"
37
+ elif cell_type == ColumnType.DOLLAR_1:
38
+ return f"${float(value):,.1f}%"
39
+ elif cell_type == ColumnType.DOLLAR_2:
40
+ return f"${float(value):,.2f}%"
41
+ elif cell_type == ColumnType.DOLLAR_3:
42
+ return f"${float(value):,.3f}%"
43
+
44
+ elif cell_type == ColumnType.PERCENT:
45
+ return f"{float(value):,.0f}%"
46
+ elif cell_type == ColumnType.PERCENT_1:
47
+ return f"{float(value):,.1f}%"
48
+ elif cell_type == ColumnType.PERCENT_2:
49
+ return f"{float(value):,.2f}%"
50
+ elif cell_type == ColumnType.PERCENT_3:
51
+ return f"{float(value):,.3f}%"
52
+
39
53
  elif cell_type == ColumnType.NUMBER:
40
54
  return f"{float(value):,.0f}"
41
- elif cell_type == ColumnType.PERCENT:
42
- return f"{float(value):.1f}%"
43
- elif cell_type == ColumnType.DECIMAL_1:
44
- return f"{float(value):.1f}"
45
- elif cell_type == ColumnType.DECIMAL_2:
46
- return f"{float(value):.2f}"
47
- elif cell_type == ColumnType.DECIMAL_3:
48
- return f"{float(value):.3f}"
55
+ elif cell_type == ColumnType.NUMBER_1:
56
+ return f"{float(value):,.1f}"
57
+ elif cell_type == ColumnType.NUMBER_2:
58
+ return f"{float(value):,.2f}"
59
+ elif cell_type == ColumnType.NUMBER_3:
60
+ return f"{float(value):,.3f}"
49
61
 
50
62
  return str(value)
51
63
 
@@ -4,8 +4,20 @@ from django_spire.metric.report.enums import ColumnType
4
4
 
5
5
 
6
6
  def get_text_alignment_css_class(column_type: ColumnType) -> str:
7
- if column_type in (ColumnType.DOLLAR, ColumnType.NUMBER, ColumnType.PERCENT, ColumnType.DECIMAL_1,
8
- ColumnType.DECIMAL_2, ColumnType.DECIMAL_3):
7
+ if column_type in (
8
+ ColumnType.DOLLAR,
9
+ ColumnType.DOLLAR_1,
10
+ ColumnType.DOLLAR_2,
11
+ ColumnType.DOLLAR_3,
12
+ ColumnType.PERCENT,
13
+ ColumnType.PERCENT_1,
14
+ ColumnType.PERCENT_2,
15
+ ColumnType.PERCENT_3,
16
+ ColumnType.NUMBER,
17
+ ColumnType.NUMBER_1,
18
+ ColumnType.NUMBER_2,
19
+ ColumnType.NUMBER_3
20
+ ):
9
21
  return 'text-end'
10
22
  if column_type == ColumnType.CHOICE:
11
23
  return 'text-center'
@@ -5,16 +5,11 @@ import pytest
5
5
 
6
6
  from typing import Any, TYPE_CHECKING
7
7
 
8
- from django.contrib.auth import get_user_model
9
-
10
8
  if TYPE_CHECKING:
11
9
  from playwright.sync_api import Page
12
10
  from pytest_django.plugin import _LiveServer
13
11
 
14
12
 
15
- User = get_user_model()
16
-
17
-
18
13
  def pytest_configure(config: Any) -> None:
19
14
  os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = 'true'
20
15
 
@@ -38,6 +33,10 @@ def browser_type_launch_args(browser_type_launch_args: dict[str, Any]) -> dict[s
38
33
 
39
34
  @pytest.fixture
40
35
  def authenticated_page(page: Page, live_server: _LiveServer, transactional_db: None) -> Page:
36
+ from django.contrib.auth import get_user_model
37
+
38
+ User = get_user_model()
39
+
41
40
  User.objects.create_user(
42
41
  username='testuser',
43
42
  password='testpass123',
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-spire
3
- Version: 0.25.1
3
+ Version: 0.25.2
4
4
  Summary: A project for Django Spire
5
5
  Author-email: Brayden Carlson <braydenc@stratusadv.com>, Nathan Johnson <nathanj@stratusadv.com>
6
6
  License: Copyright (c) 2025 Stratus Advanced Technologies and Contributors.