django-spire 0.16.5__py3-none-any.whl → 0.16.7__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 (71) hide show
  1. django_spire/auth/group/templates/django_spire/auth/group/navigation/accordion/permission_nav_accordion.html +2 -2
  2. django_spire/consts.py +1 -1
  3. django_spire/core/management/commands/spire_startapp.py +0 -4
  4. django_spire/core/management/commands/spire_startapp_pkg/processor.py +18 -3
  5. django_spire/core/management/commands/spire_startapp_pkg/reporter.py +32 -30
  6. django_spire/core/templates/django_spire/element/pagination_element.html +2 -0
  7. django_spire/knowledge/auth/controller.py +10 -0
  8. django_spire/knowledge/collection/admin.py +10 -3
  9. django_spire/knowledge/collection/models.py +23 -1
  10. django_spire/knowledge/collection/querysets.py +42 -1
  11. django_spire/knowledge/collection/services/factory_service.py +42 -0
  12. django_spire/knowledge/collection/services/service.py +9 -1
  13. django_spire/knowledge/collection/services/transformation_service.py +22 -8
  14. django_spire/knowledge/collection/tests/test_services/test_transformation_service.py +4 -2
  15. django_spire/knowledge/collection/views/form_views.py +26 -4
  16. django_spire/knowledge/context_processors.py +1 -2
  17. django_spire/knowledge/entry/views/json_views.py +1 -1
  18. django_spire/knowledge/migrations/0004_alter_collection_options_collectiongroup.py +27 -0
  19. django_spire/knowledge/static/django_spire/knowledge/collection/js/managers.js +17 -14
  20. django_spire/knowledge/templates/django_spire/knowledge/collection/card/context_menu_card.html +16 -10
  21. django_spire/knowledge/templates/django_spire/knowledge/collection/form/form.html +10 -1
  22. django_spire/knowledge/templates/django_spire/knowledge/collection/navigation/navigation.html +3 -0
  23. django_spire/knowledge/templates/django_spire/knowledge/entry/card/context_menu_card.html +1 -1
  24. django_spire/knowledge/templates/django_spire/knowledge/page/full_page.html +1 -1
  25. django_spire/theme/tests/test_filesystem.py +2 -2
  26. {django_spire-0.16.5.dist-info → django_spire-0.16.7.dist-info}/METADATA +2 -2
  27. {django_spire-0.16.5.dist-info → django_spire-0.16.7.dist-info}/RECORD +30 -69
  28. django_spire/core/management/commands/spire_startapp_pkg/template/app/__init__.py +0 -0
  29. django_spire/core/management/commands/spire_startapp_pkg/template/app/apps.py +0 -7
  30. django_spire/core/management/commands/spire_startapp_pkg/template/app/forms.py +0 -15
  31. django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/__init__.py +0 -0
  32. django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/bots.py +0 -19
  33. django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/intel.py +0 -7
  34. django_spire/core/management/commands/spire_startapp_pkg/template/app/intelligence/prompts.py +0 -30
  35. django_spire/core/management/commands/spire_startapp_pkg/template/app/migrations/__init__.py +0 -0
  36. django_spire/core/management/commands/spire_startapp_pkg/template/app/models.py +0 -52
  37. django_spire/core/management/commands/spire_startapp_pkg/template/app/querysets.py +0 -12
  38. django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/__init__.py +0 -0
  39. django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/seed.py +0 -3
  40. django_spire/core/management/commands/spire_startapp_pkg/template/app/seeding/seeder.py +0 -13
  41. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/__init__.py +0 -0
  42. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/factory_service.py +0 -11
  43. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/intelligence_service.py +0 -11
  44. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/processor_service.py +0 -11
  45. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/service.py +0 -20
  46. django_spire/core/management/commands/spire_startapp_pkg/template/app/services/transformation_service.py +0 -11
  47. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/__init__.py +0 -0
  48. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/__init__.py +0 -0
  49. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_intelligence/test_bots.py +0 -6
  50. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_models.py +0 -6
  51. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/__init__.py +0 -0
  52. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_factory_service.py +0 -6
  53. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_intelligence_service.py +0 -6
  54. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_processor_service.py +0 -6
  55. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_service.py +0 -6
  56. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_services/test_transformation_service.py +0 -6
  57. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/__init__.py +0 -0
  58. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/test_form_urls.py +0 -6
  59. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_urls/test_page_urls.py +0 -6
  60. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/__init__.py +0 -0
  61. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/test_form_views.py +0 -6
  62. django_spire/core/management/commands/spire_startapp_pkg/template/app/tests/test_views/test_page_views.py +0 -6
  63. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/__init__.py +0 -9
  64. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/form_urls.py +0 -13
  65. django_spire/core/management/commands/spire_startapp_pkg/template/app/urls/page_urls.py +0 -11
  66. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/__init__.py +0 -0
  67. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/form_views.py +0 -120
  68. django_spire/core/management/commands/spire_startapp_pkg/template/app/views/page_views.py +0 -45
  69. {django_spire-0.16.5.dist-info → django_spire-0.16.7.dist-info}/WHEEL +0 -0
  70. {django_spire-0.16.5.dist-info → django_spire-0.16.7.dist-info}/licenses/LICENSE.md +0 -0
  71. {django_spire-0.16.5.dist-info → django_spire-0.16.7.dist-info}/top_level.txt +0 -0
