django-cfg 1.1.81__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 (246) 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/templates/guide.md +266 -0
  198. django_cfg/modules/django_twilio/twilio_service.py +2 -2
  199. django_cfg/modules/django_unfold/__init__.py +69 -0
  200. django_cfg/modules/{unfold → django_unfold}/callbacks.py +23 -22
  201. django_cfg/modules/django_unfold/dashboard.py +278 -0
  202. django_cfg/modules/django_unfold/icons/README.md +145 -0
  203. django_cfg/modules/django_unfold/icons/__init__.py +12 -0
  204. django_cfg/modules/django_unfold/icons/constants.py +2851 -0
  205. django_cfg/modules/django_unfold/icons/generate_icons.py +486 -0
  206. django_cfg/modules/django_unfold/models/__init__.py +42 -0
  207. django_cfg/modules/django_unfold/models/config.py +601 -0
  208. django_cfg/modules/django_unfold/models/dashboard.py +206 -0
  209. django_cfg/modules/django_unfold/models/dropdown.py +40 -0
  210. django_cfg/modules/django_unfold/models/navigation.py +73 -0
  211. django_cfg/modules/django_unfold/models/tabs.py +25 -0
  212. django_cfg/modules/{unfold → django_unfold}/system_monitor.py +2 -2
  213. django_cfg/modules/django_unfold/utils.py +140 -0
  214. django_cfg/registry/__init__.py +23 -0
  215. django_cfg/registry/core.py +61 -0
  216. django_cfg/registry/exceptions.py +11 -0
  217. django_cfg/registry/modules.py +12 -0
  218. django_cfg/registry/services.py +26 -0
  219. django_cfg/registry/third_party.py +52 -0
  220. django_cfg/routing/__init__.py +19 -0
  221. django_cfg/routing/callbacks.py +198 -0
  222. django_cfg/routing/routers.py +48 -0
  223. django_cfg/templates/admin/layouts/dashboard_with_tabs.html +8 -9
  224. django_cfg/templatetags/__init__.py +0 -0
  225. django_cfg/templatetags/django_cfg.py +33 -0
  226. django_cfg/urls.py +33 -0
  227. django_cfg/utils/path_resolution.py +1 -1
  228. django_cfg/utils/smart_defaults.py +7 -61
  229. django_cfg/utils/toolkit.py +663 -0
  230. {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/METADATA +83 -86
  231. django_cfg-1.2.0.dist-info/RECORD +441 -0
  232. django_cfg/apps/tasks/@docs/README.md +0 -195
  233. django_cfg/archive/django_sample.zip +0 -0
  234. django_cfg/models/unfold.py +0 -271
  235. django_cfg/modules/unfold/__init__.py +0 -29
  236. django_cfg/modules/unfold/dashboard.py +0 -318
  237. django_cfg/pyproject.toml +0 -370
  238. django_cfg/routers.py +0 -83
  239. django_cfg-1.1.81.dist-info/RECORD +0 -278
  240. /django_cfg/{exceptions.py → core/exceptions.py} +0 -0
  241. /django_cfg/modules/{unfold → django_unfold}/models.py +0 -0
  242. /django_cfg/modules/{unfold → django_unfold}/tailwind.py +0 -0
  243. /django_cfg/{version_check.py → utils/version_check.py} +0 -0
  244. {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/WHEEL +0 -0
  245. {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/entry_points.txt +0 -0
  246. {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,650 @@
1
+ """
2
+ Document admin interfaces with Unfold optimization.
3
+ """
4
+
5
+ from django.contrib import admin
6
+ from django.utils.html import format_html
7
+ from django.urls import reverse
8
+ from django.utils.safestring import mark_safe
9
+ from django.db import models, IntegrityError
10
+ from django.db.models import Count, Sum, Avg
11
+ from django.db.models.fields.json import JSONField
12
+ from django.contrib import messages
13
+ from django_json_widget.widgets import JSONEditorWidget
14
+ from unfold.admin import ModelAdmin, TabularInline
15
+ from unfold.decorators import display
16
+ from unfold.contrib.filters.admin import AutocompleteSelectFilter, AutocompleteSelectMultipleFilter
17
+ from unfold.contrib.forms.widgets import WysiwygWidget
18
+
19
+ from ..models import Document, DocumentChunk, DocumentCategory
20
+
21
+
22
+ class DocumentChunkInline(TabularInline):
23
+ """Inline for document chunks with Unfold styling."""
24
+
25
+ model = DocumentChunk
26
+ verbose_name = "Document Chunk"
27
+ verbose_name_plural = "📄 Document Chunks (Read-only)"
28
+ extra = 0
29
+ max_num = 0 # No new chunks allowed
30
+ can_delete = False # Prevent deletion through inline
31
+ show_change_link = True # Allow viewing individual chunks
32
+
33
+ def has_add_permission(self, request, obj=None):
34
+ """Disable adding new chunks through inline."""
35
+ return False
36
+
37
+ def has_change_permission(self, request, obj=None):
38
+ """Disable editing chunks through inline."""
39
+ return False
40
+
41
+ def has_delete_permission(self, request, obj=None):
42
+ """Disable deleting chunks through inline."""
43
+ return False
44
+
45
+ fields = [
46
+ 'short_uuid', 'chunk_index', 'content_preview_inline', 'token_count',
47
+ 'has_embedding_inline', 'embedding_cost'
48
+ ]
49
+ readonly_fields = [
50
+ 'short_uuid', 'chunk_index', 'content_preview_inline', 'token_count', 'character_count',
51
+ 'has_embedding_inline', 'embedding_cost', 'created_at'
52
+ ]
53
+
54
+ # Unfold specific options
55
+ hide_title = False # Show titles for better UX
56
+ classes = ['collapse'] # Collapsed by default
57
+
58
+ @display(description="Content Preview")
59
+ def content_preview_inline(self, obj):
60
+ """Shortened content preview for inline display."""
61
+ if not obj.content:
62
+ return "-"
63
+ preview = obj.content[:100] + "..." if len(obj.content) > 100 else obj.content
64
+ return format_html(
65
+ '<div style="max-width: 300px; font-size: 12px; color: #666;">{}</div>',
66
+ preview
67
+ )
68
+
69
+ @display(description="Has Embedding", boolean=True)
70
+ def has_embedding_inline(self, obj):
71
+ """Check if chunk has embedding vector for inline."""
72
+ return obj.embedding is not None and len(obj.embedding) > 0
73
+
74
+ def get_queryset(self, request):
75
+ """Optimize queryset for inline display."""
76
+ return super().get_queryset(request).select_related('document', 'user')
77
+
78
+
79
+ @admin.register(Document)
80
+ class DocumentAdmin(ModelAdmin):
81
+ """Admin interface for Document model with Unfold styling."""
82
+
83
+ list_display = [
84
+ 'title_display', 'categories_display', 'user',
85
+ 'visibility_badge', 'status_badge', 'chunks_count_display', 'vectorization_progress', 'tokens_display', 'cost_display', 'created_at'
86
+ ]
87
+ ordering = ['-created_at'] # Newest first
88
+ inlines = [DocumentChunkInline]
89
+ list_filter = [
90
+ 'processing_status', 'is_public', 'file_type', 'created_at',
91
+ ('user', AutocompleteSelectFilter),
92
+ ('categories', AutocompleteSelectMultipleFilter)
93
+ ]
94
+ search_fields = ['title', 'user__username', 'user__email']
95
+ autocomplete_fields = ['user', 'categories']
96
+ readonly_fields = [
97
+ 'id', 'user', 'content_hash', 'file_size', 'processing_started_at',
98
+ 'processing_completed_at', 'chunks_count', 'total_tokens',
99
+ 'processing_error', 'processing_duration', 'processing_status',
100
+ 'total_cost_usd', 'created_at', 'updated_at', 'duplicate_check'
101
+ ]
102
+
103
+ fieldsets = (
104
+ ('Basic Information', {
105
+ 'fields': ('id', 'title', 'user', 'categories', 'is_public', 'file_type', 'file_size')
106
+ }),
107
+ ('Content', {
108
+ 'fields': ('content', 'content_hash', 'duplicate_check'),
109
+ }),
110
+ ('Processing Status', {
111
+ 'fields': (
112
+ 'processing_status', 'processing_started_at',
113
+ 'processing_completed_at', 'processing_error'
114
+ )
115
+ }),
116
+ ('Statistics', {
117
+ 'fields': ('chunks_count', 'total_tokens', 'total_cost_usd')
118
+ }),
119
+ ('Metadata', {
120
+ 'fields': ('metadata',),
121
+ 'classes': ('collapse',),
122
+ 'description': 'Auto-generated metadata (read-only)'
123
+ }),
124
+ ('Timestamps', {
125
+ 'fields': ('created_at', 'updated_at'),
126
+ 'classes': ('collapse',)
127
+ })
128
+ )
129
+ filter_horizontal = ['categories']
130
+
131
+ # Unfold configuration
132
+ compressed_fields = True
133
+ warn_unsaved_form = True
134
+
135
+ # Form field overrides
136
+ formfield_overrides = {
137
+ models.TextField: {
138
+ "widget": WysiwygWidget,
139
+ },
140
+ JSONField: {
141
+ "widget": JSONEditorWidget,
142
+ }
143
+ }
144
+
145
+ def get_queryset(self, request):
146
+ """Optimize queryset with select_related and prefetch_related."""
147
+ # Use all_users() to show all documents in admin, then filter by user if needed
148
+ queryset = Document.objects.all_users().select_related('user').prefetch_related('categories')
149
+
150
+ # For non-superusers, filter by their own documents
151
+ if not request.user.is_superuser:
152
+ queryset = queryset.filter(user=request.user)
153
+
154
+ return queryset
155
+
156
+ def save_model(self, request, obj, form, change):
157
+ """Automatically set user to current user when creating new documents."""
158
+ if not change: # Only for new documents
159
+ obj.user = request.user
160
+
161
+ # Check for duplicates using manager method
162
+ is_duplicate, existing_doc = Document.objects.check_duplicate_before_save(
163
+ user=obj.user,
164
+ content=obj.content
165
+ )
166
+
167
+ if is_duplicate and existing_doc:
168
+ messages.error(
169
+ request,
170
+ f'❌ A document with identical content already exists: "{existing_doc.title}" '
171
+ f'(created {existing_doc.created_at.strftime("%Y-%m-%d %H:%M")}). '
172
+ f'Please modify the content or update the existing document.'
173
+ )
174
+ # Don't save, just return - this will keep the form open with the error
175
+ return
176
+
177
+ try:
178
+ super().save_model(request, obj, form, change)
179
+ except IntegrityError as e:
180
+ if 'unique_user_document' in str(e):
181
+ messages.error(
182
+ request,
183
+ 'A document with identical content already exists for this user. '
184
+ 'Please modify the content or update the existing document.'
185
+ )
186
+ else:
187
+ messages.error(request, f'Database error: {str(e)}')
188
+
189
+ # Re-raise the exception to prevent saving
190
+ raise
191
+
192
+ @display(description="Document Title", ordering="title")
193
+ def title_display(self, obj):
194
+ """Display document title with truncation."""
195
+ title = obj.title or "Untitled Document"
196
+ if len(title) > 50:
197
+ title = title[:47] + "..."
198
+ return format_html(
199
+ '<div style="font-weight: 500;">{}</div>',
200
+ title
201
+ )
202
+
203
+ @display(
204
+ description="Visibility",
205
+ ordering="is_public",
206
+ label={
207
+ True: 'success', # green for public
208
+ False: 'danger' # red for private
209
+ }
210
+ )
211
+ def visibility_badge(self, obj):
212
+ """Display visibility status with color coding."""
213
+ return obj.is_public, "Public" if obj.is_public else "Private"
214
+
215
+ @display(
216
+ description="Status",
217
+ ordering="processing_status",
218
+ label={
219
+ 'pending': 'warning', # orange for pending
220
+ 'processing': 'info', # blue for processing
221
+ 'completed': 'success', # green for completed
222
+ 'failed': 'danger', # red for failed
223
+ 'cancelled': 'secondary' # gray for cancelled
224
+ }
225
+ )
226
+ def status_badge(self, obj):
227
+ """Display processing status with color coding."""
228
+ return obj.processing_status, obj.get_processing_status_display()
229
+
230
+ @display(
231
+ description="Categories",
232
+ ordering="categories__name",
233
+ label={
234
+ 'public': 'success', # green for public categories
235
+ 'private': 'danger', # red for private categories
236
+ 'mixed': 'warning', # orange for mixed visibility
237
+ 'none': 'info' # blue for no categories
238
+ }
239
+ )
240
+ def categories_display(self, obj):
241
+ """Display multiple categories with Unfold label styling."""
242
+ categories = obj.categories.all()
243
+
244
+ if not categories:
245
+ return 'none', 'No categories'
246
+
247
+ # Determine overall visibility status
248
+ public_count = sum(1 for cat in categories if cat.is_public)
249
+ private_count = len(categories) - public_count
250
+
251
+ if private_count == 0:
252
+ status = 'public'
253
+ description = f"{len(categories)} public"
254
+ elif public_count == 0:
255
+ status = 'private'
256
+ description = f"{len(categories)} private"
257
+ else:
258
+ status = 'mixed'
259
+ description = f"{public_count} public, {private_count} private"
260
+
261
+ # Return tuple for label display: (status_key, display_text)
262
+ return status, f"{', '.join(cat.name for cat in categories)} ({description})"
263
+
264
+ @display(description="Category Details", dropdown=True)
265
+ def category_dropdown(self, obj):
266
+ """Display category details in dropdown."""
267
+ categories = obj.categories.all()
268
+
269
+ if not categories:
270
+ return {
271
+ "title": "No Categories",
272
+ "content": "<p class='text-gray-500 p-4'>This document has no categories assigned.</p>"
273
+ }
274
+
275
+ # Build dropdown items for each category
276
+ items = []
277
+ for category in categories:
278
+ status_icon = "🟢" if category.is_public else "🔴"
279
+ visibility = "Public" if category.is_public else "Private"
280
+
281
+ items.append({
282
+ "title": f"{status_icon} {category.name}",
283
+ "link": f"/admin/knowbase/documentcategory/{category.id}/change/"
284
+ })
285
+
286
+ return {
287
+ "title": f"Categories ({len(categories)})",
288
+ "striped": True,
289
+ "height": 200,
290
+ "width": 300,
291
+ "items": items
292
+ }
293
+
294
+ @display(description="Chunks", ordering="chunks_count")
295
+ def chunks_count_display(self, obj):
296
+ """Display chunks count with link."""
297
+ count = obj.chunks_count
298
+ if count > 0:
299
+ url = f"/admin/knowbase/documentchunk/?document__id__exact={obj.id}"
300
+ return format_html(
301
+ '<a href="{}" style="text-decoration: none;">{} chunks</a>',
302
+ url, count
303
+ )
304
+ return "0 chunks"
305
+
306
+ @display(description="Tokens", ordering="total_tokens")
307
+ def tokens_display(self, obj):
308
+ """Display token count with formatting."""
309
+ tokens = obj.total_tokens
310
+ if tokens > 1000:
311
+ return f"{tokens/1000:.1f}K"
312
+ return str(tokens)
313
+
314
+ @display(description="Cost (USD)", ordering="total_cost_usd")
315
+ def cost_display(self, obj):
316
+ """Display cost with currency formatting."""
317
+ return f"${obj.total_cost_usd:.6f}"
318
+
319
+ @display(
320
+ description="Vectorization",
321
+ label={
322
+ 'completed': 'success', # green for 100%
323
+ 'partial': 'warning', # orange for partial
324
+ 'none': 'danger', # red for 0%
325
+ 'no_chunks': 'info' # blue for no chunks
326
+ }
327
+ )
328
+ def vectorization_progress(self, obj):
329
+ """Display vectorization progress with color coding."""
330
+ return Document.objects.get_vectorization_status_display(obj)
331
+
332
+ @display(description="Processing Duration")
333
+ def processing_duration_display(self, obj):
334
+ """Display processing duration in readable format."""
335
+ duration = obj.processing_duration
336
+ if duration is None:
337
+ return "N/A"
338
+
339
+ if duration < 60:
340
+ return f"{duration:.1f}s"
341
+ elif duration < 3600:
342
+ minutes = duration / 60
343
+ return f"{minutes:.1f}m"
344
+ else:
345
+ hours = duration / 3600
346
+ return f"{hours:.1f}h"
347
+
348
+ @display(description="Duplicate Check")
349
+ def duplicate_check(self, obj):
350
+ """Check for duplicate documents with same content."""
351
+ duplicate_info = Document.objects.get_duplicate_info(obj)
352
+
353
+ if isinstance(duplicate_info, str):
354
+ if "No duplicates found" in duplicate_info:
355
+ return format_html(
356
+ '<span style="color: #059669;">✓ No duplicates found</span>'
357
+ )
358
+ return duplicate_info # "No content hash"
359
+
360
+ # Format the duplicate information for display
361
+ duplicates_data = duplicate_info['duplicates']
362
+ count = duplicate_info['count']
363
+
364
+ duplicate_list = []
365
+ for dup in duplicates_data:
366
+ url = reverse('admin:django_cfg_knowbase_document_change', args=[dup.pk])
367
+ duplicate_list.append(
368
+ f'<a href="{url}" target="_blank">{dup.title}</a> '
369
+ f'({dup.created_at.strftime("%Y-%m-%d")})'
370
+ )
371
+
372
+ warning_text = f"⚠️ Found {count} duplicate(s):<br>" + "<br>".join(duplicate_list)
373
+ if count > 3:
374
+ warning_text += f"<br>... and {count - 3} more"
375
+
376
+ return format_html(
377
+ '<div style="color: #d97706; font-weight: 500;">{}</div>',
378
+ warning_text
379
+ )
380
+
381
+ def changelist_view(self, request, extra_context=None):
382
+ """Add summary statistics to changelist."""
383
+ extra_context = extra_context or {}
384
+
385
+ # Get summary statistics
386
+ queryset = self.get_queryset(request)
387
+ stats = queryset.aggregate(
388
+ total_documents=Count('id'),
389
+ total_chunks=Sum('chunks_count'),
390
+ total_tokens=Sum('total_tokens'),
391
+ total_cost=Sum('total_cost_usd')
392
+ )
393
+
394
+ # Status breakdown
395
+ status_counts = dict(
396
+ queryset.values_list('processing_status').annotate(
397
+ count=Count('id')
398
+ )
399
+ )
400
+
401
+ extra_context['summary_stats'] = {
402
+ 'total_documents': stats['total_documents'] or 0,
403
+ 'total_chunks': stats['total_chunks'] or 0,
404
+ 'total_tokens': stats['total_tokens'] or 0,
405
+ 'total_cost': f"${(stats['total_cost'] or 0):.6f}",
406
+ 'status_counts': status_counts
407
+ }
408
+
409
+ return super().changelist_view(request, extra_context)
410
+
411
+
412
+ @admin.register(DocumentChunk)
413
+ class DocumentChunkAdmin(ModelAdmin):
414
+ """Admin interface for DocumentChunk model with Unfold styling."""
415
+
416
+ list_display = [
417
+ 'chunk_display', 'document_link', 'user', 'token_count_display',
418
+ 'embedding_status', 'embedding_cost_display', 'created_at'
419
+ ]
420
+ ordering = ['-created_at'] # Newest first
421
+ list_filter = [
422
+ 'embedding_model', 'created_at',
423
+ ('user', AutocompleteSelectFilter),
424
+ ('document', AutocompleteSelectFilter)
425
+ ]
426
+ search_fields = ['document__title', 'user__username', 'content']
427
+ readonly_fields = [
428
+ 'id', 'embedding_info', 'token_count', 'character_count',
429
+ 'embedding_cost', 'created_at', 'updated_at', 'content_preview'
430
+ ]
431
+
432
+ fieldsets = (
433
+ ('Basic Information', {
434
+ 'fields': ('id', 'document', 'user', 'chunk_index')
435
+ }),
436
+ ('Content', {
437
+ 'fields': ('content_preview', 'content')
438
+ }),
439
+ ('Embedding Information', {
440
+ 'fields': ('embedding_model', 'token_count', 'character_count', 'embedding_cost'),
441
+ }),
442
+ ('Vector Embedding', {
443
+ 'fields': ('embedding',),
444
+ 'classes': ('collapse',)
445
+ }),
446
+ ('Metadata', {
447
+ 'fields': ('metadata',),
448
+ 'classes': ('collapse',),
449
+ 'description': 'Auto-generated chunk metadata (read-only)'
450
+ }),
451
+ ('Timestamps', {
452
+ 'fields': ('created_at', 'updated_at'),
453
+ 'classes': ('collapse',)
454
+ })
455
+ )
456
+
457
+ # Unfold configuration
458
+ compressed_fields = True
459
+ warn_unsaved_form = True
460
+
461
+ # Form field overrides
462
+ formfield_overrides = {
463
+ JSONField: {
464
+ "widget": JSONEditorWidget,
465
+ }
466
+ }
467
+
468
+ def get_queryset(self, request):
469
+ """Optimize queryset with select_related."""
470
+ return super().get_queryset(request).select_related('document', 'user')
471
+
472
+ @display(description="Chunk", ordering="chunk_index")
473
+ def chunk_display(self, obj):
474
+ """Display chunk identifier."""
475
+ return f"Chunk {obj.chunk_index + 1}"
476
+
477
+ @display(description="Tokens", ordering="token_count")
478
+ def token_count_display(self, obj):
479
+ """Display token count with formatting."""
480
+ tokens = obj.token_count
481
+ if tokens > 1000:
482
+ return f"{tokens/1000:.1f}K"
483
+ return str(tokens)
484
+
485
+ @display(
486
+ description="Embedding",
487
+ label={
488
+ True: 'success', # green for has embedding
489
+ False: 'danger' # red for no embedding
490
+ }
491
+ )
492
+ def embedding_status(self, obj):
493
+ """Display embedding status."""
494
+ has_embedding = obj.embedding is not None and len(obj.embedding) > 0
495
+ return has_embedding, "✓ Vectorized" if has_embedding else "✗ Not vectorized"
496
+
497
+ @display(description="Document", ordering="document__title")
498
+ def document_link(self, obj):
499
+ """Display document title with admin link."""
500
+ url = reverse('admin:django_cfg_knowbase_document_change', args=[obj.document.id])
501
+ return format_html(
502
+ '<a href="{}" style="text-decoration: none;">{}</a>',
503
+ url,
504
+ obj.document.title
505
+ )
506
+
507
+ @display(description="Cost (USD)", ordering="embedding_cost")
508
+ def embedding_cost_display(self, obj):
509
+ """Display embedding cost with currency formatting."""
510
+ return f"${obj.embedding_cost:.6f}"
511
+
512
+ @display(description="Content Preview")
513
+ def content_preview(self, obj):
514
+ """Display content preview with truncation."""
515
+ preview = obj.content[:200] + "..." if len(obj.content) > 200 else obj.content
516
+ return format_html(
517
+ '<div style="max-width: 400px; word-wrap: break-word;">{}</div>',
518
+ preview
519
+ )
520
+
521
+ @display(description="Has Embedding", boolean=True)
522
+ def has_embedding(self, obj):
523
+ """Check if chunk has embedding vector."""
524
+ return obj.embedding is not None and len(obj.embedding) > 0
525
+
526
+ @display(description="Embedding Info")
527
+ def embedding_info(self, obj):
528
+ """Display embedding information safely."""
529
+ if obj.embedding is not None and len(obj.embedding) > 0:
530
+ return format_html(
531
+ '<span style="color: green;">✓ Vector ({} dimensions)</span>',
532
+ len(obj.embedding)
533
+ )
534
+ return format_html(
535
+ '<span style="color: red;">✗ No embedding</span>'
536
+ )
537
+
538
+ def changelist_view(self, request, extra_context=None):
539
+ """Add chunk statistics to changelist."""
540
+ extra_context = extra_context or {}
541
+
542
+ queryset = self.get_queryset(request)
543
+ stats = queryset.aggregate(
544
+ total_chunks=Count('id'),
545
+ total_tokens=Sum('token_count'),
546
+ total_characters=Sum('character_count'),
547
+ total_embedding_cost=Sum('embedding_cost'),
548
+ avg_tokens_per_chunk=Avg('token_count')
549
+ )
550
+
551
+ # Model breakdown
552
+ model_counts = dict(
553
+ queryset.values_list('embedding_model').annotate(
554
+ count=Count('id')
555
+ )
556
+ )
557
+
558
+ extra_context['chunk_stats'] = {
559
+ 'total_chunks': stats['total_chunks'] or 0,
560
+ 'total_tokens': stats['total_tokens'] or 0,
561
+ 'total_characters': stats['total_characters'] or 0,
562
+ 'total_embedding_cost': f"${(stats['total_embedding_cost'] or 0):.6f}",
563
+ 'avg_tokens_per_chunk': f"{(stats['avg_tokens_per_chunk'] or 0):.0f}",
564
+ 'model_counts': model_counts
565
+ }
566
+
567
+ return super().changelist_view(request, extra_context)
568
+
569
+
570
+ @admin.register(DocumentCategory)
571
+ class DocumentCategoryAdmin(ModelAdmin):
572
+ """Admin interface for DocumentCategory model with Unfold styling."""
573
+
574
+ list_display = [
575
+ 'short_uuid', 'name', 'visibility_badge', 'document_count', 'created_at'
576
+ ]
577
+ ordering = ['-created_at'] # Newest first
578
+ list_filter = ['is_public', 'created_at']
579
+ search_fields = ['name', 'description']
580
+ readonly_fields = ['id', 'created_at', 'updated_at']
581
+
582
+ fieldsets = (
583
+ ('Basic Information', {
584
+ 'fields': ('id', 'name', 'description', 'is_public')
585
+ }),
586
+ ('Timestamps', {
587
+ 'fields': ('created_at', 'updated_at'),
588
+ 'classes': ('collapse',)
589
+ })
590
+ )
591
+
592
+ # Unfold configuration
593
+ compressed_fields = True
594
+ warn_unsaved_form = True
595
+
596
+ # Form field overrides
597
+ formfield_overrides = {
598
+ models.TextField: {
599
+ "widget": WysiwygWidget,
600
+ }
601
+ }
602
+
603
+ @display(
604
+ description="Visibility",
605
+ ordering="is_public",
606
+ label={
607
+ 'public': 'success',
608
+ 'private': 'danger'
609
+ }
610
+ )
611
+ def visibility_badge(self, obj):
612
+ """Display visibility status with color coding."""
613
+ status = 'public' if obj.is_public else 'private'
614
+ label = 'Public' if obj.is_public else 'Private'
615
+ return status, label
616
+
617
+ @display(description="Documents", ordering="document_count")
618
+ def document_count(self, obj):
619
+ """Display count of documents in this category."""
620
+ count = obj.documents.count()
621
+ if count > 0:
622
+ url = f"/admin/knowbase/document/?categories__id__exact={obj.id}"
623
+ return format_html(
624
+ '<a href="{}" style="text-decoration: none;">{} documents</a>',
625
+ url, count
626
+ )
627
+ return "0 documents"
628
+
629
+ def get_queryset(self, request):
630
+ """Optimize queryset with prefetch_related."""
631
+ return super().get_queryset(request).prefetch_related('documents')
632
+
633
+ def changelist_view(self, request, extra_context=None):
634
+ """Add category statistics to changelist."""
635
+ extra_context = extra_context or {}
636
+
637
+ queryset = self.get_queryset(request)
638
+ stats = queryset.aggregate(
639
+ total_categories=Count('id'),
640
+ public_categories=Count('id', filter=models.Q(is_public=True)),
641
+ private_categories=Count('id', filter=models.Q(is_public=False))
642
+ )
643
+
644
+ extra_context['category_stats'] = {
645
+ 'total_categories': stats['total_categories'] or 0,
646
+ 'public_categories': stats['public_categories'] or 0,
647
+ 'private_categories': stats['private_categories'] or 0
648
+ }
649
+
650
+ return super().changelist_view(request, extra_context)