django-spire 0.23.12__py3-none-any.whl → 0.24.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 (45) hide show
  1. django_spire/consts.py +1 -1
  2. django_spire/contrib/generic_views/modal_views.py +6 -1
  3. django_spire/core/static/django_spire/css/app-import.css +5 -0
  4. django_spire/core/static/django_spire/css/app-printing.css +31 -0
  5. django_spire/core/static/django_spire/css/bootstrap-extension.css +14 -2
  6. django_spire/core/tag/mixins.py +1 -1
  7. django_spire/core/templates/django_spire/page/full_page.html +2 -2
  8. django_spire/core/templates/django_spire/table/base.html +1 -1
  9. django_spire/core/templates/django_spire/table/element/row.html +20 -16
  10. django_spire/knowledge/migrations/0009_alter_collection_tags_alter_entry_tags.py +24 -0
  11. django_spire/metric/__init__.py +0 -0
  12. django_spire/metric/apps.py +19 -0
  13. django_spire/metric/report/__init__.py +0 -0
  14. django_spire/metric/report/admin.py +8 -0
  15. django_spire/metric/report/apps.py +25 -0
  16. django_spire/metric/report/auth/__init__.py +0 -0
  17. django_spire/metric/report/auth/controller.py +17 -0
  18. django_spire/metric/report/auth/tests/__init__.py +0 -0
  19. django_spire/metric/report/auth/tests/test_controller.py +86 -0
  20. django_spire/metric/report/enums.py +14 -0
  21. django_spire/metric/report/migrations/0001_initial.py +28 -0
  22. django_spire/metric/report/migrations/__init__.py +0 -0
  23. django_spire/metric/report/models.py +24 -0
  24. django_spire/metric/report/querysets.py +26 -0
  25. django_spire/metric/report/registry.py +38 -0
  26. django_spire/metric/report/report.py +187 -0
  27. django_spire/metric/report/templates/django_spire/metric/report/element/report_sub_navigation_element.html +34 -0
  28. django_spire/metric/report/templates/django_spire/metric/report/form/report_form.html +67 -0
  29. django_spire/metric/report/templates/django_spire/metric/report/page/report_page.html +37 -0
  30. django_spire/metric/report/templates/django_spire/metric/report/print/report_print.html +73 -0
  31. django_spire/metric/report/tests/__init__.py +0 -0
  32. django_spire/metric/report/tests/test_urls/__init__.py +0 -0
  33. django_spire/metric/report/tests/test_urls/test_page_urls.py +16 -0
  34. django_spire/metric/report/tools.py +11 -0
  35. django_spire/metric/report/urls/__init__.py +10 -0
  36. django_spire/metric/report/urls/page_urls.py +14 -0
  37. django_spire/metric/report/views/__init__.py +0 -0
  38. django_spire/metric/report/views/page_views.py +100 -0
  39. django_spire/metric/urls/__init__.py +10 -0
  40. django_spire/settings.py +3 -0
  41. {django_spire-0.23.12.dist-info → django_spire-0.24.0.dist-info}/METADATA +1 -1
  42. {django_spire-0.23.12.dist-info → django_spire-0.24.0.dist-info}/RECORD +45 -14
  43. {django_spire-0.23.12.dist-info → django_spire-0.24.0.dist-info}/WHEEL +0 -0
  44. {django_spire-0.23.12.dist-info → django_spire-0.24.0.dist-info}/licenses/LICENSE.md +0 -0
  45. {django_spire-0.23.12.dist-info → django_spire-0.24.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,187 @@
1
+ import inspect
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass
4
+ from dataclasses import field
5
+ from typing import Literal, Callable, Any
6
+
7
+ from django_spire.metric.report.enums import ColumnType
8
+ from django_spire.metric.report.tools import get_text_alignment_css_class
9
+
10
+ ColumnLiteralType = Literal['text', 'choice', 'number', 'dollar', 'percent']
11
+
12
+
13
+ @dataclass
14
+ class ReportColumn:
15
+ title: str
16
+ sub_title: str | None = None
17
+ type: ColumnType = ColumnType.TEXT
18
+ sub_type: ColumnType = ColumnType.TEXT
19
+
20
+ def css_class(self) -> str:
21
+ return get_text_alignment_css_class(self.type)
22
+
23
+
24
+ @dataclass
25
+ class ReportCell:
26
+ value: Any
27
+ sub_value: Any = None
28
+ type: ColumnType = ColumnType.TEXT
29
+ sub_type: ColumnType = ColumnType.TEXT
30
+
31
+ def css_class(self) -> str:
32
+ return get_text_alignment_css_class(self.type)
33
+
34
+ def cell_value_verbose(self, value):
35
+ if self.type == ColumnType.DOLLAR:
36
+ return f"${float(value):,.2f}"
37
+ elif self.type == ColumnType.NUMBER:
38
+ return f"{float(value):,.0f}"
39
+ elif self.type == ColumnType.PERCENT:
40
+ return f"{float(value):.1f}%"
41
+ elif self.type == ColumnType.DECIMAL_1:
42
+ return f"{float(value):.1f}"
43
+ elif self.type == ColumnType.DECIMAL_2:
44
+ return f"{float(value):.2f}"
45
+ elif self.type == ColumnType.DECIMAL_3:
46
+ return f"{float(value):.3f}"
47
+
48
+ return str(value)
49
+
50
+ def value_verbose(self):
51
+ return self.cell_value_verbose(self.value)
52
+
53
+ def sub_value_verbose(self):
54
+ return self.cell_value_verbose(self.sub_value)
55
+
56
+
57
+ @dataclass
58
+ class ReportRow:
59
+ cells: list[ReportCell] = field(default_factory=list)
60
+ bold: bool = False
61
+ page_break: bool = False
62
+ span_all_columns: bool = False
63
+ table_break: bool = False
64
+
65
+
66
+ class BaseReport(ABC):
67
+ title: str
68
+ description: str | None = None
69
+ is_financially_accurate: bool = False
70
+ ColumnType: type[ColumnType] = ColumnType
71
+
72
+ def __init__(self):
73
+ if not self.title:
74
+ message = 'Report title is required'
75
+ raise ValueError(message)
76
+
77
+ self.columns: list[ReportColumn] = []
78
+ self.rows: list[ReportRow] = []
79
+
80
+ @property
81
+ def column_count(self) -> int:
82
+ return len(self.columns)
83
+
84
+ @property
85
+ def is_ready(self):
86
+ return len(self.columns) > 0
87
+
88
+ @property
89
+ def run_arguments(self) -> dict[str, dict[str, str]]:
90
+ arguments = {}
91
+ signature = inspect.signature(self.run)
92
+
93
+ for name, param in signature.parameters.items():
94
+ arguments[name] = {}
95
+ arguments[name]['default'] = param.default
96
+
97
+ choices_method = getattr(self, f'{name}_choices', None)
98
+
99
+ if choices_method and isinstance(choices_method, Callable):
100
+ arguments[name]['choices'] = choices_method()
101
+ arguments[name]['annotation'] = 'select'
102
+ else:
103
+ arguments[name]['annotation'] = param.annotation.__name__
104
+
105
+ return arguments
106
+
107
+ @abstractmethod
108
+ def run(self, **kwargs: Any):
109
+ raise NotImplementedError
110
+
111
+ def add_blank_row(self):
112
+ self.add_row(
113
+ cell_values=[' '],
114
+ span_all_columns=True,
115
+ )
116
+
117
+ def add_column(
118
+ self,
119
+ title: str,
120
+ sub_title: str | None = None,
121
+ type: ColumnType = ColumnType.TEXT,
122
+ sub_type: ColumnType = ColumnType.TEXT,
123
+
124
+ ):
125
+ self.columns.append(
126
+ ReportColumn(title=title, sub_title=sub_title, type=type, sub_type=sub_type)
127
+ )
128
+
129
+ def add_divider_row(
130
+ self,
131
+ title: str,
132
+ page_break: bool = False,
133
+ ):
134
+ self.add_row(
135
+ cell_values=[title],
136
+ bold=True,
137
+ page_break=page_break,
138
+ span_all_columns=True,
139
+ )
140
+
141
+ def add_footer_row(
142
+ self,
143
+ cell_values: list[Any],
144
+ cell_sub_values: list[Any] | None = None,
145
+ ):
146
+ self.add_row(
147
+ cell_values=cell_values,
148
+ cell_sub_values=cell_sub_values,
149
+ bold=True,
150
+ )
151
+
152
+ def add_row(
153
+ self,
154
+ cell_values: list[Any],
155
+ cell_sub_values: list[Any] | None = None,
156
+ bold: bool = False,
157
+ page_break: bool = False,
158
+ span_all_columns: bool = False,
159
+ table_break: bool = False,
160
+ ):
161
+ if span_all_columns or table_break:
162
+ if len(cell_values) > 1:
163
+ message = 'Cannot span all columns or have a table break with more than one cell value'
164
+ raise ValueError(message)
165
+
166
+ elif len(cell_values) != len(self.columns) or (
167
+ cell_sub_values is not None and len(cell_sub_values) != len(self.columns)):
168
+ message = f'Number of cell values ({len(cell_values)}) and sub values ({len(cell_sub_values) if cell_sub_values else "None"}) must match number of columns: {len(self.columns)}'
169
+ raise ValueError(message)
170
+
171
+ self.rows.append(
172
+ ReportRow(
173
+ cells=[
174
+ ReportCell(
175
+ value=cell_values[i],
176
+ sub_value=cell_sub_values[i] if cell_sub_values else None,
177
+ type=self.columns[i].type,
178
+ sub_type=self.columns[i].sub_type
179
+ )
180
+ for i in range(len(cell_values))
181
+ ],
182
+ bold=bold,
183
+ page_break=page_break,
184
+ span_all_columns=span_all_columns,
185
+ table_break=table_break,
186
+ )
187
+ )
@@ -0,0 +1,34 @@
1
+ {% load variable_types %}
2
+
3
+
4
+ <div class="row">
5
+ <div class="col {% if depth %}ps-4{% endif %}">
6
+
7
+ {% if sub_nav_value|is_dict %}
8
+
9
+ {% for key, val in sub_nav_value.items %}
10
+
11
+ {% if val|is_dict %}
12
+
13
+ <div class="fs--1 pb-1">{{ key }}</div>
14
+
15
+ {% if key_stack is None %}
16
+ {% include 'django_spire/metric/report/element/report_sub_navigation_element.html' with depth=True sub_nav_value=val key_stack=key %}
17
+ {% else %}
18
+ {% include 'django_spire/metric/report/element/report_sub_navigation_element.html' with depth=True sub_nav_value=val key_stack=key_stack|add:'|'|add:key %}
19
+ {% endif %}
20
+
21
+ {% else %}
22
+ <div class="fs--1 pb-1">
23
+ <a href="?report_key_stack={{ key_stack }}|{{ key }}">> {{ key }}</a>
24
+ </div>
25
+ {% endif %}
26
+
27
+ {% endfor %}
28
+
29
+ {% endif %}
30
+
31
+ </div>
32
+ </div>
33
+
34
+
@@ -0,0 +1,67 @@
1
+ {% load string_formating %}
2
+
3
+ <div class="row">
4
+ <div class="col-11 col-md-10 col-lg-8 col-xl-6 mx-auto my-3">
5
+
6
+ <div class="row">
7
+ <div class="col fs-5 text-center">
8
+ {{ report.title }}
9
+ </div>
10
+ </div>
11
+ {% if report.description %}
12
+ <div class="row">
13
+ <div class="col text-center text-muted fs--1 mb-3">
14
+ {{ report.description }}
15
+ </div>
16
+ </div>
17
+ {% endif %}
18
+
19
+ <form class="form" method="GET">
20
+ <input class="form-control" type="hidden" name="report_key_stack"
21
+ value="{{ request.GET.report_key_stack }}">
22
+ <input class="form-control" type="hidden" name="report_should_run" value="True">
23
+
24
+ {% for argument, params in report_run_arguments.items %}
25
+ <div class="row">
26
+ <div class="col mb-2">
27
+
28
+ <label class="form-label">
29
+ {{ argument|underscores_to_spaces|title }}
30
+ </label>
31
+ {% if params.annotation == 'str' %}
32
+ <input class="form-control" type="text" name="{{ argument }}" value="{{ params.default }}">
33
+ {% elif params.annotation == 'int' %}
34
+ <input class="form-control" type="number" name="{{ argument }}"
35
+ value="{{ params.default }}">
36
+ {% elif params.annotation == 'float' %}
37
+ <input class="form-control" type="number" name="{{ argument }}"
38
+ value="{{ params.default }}">
39
+ {% elif params.annotation == 'bool' %}
40
+ <br><input class="form-check-input" type="checkbox" name="{{ argument }}"
41
+ value="{{ params.default }}">
42
+ {% elif params.annotation == 'select' %}
43
+ <select class="form-select" name="{{ argument }}" aria-selected="{{ params.default }}">
44
+ {% for key, val in params.choices %}
45
+ <option value="{{ key }}">{{ val }}</option>
46
+ {% endfor %}
47
+ </select>
48
+ {% endif %}
49
+ </div>
50
+ </div>
51
+ {% endfor %}
52
+
53
+ <div class="row">
54
+ <div class="col mt-3 text-center">
55
+ <input class="btn btn-primary" type="submit" value="Run Report">
56
+ </div>
57
+ </div>
58
+ <div class="row">
59
+ <div class="col mt-3 text-center text-muted fs--1">
60
+ This report has been informative {{ report_run_count }} times!
61
+ </div>
62
+ </div>
63
+ </form>
64
+
65
+ </div>
66
+ </div>
67
+
@@ -0,0 +1,37 @@
1
+ {% extends 'django_spire/page/full_page.html' %}
2
+
3
+ {% load variable_types %}
4
+
5
+ {% block full_page_sub_navigation_title %}
6
+ Reports
7
+ {% endblock %}
8
+
9
+ {% block full_page_sub_navigation %}
10
+ {% if not report.is_ready %}
11
+ {% include 'django_spire/metric/report/element/report_sub_navigation_element.html' with depth=0 sub_nav_value=registry.report_names_classes %}
12
+ {% endif %}
13
+ {% endblock %}
14
+
15
+ {% block full_page_content %}
16
+ {% if report %}
17
+ {% if report.is_ready %}
18
+ {% include 'django_spire/metric/report/print/report_print.html' %}
19
+ {% else %}
20
+ {% include 'django_spire/metric/report/form/report_form.html' %}
21
+ {% endif %}
22
+ {% else %}
23
+ <div class="row">
24
+ <div class="col fs-5 my-3 text-center">
25
+ Top 10 Reports
26
+ </div>
27
+ </div>
28
+ {% for report_run in top_ten_report_runs %}
29
+ <div class="row">
30
+ <div class="col text-center pb-2">
31
+ <a href="?report_key_stack={{ report_run.report_key_stack }}">{{ report_run.report_key_stack_verbose }}</a><br>
32
+ <div class="text-muted fs--1">{{ report_run.run_count }} Total Runs</div>
33
+ </div>
34
+ </div>
35
+ {% endfor %}
36
+ {% endif %}
37
+ {% endblock %}
@@ -0,0 +1,73 @@
1
+ {% load string_formating %}
2
+
3
+ <div class="row mb-1 font-monospace">
4
+ <div class="col">
5
+ <div class="fw-bold">
6
+ {{ report.title }}
7
+ </div>
8
+ </div>
9
+ </div>
10
+
11
+ <div class="row mx-0 mb-3 text-muted fw-normal fs--2 border font-monospace">
12
+ {% if report.description %}
13
+ <div class="col-auto ps-1 pe-3">
14
+ Description: {{ report.description }}
15
+ </div>
16
+ {% endif %}
17
+ <div class="col-auto ps-1 pe-3">
18
+ Printed On: {% now "D, M j, Y" %} at {% now "g:i A" %}
19
+ </div>
20
+ <div class="col-auto ps-1 pe-3">
21
+ Financially Accurate: {% if report.is_financially_accurate %}True{% else %}False{% endif %}
22
+ </div>
23
+ {% for argument, value in report_run_arguments_values.items %}
24
+ <div class="col-auto ps-1 pe-3">
25
+ {{ argument|underscores_to_spaces|title }}: {{ value }}
26
+ </div>
27
+ {% endfor %}
28
+ </div>
29
+
30
+ <div class="row">
31
+ <div class="col fs--2">
32
+
33
+ <table class="table table-sm table-hover table-print">
34
+ <thead>
35
+ {% for column in report.columns %}
36
+ <th class="align-text-top {{ column.css_class }}">
37
+ {{ column.title }}
38
+ {% if column.sub_title %}
39
+ <span class="text-muted fw-normal fs--2">
40
+ <br>{{ column.sub_title }}
41
+ </span>
42
+ {% endif %}
43
+ </th>
44
+ {% endfor %}
45
+ </thead>
46
+ <tbody>
47
+ {% for row in report.rows %}
48
+ <tr class="{% if row.bold %}fw-bold{% endif %}"
49
+ {% if row.page_break %}style="page-break-before: always;"{% endif %}>
50
+ {% if row.span_all_columns %}
51
+ <td colspan="{{ report.column_count }}">
52
+ {% for cell in row.cells %}
53
+ {{ cell.value|safe }}
54
+ {% endfor %}
55
+ </td>
56
+ {% else %}
57
+ {% for cell in row.cells %}
58
+ <td class="{{ cell.css_class }}">
59
+ {{ cell.value_verbose }}
60
+ {% if cell.sub_value %}
61
+ <br>
62
+ {{ cell.sub_value_verbose }}
63
+ {% endif %}
64
+ </td>
65
+ {% endfor %}
66
+ {% endif %}
67
+ </tr>
68
+ {% endfor %}
69
+ </tbody>
70
+ </table>
71
+
72
+ </div>
73
+ </div>
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import reverse
4
+
5
+ from django_spire.core.tests.test_cases import BaseTestCase
6
+
7
+
8
+ class ReportPageUrlsTests(BaseTestCase):
9
+ def setUp(self):
10
+ super().setUp()
11
+
12
+ def test_report_view_url_path(self):
13
+ response = self.client.get(
14
+ reverse('django_spire:metric:report:page:report')
15
+ )
16
+ assert response.status_code == 200
@@ -0,0 +1,11 @@
1
+ from django_spire.metric.report.enums import ColumnType
2
+
3
+
4
+ def get_text_alignment_css_class(column_type: ColumnType) -> str:
5
+ if column_type in (ColumnType.DOLLAR, ColumnType.NUMBER, ColumnType.PERCENT, ColumnType.DECIMAL_1,
6
+ ColumnType.DECIMAL_2, ColumnType.DECIMAL_3):
7
+ return 'text-end'
8
+ if column_type == ColumnType.CHOICE:
9
+ return 'text-center'
10
+
11
+ return 'text-start'
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import include, path
4
+
5
+
6
+ app_name = 'report'
7
+
8
+ urlpatterns = [
9
+ path('page/', include('django_spire.metric.report.urls.page_urls', namespace='page')),
10
+ ]
@@ -0,0 +1,14 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import path
4
+
5
+ from django_spire.metric.report.views import page_views
6
+
7
+
8
+ app_name = 'report'
9
+
10
+ urlpatterns = [
11
+ path('',
12
+ page_views.report_view,
13
+ name='report'),
14
+ ]
File without changes
@@ -0,0 +1,100 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from django.conf import settings
6
+ from django.urls import reverse
7
+
8
+ from django_spire.auth.controller.controller import AppAuthController
9
+ from django_spire.contrib import Breadcrumbs
10
+ from django_spire.contrib.generic_views import portal_views
11
+ from django_spire.core.utils import get_object_from_module_string
12
+ from django_spire.metric.report.models import ReportRun
13
+ from django_spire.metric.report.registry import ReportRegistry
14
+
15
+ if TYPE_CHECKING:
16
+ from django.core.handlers.wsgi import WSGIRequest
17
+ from django.template.response import TemplateResponse
18
+
19
+
20
+ @AppAuthController('report').permission_required('can_view')
21
+ def report_view(request: WSGIRequest) -> TemplateResponse:
22
+ breadcrumbs = Breadcrumbs()
23
+
24
+ breadcrumbs.add_breadcrumb(
25
+ 'Reports',
26
+ reverse('django_spire:metric:report:page:report'),
27
+ )
28
+
29
+ page_report_registry = ReportRegistry()
30
+
31
+ for report_registry in settings.DJANGO_SPIRE_REPORT_REGISTRIES:
32
+ report_registry_class = get_object_from_module_string(
33
+ report_registry
34
+ )
35
+
36
+ page_report_registry.add_registry(
37
+ report_registry_class()
38
+ )
39
+
40
+ context_data = {
41
+ 'registry': page_report_registry,
42
+ }
43
+
44
+ if request.GET:
45
+ report_key_stack = request.GET.get('report_key_stack', None)
46
+
47
+ if report_key_stack:
48
+ report = page_report_registry.get_report_from_key_stack(report_key_stack)
49
+
50
+ if report:
51
+ for key in report_key_stack.split('|'):
52
+ breadcrumbs.add_breadcrumb(
53
+ key,
54
+ )
55
+
56
+ context_data['report_run_arguments'] = report.run_arguments
57
+
58
+ context_data['report_run_arguments_values'] = {}
59
+
60
+ for argument in report.run_arguments:
61
+ if context_data['report_run_arguments'][argument]['annotation'] == 'bool':
62
+ get_request_value = request.GET.get(argument, False)
63
+ else:
64
+ get_request_value = request.GET.get(argument, None)
65
+
66
+ context_data['report_run_arguments_values'][argument] = get_request_value
67
+
68
+ if request.GET.get('report_should_run', 'false').lower() == 'true':
69
+ for argument, value in context_data['report_run_arguments_values'].items():
70
+ if value is None:
71
+ break
72
+ else:
73
+ ReportRun.objects.create(
74
+ report_key_stack=report_key_stack,
75
+ )
76
+ report.run()
77
+
78
+ context_data['report'] = report
79
+ context_data['report_run_count'] = ReportRun.objects.run_count(report_key_stack)
80
+
81
+ else:
82
+ top_ten_report_runs = [
83
+ {
84
+ **report_run,
85
+ 'report_key_stack_verbose': report_run['report_key_stack'].replace('|', ' > '),
86
+ }
87
+ for report_run in
88
+ ReportRun.objects.by_top_ten()
89
+ ]
90
+
91
+ context_data['top_ten_report_runs'] = top_ten_report_runs
92
+
93
+ return portal_views.template_view(
94
+ request,
95
+ page_title='Reports',
96
+ page_description='More Reporting Info',
97
+ breadcrumbs=breadcrumbs,
98
+ context_data=context_data,
99
+ template='django_spire/metric/report/page/report_page.html'
100
+ )
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import include, path
4
+
5
+
6
+ app_name = 'metric'
7
+
8
+ urlpatterns = [
9
+ path('report/', include('django_spire.metric.report.urls', namespace='report')),
10
+ ]
django_spire/settings.py CHANGED
@@ -5,6 +5,7 @@ DJANGO_SPIRE_AUTH_CONTROLLERS = {
5
5
  'ai_chat': 'django_spire.ai.chat.auth.controller.BaseAiChatAuthController',
6
6
  'help_desk': 'django_spire.help_desk.auth.controller.BaseHelpDeskAuthController',
7
7
  'knowledge': 'django_spire.knowledge.auth.controller.BaseKnowledgeAuthController',
8
+ 'report': 'django_spire.metric.report.auth.controller.BaseReportAuthController',
8
9
  }
9
10
 
10
11
  # AI Settings
@@ -23,6 +24,8 @@ DJANGO_SPIRE_AI_INTENT_CHAT_ROUTERS = {
23
24
  },
24
25
  }
25
26
 
27
+ DJANGO_SPIRE_REPORT_REGISTRIES = []
28
+
26
29
  # Theme Settings
27
30
  DJANGO_SPIRE_DEFAULT_THEME = 'default-light'
28
31
  DJANGO_SPIRE_THEME_PATH = '/static/django_spire/css/themes/{family}/app-{mode}.css'
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: django-spire
3
- Version: 0.23.12
3
+ Version: 0.24.0
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.