@@ -8,12 +8,12 @@
8
8
 
9
9
  {% block accordion_links %}
10
10
  {% if perms.django_spire_auth_group.view_authgroup %}
11
- {% url 'django_spire_permission:list' as group_list_url %}
11
+ {% url 'django_spire:auth:group:page:list' as group_list_url %}
12
12
  {% include 'django_spire/navigation/elements/nav_link.html' with link_text='Groups' link_icon='bi bi-people' link_url=group_list_url %}
13
13
  {% endif %}
14
14
 
15
15
  {% if perms.django_spire_auth_user.view_authuser %}
16
- {% url 'django_spire_permission:user_list' as user_list_url %}
16
+ {% url 'django_spire:auth:user:page:list' as user_list_url %}
17
17
  {% include 'django_spire/navigation/elements/nav_link.html' with link_text='Users' link_icon='bi bi-person-badge' link_url=user_list_url %}
18
18
  {% endif %}
19
19
  {% endblock %}
django_spire/consts.py CHANGED
@@ -1,4 +1,4 @@
1
- __VERSION__ = '0.16.5'
1
+ __VERSION__ = '0.16.7'
2
2
 
3
3
 
4
4
  AI_CHAT_WORKFLOW_SENDER_SETTINGS_NAME = 'AI_CHAT_WORKFLOW_NAME'
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
  import django_spire
4
4
 
5
5
  from pathlib import Path
6
- from typing_extensions import TYPE_CHECKING
7
6
 
8
7
  from django.conf import settings
9
8
  from django.core.management.base import BaseCommand, CommandError
@@ -18,9 +17,6 @@ from django_spire.core.management.commands.spire_startapp_pkg.processor import (
18
17
  )
19
18
  from django_spire.core.management.commands.spire_startapp_pkg.reporter import Reporter
20
19
 
21
- if TYPE_CHECKING:
22
- import argparse
23
-
24
20
 
25
21
  class Command(BaseCommand):
26
22
  help = 'Create a custom Spire app.'
@@ -24,8 +24,8 @@ class BaseTemplateProcessor:
24
24
 
25
25
  updated_content = self.apply_replacement(content, replacement)
26
26
 
27
- with open(path, 'w', encoding='utf-8') as file:
28
- file.write(updated_content)
27
+ with open(path, 'w', encoding='utf-8') as handle:
28
+ handle.write(updated_content)
29
29
 
30
30
  def rename_file(self, path: Path, components: list[str]) -> None:
