django-cfg 1.1.82__py3-none-any.whl → 1.2.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 (244) hide show
  1. django_cfg/__init__.py +20 -448
  2. django_cfg/apps/accounts/README.md +3 -3
  3. django_cfg/apps/accounts/admin/__init__.py +0 -2
  4. django_cfg/apps/accounts/admin/activity.py +2 -9
  5. django_cfg/apps/accounts/admin/filters.py +0 -42
  6. django_cfg/apps/accounts/admin/inlines.py +8 -8
  7. django_cfg/apps/accounts/admin/otp.py +5 -5
  8. django_cfg/apps/accounts/admin/registration_source.py +1 -8
  9. django_cfg/apps/accounts/admin/user.py +12 -20
  10. django_cfg/apps/accounts/managers/user_manager.py +2 -129
  11. django_cfg/apps/accounts/migrations/0006_remove_twilioresponse_otp_secret_and_more.py +46 -0
  12. django_cfg/apps/accounts/models.py +3 -123
  13. django_cfg/apps/accounts/serializers/otp.py +40 -44
  14. django_cfg/apps/accounts/serializers/profile.py +0 -2
  15. django_cfg/apps/accounts/services/otp_service.py +98 -186
  16. django_cfg/apps/accounts/signals.py +25 -15
  17. django_cfg/apps/accounts/utils/auth_email_service.py +84 -0
  18. django_cfg/apps/accounts/views/otp.py +35 -36
  19. django_cfg/apps/agents/README.md +129 -0
  20. django_cfg/apps/agents/__init__.py +68 -0
  21. django_cfg/apps/agents/admin/__init__.py +17 -0
  22. django_cfg/apps/agents/admin/execution_admin.py +460 -0
  23. django_cfg/apps/agents/admin/registry_admin.py +360 -0
  24. django_cfg/apps/agents/admin/toolsets_admin.py +482 -0
  25. django_cfg/apps/agents/apps.py +29 -0
  26. django_cfg/apps/agents/core/__init__.py +20 -0
  27. django_cfg/apps/agents/core/agent.py +281 -0
  28. django_cfg/apps/agents/core/dependencies.py +154 -0
  29. django_cfg/apps/agents/core/exceptions.py +66 -0
  30. django_cfg/apps/agents/core/models.py +106 -0
  31. django_cfg/apps/agents/core/orchestrator.py +391 -0
  32. django_cfg/apps/agents/examples/__init__.py +3 -0
  33. django_cfg/apps/agents/examples/simple_example.py +161 -0
  34. django_cfg/apps/agents/integration/__init__.py +14 -0
  35. django_cfg/apps/agents/integration/middleware.py +80 -0
  36. django_cfg/apps/agents/integration/registry.py +345 -0
  37. django_cfg/apps/agents/integration/signals.py +50 -0
  38. django_cfg/apps/agents/management/__init__.py +3 -0
  39. django_cfg/apps/agents/management/commands/__init__.py +3 -0
  40. django_cfg/apps/agents/management/commands/create_agent.py +365 -0
  41. django_cfg/apps/agents/management/commands/orchestrator_status.py +191 -0
  42. django_cfg/apps/agents/managers/__init__.py +23 -0
  43. django_cfg/apps/agents/managers/execution.py +236 -0
  44. django_cfg/apps/agents/managers/registry.py +254 -0
  45. django_cfg/apps/agents/managers/toolsets.py +496 -0
  46. django_cfg/apps/agents/migrations/0001_initial.py +286 -0
  47. django_cfg/apps/agents/migrations/__init__.py +5 -0
  48. django_cfg/apps/agents/models/__init__.py +15 -0
  49. django_cfg/apps/agents/models/execution.py +215 -0
  50. django_cfg/apps/agents/models/registry.py +220 -0
  51. django_cfg/apps/agents/models/toolsets.py +305 -0
  52. django_cfg/apps/agents/patterns/__init__.py +24 -0
  53. django_cfg/apps/agents/patterns/content_agents.py +234 -0
  54. django_cfg/apps/agents/toolsets/__init__.py +15 -0
  55. django_cfg/apps/agents/toolsets/cache_toolset.py +285 -0
  56. django_cfg/apps/agents/toolsets/django_toolset.py +220 -0
  57. django_cfg/apps/agents/toolsets/file_toolset.py +324 -0
  58. django_cfg/apps/agents/toolsets/orm_toolset.py +319 -0
  59. django_cfg/apps/agents/urls.py +46 -0
  60. django_cfg/apps/knowbase/README.md +150 -0
  61. django_cfg/apps/knowbase/__init__.py +27 -0
  62. django_cfg/apps/knowbase/admin/__init__.py +23 -0
  63. django_cfg/apps/knowbase/admin/archive_admin.py +857 -0
  64. django_cfg/apps/knowbase/admin/chat_admin.py +386 -0
  65. django_cfg/apps/knowbase/admin/document_admin.py +650 -0
  66. django_cfg/apps/knowbase/admin/external_data_admin.py +685 -0
  67. django_cfg/apps/knowbase/apps.py +81 -0
  68. django_cfg/apps/knowbase/config/README.md +176 -0
  69. django_cfg/apps/knowbase/config/__init__.py +51 -0
  70. django_cfg/apps/knowbase/config/constance_fields.py +186 -0
  71. django_cfg/apps/knowbase/config/constance_settings.py +200 -0
  72. django_cfg/apps/knowbase/config/settings.py +444 -0
  73. django_cfg/apps/knowbase/examples/__init__.py +3 -0
  74. django_cfg/apps/knowbase/examples/external_data_usage.py +191 -0
  75. django_cfg/apps/knowbase/management/__init__.py +0 -0
  76. django_cfg/apps/knowbase/management/commands/__init__.py +0 -0
  77. django_cfg/apps/knowbase/management/commands/knowbase_stats.py +158 -0
  78. django_cfg/apps/knowbase/management/commands/setup_knowbase.py +59 -0
  79. django_cfg/apps/knowbase/managers/__init__.py +22 -0
  80. django_cfg/apps/knowbase/managers/archive.py +426 -0
  81. django_cfg/apps/knowbase/managers/base.py +32 -0
  82. django_cfg/apps/knowbase/managers/chat.py +141 -0
  83. django_cfg/apps/knowbase/managers/document.py +203 -0
  84. django_cfg/apps/knowbase/managers/external_data.py +471 -0
  85. django_cfg/apps/knowbase/migrations/0001_initial.py +427 -0
  86. django_cfg/apps/knowbase/migrations/0002_archiveitem_archiveitemchunk_documentarchive_and_more.py +434 -0
  87. django_cfg/apps/knowbase/migrations/__init__.py +5 -0
  88. django_cfg/apps/knowbase/mixins/__init__.py +15 -0
  89. django_cfg/apps/knowbase/mixins/config.py +108 -0
  90. django_cfg/apps/knowbase/mixins/creator.py +81 -0
  91. django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +199 -0
  92. django_cfg/apps/knowbase/mixins/external_data_mixin.py +813 -0
  93. django_cfg/apps/knowbase/mixins/service.py +362 -0
  94. django_cfg/apps/knowbase/models/__init__.py +41 -0
  95. django_cfg/apps/knowbase/models/archive.py +599 -0
  96. django_cfg/apps/knowbase/models/base.py +58 -0
  97. django_cfg/apps/knowbase/models/chat.py +157 -0
  98. django_cfg/apps/knowbase/models/document.py +267 -0
  99. django_cfg/apps/knowbase/models/external_data.py +376 -0
  100. django_cfg/apps/knowbase/serializers/__init__.py +68 -0
  101. django_cfg/apps/knowbase/serializers/archive_serializers.py +386 -0
  102. django_cfg/apps/knowbase/serializers/chat_serializers.py +137 -0
  103. django_cfg/apps/knowbase/serializers/document_serializers.py +94 -0
  104. django_cfg/apps/knowbase/serializers/external_data_serializers.py +256 -0
  105. django_cfg/apps/knowbase/serializers/public_serializers.py +74 -0
  106. django_cfg/apps/knowbase/services/__init__.py +40 -0
  107. django_cfg/apps/knowbase/services/archive/__init__.py +42 -0
  108. django_cfg/apps/knowbase/services/archive/archive_service.py +541 -0
  109. django_cfg/apps/knowbase/services/archive/chunking_service.py +791 -0
  110. django_cfg/apps/knowbase/services/archive/exceptions.py +52 -0
  111. django_cfg/apps/knowbase/services/archive/extraction_service.py +508 -0
  112. django_cfg/apps/knowbase/services/archive/vectorization_service.py +362 -0
  113. django_cfg/apps/knowbase/services/base.py +53 -0
  114. django_cfg/apps/knowbase/services/chat_service.py +239 -0
  115. django_cfg/apps/knowbase/services/document_service.py +144 -0
  116. django_cfg/apps/knowbase/services/embedding/__init__.py +43 -0
  117. django_cfg/apps/knowbase/services/embedding/async_processor.py +244 -0
  118. django_cfg/apps/knowbase/services/embedding/batch_processor.py +250 -0
  119. django_cfg/apps/knowbase/services/embedding/batch_result.py +61 -0
  120. django_cfg/apps/knowbase/services/embedding/models.py +229 -0
  121. django_cfg/apps/knowbase/services/embedding/processors.py +148 -0
  122. django_cfg/apps/knowbase/services/embedding/utils.py +176 -0
  123. django_cfg/apps/knowbase/services/prompt_builder.py +191 -0
  124. django_cfg/apps/knowbase/services/search_service.py +293 -0
  125. django_cfg/apps/knowbase/signals/__init__.py +21 -0
  126. django_cfg/apps/knowbase/signals/archive_signals.py +211 -0
  127. django_cfg/apps/knowbase/signals/chat_signals.py +37 -0
  128. django_cfg/apps/knowbase/signals/document_signals.py +143 -0
  129. django_cfg/apps/knowbase/signals/external_data_signals.py +157 -0
  130. django_cfg/apps/knowbase/tasks/__init__.py +39 -0
  131. django_cfg/apps/knowbase/tasks/archive_tasks.py +316 -0
  132. django_cfg/apps/knowbase/tasks/document_processing.py +341 -0
  133. django_cfg/apps/knowbase/tasks/external_data_tasks.py +341 -0
  134. django_cfg/apps/knowbase/tasks/maintenance.py +195 -0
  135. django_cfg/apps/knowbase/urls.py +43 -0
  136. django_cfg/apps/knowbase/utils/__init__.py +12 -0
  137. django_cfg/apps/knowbase/utils/chunk_settings.py +261 -0
  138. django_cfg/apps/knowbase/utils/text_processing.py +375 -0
  139. django_cfg/apps/knowbase/utils/validation.py +99 -0
  140. django_cfg/apps/knowbase/views/__init__.py +28 -0
  141. django_cfg/apps/knowbase/views/archive_views.py +469 -0
  142. django_cfg/apps/knowbase/views/base.py +49 -0
  143. django_cfg/apps/knowbase/views/chat_views.py +181 -0
  144. django_cfg/apps/knowbase/views/document_views.py +183 -0
  145. django_cfg/apps/knowbase/views/public_views.py +129 -0
  146. django_cfg/apps/leads/admin.py +70 -0
  147. django_cfg/apps/newsletter/admin.py +234 -0
  148. django_cfg/apps/newsletter/admin_filters.py +124 -0
  149. django_cfg/apps/support/admin.py +196 -0
  150. django_cfg/apps/support/admin_filters.py +71 -0
  151. django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
  152. django_cfg/apps/urls.py +5 -4
  153. django_cfg/cli/README.md +1 -1
  154. django_cfg/cli/commands/create_project.py +2 -2
  155. django_cfg/cli/commands/info.py +1 -1
  156. django_cfg/config.py +44 -0
  157. django_cfg/core/config.py +29 -82
  158. django_cfg/core/environment.py +1 -1
  159. django_cfg/core/generation.py +19 -107
  160. django_cfg/{integration.py → core/integration.py} +18 -16
  161. django_cfg/core/validation.py +1 -1
  162. django_cfg/management/__init__.py +1 -1
  163. django_cfg/management/commands/__init__.py +1 -1
  164. django_cfg/management/commands/auto_generate.py +482 -0
  165. django_cfg/management/commands/migrator.py +19 -101
  166. django_cfg/management/commands/test_email.py +1 -1
  167. django_cfg/middleware/README.md +0 -158
  168. django_cfg/middleware/__init__.py +0 -2
  169. django_cfg/middleware/user_activity.py +3 -3
  170. django_cfg/models/api.py +145 -0
  171. django_cfg/models/base.py +287 -0
  172. django_cfg/models/cache.py +4 -4
  173. django_cfg/models/constance.py +25 -88
  174. django_cfg/models/database.py +9 -9
  175. django_cfg/models/drf.py +3 -36
  176. django_cfg/models/email.py +163 -0
  177. django_cfg/models/environment.py +276 -0
  178. django_cfg/models/limits.py +1 -1
  179. django_cfg/models/logging.py +366 -0
  180. django_cfg/models/revolution.py +41 -2
  181. django_cfg/models/security.py +125 -0
  182. django_cfg/models/services.py +1 -1
  183. django_cfg/modules/__init__.py +2 -56
  184. django_cfg/modules/base.py +78 -52
  185. django_cfg/modules/django_currency/service.py +2 -2
  186. django_cfg/modules/django_email.py +2 -2
  187. django_cfg/modules/django_health.py +267 -0
  188. django_cfg/modules/django_llm/llm/client.py +79 -17
  189. django_cfg/modules/django_llm/translator/translator.py +2 -2
  190. django_cfg/modules/django_logger.py +2 -2
  191. django_cfg/modules/django_ngrok.py +2 -2
  192. django_cfg/modules/django_tasks.py +68 -3
  193. django_cfg/modules/django_telegram.py +3 -3
  194. django_cfg/modules/django_twilio/sendgrid_service.py +2 -2
  195. django_cfg/modules/django_twilio/service.py +2 -2
  196. django_cfg/modules/django_twilio/simple_service.py +2 -2
  197. django_cfg/modules/django_twilio/twilio_service.py +2 -2
  198. django_cfg/modules/django_unfold/__init__.py +69 -0
  199. django_cfg/modules/{unfold → django_unfold}/callbacks.py +23 -22
  200. django_cfg/modules/django_unfold/dashboard.py +278 -0
  201. django_cfg/modules/django_unfold/icons/README.md +145 -0
  202. django_cfg/modules/django_unfold/icons/__init__.py +12 -0
  203. django_cfg/modules/django_unfold/icons/constants.py +2851 -0
  204. django_cfg/modules/django_unfold/icons/generate_icons.py +486 -0
  205. django_cfg/modules/django_unfold/models/__init__.py +42 -0
  206. django_cfg/modules/django_unfold/models/config.py +601 -0
  207. django_cfg/modules/django_unfold/models/dashboard.py +206 -0
  208. django_cfg/modules/django_unfold/models/dropdown.py +40 -0
  209. django_cfg/modules/django_unfold/models/navigation.py +73 -0
  210. django_cfg/modules/django_unfold/models/tabs.py +25 -0
  211. django_cfg/modules/{unfold → django_unfold}/system_monitor.py +2 -2
  212. django_cfg/modules/django_unfold/utils.py +140 -0
  213. django_cfg/registry/__init__.py +23 -0
  214. django_cfg/registry/core.py +61 -0
  215. django_cfg/registry/exceptions.py +11 -0
  216. django_cfg/registry/modules.py +12 -0
  217. django_cfg/registry/services.py +26 -0
  218. django_cfg/registry/third_party.py +52 -0
  219. django_cfg/routing/__init__.py +19 -0
  220. django_cfg/routing/callbacks.py +198 -0
  221. django_cfg/routing/routers.py +48 -0
  222. django_cfg/templates/admin/layouts/dashboard_with_tabs.html +8 -9
  223. django_cfg/templatetags/__init__.py +0 -0
  224. django_cfg/templatetags/django_cfg.py +33 -0
  225. django_cfg/urls.py +33 -0
  226. django_cfg/utils/path_resolution.py +1 -1
  227. django_cfg/utils/smart_defaults.py +7 -61
  228. django_cfg/utils/toolkit.py +663 -0
  229. {django_cfg-1.1.82.dist-info → django_cfg-1.2.0.dist-info}/METADATA +83 -86
  230. django_cfg-1.2.0.dist-info/RECORD +441 -0
  231. django_cfg/archive/django_sample.zip +0 -0
  232. django_cfg/models/unfold.py +0 -271
  233. django_cfg/modules/unfold/__init__.py +0 -29
  234. django_cfg/modules/unfold/dashboard.py +0 -318
  235. django_cfg/pyproject.toml +0 -370
  236. django_cfg/routers.py +0 -83
  237. django_cfg-1.1.82.dist-info/RECORD +0 -278
  238. /django_cfg/{exceptions.py → core/exceptions.py} +0 -0
  239. /django_cfg/modules/{unfold → django_unfold}/models.py +0 -0
  240. /django_cfg/modules/{unfold → django_unfold}/tailwind.py +0 -0
  241. /django_cfg/{version_check.py → utils/version_check.py} +0 -0
  242. {django_cfg-1.1.82.dist-info → django_cfg-1.2.0.dist-info}/WHEEL +0 -0
  243. {django_cfg-1.1.82.dist-info → django_cfg-1.2.0.dist-info}/entry_points.txt +0 -0
  244. {django_cfg-1.1.82.dist-info → django_cfg-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,857 @@
1
+ """
2
+ Admin interface for archive models with Unfold optimization.
3
+ """
4
+
5
+ import hashlib
6
+ import logging
7
+ from django.contrib import admin, messages
8
+ from django.utils.html import format_html
9
+ from django.urls import reverse
10
+ from django.utils.safestring import mark_safe
11
+ from django.db import models
12
+ from django.db.models import Count, Sum, Avg, Q
13
+ from django.db.models.fields.json import JSONField
14
+ from django.shortcuts import redirect
15
+ from django_json_widget.widgets import JSONEditorWidget
16
+ from unfold.admin import ModelAdmin, TabularInline
17
+ from unfold.decorators import display, action
18
+ from unfold.enums import ActionVariant
19
+ from unfold.contrib.filters.admin import AutocompleteSelectFilter
20
+ from unfold.contrib.forms.widgets import WysiwygWidget
21
+
22
+ from ..models.archive import DocumentArchive, ArchiveItem, ArchiveItemChunk
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class ArchiveItemInline(TabularInline):
28
+ """Inline for archive items with Unfold styling."""
29
+
30
+ model = ArchiveItem
31
+ verbose_name = "Archive Item"
32
+ verbose_name_plural = "📁 Archive Items (Read-only)"
33
+ extra = 0
34
+ max_num = 0 # No new items allowed
35
+ can_delete = False # Prevent deletion through inline
36
+ show_change_link = True # Allow viewing individual items
37
+
38
+ def has_add_permission(self, request, obj=None):
39
+ """Disable adding new items through inline."""
40
+ return False
41
+
42
+ def has_change_permission(self, request, obj=None):
43
+ """Disable editing items through inline."""
44
+ return False
45
+
46
+ def has_delete_permission(self, request, obj=None):
47
+ """Disable deleting items through inline."""
48
+ return False
49
+
50
+ fields = [
51
+ 'item_name', 'content_type', 'file_size_display_inline',
52
+ 'is_processable', 'chunks_count', 'created_at'
53
+ ]
54
+ readonly_fields = [
55
+ 'item_name', 'content_type', 'file_size_display_inline',
56
+ 'is_processable', 'chunks_count', 'created_at'
57
+ ]
58
+
59
+ # Unfold specific options
60
+ hide_title = False # Show titles for better UX
61
+ classes = ['collapse'] # Collapsed by default
62
+
63
+ @display(description="File Size")
64
+ def file_size_display_inline(self, obj):
65
+ """Display file size in human readable format for inline."""
66
+ size = obj.file_size
67
+ for unit in ['B', 'KB', 'MB']:
68
+ if size < 1024.0:
69
+ return f"{size:.1f} {unit}"
70
+ size /= 1024.0
71
+ return f"{size:.1f} GB"
72
+
73
+ def get_queryset(self, request):
74
+ """Optimize queryset for inline display."""
75
+ return super().get_queryset(request).select_related('archive', 'user')
76
+
77
+
78
+ @admin.register(DocumentArchive)
79
+ class DocumentArchiveAdmin(ModelAdmin):
80
+ """Admin interface for DocumentArchive."""
81
+
82
+ list_display = [
83
+ 'title_display', 'user', 'archive_type_badge', 'status_badge',
84
+ 'items_count', 'chunks_count', 'vectorization_progress', 'file_size_display',
85
+ 'progress_display', 'created_at'
86
+ ]
87
+ ordering = ['-created_at'] # Newest first
88
+ inlines = [ArchiveItemInline]
89
+ list_filter = [
90
+ 'processing_status', 'archive_type', 'is_public',
91
+ 'created_at', 'processed_at',
92
+ ('user', AutocompleteSelectFilter)
93
+ ]
94
+ search_fields = ['title', 'description', 'original_filename', 'user__username']
95
+ autocomplete_fields = ['user', 'categories']
96
+ readonly_fields = [
97
+ 'id', 'user', 'content_hash', 'original_filename', 'file_size', 'archive_type',
98
+ 'processing_status', 'processed_at', 'processing_duration_ms',
99
+ 'processing_error', 'total_items', 'processed_items', 'total_chunks',
100
+ 'vectorized_chunks', 'total_tokens', 'total_cost_usd', 'created_at',
101
+ 'updated_at', 'progress_display', 'vectorization_progress_display',
102
+ 'items_link', 'chunks_link'
103
+ ]
104
+ fieldsets = (
105
+ ('Basic Information', {
106
+ 'fields': ('id', 'title', 'description', 'user', 'categories', 'is_public')
107
+ }),
108
+ ('Archive Details', {
109
+ 'fields': (
110
+ 'archive_file', 'original_filename', 'file_size', 'archive_type',
111
+ 'content_hash'
112
+ )
113
+ }),
114
+ ('Processing Status', {
115
+ 'fields': (
116
+ 'processing_status', 'processed_at', 'processing_duration_ms',
117
+ 'processing_error', 'progress_display',
118
+ 'vectorization_progress_display'
119
+ )
120
+ }),
121
+ ('Statistics', {
122
+ 'fields': (
123
+ 'total_items', 'processed_items', 'total_chunks',
124
+ 'vectorized_chunks', 'total_tokens', 'total_cost_usd'
125
+ )
126
+ }),
127
+ ('Related Data', {
128
+ 'fields': ('items_link', 'chunks_link')
129
+ }),
130
+ ('Timestamps', {
131
+ 'fields': ('created_at', 'updated_at'),
132
+ 'classes': ('collapse',)
133
+ }),
134
+ )
135
+ filter_horizontal = ['categories']
136
+
137
+ # Unfold configuration
138
+ compressed_fields = True
139
+ warn_unsaved_form = True
140
+
141
+ # Form field overrides
142
+ formfield_overrides = {
143
+ models.TextField: {
144
+ "widget": WysiwygWidget,
145
+ },
146
+ JSONField: {
147
+ "widget": JSONEditorWidget,
148
+ }
149
+ }
150
+
151
+ @display(description="Archive Title", ordering="title")
152
+ def title_display(self, obj):
153
+ """Display archive title with truncation."""
154
+ title = obj.title or "Untitled Archive"
155
+ if len(title) > 50:
156
+ title = title[:47] + "..."
157
+ return format_html(
158
+ '<div style="font-weight: 500;">{}</div>',
159
+ title
160
+ )
161
+
162
+ @display(
163
+ description="Archive Type",
164
+ ordering="archive_type",
165
+ label={
166
+ 'DOCUMENTS': 'info', # blue for documents
167
+ 'CODE': 'success', # green for code
168
+ 'MIXED': 'warning', # orange for mixed
169
+ 'OTHER': 'danger' # red for other
170
+ }
171
+ )
172
+ def archive_type_badge(self, obj):
173
+ """Display archive type with color coding."""
174
+ return obj.archive_type, obj.get_archive_type_display()
175
+
176
+ @display(
177
+ description="Status",
178
+ ordering="processing_status",
179
+ label={
180
+ 'pending': 'warning', # orange for pending
181
+ 'processing': 'info', # blue for processing
182
+ 'completed': 'success', # green for completed
183
+ 'failed': 'danger', # red for failed
184
+ 'cancelled': 'secondary' # gray for cancelled
185
+ }
186
+ )
187
+ def status_badge(self, obj):
188
+ """Display processing status with color coding."""
189
+ return obj.processing_status, obj.get_processing_status_display()
190
+
191
+ @display(description="File Size", ordering="file_size")
192
+ def file_size_display(self, obj):
193
+ """Display file size in human readable format."""
194
+ size = obj.file_size
195
+ for unit in ['B', 'KB', 'MB', 'GB']:
196
+ if size < 1024.0:
197
+ return f"{size:.1f} {unit}"
198
+ size /= 1024.0
199
+ return f"{size:.1f} TB"
200
+
201
+ @display(description="Items", ordering="total_items")
202
+ def items_count(self, obj):
203
+ """Display items count with link."""
204
+ count = obj.total_items
205
+ if count > 0:
206
+ url = f"/admin/knowbase/archiveitem/?archive__id__exact={obj.id}"
207
+ return format_html(
208
+ '<a href="{}" style="text-decoration: none;">{} items</a>',
209
+ url, count
210
+ )
211
+ return "0 items"
212
+
213
+ @display(description="Chunks", ordering="total_chunks")
214
+ def chunks_count(self, obj):
215
+ """Display chunks count with link."""
216
+ count = obj.total_chunks
217
+ if count > 0:
218
+ url = f"/admin/knowbase/archiveitemchunk/?archive__id__exact={obj.id}"
219
+ return format_html(
220
+ '<a href="{}" style="text-decoration: none;">{} chunks</a>',
221
+ url, count
222
+ )
223
+ return "0 chunks"
224
+
225
+ @display(
226
+ description="Vectorization",
227
+ label={
228
+ 'completed': 'success', # green for 100%
229
+ 'partial': 'warning', # orange for partial
230
+ 'none': 'danger', # red for 0%
231
+ 'no_chunks': 'info' # blue for no chunks
232
+ }
233
+ )
234
+ def vectorization_progress(self, obj):
235
+ """Display vectorization progress with color coding."""
236
+ try:
237
+ # Check processing status first
238
+ if obj.processing_status == 'pending':
239
+ return 'no_chunks', 'Pending'
240
+ elif obj.processing_status == 'processing':
241
+ return 'partial', 'Processing...'
242
+ elif obj.processing_status == 'failed':
243
+ return 'none', 'Failed'
244
+
245
+ progress = DocumentArchive.objects.get_vectorization_progress(obj.id)
246
+ total = progress['total']
247
+ vectorized = progress['vectorized']
248
+ percentage = progress['percentage']
249
+
250
+ if total == 0:
251
+ return 'no_chunks', 'No chunks'
252
+ elif percentage == 100:
253
+ return 'completed', f'{vectorized}/{total} (100%)'
254
+ elif percentage > 0:
255
+ return 'partial', f'{vectorized}/{total} ({percentage}%)'
256
+ else:
257
+ return 'none', f'{vectorized}/{total} (0%)'
258
+ except Exception as e:
259
+ # Log the error for debugging
260
+ import logging
261
+ logger = logging.getLogger(__name__)
262
+ logger.error(f"Error getting vectorization progress for archive {obj.id}: {e}")
263
+ return 'no_chunks', 'Not ready'
264
+
265
+ @display(description="Overall Progress")
266
+ def progress_display(self, obj):
267
+ """Display overall progress including processing and vectorization."""
268
+ from ..models.base import ProcessingStatus
269
+
270
+ # Calculate overall progress: 50% for processing, 50% for vectorization
271
+ processing_progress = obj.processing_progress
272
+
273
+ # Get vectorization progress
274
+ try:
275
+ vectorization_stats = DocumentArchive.objects.get_vectorization_progress(obj.id)
276
+ vectorization_progress = vectorization_stats.get('percentage', 0)
277
+ except Exception:
278
+ vectorization_progress = 0
279
+
280
+ # Overall progress: processing (50%) + vectorization (50%)
281
+ overall_progress = (processing_progress * 0.5) + (vectorization_progress * 0.5)
282
+
283
+ # Determine color based on status and progress
284
+ if obj.processing_status == ProcessingStatus.COMPLETED and vectorization_progress == 100:
285
+ color_class = "bg-green-500" # Fully complete
286
+ elif obj.processing_status == ProcessingStatus.FAILED:
287
+ color_class = "bg-red-500" # Failed
288
+ elif overall_progress > 0:
289
+ color_class = "bg-blue-500" # In progress
290
+ else:
291
+ color_class = "bg-gray-300" # Not started
292
+
293
+ return format_html(
294
+ '<div class="w-24 bg-gray-200 rounded-full h-2 dark:bg-gray-700">'
295
+ '<div class="{} h-2 rounded-full transition-all duration-300" style="width: {}%"></div>'
296
+ '</div>'
297
+ '<span class="text-xs text-gray-600 dark:text-gray-400 ml-2">{}%</span>',
298
+ color_class, overall_progress, int(overall_progress)
299
+ )
300
+
301
+ def vectorization_progress_display(self, obj: DocumentArchive) -> str:
302
+ """Display vectorization progress with progress bar."""
303
+ progress = obj.vectorization_progress
304
+ color = 'green' if progress == 100 else 'blue' if progress > 0 else 'gray'
305
+
306
+ return format_html(
307
+ '<div style="width: 100px; background-color: #f0f0f0; border-radius: 3px;">'
308
+ '<div style="width: {}%; height: 20px; background-color: {}; border-radius: 3px; '
309
+ 'text-align: center; line-height: 20px; color: white; font-size: 12px;">'
310
+ '{}%</div></div>',
311
+ progress, color, round(progress, 1)
312
+ )
313
+ vectorization_progress_display.short_description = "Vectorization Progress"
314
+
315
+ def items_link(self, obj: DocumentArchive) -> str:
316
+ """Link to archive items."""
317
+ if obj.pk:
318
+ url = reverse('admin:django_cfg_knowbase_archiveitem_changelist')
319
+ return format_html(
320
+ '<a href="{}?archive__id__exact={}">View {} Items</a>',
321
+ url, obj.pk, obj.total_items
322
+ )
323
+ return "No items yet"
324
+ items_link.short_description = "Items"
325
+
326
+ def chunks_link(self, obj: DocumentArchive) -> str:
327
+ """Link to archive chunks."""
328
+ if obj.pk:
329
+ url = reverse('admin:django_cfg_knowbase_archiveitemchunk_changelist')
330
+ return format_html(
331
+ '<a href="{}?archive__id__exact={}">View {} Chunks</a>',
332
+ url, obj.pk, obj.total_chunks
333
+ )
334
+ return "No chunks yet"
335
+ chunks_link.short_description = "Chunks"
336
+
337
+ def get_queryset(self, request):
338
+ """Optimize queryset with select_related."""
339
+ # Use all_users() to show all archives in admin, then filter by user if needed
340
+ queryset = DocumentArchive.objects.all_users().select_related('user').prefetch_related('categories')
341
+
342
+ # For non-superusers, filter by their own archives
343
+ if not request.user.is_superuser:
344
+ queryset = queryset.filter(user=request.user)
345
+
346
+ return queryset
347
+
348
+ def save_model(self, request, obj, form, change):
349
+ """Automatically set user and file metadata when creating new archives."""
350
+ if not change: # Only for new archives
351
+ obj.user = request.user
352
+
353
+ # Auto-populate metadata from uploaded file
354
+ if obj.archive_file:
355
+ import hashlib
356
+ import os
357
+
358
+ # Set original filename
359
+ if not obj.original_filename:
360
+ obj.original_filename = obj.archive_file.name
361
+
362
+ # Set file size
363
+ if not obj.file_size and hasattr(obj.archive_file, 'size'):
364
+ obj.file_size = obj.archive_file.size
365
+
366
+ # Check for duplicates using manager method
367
+ is_duplicate, existing_archive = DocumentArchive.objects.check_duplicate_before_save(
368
+ user=obj.user,
369
+ title=obj.title,
370
+ file_size=obj.file_size
371
+ )
372
+
373
+ if is_duplicate and existing_archive:
374
+ messages.error(
375
+ request,
376
+ f'❌ An archive with the same title and file size already exists: "{existing_archive.title}" '
377
+ f'(created {existing_archive.created_at.strftime("%Y-%m-%d %H:%M")}). '
378
+ f'Please use a different title or check if this is a duplicate upload.'
379
+ )
380
+ # Don't save, just return - this will keep the form open with the error
381
+ return
382
+
383
+ # Set archive type based on file extension
384
+ if not obj.archive_type:
385
+ filename = obj.archive_file.name.lower()
386
+
387
+ # ZIP formats
388
+ if filename.endswith(('.zip', '.jar', '.war', '.ear')):
389
+ obj.archive_type = 'zip'
390
+ # TAR.GZ formats
391
+ elif filename.endswith(('.tar.gz', '.tgz')):
392
+ obj.archive_type = 'tar.gz'
393
+ # TAR.BZ2 formats
394
+ elif filename.endswith(('.tar.bz2', '.tbz2', '.tar.bzip2')):
395
+ obj.archive_type = 'tar.bz2'
396
+ # TAR formats
397
+ elif filename.endswith('.tar'):
398
+ obj.archive_type = 'tar'
399
+ else:
400
+ # Default to zip for unknown formats
401
+ obj.archive_type = 'zip'
402
+
403
+ # Generate content hash
404
+ if not obj.content_hash and hasattr(obj.archive_file, 'read'):
405
+ obj.archive_file.seek(0)
406
+ content = obj.archive_file.read()
407
+ obj.content_hash = hashlib.sha256(content).hexdigest()
408
+ obj.archive_file.seek(0)
409
+
410
+ super().save_model(request, obj, form, change)
411
+
412
+ def changelist_view(self, request, extra_context=None):
413
+ """Add summary statistics to changelist."""
414
+ extra_context = extra_context or {}
415
+
416
+ # Get summary statistics
417
+ queryset = self.get_queryset(request)
418
+ stats = queryset.aggregate(
419
+ total_archives=Count('id'),
420
+ total_items=Sum('total_items'),
421
+ total_chunks=Sum('total_chunks'),
422
+ total_cost=Sum('total_cost_usd')
423
+ )
424
+
425
+ # Status breakdown
426
+ status_counts = dict(
427
+ queryset.values_list('processing_status').annotate(
428
+ count=Count('id')
429
+ )
430
+ )
431
+
432
+ extra_context['archive_stats'] = {
433
+ 'total_archives': stats['total_archives'] or 0,
434
+ 'total_items': stats['total_items'] or 0,
435
+ 'total_chunks': stats['total_chunks'] or 0,
436
+ 'total_cost': f"${(stats['total_cost'] or 0):.6f}",
437
+ 'status_counts': status_counts
438
+ }
439
+
440
+ return super().changelist_view(request, extra_context)
441
+
442
+ # Actions for detail view
443
+ actions_detail = ["reprocess_archive"]
444
+
445
+ @action(
446
+ description="🔄 Reprocess Archive",
447
+ icon="refresh",
448
+ variant=ActionVariant.WARNING
449
+ )
450
+ def reprocess_archive(self, request, object_id):
451
+ """Force reprocessing of the archive."""
452
+ try:
453
+ # Get the archive object to get its title for the message
454
+ archive = self.get_object(request, object_id)
455
+ if not archive:
456
+ self.message_user(request, "Archive not found.", level='error')
457
+ return redirect(request.META.get('HTTP_REFERER', '/admin/'))
458
+
459
+ # Use custom manager method to reprocess
460
+ DocumentArchive.objects.reprocess(object_id)
461
+
462
+ self.message_user(
463
+ request,
464
+ f"Archive '{archive.title}' has been reset and queued for reprocessing.",
465
+ level='success'
466
+ )
467
+
468
+ except ValueError as e:
469
+ self.message_user(
470
+ request,
471
+ f"Error reprocessing archive: {e}",
472
+ level='error'
473
+ )
474
+ except Exception as e:
475
+ logger.exception(f"Unexpected error reprocessing archive {object_id}")
476
+ self.message_user(
477
+ request,
478
+ f"Unexpected error reprocessing archive: {e}",
479
+ level='error'
480
+ )
481
+
482
+ return redirect(request.META.get('HTTP_REFERER', '/admin/'))
483
+
484
+
485
+ @admin.register(ArchiveItem)
486
+ class ArchiveItemAdmin(ModelAdmin):
487
+ """Admin interface for ArchiveItem."""
488
+
489
+ list_display = [
490
+ 'item_name_display', 'archive_link', 'content_type_badge', 'language_badge',
491
+ 'processable_badge', 'chunks_count_display', 'file_size_display', 'created_at'
492
+ ]
493
+ ordering = ['-created_at'] # Newest first
494
+ list_filter = [
495
+ 'content_type', 'is_processable', 'language', 'created_at',
496
+ 'archive__processing_status',
497
+ ('archive', AutocompleteSelectFilter)
498
+ ]
499
+ search_fields = [
500
+ 'item_name', 'relative_path', 'archive__title',
501
+ 'language', 'archive__user__username'
502
+ ]
503
+ autocomplete_fields = ['archive']
504
+ readonly_fields = [
505
+ 'id', 'user', 'content_hash', 'chunks_count', 'total_tokens',
506
+ 'processing_cost', 'created_at', 'updated_at',
507
+ 'archive_link', 'chunks_link', 'content_preview'
508
+ ]
509
+ fieldsets = (
510
+ ('Basic Information', {
511
+ 'fields': ('id', 'archive_link', 'user', 'relative_path', 'item_name')
512
+ }),
513
+ ('File Details', {
514
+ 'fields': (
515
+ 'item_type', 'content_type', 'file_size',
516
+ 'content_hash', 'language', 'encoding'
517
+ )
518
+ }),
519
+ ('Processing', {
520
+ 'fields': (
521
+ 'is_processable', 'chunks_count', 'total_tokens',
522
+ 'processing_cost'
523
+ )
524
+ }),
525
+ ('Content', {
526
+ 'fields': ('content_preview',),
527
+ 'classes': ('collapse',)
528
+ }),
529
+ ('Metadata', {
530
+ 'fields': ('metadata',),
531
+ 'classes': ('collapse',)
532
+ }),
533
+ ('Related Data', {
534
+ 'fields': ('chunks_link',)
535
+ }),
536
+ ('Timestamps', {
537
+ 'fields': ('created_at', 'updated_at'),
538
+ 'classes': ('collapse',)
539
+ }),
540
+ )
541
+
542
+ # Unfold configuration
543
+ compressed_fields = True
544
+ warn_unsaved_form = True
545
+
546
+ # Form field overrides
547
+ formfield_overrides = {
548
+ models.TextField: {
549
+ "widget": WysiwygWidget,
550
+ },
551
+ JSONField: {
552
+ "widget": JSONEditorWidget,
553
+ }
554
+ }
555
+
556
+ @display(description="Item Name", ordering="item_name")
557
+ def item_name_display(self, obj):
558
+ """Display item name with truncation."""
559
+ name = obj.item_name or "Unnamed Item"
560
+ if len(name) > 40:
561
+ name = name[:37] + "..."
562
+ return format_html(
563
+ '<div style="font-weight: 500;">{}</div>',
564
+ name
565
+ )
566
+
567
+ @display(description="Archive", ordering="archive__title")
568
+ def archive_link(self, obj):
569
+ """Link to parent archive."""
570
+ if obj.archive:
571
+ url = reverse('admin:django_cfg_knowbase_documentarchive_change', args=[obj.archive.pk])
572
+ title = obj.archive.title[:30] + "..." if len(obj.archive.title) > 30 else obj.archive.title
573
+ return format_html('<a href="{}" style="text-decoration: none;">{}</a>', url, title)
574
+ return "No archive"
575
+
576
+ @display(
577
+ description="Content Type",
578
+ ordering="content_type",
579
+ label={
580
+ 'TEXT': 'info', # blue for text
581
+ 'CODE': 'success', # green for code
582
+ 'BINARY': 'warning', # orange for binary
583
+ 'OTHER': 'secondary' # gray for other
584
+ }
585
+ )
586
+ def content_type_badge(self, obj):
587
+ """Display content type with color coding."""
588
+ return obj.content_type, obj.get_content_type_display()
589
+
590
+ @display(
591
+ description="Language",
592
+ ordering="language",
593
+ label=True
594
+ )
595
+ def language_badge(self, obj):
596
+ """Display language with badge."""
597
+ return obj.language or "Unknown"
598
+
599
+ @display(
600
+ description="Processable",
601
+ ordering="is_processable",
602
+ label={
603
+ True: 'success', # green for processable
604
+ False: 'danger' # red for not processable
605
+ }
606
+ )
607
+ def processable_badge(self, obj):
608
+ """Display processable status."""
609
+ return obj.is_processable, "Yes" if obj.is_processable else "No"
610
+
611
+ @display(description="Chunks", ordering="chunks_count")
612
+ def chunks_count_display(self, obj):
613
+ """Display chunks count with link."""
614
+ count = obj.chunks_count
615
+ if count > 0:
616
+ url = f"/admin/knowbase/archiveitemchunk/?item__id__exact={obj.id}"
617
+ return format_html(
618
+ '<a href="{}" style="text-decoration: none;">{} chunks</a>',
619
+ url, count
620
+ )
621
+ return "0 chunks"
622
+
623
+ @display(description="File Size", ordering="file_size")
624
+ def file_size_display(self, obj):
625
+ """Display file size in human readable format."""
626
+ size = obj.file_size
627
+ for unit in ['B', 'KB', 'MB']:
628
+ if size < 1024.0:
629
+ return f"{size:.1f} {unit}"
630
+ size /= 1024.0
631
+ return f"{size:.1f} GB"
632
+
633
+ def chunks_link(self, obj: ArchiveItem) -> str:
634
+ """Link to item chunks."""
635
+ if obj.pk:
636
+ url = reverse('admin:django_cfg_knowbase_archiveitemchunk_changelist')
637
+ return format_html(
638
+ '<a href="{}?item__id__exact={}">View {} Chunks</a>',
639
+ url, obj.pk, obj.chunks_count
640
+ )
641
+ return "No chunks yet"
642
+ chunks_link.short_description = "Chunks"
643
+
644
+ def content_preview(self, obj: ArchiveItem) -> str:
645
+ """Show content preview."""
646
+ if obj.raw_content:
647
+ preview = obj.raw_content[:500]
648
+ if len(obj.raw_content) > 500:
649
+ preview += "..."
650
+ return format_html('<pre style="white-space: pre-wrap;">{}</pre>', preview)
651
+ return "No content"
652
+ content_preview.short_description = "Content Preview"
653
+
654
+ def get_queryset(self, request):
655
+ """Optimize queryset with select_related."""
656
+ # Use all_users() to show all archive items in admin, then filter by user if needed
657
+ queryset = ArchiveItem.objects.all_users().select_related('archive', 'user')
658
+
659
+ # For non-superusers, filter by their own items
660
+ if not request.user.is_superuser:
661
+ queryset = queryset.filter(user=request.user)
662
+
663
+ return queryset
664
+
665
+ def save_model(self, request, obj, form, change):
666
+ """Automatically set user to current user when creating new archive items."""
667
+ if not change: # Only for new items
668
+ obj.user = request.user
669
+ super().save_model(request, obj, form, change)
670
+
671
+
672
+ @admin.register(ArchiveItemChunk)
673
+ class ArchiveItemChunkAdmin(ModelAdmin):
674
+ """Admin interface for ArchiveItemChunk."""
675
+
676
+ list_display = [
677
+ 'chunk_display', 'archive_link', 'item_link', 'chunk_type_badge',
678
+ 'token_count_display', 'embedding_status', 'cost_display', 'created_at'
679
+ ]
680
+ ordering = ['-created_at'] # Newest first
681
+ list_filter = [
682
+ 'chunk_type', 'created_at', 'embedding_model',
683
+ 'item__content_type', 'item__language',
684
+ ('item', AutocompleteSelectFilter),
685
+ ('archive', AutocompleteSelectFilter)
686
+ ]
687
+ search_fields = [
688
+ 'content', 'item__item_name', 'item__relative_path',
689
+ 'archive__title', 'item__archive__user__username'
690
+ ]
691
+ autocomplete_fields = ['item', 'archive']
692
+ readonly_fields = [
693
+ 'id', 'user', 'token_count', 'character_count', 'embedding_model',
694
+ 'embedding_cost', 'created_at', 'updated_at',
695
+ 'archive_link', 'item_link', 'content_preview',
696
+ 'context_summary', 'embedding_status'
697
+ ]
698
+ fieldsets = (
699
+ ('Basic Information', {
700
+ 'fields': ('id', 'archive_link', 'item_link', 'user', 'chunk_index', 'chunk_type')
701
+ }),
702
+ ('Content', {
703
+ 'fields': ('content_preview',)
704
+ }),
705
+ ('Context', {
706
+ 'fields': ('context_summary',),
707
+ 'classes': ('collapse',)
708
+ }),
709
+ ('Vectorization', {
710
+ 'fields': (
711
+ 'embedding_status', 'token_count', 'character_count',
712
+ 'embedding_model', 'embedding_cost'
713
+ )
714
+ }),
715
+ ('Timestamps', {
716
+ 'fields': ('created_at', 'updated_at'),
717
+ 'classes': ('collapse',)
718
+ }),
719
+ )
720
+
721
+ # Unfold configuration
722
+ compressed_fields = True
723
+ warn_unsaved_form = True
724
+
725
+ # Form field overrides
726
+ formfield_overrides = {
727
+ JSONField: {
728
+ "widget": JSONEditorWidget,
729
+ }
730
+ }
731
+
732
+ @display(description="Chunk", ordering="chunk_index")
733
+ def chunk_display(self, obj):
734
+ """Display chunk identifier."""
735
+ return f"Chunk {obj.chunk_index + 1}"
736
+
737
+ @display(
738
+ description="Chunk Type",
739
+ ordering="chunk_type",
740
+ label={
741
+ 'CONTENT': 'info', # blue for content
742
+ 'HEADER': 'success', # green for header
743
+ 'FOOTER': 'warning', # orange for footer
744
+ 'METADATA': 'secondary' # gray for metadata
745
+ }
746
+ )
747
+ def chunk_type_badge(self, obj):
748
+ """Display chunk type with color coding."""
749
+ return obj.chunk_type, obj.get_chunk_type_display()
750
+
751
+ @display(description="Tokens", ordering="token_count")
752
+ def token_count_display(self, obj):
753
+ """Display token count with formatting."""
754
+ tokens = obj.token_count
755
+ if tokens > 1000:
756
+ return f"{tokens/1000:.1f}K"
757
+ return str(tokens)
758
+
759
+ @display(
760
+ description="Embedding",
761
+ label={
762
+ True: 'success', # green for has embedding
763
+ False: 'danger' # red for no embedding
764
+ }
765
+ )
766
+ def embedding_status(self, obj):
767
+ """Display embedding status."""
768
+ has_embedding = obj.embedding is not None and len(obj.embedding) > 0
769
+ return has_embedding, "✓ Vectorized" if has_embedding else "✗ Not vectorized"
770
+
771
+ @display(description="Cost (USD)", ordering="embedding_cost")
772
+ def cost_display(self, obj):
773
+ """Display embedding cost with currency formatting."""
774
+ return f"${obj.embedding_cost:.6f}"
775
+
776
+ def archive_link(self, obj: ArchiveItemChunk) -> str:
777
+ """Link to parent archive."""
778
+ if obj.archive:
779
+ url = reverse('admin:django_cfg_knowbase_documentarchive_change', args=[obj.archive.pk])
780
+ return format_html('<a href="{}">{}</a>', url, obj.archive.title)
781
+ return "No archive"
782
+ archive_link.short_description = "Archive"
783
+
784
+ def item_link(self, obj: ArchiveItemChunk) -> str:
785
+ """Link to parent item."""
786
+ if obj.item:
787
+ url = reverse('admin:django_cfg_knowbase_archiveitem_change', args=[obj.item.pk])
788
+ return format_html('<a href="{}">{}</a>', url, obj.item.item_name)
789
+ return "No item"
790
+ item_link.short_description = "Item"
791
+
792
+ def changelist_view(self, request, extra_context=None):
793
+ """Add chunk statistics to changelist."""
794
+ extra_context = extra_context or {}
795
+
796
+ queryset = self.get_queryset(request)
797
+ stats = queryset.aggregate(
798
+ total_chunks=Count('id'),
799
+ vectorized_chunks=Count('id', filter=Q(embedding__isnull=False)),
800
+ total_tokens=Sum('token_count'),
801
+ total_characters=Sum('character_count'),
802
+ total_embedding_cost=Sum('embedding_cost'),
803
+ avg_tokens_per_chunk=Avg('token_count')
804
+ )
805
+
806
+ # Type breakdown
807
+ type_counts = dict(
808
+ queryset.values_list('chunk_type').annotate(
809
+ count=Count('id')
810
+ )
811
+ )
812
+
813
+ extra_context['chunk_stats'] = {
814
+ 'total_chunks': stats['total_chunks'] or 0,
815
+ 'vectorized_chunks': stats['vectorized_chunks'] or 0,
816
+ 'total_tokens': stats['total_tokens'] or 0,
817
+ 'total_characters': stats['total_characters'] or 0,
818
+ 'total_embedding_cost': f"${(stats['total_embedding_cost'] or 0):.6f}",
819
+ 'avg_tokens_per_chunk': f"{(stats['avg_tokens_per_chunk'] or 0):.0f}",
820
+ 'type_counts': type_counts
821
+ }
822
+
823
+ return super().changelist_view(request, extra_context)
824
+
825
+ def content_preview(self, obj: ArchiveItemChunk) -> str:
826
+ """Show content preview."""
827
+ preview = obj.content[:300]
828
+ if len(obj.content) > 300:
829
+ preview += "..."
830
+ return format_html('<pre style="white-space: pre-wrap;">{}</pre>', preview)
831
+ content_preview.short_description = "Content Preview"
832
+
833
+ def context_summary(self, obj: ArchiveItemChunk) -> str:
834
+ """Show context summary."""
835
+ context = obj.get_context_summary()
836
+ return format_html('<pre>{}</pre>',
837
+ '\n'.join(f"{k}: {v}" for k, v in context.items()))
838
+ context_summary.short_description = "Context Summary"
839
+
840
+ def get_queryset(self, request):
841
+ """Optimize queryset with select_related."""
842
+ # Use all_users() to show all archive item chunks in admin, then filter by user if needed
843
+ queryset = ArchiveItemChunk.objects.all_users().select_related(
844
+ 'archive', 'item', 'user'
845
+ )
846
+
847
+ # For non-superusers, filter by their own chunks
848
+ if not request.user.is_superuser:
849
+ queryset = queryset.filter(user=request.user)
850
+
851
+ return queryset
852
+
853
+ def save_model(self, request, obj, form, change):
854
+ """Automatically set user to current user when creating new archive item chunks."""
855
+ if not change: # Only for new chunks
856
+ obj.user = request.user
857
+ super().save_model(request, obj, form, change)