django-spire 0.24.1__py3-none-any.whl → 0.24.3__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/contrib/service/django_model_service.py +65 -47
- django_spire/contrib/service/tests/test_service.py +13 -66
- 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/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.1.dist-info → django_spire-0.24.3.dist-info}/METADATA +1 -1
- {django_spire-0.24.1.dist-info → django_spire-0.24.3.dist-info}/RECORD +26 -18
- {django_spire-0.24.1.dist-info → django_spire-0.24.3.dist-info}/WHEEL +1 -1
- django_spire/notification/app/templates/django_spire/notification/app/card/list_card.html +0 -11
- {django_spire-0.24.1.dist-info → django_spire-0.24.3.dist-info}/licenses/LICENSE.md +0 -0
- {django_spire-0.24.1.dist-info → django_spire-0.24.3.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']]
|
|
@@ -3,15 +3,18 @@ from __future__ import annotations
|
|
|
3
3
|
import logging
|
|
4
4
|
|
|
5
5
|
from abc import ABC
|
|
6
|
+
from itertools import chain
|
|
6
7
|
from typing import Generic, TypeVar
|
|
7
8
|
|
|
9
|
+
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
8
10
|
from django.db import transaction
|
|
9
|
-
from django.db.models import Model
|
|
11
|
+
from django.db.models import Model, Field, AutoField, ForeignObjectRel, FileField
|
|
10
12
|
|
|
11
13
|
from django_spire.contrib.constructor.django_model_constructor import BaseDjangoModelConstructor
|
|
12
14
|
from django_spire.contrib.service.exceptions import ServiceError
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
|
|
15
18
|
log = logging.getLogger(__name__)
|
|
16
19
|
|
|
17
20
|
TypeDjangoModel_co = TypeVar('TypeDjangoModel_co', bound=Model, covariant=True)
|
|
@@ -22,63 +25,78 @@ class BaseDjangoModelService(
|
|
|
22
25
|
ABC,
|
|
23
26
|
Generic[TypeDjangoModel_co]
|
|
24
27
|
):
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
field.name: field
|
|
28
|
-
for field in self.obj._meta.get_fields()
|
|
29
|
-
if field.concrete and not field.many_to_many and not field.one_to_many
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
def _get_touched_fields(self, concrete_fields: dict, **field_data: dict) -> list[str]:
|
|
33
|
-
foreign_key_id_aliases = {f"{name}_id" for name, field in concrete_fields.items() if field.many_to_one}
|
|
34
|
-
allowed = set(concrete_fields) | foreign_key_id_aliases
|
|
35
|
-
|
|
36
|
-
touched_fields: list[str] = []
|
|
37
|
-
|
|
38
|
-
for field, value in field_data.items():
|
|
39
|
-
if field not in allowed:
|
|
40
|
-
log.warning(f'Field {field!r} is not valid for {self.obj.__class__.__name__}')
|
|
41
|
-
continue
|
|
28
|
+
def _set_non_m2m_fields(self, **field_data: dict):
|
|
29
|
+
model_fields = self.obj._meta.fields
|
|
42
30
|
|
|
43
|
-
|
|
31
|
+
file_field_list = []
|
|
32
|
+
updated_fields = []
|
|
44
33
|
|
|
45
|
-
|
|
34
|
+
for field in model_fields:
|
|
35
|
+
if isinstance(field, AutoField) or field.name not in field_data:
|
|
46
36
|
continue
|
|
47
37
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
38
|
+
# Defer saving file-type fields until after the other fields, so a
|
|
39
|
+
# callable upload_to can use the values from other fields (from django's construct_instance).
|
|
40
|
+
if isinstance(field, FileField):
|
|
41
|
+
file_field_list.append(field)
|
|
42
|
+
updated_fields.append(field.name)
|
|
43
|
+
else:
|
|
44
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
45
|
+
updated_fields.append(field.name)
|
|
46
|
+
|
|
47
|
+
# Update foreign key id aliases in field_data for
|
|
48
|
+
# related fields that weren't already updated above
|
|
49
|
+
foreign_key_id_aliases = [
|
|
50
|
+
f"{field.name}_id" for field in model_fields
|
|
51
|
+
if f"{field.name}_id" in field_data and field.many_to_one and field.name not in updated_fields
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
for field_name in foreign_key_id_aliases:
|
|
55
|
+
setattr(self.obj, field_name, field_data[field_name])
|
|
56
|
+
|
|
57
|
+
# Update file fields deferred from earlier
|
|
58
|
+
for field in file_field_list:
|
|
59
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
60
|
+
|
|
61
|
+
def _set_m2m_fields(self, **field_data):
|
|
62
|
+
model_meta = self.obj._meta
|
|
63
|
+
|
|
64
|
+
for field in chain(model_meta.many_to_many, model_meta.private_fields):
|
|
65
|
+
if not hasattr(field, "save_form_data"):
|
|
66
|
+
continue
|
|
67
|
+
if field.name in field_data:
|
|
68
|
+
field.save_form_data(self.obj, field_data[field.name])
|
|
57
69
|
|
|
58
|
-
|
|
59
|
-
|
|
70
|
+
@transaction.atomic
|
|
71
|
+
def save_model_obj(self, **field_data: dict | None) -> tuple[Model, bool]:
|
|
72
|
+
"""
|
|
73
|
+
This is the core service method for saving a Django model object with field data provided via kwargs.
|
|
74
|
+
It will update the object with the given kwargs and handle any upstream attribute changes that were applied
|
|
75
|
+
directly to the model instance (i.e. it can also be called without any kwargs, similar to `Model.save()`).
|
|
60
76
|
|
|
61
|
-
|
|
77
|
+
Its purpose is to run extra operations related to the model instance that need to run each time
|
|
78
|
+
the model is saved - it is meant to replace the need for overriding `BaseModelForm.save()` or `Model.save()`.
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
It is designed to emulate django's `BaseModelForm.save()` method as close as possible:
|
|
81
|
+
first, in `_set_non_m2m_fields`, it updates the fields on `self.obj` using logic similar to `django.forms.models.construct_instance`,
|
|
82
|
+
then it calls self.obj.save(), then updates the m2m fields on the object instance
|
|
83
|
+
using logic similar to django's `BaseModelForm._save_m2m()` method. In all cases,
|
|
84
|
+
it treats the incoming `field_data` exactly the same as `cleaned_data` is treated
|
|
85
|
+
in the django code that it is emulating, and therefore does not perform any validation
|
|
86
|
+
on the data or the model instance as it is assumed that field_data has already been validated upstream.
|
|
66
87
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
raise ServiceError(message)
|
|
88
|
+
Args:
|
|
89
|
+
**field_data:
|
|
70
90
|
|
|
71
|
-
|
|
91
|
+
Returns:
|
|
92
|
+
tuple[Model, bool]
|
|
72
93
|
|
|
73
|
-
|
|
74
|
-
new_model_obj_was_created = True
|
|
75
|
-
self.obj.save()
|
|
94
|
+
"""
|
|
76
95
|
|
|
77
|
-
|
|
78
|
-
self.obj.save(update_fields=touched_fields)
|
|
96
|
+
new_model_obj_was_created = True if self.model_obj_is_new else False
|
|
79
97
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
98
|
+
self._set_non_m2m_fields(**field_data)
|
|
99
|
+
self.obj.save()
|
|
100
|
+
self._set_m2m_fields(**field_data)
|
|
83
101
|
|
|
84
102
|
return self.obj, new_model_obj_was_created
|
|
@@ -37,66 +37,26 @@ class TestBaseDjangoModelService(TestCase):
|
|
|
37
37
|
password='testpass' # noqa: S106
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def test_set_non_m2m_fields_sets_attribute(self) -> None:
|
|
41
41
|
service = UserService(self.user)
|
|
42
|
-
|
|
43
|
-
result = service._get_concrete_fields()
|
|
44
|
-
|
|
45
|
-
assert isinstance(result, dict)
|
|
46
|
-
assert 'username' in result
|
|
47
|
-
assert 'email' in result
|
|
48
|
-
|
|
49
|
-
def test_get_touched_fields_returns_list(self) -> None:
|
|
50
|
-
service = UserService(self.user)
|
|
51
|
-
concrete_fields = service._get_concrete_fields()
|
|
52
|
-
|
|
53
|
-
result = service._get_touched_fields(concrete_fields, username='newuser')
|
|
54
|
-
|
|
55
|
-
assert isinstance(result, list)
|
|
56
|
-
|
|
57
|
-
def test_get_touched_fields_sets_attribute(self) -> None:
|
|
58
|
-
service = UserService(self.user)
|
|
59
|
-
concrete_fields = service._get_concrete_fields()
|
|
60
|
-
|
|
61
|
-
service._get_touched_fields(concrete_fields, first_name='NewName')
|
|
42
|
+
service._set_non_m2m_fields(first_name='NewName')
|
|
62
43
|
|
|
63
44
|
assert service.obj.first_name == 'NewName'
|
|
64
45
|
|
|
65
|
-
def
|
|
66
|
-
|
|
67
|
-
concrete_fields = service._get_concrete_fields()
|
|
68
|
-
|
|
69
|
-
with patch('django_spire.contrib.service.django_model_service.log') as mock_log:
|
|
70
|
-
service._get_touched_fields(concrete_fields, invalid_field='value')
|
|
71
|
-
|
|
72
|
-
mock_log.warning.assert_called_once()
|
|
46
|
+
def test_set_non_m2m_fields_skips_auto_created_fields(self) -> None:
|
|
47
|
+
UserService(self.user)._set_non_m2m_fields(id=999)
|
|
73
48
|
|
|
74
|
-
|
|
75
|
-
service = UserService(self.user)
|
|
76
|
-
concrete_fields = service._get_concrete_fields()
|
|
49
|
+
assert self.user.id != 999
|
|
77
50
|
|
|
78
|
-
|
|
51
|
+
def test_has_set_non_m2m_fields_method(self) -> None:
|
|
52
|
+
assert hasattr(BaseDjangoModelService, '_set_non_m2m_fields')
|
|
79
53
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def test_has_get_concrete_fields_method(self) -> None:
|
|
83
|
-
assert hasattr(BaseDjangoModelService, '_get_concrete_fields')
|
|
84
|
-
|
|
85
|
-
def test_has_get_touched_fields_method(self) -> None:
|
|
86
|
-
assert hasattr(BaseDjangoModelService, '_get_touched_fields')
|
|
54
|
+
def test_has_set_m2m_fields_method(self) -> None:
|
|
55
|
+
assert hasattr(BaseDjangoModelService, '_set_m2m_fields')
|
|
87
56
|
|
|
88
57
|
def test_has_save_model_obj_method(self) -> None:
|
|
89
58
|
assert hasattr(BaseDjangoModelService, 'save_model_obj')
|
|
90
59
|
|
|
91
|
-
def test_has_validate_model_obj_method(self) -> None:
|
|
92
|
-
assert hasattr(BaseDjangoModelService, 'validate_model_obj')
|
|
93
|
-
|
|
94
|
-
def test_save_model_obj_raises_error_without_field_data(self) -> None:
|
|
95
|
-
service = UserService(self.user)
|
|
96
|
-
|
|
97
|
-
with pytest.raises(ServiceError, match='Field data is required'):
|
|
98
|
-
service.save_model_obj()
|
|
99
|
-
|
|
100
60
|
def test_save_model_obj_returns_tuple(self) -> None:
|
|
101
61
|
service = UserService(self.user)
|
|
102
62
|
|
|
@@ -130,24 +90,11 @@ class TestBaseDjangoModelService(TestCase):
|
|
|
130
90
|
assert created is True
|
|
131
91
|
assert obj.pk is not None
|
|
132
92
|
|
|
133
|
-
def
|
|
93
|
+
def test_save_model_updates_obj_with_previous_changes(self) -> None:
|
|
134
94
|
service = UserService(self.user)
|
|
135
95
|
|
|
136
|
-
|
|
137
|
-
service.save_model_obj(id=self.user.id)
|
|
96
|
+
service.obj.first_name = 'new_test'
|
|
138
97
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def test_validate_model_obj_returns_touched_fields(self) -> None:
|
|
142
|
-
service = UserService(self.user)
|
|
143
|
-
|
|
144
|
-
result = service.validate_model_obj(first_name='Valid')
|
|
145
|
-
|
|
146
|
-
assert isinstance(result, list)
|
|
147
|
-
assert 'first_name' in result
|
|
148
|
-
|
|
149
|
-
def test_validate_model_obj_raises_validation_error(self) -> None:
|
|
150
|
-
service = UserService(self.user)
|
|
98
|
+
service.save_model_obj(id=self.user.id)
|
|
151
99
|
|
|
152
|
-
|
|
153
|
-
service.validate_model_obj(email='invalid-email')
|
|
100
|
+
assert service.obj.first_name == 'new_test'
|
|
@@ -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>
|
|
@@ -4,28 +4,28 @@
|
|
|
4
4
|
<div x-data="{
|
|
5
5
|
new_notification: false,
|
|
6
6
|
async init(){
|
|
7
|
-
await this.check_new_notifications()
|
|
7
|
+
await this.check_new_notifications();
|
|
8
8
|
setInterval(await this.check_new_notifications, 30000);
|
|
9
9
|
},
|
|
10
10
|
|
|
11
11
|
async check_new_notifications(){
|
|
12
|
-
let url = '{% url "django_spire:notification:app:json:check_new" %}'
|
|
13
|
-
let response = await django_glue_fetch(url)
|
|
14
|
-
this.new_notification = response.has_new_notifications
|
|
12
|
+
let url = '{% url "django_spire:notification:app:json:check_new" %}';
|
|
13
|
+
let response = await django_glue_fetch(url);
|
|
14
|
+
this.new_notification = response.has_new_notifications;
|
|
15
15
|
},
|
|
16
16
|
|
|
17
17
|
async render_dropdown(){
|
|
18
18
|
let dropdown_content = new ViewGlue(
|
|
19
19
|
url='{% url "django_spire:notification:app:template:notification_dropdown" %}',
|
|
20
20
|
shared_payload={app_notification_list_url: '{{ app_notification_list_url|default:"" }}'}
|
|
21
|
-
)
|
|
22
|
-
await dropdown_content.render_inner($refs.spire_notification_dropdown_content)
|
|
23
|
-
await this.mark_notifications_as_viewed()
|
|
21
|
+
);
|
|
22
|
+
await dropdown_content.render_inner($refs.spire_notification_dropdown_content);
|
|
23
|
+
await this.mark_notifications_as_viewed();
|
|
24
24
|
},
|
|
25
25
|
|
|
26
26
|
async mark_notifications_as_viewed(){
|
|
27
|
-
let response = await django_glue_fetch('{% url "django_spire:notification:app:json:set_viewed" %}')
|
|
28
|
-
this.new_notification = false
|
|
27
|
+
let response = await django_glue_fetch('{% url "django_spire:notification:app:json:set_viewed" %}');
|
|
28
|
+
this.new_notification = false;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
}"
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
{% block dropdown_position %}top-100 end-50{% endblock %}
|
|
45
45
|
|
|
46
46
|
{% block dropdown_content %}
|
|
47
|
-
<div x-ref="spire_notification_dropdown_content"></div>
|
|
47
|
+
<div :style="{ width: window.innerWidth < 768 ? (window.innerWidth / 2).toFixed(0) + 'px' : '350px' }" x-ref="spire_notification_dropdown_content"></div>
|
|
48
48
|
{% endblock %}
|
|
@@ -1,33 +1 @@
|
|
|
1
|
-
|
|
2
|
-
:style="window.innerWidth < 768
|
|
3
|
-
? 'width: 350px; position: fixed; top: 5%; left: 50%; transform: translateX(-50%);'
|
|
4
|
-
: 'width: 350px'"
|
|
5
|
-
class="container border rounded-2 bg-app-layer-two"
|
|
6
|
-
>
|
|
7
|
-
<div class="col-12 w-100">
|
|
8
|
-
<h6 class="mt-2 ms-2 text-center">Notifications</h6>
|
|
9
|
-
<hr class="m-0">
|
|
10
|
-
</div>
|
|
11
|
-
|
|
12
|
-
<div
|
|
13
|
-
style="max-height: 300px !important;"
|
|
14
|
-
class="text-start overflow-y-scroll overflow-x-hidden"
|
|
15
|
-
>
|
|
16
|
-
{% for app_notification in app_notification_list %}
|
|
17
|
-
<div class="col-12">
|
|
18
|
-
{% include app_notification.template %}
|
|
19
|
-
</div>
|
|
20
|
-
{% empty %}
|
|
21
|
-
No Notifications
|
|
22
|
-
{% endfor %}
|
|
23
|
-
</div>
|
|
24
|
-
<div class="col-12">
|
|
25
|
-
<hr class="m-0">
|
|
26
|
-
</div>
|
|
27
|
-
<div class="col-12 fs-7 text-center py-1">
|
|
28
|
-
{% if not app_notification_list_url %}
|
|
29
|
-
{% url "django_spire:notification:app:page:list" as app_notification_list_url %}
|
|
30
|
-
{% endif %}
|
|
31
|
-
<a href='{{ app_notification_list_url }}' class="text-decoration-none text-app-secondary my-2">View All</a>
|
|
32
|
-
</div>
|
|
33
|
-
</div>
|
|
1
|
+
{% include 'django_spire/notification/app/scroll/container/dropdown_container.html' with endpoint=notification_endpoint container_height='300px' batch_size=10 %}
|
django_spire/notification/app/templates/django_spire/notification/app/item/notification_item.html
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
{% extends 'django_spire/item/infinite_scroll_item.html' %}
|
|
2
|
+
|
|
3
|
+
{% block item_content %}
|
|
4
|
+
{% if app_notification.notification.url %}
|
|
5
|
+
<a href="{{ app_notification.notification.url }}"
|
|
6
|
+
{% else %}
|
|
7
|
+
<div
|
|
8
|
+
{% endif %}
|
|
9
|
+
class="row bg-app-layer-two-hover rounded m-0"
|
|
10
|
+
>
|
|
11
|
+
<div class="col-12">
|
|
12
|
+
<span class="fs-7 fw-semibold">{{ app_notification.notification.title }}</span>
|
|
13
|
+
{% if not app_notification.viewed %}
|
|
14
|
+
{% include 'django_spire/badge/primary_badge.html' with badge_text='New' %}
|
|
15
|
+
{% endif %}
|
|
16
|
+
</div>
|
|
17
|
+
<div class="col-12">
|
|
18
|
+
<span class="fs--1">{{ app_notification.notification.body }}</span><br>
|
|
19
|
+
<span class="text-app-secondary text-muted fs--2">{{ app_notification.verbose_time_since_delivered }}</span>
|
|
20
|
+
</div>
|
|
21
|
+
{% if app_notification.notification.url %}
|
|
22
|
+
</a>
|
|
23
|
+
{% else %}
|
|
21
24
|
</div>
|
|
22
|
-
{% if not forloop.last %}
|
|
23
|
-
<hr class="m-0 mt-1">
|
|
24
25
|
{% endif %}
|
|
25
|
-
|
|
26
|
+
{% endblock %}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{% extends 'django_spire/page/full_page.html' %}
|
|
2
2
|
|
|
3
3
|
{% block full_page_content %}
|
|
4
|
-
{% include "django_spire/
|
|
4
|
+
{% include "django_spire/card/infinite_scroll_card.html" with card_title='Notifications' endpoint=notification_endpoint scroll_height='55vh' %}
|
|
5
5
|
{% endblock %}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{% extends 'django_spire/infinite_scroll/base.html' %}
|
|
2
|
+
|
|
3
|
+
{% block scroll_wrapper_class %}bg-app-layer-two rounded-2{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block scroll_container %}
|
|
6
|
+
<div class="row">
|
|
7
|
+
<div class="col-12">
|
|
8
|
+
<div
|
|
9
|
+
:style="{
|
|
10
|
+
maxHeight: '{{ container_height|default:'300px' }}',
|
|
11
|
+
overflowX: 'hidden',
|
|
12
|
+
overflowY: 'auto',
|
|
13
|
+
overscrollBehavior: 'contain',
|
|
14
|
+
WebkitOverflowScrolling: 'touch',
|
|
15
|
+
maxWidth: window.innerWidth < 768 ? (window.innerWidth / 2).toFixed(0) + 'px' : '350px',
|
|
16
|
+
}"
|
|
17
|
+
x-ref="scroll_container"
|
|
18
|
+
:data-scroll-id="scroll_id"
|
|
19
|
+
>
|
|
20
|
+
<div x-ref="content_container">
|
|
21
|
+
{% block scroll_content %}{% endblock %}
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div style="height: 10px;" x-ref="infinite_scroll_trigger"></div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<template x-if="show_loading && !is_refreshing">
|
|
28
|
+
<div
|
|
29
|
+
class="position-absolute d-flex justify-content-center align-items-center"
|
|
30
|
+
style="top: 0; left: 0; right: 0; bottom: 0; background: color-mix(in srgb, var(--app-layer-one) 85%, transparent);"
|
|
31
|
+
>
|
|
32
|
+
<div class="spinner-border text-app-primary" role="status"></div>
|
|
33
|
+
</div>
|
|
34
|
+
</template>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
{% endblock %}
|
|
38
|
+
|
|
39
|
+
{% block scroll_footer %}
|
|
40
|
+
<div class="row text-center border-top border-dark-subtle m-0">
|
|
41
|
+
<div class="col-12">
|
|
42
|
+
<span class="fs-7 text-app-secondary">
|
|
43
|
+
Showing <span x-text="loaded_count"></span> of <span x-text="total_count"></span> {{ footer_label|default:'items' }}
|
|
44
|
+
</span>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<div class="col-12 fs-7 text-center py-1">
|
|
48
|
+
{% if not app_notification_list_url %}
|
|
49
|
+
{% url "django_spire:notification:app:page:list" as app_notification_list_url %}
|
|
50
|
+
{% endif %}
|
|
51
|
+
<a href='{{ app_notification_list_url }}' class="text-decoration-none text-app-secondary my-2">View All</a>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{% endblock %}
|
|
@@ -8,7 +8,14 @@ from django_spire.notification.app.views import template_views
|
|
|
8
8
|
app_name = 'django_spire_notification'
|
|
9
9
|
|
|
10
10
|
urlpatterns = [
|
|
11
|
-
path(
|
|
11
|
+
path(
|
|
12
|
+
'notficiation/scroll/items',
|
|
13
|
+
template_views.notification_infinite_scroll_view,
|
|
14
|
+
name='scroll_items'
|
|
15
|
+
),
|
|
16
|
+
path(
|
|
17
|
+
'notficiation/dropdown/template/',
|
|
12
18
|
template_views.notification_dropdown_template_view,
|
|
13
|
-
name='notification_dropdown'
|
|
19
|
+
name='notification_dropdown'
|
|
20
|
+
)
|
|
14
21
|
]
|
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
5
|
from django.contrib.auth.decorators import login_required
|
|
6
|
+
from django.urls import reverse
|
|
6
7
|
|
|
7
8
|
from django_spire.contrib.generic_views import portal_views
|
|
8
9
|
|
|
@@ -15,18 +16,9 @@ if TYPE_CHECKING:
|
|
|
15
16
|
|
|
16
17
|
@login_required()
|
|
17
18
|
def app_notification_list_view(request: WSGIRequest) -> TemplateResponse:
|
|
18
|
-
app_notification_list = (
|
|
19
|
-
AppNotification.objects.active()
|
|
20
|
-
.is_sent()
|
|
21
|
-
.annotate_is_viewed_by_user(request.user)
|
|
22
|
-
.select_related('notification')
|
|
23
|
-
.distinct()
|
|
24
|
-
.ordered_by_priority_and_sent_datetime()
|
|
25
|
-
)
|
|
26
|
-
|
|
27
19
|
return portal_views.list_view(
|
|
28
20
|
request,
|
|
29
|
-
context_data={'
|
|
21
|
+
context_data={'notification_endpoint': reverse('django_spire:notification:app:template:scroll_items')},
|
|
30
22
|
model=AppNotification,
|
|
31
23
|
template='django_spire/notification/app/page/list_page.html'
|
|
32
24
|
)
|
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
-
|
|
5
4
|
from typing import TYPE_CHECKING
|
|
6
5
|
|
|
7
6
|
from django.contrib.auth.models import AnonymousUser
|
|
8
|
-
from django.
|
|
7
|
+
from django.urls import reverse
|
|
9
8
|
|
|
9
|
+
from django_spire.contrib.generic_views.portal_views import infinite_scrolling_view
|
|
10
|
+
from django.template.response import TemplateResponse
|
|
10
11
|
from django_spire.notification.app.models import AppNotification
|
|
11
12
|
|
|
13
|
+
|
|
12
14
|
if TYPE_CHECKING:
|
|
13
15
|
from django.core.handlers.wsgi import WSGIRequest
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
def notification_dropdown_template_view(request: WSGIRequest) -> TemplateResponse:
|
|
17
|
+
def notification_infinite_scroll_view(request: WSGIRequest) -> TemplateResponse:
|
|
17
18
|
if isinstance(request.user, AnonymousUser):
|
|
18
|
-
|
|
19
|
+
notifications = []
|
|
19
20
|
|
|
20
21
|
else:
|
|
21
|
-
|
|
22
|
+
notifications = (
|
|
22
23
|
AppNotification.objects.active()
|
|
23
24
|
.is_sent()
|
|
24
25
|
.annotate_is_viewed_by_user(request.user)
|
|
@@ -29,11 +30,25 @@ def notification_dropdown_template_view(request: WSGIRequest) -> TemplateRespons
|
|
|
29
30
|
|
|
30
31
|
body_data = json.loads(request.body.decode('utf-8'))
|
|
31
32
|
|
|
33
|
+
return infinite_scrolling_view(
|
|
34
|
+
request,
|
|
35
|
+
queryset=notifications,
|
|
36
|
+
queryset_name='notifications',
|
|
37
|
+
context_data={
|
|
38
|
+
'app_notification_list_url': body_data.get('app_notification_list_url'),
|
|
39
|
+
},
|
|
40
|
+
template='django_spire/notification/app/scroll/item/items.html',
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def notification_dropdown_template_view(request: WSGIRequest) -> TemplateResponse:
|
|
45
|
+
body_data = json.loads(request.body.decode('utf-8'))
|
|
46
|
+
|
|
32
47
|
return TemplateResponse(
|
|
33
48
|
request,
|
|
34
49
|
context={
|
|
35
|
-
'app_notification_list': app_notification_list,
|
|
36
50
|
'app_notification_list_url': body_data.get('app_notification_list_url'),
|
|
51
|
+
'notification_endpoint': reverse('django_spire:notification:app:template:scroll_items')
|
|
37
52
|
},
|
|
38
53
|
template='django_spire/notification/app/dropdown/notification_dropdown_content.html'
|
|
39
54
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: django-spire
|
|
3
|
-
Version: 0.24.
|
|
3
|
+
Version: 0.24.3
|
|
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.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
django_spire/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
2
|
django_spire/conf.py,sha256=3oUB1mtgHRjvbJsxfQWG5uL1KUP9uGig3zdP2dZphe8,942
|
|
3
|
-
django_spire/consts.py,sha256=
|
|
3
|
+
django_spire/consts.py,sha256=pGfQC8jGUV7BYSAN3PnjA-Ur64Gr7KGC5UkkXkKWfOk,171
|
|
4
4
|
django_spire/exceptions.py,sha256=M7buFvm-K4lK09pH5fVcZ-MxsDIzdpEJBF33Xss5bSw,289
|
|
5
5
|
django_spire/settings.py,sha256=Pr98O2Na5Cv9YXs5y8c2CvGYv1szmXED8RJVT5q2-W4,1164
|
|
6
6
|
django_spire/urls.py,sha256=wQx6R-nXx69MeOF-WmDxcEUM5WmUHGplbY5uZ_HnDp8,703
|
|
@@ -339,11 +339,17 @@ django_spire/comment/tests/test_querysets.py,sha256=01nhMO9Wpi_flkSA41d4l5Il8MaS
|
|
|
339
339
|
django_spire/comment/tests/test_utils.py,sha256=x5WQvkXOoult5V4RIUjSp4qK6Q-wEgqVm-BnyL1qWyU,3371
|
|
340
340
|
django_spire/contrib/__init__.py,sha256=Cw4KTzAMKPQucNRy541gYSe9ph_JtA1rRrILaYVsb1w,100
|
|
341
341
|
django_spire/contrib/utils.py,sha256=IuNcM6ft6whrkPuoDkB100JqUYGZomgXMnTZeCoUvSQ,168
|
|
342
|
+
django_spire/contrib/admin/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
343
|
+
django_spire/contrib/admin/admin.py,sha256=zb4bnfum7ZpfnWI17BLDQ3LHM4nTTfNv1Sr13Xbb0rs,4281
|
|
342
344
|
django_spire/contrib/breadcrumb/__init__.py,sha256=Cw4KTzAMKPQucNRy541gYSe9ph_JtA1rRrILaYVsb1w,100
|
|
343
345
|
django_spire/contrib/breadcrumb/apps.py,sha256=8rDWD4nAyAdH0QTHHOqXDPv6tVJTLilJeRV0-YBH2Uw,248
|
|
344
346
|
django_spire/contrib/breadcrumb/breadcrumbs.py,sha256=H_Cpcsli9X4GsSQ1xa_ylKWCJxw9uzUj0_RxP61hOGs,2128
|
|
345
347
|
django_spire/contrib/breadcrumb/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
346
348
|
django_spire/contrib/breadcrumb/tests/test_breadcrumbs.py,sha256=gGdmR8m4jH-raAHA7popA1vX1-I4hCCWBNUi7kLdleM,5864
|
|
349
|
+
django_spire/contrib/choices/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
350
|
+
django_spire/contrib/choices/choices.py,sha256=HXdQakkQ5QTaWsYLFNo8efyBsaXZGBIqLPUIJ4IItN0,185
|
|
351
|
+
django_spire/contrib/choices/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
352
|
+
django_spire/contrib/choices/tests/test_choices.py,sha256=fkm4syq7CY5o9wz8XvqAfP5JN_iNmHhWLx85VhfpIdY,2038
|
|
347
353
|
django_spire/contrib/constructor/__init__.py,sha256=lcBPE05Oa4wNmZRGNSscJ9zPxYuPobPmj8O1Dm78h1c,338
|
|
348
354
|
django_spire/contrib/constructor/constructor.py,sha256=QjOBGMI4vFR-4vnGNcYlf9W9NBIa2vB6-Y8BPKQmgGI,2973
|
|
349
355
|
django_spire/contrib/constructor/django_model_constructor.py,sha256=KSaazWoQaaOJ6goKDz_J-OZuPRSLLgpqEBUa4xUPo-k,1145
|
|
@@ -463,10 +469,10 @@ django_spire/contrib/seeding/tests/test_enums.py,sha256=tpysk3dKDtOXfD-0ebqSTBba
|
|
|
463
469
|
django_spire/contrib/seeding/tests/test_intel.py,sha256=Hi6Jr91nBIPoLGt4KtRZO4-uGq5gTCMltJ8tHMgDCUs,980
|
|
464
470
|
django_spire/contrib/seeding/tests/test_override.py,sha256=8eliLQBsQ7n1-ARqXLQdZOcrWDD8XhWfYoc_iaDaktM,1843
|
|
465
471
|
django_spire/contrib/service/__init__.py,sha256=EB44rklqr317T2tDXDW5qocfQDE7b4f2UFjf7DAWj-0,215
|
|
466
|
-
django_spire/contrib/service/django_model_service.py,sha256=
|
|
472
|
+
django_spire/contrib/service/django_model_service.py,sha256=yQyNQZMfidRQKwDcPyXydF0yBSNIG1iSzEuJi-KKRXE,4188
|
|
467
473
|
django_spire/contrib/service/exceptions.py,sha256=nIfh1kjeCN94eZE12zR4cGtvn3xKC2WAr_1KVakU28E,138
|
|
468
474
|
django_spire/contrib/service/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
469
|
-
django_spire/contrib/service/tests/test_service.py,sha256=
|
|
475
|
+
django_spire/contrib/service/tests/test_service.py,sha256=BlRY-UgQtZwylTj6nderZ-NjKB4Kj9o1iL4zWbGzKhw,3077
|
|
470
476
|
django_spire/contrib/session/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
471
477
|
django_spire/contrib/session/apps.py,sha256=zrDjGlxth4TvLZel-CSGt-CQa-S8Xs92sM-uq5m7d58,257
|
|
472
478
|
django_spire/contrib/session/controller.py,sha256=4kOTDw4URLy3pCBWqWfwuOKEHzpjNT6iKVDU8-uH7Zk,3022
|
|
@@ -712,12 +718,13 @@ django_spire/core/templates/django_spire/container/form_container.html,sha256=FZ
|
|
|
712
718
|
django_spire/core/templates/django_spire/container/infinite_scroll_container.html,sha256=xaHlMZ4yLKhZCvbbRHUAHeEZOFKJf-s8eGUN_y9FNJE,2558
|
|
713
719
|
django_spire/core/templates/django_spire/dropdown/dropdown.html,sha256=O3gUp2YZm_2g0Qd-odFOnxfBkRc4c4af4zTbyGibSU0,528
|
|
714
720
|
django_spire/core/templates/django_spire/dropdown/ellipsis_dropdown.html,sha256=6DrFtcvfCnegs_gLfDZDkEGYb6ZpJ85clKQWckiTH00,1431
|
|
715
|
-
django_spire/core/templates/django_spire/dropdown/ellipsis_modal_dropdown.html,sha256=
|
|
721
|
+
django_spire/core/templates/django_spire/dropdown/ellipsis_modal_dropdown.html,sha256=0lbHn6vn2ip_Sia0khQ8k3Vp0nsYUIZHa-XbQXnsB9Q,1540
|
|
716
722
|
django_spire/core/templates/django_spire/dropdown/ellipsis_table_dropdown.html,sha256=_Rf9hKmOchDdE00NU5Aq8pvVb4Q4vPxlIWf1NWOUGg8,935
|
|
717
723
|
django_spire/core/templates/django_spire/dropdown/element/dropdown_link_element.html,sha256=KB4KRolkw5zOImRkXcVXxIH_bxsN648gQRnRDijgvB4,1005
|
|
718
724
|
django_spire/core/templates/django_spire/dropdown/element/ellipsis_dropdown_modal_link_element.html,sha256=WC2ruVWh5c_5hYZmrKFYrfJXGW6r5z7KGu8_X-vPvhs,438
|
|
719
|
-
django_spire/core/templates/django_spire/element/attribute_element.html,sha256=
|
|
725
|
+
django_spire/core/templates/django_spire/element/attribute_element.html,sha256=0cftkHl-b4ePrQioCXMwpyxW2RlU6AVVJ4OyhyCAzMM,1778
|
|
720
726
|
django_spire/core/templates/django_spire/element/breadcrumb_element.html,sha256=NUoLKKKIPDIF8G8mIM1dVF5lhbqmRcOAWLA6qw9o2x8,598
|
|
727
|
+
django_spire/core/templates/django_spire/element/copy_to_clipboard_element.html,sha256=5YG6sdjIFu0CvssEs5HSCihvrLc7bTeIl3B_wsnk3AA,722
|
|
721
728
|
django_spire/core/templates/django_spire/element/divider_element.html,sha256=EjpdMPQZmefh0BfUGUQvazFR5eF0RHQ5pqmYD253GMc,302
|
|
722
729
|
django_spire/core/templates/django_spire/element/grabber_element.html,sha256=ZH6abnf8p4jMRSdjWNsbRqNBuM1yMJF_EKNLh7c4pj8,198
|
|
723
730
|
django_spire/core/templates/django_spire/element/no_data_element.html,sha256=-WSsQZVh3T3NROSfQo8U-ZPxt8nxmDggp3Cz_gbeNL4,145
|
|
@@ -1220,12 +1227,13 @@ django_spire/notification/app/processor.py,sha256=qV4nK37vMpoL9CDEgB8diAG1oBQrEt
|
|
|
1220
1227
|
django_spire/notification/app/querysets.py,sha256=gEi26CfQlLkSGHyWsKFvRbxcOmoCAIjcbe_SqCHr0ns,1821
|
|
1221
1228
|
django_spire/notification/app/migrations/0001_initial.py,sha256=ecwU6tJ5qZBKl3uAgpv_L74OUepxYgLvr3ssRYSfxxs,1439
|
|
1222
1229
|
django_spire/notification/app/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1223
|
-
django_spire/notification/app/templates/django_spire/notification/app/
|
|
1224
|
-
django_spire/notification/app/templates/django_spire/notification/app/dropdown/
|
|
1225
|
-
django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown_content.html,sha256=Lcy1laXb9HWrr0TEgCrK8PEoE0-hnCPrIa7OuZrSkKI,1113
|
|
1230
|
+
django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown.html,sha256=zIsIQ-mEgwzIutI-Jp4Z5vIb9426aAGH9ksNJ0MU4Bk,2034
|
|
1231
|
+
django_spire/notification/app/templates/django_spire/notification/app/dropdown/notification_dropdown_content.html,sha256=iiRG0us7lIS3q_CPM3h7Uyy_VegNH1oUxQM11FpdJoU,162
|
|
1226
1232
|
django_spire/notification/app/templates/django_spire/notification/app/element/notification_bell.html,sha256=fwXOY5yi76jQOx5wZQky2mfAs0DFIOwBDwJwOZCdw-8,440
|
|
1227
|
-
django_spire/notification/app/templates/django_spire/notification/app/item/notification_item.html,sha256=
|
|
1228
|
-
django_spire/notification/app/templates/django_spire/notification/app/page/list_page.html,sha256=
|
|
1233
|
+
django_spire/notification/app/templates/django_spire/notification/app/item/notification_item.html,sha256=KagHR9pTBWPxHFEIv9TLhWxT_jsGQdIaajqPXCFSaLQ,952
|
|
1234
|
+
django_spire/notification/app/templates/django_spire/notification/app/page/list_page.html,sha256=vE8eDZ8UmQWRI3tqYYAW3FJAIHYkAFcPr86B7ujw_qc,244
|
|
1235
|
+
django_spire/notification/app/templates/django_spire/notification/app/scroll/container/dropdown_container.html,sha256=XCMEJ_XjjI_wwIjUHVK8IB24JIGsJmWxM6Kc98_xBco,2207
|
|
1236
|
+
django_spire/notification/app/templates/django_spire/notification/app/scroll/item/items.html,sha256=LsNhh5n_x_IaRuvDIHRbAHqyBmwfzXfsa12ycZO4XJA,145
|
|
1229
1237
|
django_spire/notification/app/tests/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1230
1238
|
django_spire/notification/app/tests/factories.py,sha256=V0NHRQ-cY-HkSN4jegEFoNk6pVdrTJL6YaQ7hwtxIO8,1280
|
|
1231
1239
|
django_spire/notification/app/tests/test_apps.py,sha256=xo94F1iYXoHmkDkGlDMcRpxXoF0U83YWoK2Zm5kHgD4,863
|
|
@@ -1238,11 +1246,11 @@ django_spire/notification/app/tests/test_views/test_page_views.py,sha256=VIcCcwy
|
|
|
1238
1246
|
django_spire/notification/app/urls/__init__.py,sha256=Scogp1Oni-8fJU80cffWj0UoA43tfRaZ43PgjofrW2Y,406
|
|
1239
1247
|
django_spire/notification/app/urls/json_urls.py,sha256=r-ly6mxXcByH4x54gkTC1-AmwVVA7HiQd-a_5lnL1uU,427
|
|
1240
1248
|
django_spire/notification/app/urls/page_urls.py,sha256=K2jwK-zoY0N-kcQ4tsYmUZtnKm5m0-b2KHv4bCU2xC0,302
|
|
1241
|
-
django_spire/notification/app/urls/template_urls.py,sha256=
|
|
1249
|
+
django_spire/notification/app/urls/template_urls.py,sha256=EpMmjpnyUVU5e5-W5zXfNRm03_FNPx32PtOrXfr0Qg8,485
|
|
1242
1250
|
django_spire/notification/app/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1243
1251
|
django_spire/notification/app/views/json_views.py,sha256=WhdbbeZyzf408vHMUgMQ9ERbawQk085A-Nlrxzve56U,1466
|
|
1244
|
-
django_spire/notification/app/views/page_views.py,sha256=
|
|
1245
|
-
django_spire/notification/app/views/template_views.py,sha256=
|
|
1252
|
+
django_spire/notification/app/views/page_views.py,sha256=IojY_oLWrcgOfUaWn2UERkEU3Ka3Ie9nAqCDv_Jb4S8,782
|
|
1253
|
+
django_spire/notification/app/views/template_views.py,sha256=h9UgUXaLxHzLwZhdduVgKHIZYelFrYSupNlQumis3uw,1775
|
|
1246
1254
|
django_spire/notification/email/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1247
1255
|
django_spire/notification/email/admin.py,sha256=KY7AbmQkc07HeQcpiTgcyohemrUBY5eHpniUkD1mLeU,316
|
|
1248
1256
|
django_spire/notification/email/apps.py,sha256=H3ukWNu1aYgNEYsXQwH6WlIAvF0wGl4UBdOWXF-UBm0,456
|
|
@@ -1401,8 +1409,8 @@ django_spire/theme/urls/page_urls.py,sha256=Oak3x_xwQEb01NKdrsB1nk6yPaOEnheuSG1m
|
|
|
1401
1409
|
django_spire/theme/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
1402
1410
|
django_spire/theme/views/json_views.py,sha256=PWwVTaty0BVGbj65L5cxex6JNhc-xVAI_rEYjbJWqEM,1893
|
|
1403
1411
|
django_spire/theme/views/page_views.py,sha256=WenjOa6Welpu3IMolY56ZwBjy4aK9hpbiMNuygjAl1A,3922
|
|
1404
|
-
django_spire-0.24.
|
|
1405
|
-
django_spire-0.24.
|
|
1406
|
-
django_spire-0.24.
|
|
1407
|
-
django_spire-0.24.
|
|
1408
|
-
django_spire-0.24.
|
|
1412
|
+
django_spire-0.24.3.dist-info/licenses/LICENSE.md,sha256=ZAeCT76WvaoEZE9xPhihyWjTwH0wQZXQmyRsnV2VPFs,1091
|
|
1413
|
+
django_spire-0.24.3.dist-info/METADATA,sha256=cNiLXLnSnLFw9Bw7YbGw-FcKJPNaziT58JqIagte4lU,5127
|
|
1414
|
+
django_spire-0.24.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
1415
|
+
django_spire-0.24.3.dist-info/top_level.txt,sha256=xf3QV1e--ONkVpgMDQE9iqjQ1Vg4--_6C8wmO-KxPHQ,13
|
|
1416
|
+
django_spire-0.24.3.dist-info/RECORD,,
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
{% extends 'django_spire/card/title_card.html' %}
|
|
2
|
-
|
|
3
|
-
{% block card_title %}
|
|
4
|
-
Notification List
|
|
5
|
-
{% endblock %}
|
|
6
|
-
|
|
7
|
-
{% block card_title_content %}
|
|
8
|
-
{% for app_notification in notification_list %}
|
|
9
|
-
{% include app_notification.template %}
|
|
10
|
-
{% endfor %}
|
|
11
|
-
{% endblock %}
|
|
File without changes
|
|
File without changes
|