31
31
  replacement = generate_replacement_map(components)
@@ -55,10 +55,25 @@ class AppTemplateProcessor(BaseTemplateProcessor):
55
55
  self._process_files(
56
56
  directory,
57
57
  components,
58
- '*',
58
+ '*.template',
59
59
  lambda path: path.is_file()
60
60
  )
61
61
 
62
+ self._process_files(
63
+ directory,
64
+ components,
65
+ '*.py',
66
+ lambda path: path.is_file()
67
+ )
68
+
69
+ self._rename_template_files(directory)
70
+
71
+ def _rename_template_files(self, directory: Path) -> None:
72
+ for template_file in directory.rglob('*.py.template'):
73
+ new_name = template_file.name.replace('.py.template', '.py')
74
+ new_path = template_file.parent / new_name
75
+ template_file.rename(new_path)
76
+
62
77
 
63
78
  class HTMLTemplateProcessor(BaseTemplateProcessor):
64
79
  def replace_template_names(self, directory: Path, components: list[str]) -> None:
@@ -26,6 +26,15 @@ class Reporter:
26
26
 
27
27
  return name
28
28
 
29
+ def _sort_template_items(self, path: Path) -> tuple[bool, str]:
30
+ return (path.is_file(), path.name.lower())
31
+
32
+ def _app_transformation(self, _index: int, component: str) -> str:
33
+ return component
34
+
35
+ def _html_transformation(self, index: int, component: str) -> str:
36
+ return 'templates' if index == 0 else component
37
+
29
38
  def _report_tree_structure(
30
39
  self,
31
40
  title: str,
@@ -34,8 +43,11 @@ class Reporter:
34
43
  registry: list[str],
35
44
  template: Path,
36
45
  formatter: Callable[[Path], str],
37
- transformation: Callable[[int, str], str] = lambda _, component: component,
46
+ transformation: Callable[[int, str], str] | None = None,
38
47
  ) -> None:
48
+ if transformation is None:
49
+ transformation = self._app_transformation
50
+
39
51
  self.command.stdout.write(title)
40
52
  current = base
41
53
 
@@ -51,11 +63,9 @@ class Reporter:
51
63
  self.command.stdout.write(f'{indent}{ICON_FOLDER_OPEN} {component}/')
52
64
 
53
65
  if i == len(components) - 1 and app not in registry and template.exists():
54
- local_formatter = lambda item: (
55
- item.name
56
- if item.is_dir()
57
- else self._apply_replacement(item.name, replacement)
58
- )
66
+ def local_formatter(item: Path, mapping: dict[str, str] = replacement) -> str:
67
+ base_name = formatter(item)
68
+ return self._apply_replacement(base_name, mapping)
59
69
 
60
70
  self._show_tree_from_template(template, indent + INDENTATION, local_formatter)
61
71
 
@@ -67,12 +77,7 @@ class Reporter:
67
77
  ) -> None:
68
78
  ignore = {'__init__.py', '__pycache__'}
69
79
 
70
- key = lambda p: (
71
- p.is_file(),
72
- p.name.lower()
73
- )
74
-
75
- items = sorted(template.iterdir(), key=key)
80
+ items = sorted(template.iterdir(), key=self._sort_template_items)
76
81
 
77
82
  for item in items:
78
83
  if item.name in ignore:
@@ -88,6 +93,15 @@ class Reporter:
88
93
  formatter
89
94
  )
90
95
 
96
+ def _app_formatter(self, item: Path) -> str:
97
+ return item.name.replace('.py.template', '.py')
98
+
99
+ def _html_formatter(self, item: Path, replacement: dict[str, str]) -> str:
100
+ if item.is_dir():
101
+ return item.name
102
+
103
+ return self._apply_replacement(item.name, replacement)
104
+
91
105
  def report_app_tree_structure(
92
106
  self,
93
107
  base: Path,
@@ -95,17 +109,14 @@ class Reporter:
95
109
  registry: list[str],
96
110
  template: Path
97
111
  ) -> None:
