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.
Files changed (33) hide show
  1. django_spire/consts.py +1 -1
  2. django_spire/contrib/admin/__init__.py +0 -0
  3. django_spire/contrib/admin/admin.py +140 -0
  4. django_spire/contrib/choices/__init__.py +0 -0
  5. django_spire/contrib/choices/choices.py +9 -0
  6. django_spire/contrib/choices/tests/__init__.py +0 -0
  7. django_spire/contrib/choices/tests/test_choices.py +62 -0
  8. django_spire/core/static/django_spire/css/app-printing.css +25 -0
  9. django_spire/core/templates/django_spire/dropdown/ellipsis_modal_dropdown.html +15 -3
  10. django_spire/core/templates/django_spire/element/attribute_element.html +7 -0
  11. django_spire/core/templates/django_spire/element/copy_to_clipboard_element.html +31 -0
  12. django_spire/metric/report/__init__.py +7 -0
  13. django_spire/metric/report/helper.py +92 -0
  14. django_spire/metric/report/report.py +79 -13
  15. django_spire/metric/report/templates/django_spire/metric/report/form/report_form.html +32 -10
  16. django_spire/metric/report/templates/django_spire/metric/report/print/report_print.html +29 -16
  17. django_spire/metric/report/tools.py +2 -0
  18. django_spire/metric/report/views/page_views.py +27 -5
  19. django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown.html +10 -10
  20. django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown_content.html +1 -33
  21. django_spire/notification/app/templates/django_spire/notification/app/item/notification_item.html +24 -23
  22. django_spire/notification/app/templates/django_spire/notification/app/page/list_page.html +1 -1
  23. django_spire/notification/app/templates/django_spire/notification/app/scroll/container/dropdown_container.html +54 -0
  24. django_spire/notification/app/templates/django_spire/notification/app/scroll/item/items.html +5 -0
  25. django_spire/notification/app/urls/template_urls.py +9 -2
  26. django_spire/notification/app/views/page_views.py +2 -10
  27. django_spire/notification/app/views/template_views.py +22 -7
  28. {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/METADATA +2 -2
  29. {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/RECORD +32 -23
  30. django_spire/notification/app/templates/django_spire/notification/app/card/list_card.html +0 -11
  31. {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/WHEEL +0 -0
  32. {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/licenses/LICENSE.md +0 -0
  33. {django_spire-0.24.2.dist-info → django_spire-0.25.0.dist-info}/top_level.txt +0 -0
django_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.24.2'
1
+ __VERSION__ = '0.25.0'
2
2
 
3
3
  MAINTENANCE_MODE_SETTINGS_NAME = 'MAINTENANCE_MODE'
4
4
 
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
@@ -0,0 +1,9 @@
1
+ import json
2
+
3
+ from django.db.models import TextChoices
4
+
5
+
6
+ class SpireTextChoices(TextChoices):
7
+ @classmethod
8
+ def to_glue_choices(cls) -> str:
9
+ return json.dumps(cls.choices)
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
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=view_url link_text='View' %}
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
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=edit_url link_text='Edit' %}
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
- {% include 'django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html' with view_url=delete_url link_text='Delete' link_css='text-app-danger' %}
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,7 @@
1
+ from django_spire.metric.report.report import BaseReport
2
+ from django_spire.metric.report.registry import ReportRegistry
3
+
4
+ __all__ = [
5
+ 'BaseReport',
6
+ 'ReportRegistry',
7
+ ]
@@ -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
- def cell_value_verbose(self, value):
35
- if self.type == ColumnType.DOLLAR:
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 self.type == ColumnType.NUMBER:
39
+ elif cell_type == ColumnType.NUMBER:
38
40
  return f"{float(value):,.0f}"
39
- elif self.type == ColumnType.PERCENT:
41
+ elif cell_type == ColumnType.PERCENT:
40
42
  return f"{float(value):.1f}%"
41
- elif self.type == ColumnType.DECIMAL_1:
43
+ elif cell_type == ColumnType.DECIMAL_1:
42
44
  return f"{float(value):.1f}"
43
- elif self.type == ColumnType.DECIMAL_2:
45
+ elif cell_type == ColumnType.DECIMAL_2:
44
46
  return f"{float(value):.2f}"
45
- elif self.type == ColumnType.DECIMAL_3:
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
- arguments[name]['choices'] = choices_method()
101
- arguments[name]['annotation'] = 'select'
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(self):
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=['&nbsp;'],
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