django-spire 0.24.2__py3-none-any.whl → 0.25.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/consts.py +1 -1
- django_spire/contrib/admin/__init__.py +0 -0
- django_spire/contrib/admin/admin.py +140 -0
- django_spire/contrib/choices/__init__.py +0 -0
- django_spire/contrib/choices/choices.py +9 -0
- django_spire/contrib/choices/tests/__init__.py +0 -0
- django_spire/contrib/choices/tests/test_choices.py +62 -0
- django_spire/core/static/django_spire/css/app-printing.css +25 -0
- django_spire/core/templates/django_spire/dropdown/ellipsis_modal_dropdown.html +15 -3
- django_spire/core/templates/django_spire/element/attribute_element.html +7 -0
- django_spire/core/templates/django_spire/element/copy_to_clipboard_element.html +31 -0
- django_spire/metric/report/__init__.py +7 -0
- django_spire/metric/report/helper.py +92 -0
- django_spire/metric/report/report.py +79 -13
- django_spire/metric/report/templates/django_spire/metric/report/form/report_form.html +32 -10
- django_spire/metric/report/templates/django_spire/metric/report/print/report_print.html +29 -16
- django_spire/metric/report/tools.py +2 -0
- django_spire/metric/report/views/page_views.py +27 -5
- django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown.html +10 -10
- django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown_content.html +1 -33
- django_spire/notification/app/templates/django_spire/notification/app/item/notification_item.html +24 -23
- django_spire/notification/app/templates/django_spire/notification/app/page/list_page.html +1 -1
- django_spire/notification/app/templates/django_spire/notification/app/scroll/container/dropdown_container.html +54 -0
- django_spire/notification/app/templates/django_spire/notification/app/scroll/item/items.html +5 -0
- django_spire/notification/app/urls/template_urls.py +9 -2
- django_spire/notification/app/views/page_views.py +2 -10
- django_spire/notification/app/views/template_views.py +22 -7
- {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/METADATA +2 -2
- {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/RECORD +32 -23
- django_spire/notification/app/templates/django_spire/notification/app/card/list_card.html +0 -11
- {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/WHEEL +0 -0
- {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/top_level.txt +0 -0
django_spire/consts.py
CHANGED
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from typing import Type, Tuple
|
|
2
|
+
|
|
3
|
+
from django.contrib import admin
|
|
4
|
+
from django.contrib.contenttypes.fields import GenericRelation
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SpireModelAdmin(admin.ModelAdmin):
|
|
9
|
+
model_class: Type[models.Model] = None
|
|
10
|
+
|
|
11
|
+
max_search_fields: int = 5
|
|
12
|
+
max_list_display: int = 10
|
|
13
|
+
|
|
14
|
+
trailing_fields = ('is_active', 'is_deleted')
|
|
15
|
+
|
|
16
|
+
auto_readonly_fields: Tuple[str] = (
|
|
17
|
+
'created_datetime', 'is_active', 'is_deleted',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
filter_field_types = (
|
|
21
|
+
models.BooleanField,
|
|
22
|
+
models.DateField,
|
|
23
|
+
models.DateTimeField,
|
|
24
|
+
models.ForeignKey,
|
|
25
|
+
models.CharField,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def __init_subclass__(cls, **kwargs):
|
|
29
|
+
super().__init_subclass__(**kwargs)
|
|
30
|
+
|
|
31
|
+
cls.model_fields = cls.model_class._meta.get_fields()
|
|
32
|
+
|
|
33
|
+
if cls.model_class is None:
|
|
34
|
+
raise ValueError(f'{cls.__name__} must define model_class')
|
|
35
|
+
|
|
36
|
+
if cls.model_class is not None:
|
|
37
|
+
cls._configure_if_needed()
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def _configure_if_needed(cls):
|
|
41
|
+
if not hasattr(cls, '_spire_configured'):
|
|
42
|
+
cls._configure_list_display()
|
|
43
|
+
cls._configure_list_filter()
|
|
44
|
+
cls._configure_search_fields()
|
|
45
|
+
cls._configure_readonly_fields()
|
|
46
|
+
cls._configure_ordering()
|
|
47
|
+
cls._configure_list_per_page()
|
|
48
|
+
cls._spire_configured = True
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def _configure_list_display(cls):
|
|
52
|
+
if cls.list_display != ('__str__',):
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
fields = []
|
|
56
|
+
|
|
57
|
+
for field in cls.model_fields:
|
|
58
|
+
if not isinstance(
|
|
59
|
+
field,
|
|
60
|
+
(models.ManyToManyField, models.ManyToOneRel, GenericRelation),
|
|
61
|
+
):
|
|
62
|
+
if hasattr(field, 'name') and not field.name.startswith('_'):
|
|
63
|
+
if field.name not in cls.trailing_fields:
|
|
64
|
+
fields.append(field.name)
|
|
65
|
+
|
|
66
|
+
for trailing_field in cls.trailing_fields:
|
|
67
|
+
if trailing_field in [field.name for field in cls.model_fields]:
|
|
68
|
+
fields.append(trailing_field)
|
|
69
|
+
|
|
70
|
+
cls.list_display = tuple(fields[:cls.max_list_display])
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def _configure_list_filter(cls):
|
|
74
|
+
if hasattr(cls, 'list_filter') and cls.list_filter:
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
filters = []
|
|
78
|
+
|
|
79
|
+
for field in cls.model_fields:
|
|
80
|
+
if not isinstance(field, (models.ManyToManyField, models.ManyToOneRel)):
|
|
81
|
+
if isinstance(field, models.BooleanField):
|
|
82
|
+
filters.append(field.name)
|
|
83
|
+
|
|
84
|
+
elif isinstance(field, (models.DateField, models.DateTimeField)):
|
|
85
|
+
filters.append(field.name)
|
|
86
|
+
|
|
87
|
+
elif isinstance(field, models.ForeignKey):
|
|
88
|
+
filters.append(field.name)
|
|
89
|
+
|
|
90
|
+
elif (
|
|
91
|
+
isinstance(field, models.CharField)
|
|
92
|
+
and hasattr(field, 'choices')
|
|
93
|
+
and field.choices
|
|
94
|
+
):
|
|
95
|
+
filters.append(field.name)
|
|
96
|
+
|
|
97
|
+
cls.list_filter = tuple(filters)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def _configure_search_fields(cls):
|
|
101
|
+
if hasattr(cls, 'search_fields') and cls.search_fields:
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
search_fields = []
|
|
105
|
+
|
|
106
|
+
for field in cls.model_fields:
|
|
107
|
+
if isinstance(field, (models.CharField, models.TextField)):
|
|
108
|
+
if not field.name.startswith('_'):
|
|
109
|
+
search_fields.append(field.name)
|
|
110
|
+
|
|
111
|
+
if len(search_fields) >= cls.max_search_fields:
|
|
112
|
+
break
|
|
113
|
+
|
|
114
|
+
cls.search_fields = tuple(search_fields)
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def _configure_readonly_fields(cls):
|
|
118
|
+
if hasattr(cls, 'readonly_fields') and cls.readonly_fields:
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
readonly = []
|
|
122
|
+
|
|
123
|
+
for field in cls.model_fields:
|
|
124
|
+
if hasattr(field, 'name') and field.name in cls.auto_readonly_fields:
|
|
125
|
+
readonly.append(field.name)
|
|
126
|
+
|
|
127
|
+
cls.readonly_fields = tuple(readonly)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def _configure_ordering(cls):
|
|
131
|
+
if hasattr(cls, 'ordering') and cls.ordering:
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
cls.ordering = ('-id',)
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
@classmethod
|
|
138
|
+
def _configure_list_per_page(cls):
|
|
139
|
+
if not hasattr(cls, 'list_per_page') or cls.list_per_page == 100:
|
|
140
|
+
cls.list_per_page = 25
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from django_spire.contrib.choices.choices import SpireTextChoices
|
|
4
|
+
from django_spire.core.tests.test_cases import BaseTestCase
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class TestSpireTextChoices(BaseTestCase):
|
|
8
|
+
def setUp(self):
|
|
9
|
+
class StatusChoices(SpireTextChoices):
|
|
10
|
+
DRAFT = ('dra', 'Draft')
|
|
11
|
+
PUBLISHED = ('pub', 'Published')
|
|
12
|
+
ARCHIVED = ('arc', 'Archived')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
self.StatusChoices = StatusChoices
|
|
16
|
+
|
|
17
|
+
def test_inherits_from_text_choices(self):
|
|
18
|
+
assert issubclass(self.StatusChoices, SpireTextChoices), self.StatusChoices.__class__
|
|
19
|
+
|
|
20
|
+
def test_choices_property_exists(self):
|
|
21
|
+
assert hasattr(self.StatusChoices, 'choices'), self.StatusChoices.dir()
|
|
22
|
+
assert self.StatusChoices.choices is not None, self.StatusChoices.choices
|
|
23
|
+
|
|
24
|
+
def test_to_glue_choices_returns_string(self):
|
|
25
|
+
result = self.StatusChoices.to_glue_choices()
|
|
26
|
+
assert isinstance(result, str), type(result)
|
|
27
|
+
|
|
28
|
+
def test_to_glue_choices_returns_valid_json(self):
|
|
29
|
+
result = self.StatusChoices.to_glue_choices()
|
|
30
|
+
try:
|
|
31
|
+
parsed = json.loads(result)
|
|
32
|
+
except json.JSONDecodeError:
|
|
33
|
+
assert 'to_glue_choices did not return valid JSON'
|
|
34
|
+
|
|
35
|
+
def test_to_glue_choices_correct_structure(self):
|
|
36
|
+
result = self.StatusChoices.to_glue_choices()
|
|
37
|
+
parsed = json.loads(result)
|
|
38
|
+
|
|
39
|
+
assert isinstance(parsed, list), type(parsed)
|
|
40
|
+
assert len(parsed) == 3
|
|
41
|
+
|
|
42
|
+
assert parsed[0] == ['dra', 'Draft']
|
|
43
|
+
assert parsed[1] == ['pub', 'Published']
|
|
44
|
+
assert parsed[2] == ['arc', 'Archived']
|
|
45
|
+
|
|
46
|
+
def test_empty_choices(self):
|
|
47
|
+
class EmptyChoices(SpireTextChoices):
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
result = EmptyChoices.to_glue_choices()
|
|
52
|
+
parsed = json.loads(result)
|
|
53
|
+
assert parsed == []
|
|
54
|
+
|
|
55
|
+
def test_single_choice(self):
|
|
56
|
+
class SingleChoice(SpireTextChoices):
|
|
57
|
+
ONLY = 'only', 'Only Option'
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
result = SingleChoice.to_glue_choices()
|
|
61
|
+
parsed = json.loads(result)
|
|
62
|
+
assert parsed == [['only', 'Only Option']]
|
|
@@ -1,4 +1,29 @@
|
|
|
1
|
+
.fs-print-1 {
|
|
2
|
+
font-size: 1.0rem !important;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.fs-print-2 {
|
|
6
|
+
font-size: 0.90rem !important;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.fs-print-3 {
|
|
10
|
+
font-size: 0.70rem !important;
|
|
11
|
+
}
|
|
12
|
+
|
|
1
13
|
@media print {
|
|
14
|
+
|
|
15
|
+
.fs-print-1 {
|
|
16
|
+
font-size: .80rem !important;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.fs-print-2 {
|
|
20
|
+
font-size: .70rem !important;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.fs-print-3 {
|
|
24
|
+
font-size: .55rem !important;
|
|
25
|
+
}
|
|
26
|
+
|
|
2
27
|
.hide-on-print {
|
|
3
28
|
display: none !important;
|
|
4
29
|
}
|
|
@@ -12,14 +12,26 @@
|
|
|
12
12
|
|
|
13
13
|
{% block dropdown_content %}
|
|
14
14
|
{% if view_url %}
|
|
15
|
-
{%
|
|
15
|
+
{% if redirect_view_url %}
|
|
16
|
+
{% include 'django_spire/dropdown/element/dropdown_link_element.html' with view_url=view_url link_text='View' %}
|
|
17
|
+
{% else %}
|
|
18
|
+
{% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=view_url link_text='View' %}
|
|
19
|
+
{% endif %}
|
|
16
20
|
{% endif %}
|
|
17
21
|
|
|
18
22
|
{% if edit_url %}
|
|
19
|
-
{%
|
|
23
|
+
{% if redirect_edit_url %}
|
|
24
|
+
{% include 'django_spire/dropdown/element/dropdown_link_element.html' with view_url=edit_url link_text='Edit' %}
|
|
25
|
+
{% else %}
|
|
26
|
+
{% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=edit_url link_text='Edit' %}
|
|
27
|
+
{% endif %}
|
|
20
28
|
{% endif %}
|
|
21
29
|
|
|
22
30
|
{% if delete_url %}
|
|
23
|
-
{%
|
|
31
|
+
{% if redirect_delete_url %}
|
|
32
|
+
{% include 'django_spire/dropdown/element/dropdown_link_element.html' with view_url=delete_url link_text='Delete' link_css='text-app-danger' %}
|
|
33
|
+
{% else %}
|
|
34
|
+
{% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=delete_url link_text='Delete' link_css='text-app-danger' %}
|
|
35
|
+
{% endif %}
|
|
24
36
|
{% endif %}
|
|
25
37
|
{% endblock %}
|
|
@@ -32,6 +32,13 @@
|
|
|
32
32
|
<span x-text="{{ x_attribute_value_postfix }}"></span>
|
|
33
33
|
{% endif %}
|
|
34
34
|
{% endif %}
|
|
35
|
+
|
|
36
|
+
{% if show_copy_button %}
|
|
37
|
+
{% block copy_button %}
|
|
38
|
+
{% firstof attribute_value or x_attribute_value as value %}
|
|
39
|
+
{% include 'django_spire/element/copy_to_clipboard_element.html' with value=value copy_func=copy_func %}
|
|
40
|
+
{% endblock %}
|
|
41
|
+
{% endif %}
|
|
35
42
|
{% endblock %}
|
|
36
43
|
|
|
37
44
|
{% if attribute_href or x_attribute_href %}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
<span
|
|
2
|
+
x-data="{
|
|
3
|
+
clicked: false,
|
|
4
|
+
copy_value_to_clipboard() {
|
|
5
|
+
let text = '{{ value }}';
|
|
6
|
+
|
|
7
|
+
{% if copy_func %}
|
|
8
|
+
text = {{ copy_func }}(text);
|
|
9
|
+
{% endif %}
|
|
10
|
+
|
|
11
|
+
navigator.clipboard.writeText(text);
|
|
12
|
+
this.clicked = true;
|
|
13
|
+
setTimeout(() => {
|
|
14
|
+
this.clicked = false;
|
|
15
|
+
}, 1000);
|
|
16
|
+
}
|
|
17
|
+
}"
|
|
18
|
+
>
|
|
19
|
+
<i
|
|
20
|
+
@click="copy_value_to_clipboard()"
|
|
21
|
+
class="bi bi-copy text-app-primary cursor-pointer"
|
|
22
|
+
></i>
|
|
23
|
+
<span
|
|
24
|
+
class="text-app-primary"
|
|
25
|
+
x-show="clicked"
|
|
26
|
+
x-transition:enter.duration.500ms
|
|
27
|
+
x-transition:leave.duration.500ms
|
|
28
|
+
>
|
|
29
|
+
Copied!
|
|
30
|
+
</span>
|
|
31
|
+
</span>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import calendar
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
|
|
4
|
+
from django.utils.timezone import now
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Helper:
|
|
8
|
+
@property
|
|
9
|
+
def thirty_days_ago(self) -> datetime:
|
|
10
|
+
return self.today - timedelta(days=30)
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def sixty_days_ago(self) -> datetime:
|
|
14
|
+
return self.today - timedelta(days=60)
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def ninety_days_ago(self) -> datetime:
|
|
18
|
+
return self.today - timedelta(days=90)
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def today(self) -> datetime:
|
|
22
|
+
return now()
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def tomorrow(self) -> datetime:
|
|
26
|
+
return self.today + timedelta(days=1)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def yesterday(self) -> datetime:
|
|
30
|
+
return self.today - timedelta(days=1)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def start_of_current_week(self) -> datetime:
|
|
34
|
+
return self.today - timedelta(days=self.today.weekday())
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def end_of_current_week(self) -> datetime:
|
|
38
|
+
return self.start_of_week + timedelta(days=6)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def start_of_last_week(self) -> datetime:
|
|
42
|
+
return self.start_of_week - timedelta(days=6)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def end_of_last_week(self) -> datetime:
|
|
46
|
+
return self.end_of_week - timedelta(days=6)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def start_of_current_month(self) -> datetime:
|
|
50
|
+
return self.today.replace(day=1)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def end_of_current_month(self) -> datetime:
|
|
54
|
+
_, last_day = calendar.monthrange(self.today.year, self.today.month)
|
|
55
|
+
return self.today.replace(day=last_day)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def start_of_last_month(self) -> datetime:
|
|
59
|
+
return self._add_months(self.today, -1).replace(day=1)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def end_of_last_month(self) -> datetime:
|
|
63
|
+
last_month = self._add_months(self.today, -1)
|
|
64
|
+
_, last_day = calendar.monthrange(last_month.year, last_month.month)
|
|
65
|
+
|
|
66
|
+
return last_month.replace(day=last_day)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def start_of_current_year(self) -> datetime:
|
|
70
|
+
return self.today.replace(month=1, day=1)
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def end_of_current_year(self) -> datetime:
|
|
74
|
+
return self.today.replace(month=12, day=31)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def start_of_last_year(self) -> datetime:
|
|
78
|
+
return self.today.replace(year=self.today.year - 1, month=1, day=1)
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def end_of_last_year(self) -> datetime:
|
|
82
|
+
return self.today.replace(year=self.today.year - 1, month=12, day=31)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _add_months(datetime_, months):
|
|
86
|
+
month = datetime_.month - 1 + months
|
|
87
|
+
year = datetime_.year + month // 12
|
|
88
|
+
month = month % 12 + 1
|
|
89
|
+
|
|
90
|
+
day = min(datetime_.day, calendar.monthrange(year, month)[1])
|
|
91
|
+
|
|
92
|
+
return datetime_.replace(year=year, month=month, day=day)
|
|
@@ -5,6 +5,7 @@ from dataclasses import field
|
|
|
5
5
|
from typing import Literal, Callable, Any
|
|
6
6
|
|
|
7
7
|
from django_spire.metric.report.enums import ColumnType
|
|
8
|
+
from django_spire.metric.report.helper import Helper
|
|
8
9
|
from django_spire.metric.report.tools import get_text_alignment_css_class
|
|
9
10
|
|
|
10
11
|
ColumnLiteralType = Literal['text', 'choice', 'number', 'dollar', 'percent']
|
|
@@ -31,27 +32,28 @@ class ReportCell:
|
|
|
31
32
|
def css_class(self) -> str:
|
|
32
33
|
return get_text_alignment_css_class(self.type)
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
@staticmethod
|
|
36
|
+
def cell_value_verbose(value, cell_type):
|
|
37
|
+
if cell_type == ColumnType.DOLLAR:
|
|
36
38
|
return f"${float(value):,.2f}"
|
|
37
|
-
elif
|
|
39
|
+
elif cell_type == ColumnType.NUMBER:
|
|
38
40
|
return f"{float(value):,.0f}"
|
|
39
|
-
elif
|
|
41
|
+
elif cell_type == ColumnType.PERCENT:
|
|
40
42
|
return f"{float(value):.1f}%"
|
|
41
|
-
elif
|
|
43
|
+
elif cell_type == ColumnType.DECIMAL_1:
|
|
42
44
|
return f"{float(value):.1f}"
|
|
43
|
-
elif
|
|
45
|
+
elif cell_type == ColumnType.DECIMAL_2:
|
|
44
46
|
return f"{float(value):.2f}"
|
|
45
|
-
elif
|
|
47
|
+
elif cell_type == ColumnType.DECIMAL_3:
|
|
46
48
|
return f"{float(value):.3f}"
|
|
47
49
|
|
|
48
50
|
return str(value)
|
|
49
51
|
|
|
50
52
|
def value_verbose(self):
|
|
51
|
-
return self.cell_value_verbose(self.value)
|
|
53
|
+
return self.cell_value_verbose(self.value, self.type)
|
|
52
54
|
|
|
53
55
|
def sub_value_verbose(self):
|
|
54
|
-
return self.cell_value_verbose(self.sub_value)
|
|
56
|
+
return self.cell_value_verbose(self.sub_value, self.sub_type)
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
@dataclass
|
|
@@ -61,6 +63,8 @@ class ReportRow:
|
|
|
61
63
|
page_break: bool = False
|
|
62
64
|
span_all_columns: bool = False
|
|
63
65
|
table_break: bool = False
|
|
66
|
+
border_top: bool = False
|
|
67
|
+
border_bottom: bool = False
|
|
64
68
|
|
|
65
69
|
|
|
66
70
|
class BaseReport(ABC):
|
|
@@ -68,6 +72,7 @@ class BaseReport(ABC):
|
|
|
68
72
|
description: str | None = None
|
|
69
73
|
is_financially_accurate: bool = False
|
|
70
74
|
ColumnType: type[ColumnType] = ColumnType
|
|
75
|
+
helper: Helper = Helper()
|
|
71
76
|
|
|
72
77
|
def __init__(self):
|
|
73
78
|
if not self.title:
|
|
@@ -97,8 +102,15 @@ class BaseReport(ABC):
|
|
|
97
102
|
choices_method = getattr(self, f'{name}_choices', None)
|
|
98
103
|
|
|
99
104
|
if choices_method and isinstance(choices_method, Callable):
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
choices = tuple(choices_method())
|
|
106
|
+
|
|
107
|
+
self.validate_choices(tuple(choices))
|
|
108
|
+
|
|
109
|
+
arguments[name]['choices'] = choices
|
|
110
|
+
if param.annotation.__name__ == 'list':
|
|
111
|
+
arguments[name]['annotation'] = 'multi_select'
|
|
112
|
+
else:
|
|
113
|
+
arguments[name]['annotation'] = 'select'
|
|
102
114
|
else:
|
|
103
115
|
arguments[name]['annotation'] = param.annotation.__name__
|
|
104
116
|
|
|
@@ -108,10 +120,21 @@ class BaseReport(ABC):
|
|
|
108
120
|
def run(self, **kwargs: Any):
|
|
109
121
|
raise NotImplementedError
|
|
110
122
|
|
|
111
|
-
def add_blank_row(
|
|
123
|
+
def add_blank_row(
|
|
124
|
+
self,
|
|
125
|
+
text: str = '',
|
|
126
|
+
page_break: bool = False,
|
|
127
|
+
border_top: bool = False,
|
|
128
|
+
border_bottom: bool = False
|
|
129
|
+
):
|
|
112
130
|
self.add_row(
|
|
113
|
-
cell_values=[
|
|
131
|
+
cell_values=[
|
|
132
|
+
text
|
|
133
|
+
],
|
|
114
134
|
span_all_columns=True,
|
|
135
|
+
page_break=page_break,
|
|
136
|
+
border_top=border_top,
|
|
137
|
+
border_bottom=border_bottom,
|
|
115
138
|
)
|
|
116
139
|
|
|
117
140
|
def add_column(
|
|
@@ -129,24 +152,30 @@ class BaseReport(ABC):
|
|
|
129
152
|
def add_divider_row(
|
|
130
153
|
self,
|
|
131
154
|
title: str,
|
|
155
|
+
description: str | None = None,
|
|
132
156
|
page_break: bool = False,
|
|
157
|
+
border_bottom: bool = True,
|
|
133
158
|
):
|
|
134
159
|
self.add_row(
|
|
135
160
|
cell_values=[title],
|
|
161
|
+
cell_sub_values=[description] if description else None,
|
|
136
162
|
bold=True,
|
|
137
163
|
page_break=page_break,
|
|
138
164
|
span_all_columns=True,
|
|
165
|
+
border_bottom=border_bottom,
|
|
139
166
|
)
|
|
140
167
|
|
|
141
168
|
def add_footer_row(
|
|
142
169
|
self,
|
|
143
170
|
cell_values: list[Any],
|
|
144
171
|
cell_sub_values: list[Any] | None = None,
|
|
172
|
+
border_top: bool = True,
|
|
145
173
|
):
|
|
146
174
|
self.add_row(
|
|
147
175
|
cell_values=cell_values,
|
|
148
176
|
cell_sub_values=cell_sub_values,
|
|
149
177
|
bold=True,
|
|
178
|
+
border_top=border_top,
|
|
150
179
|
)
|
|
151
180
|
|
|
152
181
|
def add_row(
|
|
@@ -157,6 +186,8 @@ class BaseReport(ABC):
|
|
|
157
186
|
page_break: bool = False,
|
|
158
187
|
span_all_columns: bool = False,
|
|
159
188
|
table_break: bool = False,
|
|
189
|
+
border_top: bool = False,
|
|
190
|
+
border_bottom: bool = False,
|
|
160
191
|
):
|
|
161
192
|
if span_all_columns or table_break:
|
|
162
193
|
if len(cell_values) > 1:
|
|
@@ -183,5 +214,40 @@ class BaseReport(ABC):
|
|
|
183
214
|
page_break=page_break,
|
|
184
215
|
span_all_columns=span_all_columns,
|
|
185
216
|
table_break=table_break,
|
|
217
|
+
border_top=border_top,
|
|
218
|
+
border_bottom=border_bottom,
|
|
186
219
|
)
|
|
187
220
|
)
|
|
221
|
+
|
|
222
|
+
@staticmethod
|
|
223
|
+
def validate_choices(choices: tuple):
|
|
224
|
+
if not isinstance(choices, tuple):
|
|
225
|
+
raise TypeError(f'choices must be a tuple not {type(choices)}')
|
|
226
|
+
if not all(isinstance(item, tuple) and len(item) == 2 for item in choices):
|
|
227
|
+
raise ValueError('choices must contain tuples of length 2')
|
|
228
|
+
|
|
229
|
+
def to_markdown(self) -> str:
|
|
230
|
+
markdown = ''
|
|
231
|
+
|
|
232
|
+
for column in self.columns:
|
|
233
|
+
markdown += f'| {column.title} '
|
|
234
|
+
|
|
235
|
+
markdown += '|\n'
|
|
236
|
+
|
|
237
|
+
for column in self.columns:
|
|
238
|
+
markdown += '| ' + '-' * len(column.title) + ' '
|
|
239
|
+
|
|
240
|
+
markdown += '|\n'
|
|
241
|
+
|
|
242
|
+
for row in self.rows:
|
|
243
|
+
if row.span_all_columns:
|
|
244
|
+
markdown += f'| {row.cells[0].value}' + '|' * len(self.columns) + '\n'
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
else:
|
|
248
|
+
for cell in row.cells:
|
|
249
|
+
markdown += f'| {cell.value_verbose()} '
|
|
250
|
+
|
|
251
|
+
markdown += '|\n'
|
|
252
|
+
|
|
253
|
+
return markdown
|