98
- formatter = lambda item: item.name
99
- transformation = lambda _, component: component
100
-
101
112
  self._report_tree_structure(
102
113
  title='\nThe following app(s) will be created:\n\n',
103
114
  base=base,
104
115
  components=components,
105
116
  registry=registry,
106
117
  template=template,
107
- formatter=formatter,
108
- transformation=transformation,
118
+ formatter=self._app_formatter,
119
+ transformation=self._app_transformation,
109
120
  )
110
121
 
111
122
  def report_html_tree_structure(
@@ -117,16 +128,8 @@ class Reporter:
117
128
  ) -> None:
118
129
  replacement = generate_replacement_map(components)
119
130
 
120
- formatter = lambda item: (
121
- item.name
122
- if item.is_dir()
123
- else self._apply_replacement(item.name, replacement)
124
- )
125
-
126
- transformation = (
127
- lambda i, component:
128
- 'templates' if i == 0 else component
129
- )
131
+ def html_formatter_with_replacement(item: Path) -> str:
132
+ return self._html_formatter(item, replacement)
130
133
 
131
134
  self._report_tree_structure(
132
135
  title='\nThe following template(s) will be created:\n\n',
@@ -134,8 +137,8 @@ class Reporter:
134
137
  components=components,
135
138
  registry=registry,
136
139
  template=template,
137
- formatter=formatter,
138
- transformation=transformation,
140
+ formatter=html_formatter_with_replacement,
141
+ transformation=self._html_transformation,
139
142
  )
140
143
 
141
144
  def prompt_for_confirmation(self, message: str) -> bool:
@@ -148,7 +151,6 @@ class Reporter:
148
151
  def report_installed_apps_suggestion(self, missing_components: list[str]) -> None:
149
152
  self.command.stdout.write(self.command.style.NOTICE('\nPlease add the following to INSTALLED_APPS in settings.py:'))
150
153
  self.command.stdout.write(f'\n {missing_components[-1]}')
151
- # self.command.stdout.write('\n'.join(f'"{app}"' for app in missing_components))
152
154
 
153
155
  def report_app_creation_success(self, app: str) -> None:
154
156
  message = f'Successfully created app: {app}'
@@ -1,3 +1,5 @@
1
+ {% load pagination_tags %}
2
+
1
3
  {% include 'django_spire/element/divider_element.html' %}
2
4
  <div class="row justify-content-between pb-1">
3
5
  <div class="col-auto ps-4">
@@ -2,12 +2,22 @@ from django_spire.auth.controller.controller import BaseAuthController
2
2
 
3
3
 
4
4
  class BaseKnowledgeAuthController(BaseAuthController):
5
+ def can_access_all_collections(self):
6
+ return self.request.user.has_perm(
7
+ 'django_spire_knowledge.can_access_all_collections'
8
+ )
9
+
5
10
  def can_add(self):
6
11
  return self.request.user.has_perm('django_spire_knowledge.add_collection')
7
12
 
8
13
  def can_change(self):
9
14
  return self.request.user.has_perm('django_spire_knowledge.change_collection')
10
15
 
16
+ def can_change_collection_groups(self):
17
+ return self.request.user.has_perm(
18
+ 'django_spire_knowledge.can_change_collection_groups'
19
+ )
20
+
11
21
  def can_delete(self):
12
22
  return self.request.user.has_perm('django_spire_knowledge.delete_collection')
13
23
 
@@ -1,12 +1,19 @@
1
1
  from django.contrib import admin
2
- from .models import Collection
2
+ from .models import Collection, CollectionGroup
3
3
 
4
4
 
5
5
  @admin.register(Collection)
6
6
  class CollectionAdmin(admin.ModelAdmin):
7
- list_display = ['name', 'parent', 'is_deleted']
7
+ list_display = ['id', 'name', 'parent', 'is_deleted']
8
8
  list_select_related = ['parent']
9
9
  list_filter = ['is_deleted', 'is_active']
10
- search_fields = ['name', 'description', 'parent__name']
10
+ search_fields = ['id', 'name', 'description', 'parent__name']
11
11
  ordering = ['name']
12
12
  autocomplete_fields = ['parent']
13
+
14
+
15
+ @admin.register(CollectionGroup)
16
+ class CollectionGroupAdmin(admin.ModelAdmin):
17
+ list_display = ['id', 'collection', 'auth_group']
18
+ list_select_related = ['collection', 'auth_group']
19
+ search_fields = ['id', 'collection__name', 'auth_group__name']
@@ -1,9 +1,11 @@
1
1
  from django.db import models
2
2
 
3
+ from django_spire.auth.group.models import AuthGroup
3
4
  from django_spire.contrib.ordering.mixins import OrderingModelMixin
4
5
  from django_spire.history.mixins import HistoryModelMixin
5
6
  from django_spire.knowledge.collection.querysets import CollectionQuerySet
6
- from django_spire.knowledge.collection.services.service import CollectionService
7
+ from django_spire.knowledge.collection.services.service import CollectionGroupService, \
8
+ CollectionService
7
9
 
8
10
 
9
11
  class Collection(HistoryModelMixin, OrderingModelMixin):
@@ -29,3 +31,23 @@ class Collection(HistoryModelMixin, OrderingModelMixin):
29
31
  verbose_name = 'Collection'
30
32
  verbose_name_plural = 'Collections'
31
33
  db_table = 'django_spire_knowledge_collection'
34
+ permissions = [
35
+ ('can_access_all_collections', 'Can Access All Collections'),
36
+ ('can_change_collection_groups', 'Can Change Collection Groups')
37
+ ]
38
+
39
+
40
+ class CollectionGroup(models.Model):
41
+ collection = models.ForeignKey(
42
+ Collection,
43
+ on_delete=models.CASCADE,
44
+ related_name='groups',
45
+ related_query_name='group',
46
+ )
47
+ auth_group = models.ForeignKey(
48
+ AuthGroup,
49
+ on_delete=models.CASCADE,
50
+ related_name='collection_groups',
51
+ related_query_name='collection_group',
52
+ )
53
+ services = CollectionGroupService()
@@ -2,12 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from django.db.models import Count
5
+ from django.core.handlers.wsgi import WSGIRequest
6
+ from django.db.models import Count, Q
6
7
 
8
+ from django_spire.auth.controller.controller import AppAuthController
7
9
  from django_spire.contrib.ordering.querysets import OrderingQuerySetMixin
8
10
  from django_spire.history.querysets import HistoryQuerySet
9
11
 
10
12
  if TYPE_CHECKING:
13
+ from django_spire.auth.user.models import AuthUser
11
14
  from django.db.models import QuerySet
12
15
  from django_spire.knowledge.collection.models import Collection
13
16
 
@@ -25,5 +28,43 @@ class CollectionQuerySet(HistoryQuerySet, OrderingQuerySetMixin):
25
28
  def childless(self) -> QuerySet[Collection]:
26
29
  return self.annotate(child_count=Count('child')).filter(child_count=0)
27
30
 
31
+ def exclude_children(self, collection: Collection) -> QuerySet[Collection]:
32
+ descendant_ids = set()
33
+ current_level_ids = [collection.id]
34
+
35
+ while current_level_ids:
36
+ children = self.filter(parent_id__in=current_level_ids)
37
+ child_ids = list(children.values_list('id', flat=True))
38
+
39
+ if not child_ids:
40
+ break
41
+
42
+ descendant_ids.update(child_ids)
43
+ current_level_ids = child_ids
44
+
45
+ return self.exclude(id__in=descendant_ids)
46
+
28
47
  def parentless(self) -> QuerySet[Collection]:
29
48
  return self.filter(parent_id__isnull=True)
49
+
50
+ def request_user_has_access(self, request: WSGIRequest) -> QuerySet[Collection]:
51
+ user = request.user
52
+
53
+ if user.is_superuser or AppAuthController('knowledge', request).can_access_all_collections():
54
+ return self.all()
55
+
56
+ direct_access = self.filter(group__auth_group__user=user)
57
+ accessible_ids = set(direct_access.values_list('id', flat=True))
58
+
59
+ current_level_ids = accessible_ids.copy()
60
+ while current_level_ids:
61
+ next_level = self.filter(parent_id__in=current_level_ids)
62
+ new_ids = set(next_level.values_list('id', flat=True)) - accessible_ids
63
+
64
+ if not new_ids:
65
+ break
66
+
67
+ accessible_ids.update(new_ids)
68
+ current_level_ids = new_ids
69
+
70
+ return self.filter(id__in=accessible_ids).distinct()
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from django.core.handlers.wsgi import WSGIRequest
6
+
7
+ from django_spire.auth.controller.controller import AppAuthController
8
+ from django_spire.contrib.service import BaseDjangoModelService
9
+
10
+ if TYPE_CHECKING:
11
+ from django_spire.knowledge.collection.models import Collection, CollectionGroup
12
+
13
+
14
+ class CollectionGroupFactoryService(BaseDjangoModelService['CollectionGroup']):
15
+ obj: CollectionGroup
16
+
17
+ def replace_groups(
18
+ self,
19
+ request: WSGIRequest,
20
+ group_pks: list[int] | None,
21
+ collection: Collection
22
+ ) -> list[CollectionGroup]:
23
+ if not AppAuthController('knowledge', request).can_change_collection_groups():
24
+ return []
25
+
26
+ if group_pks is None:
27
+ return collection.groups.all().delete()
28
+
29
+ old_collection_groups = list(collection.groups.all())
30
+
31
+ new_collection_groups = []
32
+ for group_pk in group_pks:
33
+ new_collection_groups.append(
34
+ self.obj_class(auth_group_id=group_pk, collection=collection)
35
+ )
36
+
37
+ self.obj_class.objects.bulk_create(new_collection_groups)
38
+
39
+ for collection in old_collection_groups:
40
+ collection.delete()
41
+
42
+ return new_collection_groups
@@ -4,6 +4,8 @@ from django_spire.contrib.service import BaseDjangoModelService
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
+ from django_spire.knowledge.collection.services.factory_service import \
8
+ CollectionGroupFactoryService
7
9
  from django_spire.knowledge.collection.services.ordering_service import \
8
10
  CollectionOrderingService
9
11
  from django_spire.knowledge.collection.services.processor_service import \
@@ -12,7 +14,7 @@ from django_spire.knowledge.collection.services.transformation_service import \
12
14
  CollectionTransformationService
13
15
 
14
16
  if TYPE_CHECKING:
15
- from django_spire.knowledge.collection.models import Collection
17
+ from django_spire.knowledge.collection.models import Collection, CollectionGroup
16
18
 
17
19
 
18
20
  class CollectionService(BaseDjangoModelService['Collection']):
@@ -40,3 +42,9 @@ class CollectionService(BaseDjangoModelService['Collection']):
40
42
  )
41
43
 
42
44
  return self.obj, created
45
+
46
+
47
+ class CollectionGroupService(BaseDjangoModelService['CollectionGroup']):
48
+ obj: CollectionGroup
49
+
50
+ factory = CollectionGroupFactoryService()
@@ -4,7 +4,7 @@ import json
4
4
 
5
5
  from django.conf import settings
6
6
  from django.contrib.sites.models import Site
7
- from django.db.models import QuerySet, Prefetch
7
+ from django.db.models import Prefetch
8
8
  from django.urls import reverse
9
9
 
10
10
  from django_spire.contrib.service import BaseDjangoModelService
@@ -12,28 +12,34 @@ from django_spire.contrib.service import BaseDjangoModelService
12
12
  from typing import TYPE_CHECKING
13
13
 
14
14
  if TYPE_CHECKING:
15
- from django.contrib.auth.models import User
15
+ from django.core.handlers.wsgi import WSGIRequest
16
16
  from django_spire.knowledge.collection.models import Collection
17
17
 
18
18
 
19
19
  class CollectionTransformationService(BaseDjangoModelService['Collection']):
20
20
  obj: Collection
21
21
 
22
- @staticmethod
23
- def to_hierarchy_json(queryset: QuerySet[Collection], user: User) -> str:
22
+ def to_hierarchy_json(self, request: WSGIRequest) -> str:
23
+ collections = (
24
+ self.obj_class.objects
25
+ .active()
26
+ .request_user_has_access(request)
27
+ .select_related('parent')
28
+ )
29
+
24
30
  entry_queryset = (
25
- queryset.model._meta.fields_map.get('entry')
31
+ collections.model._meta.fields_map.get('entry')
26
32
  .related_model
27
33
  .objects
28
34
  .active()
29
35
  .has_current_version()
30
- .user_has_access(user=user)
36
+ .user_has_access(user=request.user)
31
37
  .select_related('current_version__author')
32
38
  .order_by('order')
33
39
  )
34
40
 
35
41
  collections = list(
36
- queryset.prefetch_related(Prefetch('entries', queryset=entry_queryset))
42
+ collections.prefetch_related(Prefetch('entries', queryset=entry_queryset))
37
43
  .active()
38
44
  .order_by('order')
39
45
  )
@@ -44,7 +50,7 @@ class CollectionTransformationService(BaseDjangoModelService['Collection']):
44
50
 
45
51
  tree = []
46
52
  for collection in collections:
47
- if collection.parent_id:
53
+ if collection.parent_id and collection.parent_id in collection_map:
48
54
  collection_map[collection.parent_id]['children'].append(
49
55
  collection_map[collection.pk]
50
56
  )
@@ -74,6 +80,14 @@ class CollectionTransformationService(BaseDjangoModelService['Collection']):
74
80
  )
75
81
  }
76
82
  ''',
83
+ 'edit_url': f'''
84
+ {site}{
85
+ reverse(
86
+ 'django_spire:knowledge:collection:form:update',
87
+ kwargs={'pk': self.obj.pk},
88
+ )
89
+ }
90
+ ''',
77
91
  'create_entry_url': f'''
78
92
  {site}{
79
93
  reverse(
@@ -2,6 +2,7 @@ import json
2
2
 
3
3
  from django.contrib.sites.models import Site
4
4
  from django.conf import settings
5
+ from django.test import RequestFactory
5
6
 
6
7
  from django_spire.core.tests.test_cases import BaseTestCase
7
8
  from django_spire.knowledge.collection.models import Collection
@@ -29,9 +30,10 @@ class TestCollectionTransformationService(BaseTestCase):
29
30
  self.test_collection_2 = Collection.objects.create(name='Parent A1', id=2, parent_id=1)
30
31
  self.test_collection_3 = Collection.objects.create(name='Child A1a', id=3, parent_id=2)
31
32
 
33
+ request = RequestFactory().get('/')
34
+ request.user = self.super_user
32
35
  family_tree = Collection.services.transformation.to_hierarchy_json(
33
- queryset=Collection.objects.all().select_related('parent'),
34
- user=self.super_user
36
+ request=request
35
37
  )
36
38
 
37
39
  for collection_json in json.loads(family_tree):
@@ -8,10 +8,11 @@ from django.http import HttpResponseRedirect
8
8
  from django.urls import reverse
9
9
 
10
10
  from django_spire.auth.controller.controller import AppAuthController
11
+ from django_spire.auth.group.models import AuthGroup
11
12
  from django_spire.contrib.form.utils import show_form_errors
12
13
  from django_spire.contrib.generic_views import portal_views
13
14
  from django_spire.core.shortcuts import get_object_or_null_obj
14
- from django_spire.knowledge.collection.models import Collection
15
+ from django_spire.knowledge.collection.models import Collection, CollectionGroup
15
16
  from django_spire.knowledge.collection.forms import CollectionForm
16
17
 
17
18
  if TYPE_CHECKING:
@@ -30,7 +31,19 @@ def form_view(
30
31
  dg.glue_query_set(
31
32
  request,
32
33
  unique_name='collections',
33
- target=Collection.objects.active(),
34
+ target=(
35
+ Collection.objects
36
+ .active()
37
+ .request_user_has_access(request)
38
+ .exclude(pk=collection.pk)
39
+ .exclude_children(collection=collection)
40
+ ),
41
+ fields=['name']
42
+ )
43
+ dg.glue_query_set(
44
+ request,
45
+ unique_name='group_query_set',
46
+ target=AuthGroup.objects.all(),
34
47
  fields=['name']
35
48
  )
36
49
 
@@ -38,7 +51,13 @@ def form_view(
38
51
  form = CollectionForm(request.POST, instance=collection)
39
52
 
40
53
  if form.is_valid():
41
- _, _ = collection.services.save_model_obj(**form.cleaned_data)
54
+ collection, _ = collection.services.save_model_obj(**form.cleaned_data)
55
+
56
+ _ = CollectionGroup.services.factory.replace_groups(
57
+ request=request,
58
+ group_pks=dict(request.POST).get('groups'),
59
+ collection=collection,
60
+ )
42
61
 
43
62
  return HttpResponseRedirect(
44
63
  reverse('django_spire:knowledge:collection:page:list')
@@ -53,7 +72,10 @@ def form_view(
53
72
  form=form,
54
73
  obj=collection,
55
74
  context_data={
56
- 'collection': collection
75
+ 'collection': collection,
76
+ 'group_ids': list(
77
+ collection.groups.all().values_list('auth_group_id', flat=True)
78
+ ) if collection.id else [],
57
79
  },
58
80
  template='django_spire/knowledge/collection/page/form_page.html'
59
81
  )
@@ -11,7 +11,6 @@ def django_spire_knowledge(request: WSGIRequest) -> dict[str, Any]:
11
11
 
12
12
  return {
13
13
  'collection_tree_json': Collection.services.transformation.to_hierarchy_json(
14
- queryset=Collection.objects.all().select_related('parent').order_by('name'),
15
- user=request.user,
14
+ request=request,
16
15
  )
17
16
  }
@@ -41,7 +41,7 @@ def reorder_view(request: WSGIRequest) -> JsonResponse:
41
41
  destination_objects=(
42
42
  collection.entries
43
43
  .has_current_version()
44
- .user_has_access(user=request.user)
44
+ .request_user_has_access(user=request.user)
45
45
  .active()
46
46
  ),
47
47
  position=order,
@@ -0,0 +1,27 @@
1
+ # Generated by Django 5.2.6 on 2025-09-23 14:11
2
+
3
+ import django.db.models.deletion
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+
9
+ dependencies = [
10
+ ('django_spire_auth_group', '0001_initial'),
11
+ ('django_spire_knowledge', '0003_alter_collection_order_alter_entry_order_and_more'),
12
+ ]
13
+
14
+ operations = [
15
+ migrations.AlterModelOptions(
16
+ name='collection',
17
+ options={'permissions': [('can_access_all_collections', 'Can Access All Collections'), ('can_change_collection_groups', 'Can Change Collection Groups')], 'verbose_name': 'Collection', 'verbose_name_plural': 'Collections'},
18
+ ),
19
+ migrations.CreateModel(
20
+ name='CollectionGroup',
21
+ fields=[
22
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23
+ ('auth_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collection_groups', related_query_name='collection_group', to='django_spire_auth_group.authgroup')),
24
+ ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='groups', related_query_name='group', to='django_spire_knowledge.collection')),
25
+ ],
26
+ ),
27
+ ]