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.
- django_cfg/__init__.py +20 -448
- django_cfg/apps/accounts/README.md +3 -3
- django_cfg/apps/accounts/admin/__init__.py +0 -2
- django_cfg/apps/accounts/admin/activity.py +2 -9
- django_cfg/apps/accounts/admin/filters.py +0 -42
- django_cfg/apps/accounts/admin/inlines.py +8 -8
- django_cfg/apps/accounts/admin/otp.py +5 -5
- django_cfg/apps/accounts/admin/registration_source.py +1 -8
- django_cfg/apps/accounts/admin/user.py +12 -20
- django_cfg/apps/accounts/managers/user_manager.py +2 -129
- django_cfg/apps/accounts/migrations/0006_remove_twilioresponse_otp_secret_and_more.py +46 -0
- django_cfg/apps/accounts/models.py +3 -123
- django_cfg/apps/accounts/serializers/otp.py +40 -44
- django_cfg/apps/accounts/serializers/profile.py +0 -2
- django_cfg/apps/accounts/services/otp_service.py +98 -186
- django_cfg/apps/accounts/signals.py +25 -15
- django_cfg/apps/accounts/utils/auth_email_service.py +84 -0
- django_cfg/apps/accounts/views/otp.py +35 -36
- django_cfg/apps/agents/README.md +129 -0
- django_cfg/apps/agents/__init__.py +68 -0
- django_cfg/apps/agents/admin/__init__.py +17 -0
- django_cfg/apps/agents/admin/execution_admin.py +460 -0
- django_cfg/apps/agents/admin/registry_admin.py +360 -0
- django_cfg/apps/agents/admin/toolsets_admin.py +482 -0
- django_cfg/apps/agents/apps.py +29 -0
- django_cfg/apps/agents/core/__init__.py +20 -0
- django_cfg/apps/agents/core/agent.py +281 -0
- django_cfg/apps/agents/core/dependencies.py +154 -0
- django_cfg/apps/agents/core/exceptions.py +66 -0
- django_cfg/apps/agents/core/models.py +106 -0
- django_cfg/apps/agents/core/orchestrator.py +391 -0
- django_cfg/apps/agents/examples/__init__.py +3 -0
- django_cfg/apps/agents/examples/simple_example.py +161 -0
- django_cfg/apps/agents/integration/__init__.py +14 -0
- django_cfg/apps/agents/integration/middleware.py +80 -0
- django_cfg/apps/agents/integration/registry.py +345 -0
- django_cfg/apps/agents/integration/signals.py +50 -0
- django_cfg/apps/agents/management/__init__.py +3 -0
- django_cfg/apps/agents/management/commands/__init__.py +3 -0
- django_cfg/apps/agents/management/commands/create_agent.py +365 -0
- django_cfg/apps/agents/management/commands/orchestrator_status.py +191 -0
- django_cfg/apps/agents/managers/__init__.py +23 -0
- django_cfg/apps/agents/managers/execution.py +236 -0
- django_cfg/apps/agents/managers/registry.py +254 -0
- django_cfg/apps/agents/managers/toolsets.py +496 -0
- django_cfg/apps/agents/migrations/0001_initial.py +286 -0
- django_cfg/apps/agents/migrations/__init__.py +5 -0
- django_cfg/apps/agents/models/__init__.py +15 -0
- django_cfg/apps/agents/models/execution.py +215 -0
- django_cfg/apps/agents/models/registry.py +220 -0
- django_cfg/apps/agents/models/toolsets.py +305 -0
- django_cfg/apps/agents/patterns/__init__.py +24 -0
- django_cfg/apps/agents/patterns/content_agents.py +234 -0
- django_cfg/apps/agents/toolsets/__init__.py +15 -0
- django_cfg/apps/agents/toolsets/cache_toolset.py +285 -0
- django_cfg/apps/agents/toolsets/django_toolset.py +220 -0
- django_cfg/apps/agents/toolsets/file_toolset.py +324 -0
- django_cfg/apps/agents/toolsets/orm_toolset.py +319 -0
- django_cfg/apps/agents/urls.py +46 -0
- django_cfg/apps/knowbase/README.md +150 -0
- django_cfg/apps/knowbase/__init__.py +27 -0
- django_cfg/apps/knowbase/admin/__init__.py +23 -0
- django_cfg/apps/knowbase/admin/archive_admin.py +857 -0
- django_cfg/apps/knowbase/admin/chat_admin.py +386 -0
- django_cfg/apps/knowbase/admin/document_admin.py +650 -0
- django_cfg/apps/knowbase/admin/external_data_admin.py +685 -0
- django_cfg/apps/knowbase/apps.py +81 -0
- django_cfg/apps/knowbase/config/README.md +176 -0
- django_cfg/apps/knowbase/config/__init__.py +51 -0
- django_cfg/apps/knowbase/config/constance_fields.py +186 -0
- django_cfg/apps/knowbase/config/constance_settings.py +200 -0
- django_cfg/apps/knowbase/config/settings.py +444 -0
- django_cfg/apps/knowbase/examples/__init__.py +3 -0
- django_cfg/apps/knowbase/examples/external_data_usage.py +191 -0
- django_cfg/apps/knowbase/management/__init__.py +0 -0
- django_cfg/apps/knowbase/management/commands/__init__.py +0 -0
- django_cfg/apps/knowbase/management/commands/knowbase_stats.py +158 -0
- django_cfg/apps/knowbase/management/commands/setup_knowbase.py +59 -0
- django_cfg/apps/knowbase/managers/__init__.py +22 -0
- django_cfg/apps/knowbase/managers/archive.py +426 -0
- django_cfg/apps/knowbase/managers/base.py +32 -0
- django_cfg/apps/knowbase/managers/chat.py +141 -0
- django_cfg/apps/knowbase/managers/document.py +203 -0
- django_cfg/apps/knowbase/managers/external_data.py +471 -0
- django_cfg/apps/knowbase/migrations/0001_initial.py +427 -0
- django_cfg/apps/knowbase/migrations/0002_archiveitem_archiveitemchunk_documentarchive_and_more.py +434 -0
- django_cfg/apps/knowbase/migrations/__init__.py +5 -0
- django_cfg/apps/knowbase/mixins/__init__.py +15 -0
- django_cfg/apps/knowbase/mixins/config.py +108 -0
- django_cfg/apps/knowbase/mixins/creator.py +81 -0
- django_cfg/apps/knowbase/mixins/examples/vehicle_model_example.py +199 -0
- django_cfg/apps/knowbase/mixins/external_data_mixin.py +813 -0
- django_cfg/apps/knowbase/mixins/service.py +362 -0
- django_cfg/apps/knowbase/models/__init__.py +41 -0
- django_cfg/apps/knowbase/models/archive.py +599 -0
- django_cfg/apps/knowbase/models/base.py +58 -0
- django_cfg/apps/knowbase/models/chat.py +157 -0
- django_cfg/apps/knowbase/models/document.py +267 -0
- django_cfg/apps/knowbase/models/external_data.py +376 -0
- django_cfg/apps/knowbase/serializers/__init__.py +68 -0
- django_cfg/apps/knowbase/serializers/archive_serializers.py +386 -0
- django_cfg/apps/knowbase/serializers/chat_serializers.py +137 -0
- django_cfg/apps/knowbase/serializers/document_serializers.py +94 -0
- django_cfg/apps/knowbase/serializers/external_data_serializers.py +256 -0
- django_cfg/apps/knowbase/serializers/public_serializers.py +74 -0
- django_cfg/apps/knowbase/services/__init__.py +40 -0
- django_cfg/apps/knowbase/services/archive/__init__.py +42 -0
- django_cfg/apps/knowbase/services/archive/archive_service.py +541 -0
- django_cfg/apps/knowbase/services/archive/chunking_service.py +791 -0
- django_cfg/apps/knowbase/services/archive/exceptions.py +52 -0
- django_cfg/apps/knowbase/services/archive/extraction_service.py +508 -0
- django_cfg/apps/knowbase/services/archive/vectorization_service.py +362 -0
- django_cfg/apps/knowbase/services/base.py +53 -0
- django_cfg/apps/knowbase/services/chat_service.py +239 -0
- django_cfg/apps/knowbase/services/document_service.py +144 -0
- django_cfg/apps/knowbase/services/embedding/__init__.py +43 -0
- django_cfg/apps/knowbase/services/embedding/async_processor.py +244 -0
- django_cfg/apps/knowbase/services/embedding/batch_processor.py +250 -0
- django_cfg/apps/knowbase/services/embedding/batch_result.py +61 -0
- django_cfg/apps/knowbase/services/embedding/models.py +229 -0
- django_cfg/apps/knowbase/services/embedding/processors.py +148 -0
- django_cfg/apps/knowbase/services/embedding/utils.py +176 -0
- django_cfg/apps/knowbase/services/prompt_builder.py +191 -0
- django_cfg/apps/knowbase/services/search_service.py +293 -0
- django_cfg/apps/knowbase/signals/__init__.py +21 -0
- django_cfg/apps/knowbase/signals/archive_signals.py +211 -0
- django_cfg/apps/knowbase/signals/chat_signals.py +37 -0
- django_cfg/apps/knowbase/signals/document_signals.py +143 -0
- django_cfg/apps/knowbase/signals/external_data_signals.py +157 -0
- django_cfg/apps/knowbase/tasks/__init__.py +39 -0
- django_cfg/apps/knowbase/tasks/archive_tasks.py +316 -0
- django_cfg/apps/knowbase/tasks/document_processing.py +341 -0
- django_cfg/apps/knowbase/tasks/external_data_tasks.py +341 -0
- django_cfg/apps/knowbase/tasks/maintenance.py +195 -0
- django_cfg/apps/knowbase/urls.py +43 -0
- django_cfg/apps/knowbase/utils/__init__.py +12 -0
- django_cfg/apps/knowbase/utils/chunk_settings.py +261 -0
- django_cfg/apps/knowbase/utils/text_processing.py +375 -0
- django_cfg/apps/knowbase/utils/validation.py +99 -0
- django_cfg/apps/knowbase/views/__init__.py +28 -0
- django_cfg/apps/knowbase/views/archive_views.py +469 -0
- django_cfg/apps/knowbase/views/base.py +49 -0
- django_cfg/apps/knowbase/views/chat_views.py +181 -0
- django_cfg/apps/knowbase/views/document_views.py +183 -0
- django_cfg/apps/knowbase/views/public_views.py +129 -0
- django_cfg/apps/leads/admin.py +70 -0
- django_cfg/apps/newsletter/admin.py +234 -0
- django_cfg/apps/newsletter/admin_filters.py +124 -0
- django_cfg/apps/support/admin.py +196 -0
- django_cfg/apps/support/admin_filters.py +71 -0
- django_cfg/apps/support/templates/support/chat/ticket_chat.html +1 -1
- django_cfg/apps/urls.py +5 -4
- django_cfg/cli/README.md +1 -1
- django_cfg/cli/commands/create_project.py +2 -2
- django_cfg/cli/commands/info.py +1 -1
- django_cfg/config.py +44 -0
- django_cfg/core/config.py +29 -82
- django_cfg/core/environment.py +1 -1
- django_cfg/core/generation.py +19 -107
- django_cfg/{integration.py → core/integration.py} +18 -16
- django_cfg/core/validation.py +1 -1
- django_cfg/management/__init__.py +1 -1
- django_cfg/management/commands/__init__.py +1 -1
- django_cfg/management/commands/auto_generate.py +482 -0
- django_cfg/management/commands/migrator.py +19 -101
- django_cfg/management/commands/test_email.py +1 -1
- django_cfg/middleware/README.md +0 -158
- django_cfg/middleware/__init__.py +0 -2
- django_cfg/middleware/user_activity.py +3 -3
- django_cfg/models/api.py +145 -0
- django_cfg/models/base.py +287 -0
- django_cfg/models/cache.py +4 -4
- django_cfg/models/constance.py +25 -88
- django_cfg/models/database.py +9 -9
- django_cfg/models/drf.py +3 -36
- django_cfg/models/email.py +163 -0
- django_cfg/models/environment.py +276 -0
- django_cfg/models/limits.py +1 -1
- django_cfg/models/logging.py +366 -0
- django_cfg/models/revolution.py +41 -2
- django_cfg/models/security.py +125 -0
- django_cfg/models/services.py +1 -1
- django_cfg/modules/__init__.py +2 -56
- django_cfg/modules/base.py +78 -52
- django_cfg/modules/django_currency/service.py +2 -2
- django_cfg/modules/django_email.py +2 -2
- django_cfg/modules/django_health.py +267 -0
- django_cfg/modules/django_llm/llm/client.py +79 -17
- django_cfg/modules/django_llm/translator/translator.py +2 -2
- django_cfg/modules/django_logger.py +2 -2
- django_cfg/modules/django_ngrok.py +2 -2
- django_cfg/modules/django_tasks.py +68 -3
- django_cfg/modules/django_telegram.py +3 -3
- django_cfg/modules/django_twilio/sendgrid_service.py +2 -2
- django_cfg/modules/django_twilio/service.py +2 -2
- django_cfg/modules/django_twilio/simple_service.py +2 -2
- django_cfg/modules/django_twilio/templates/guide.md +266 -0
- django_cfg/modules/django_twilio/twilio_service.py +2 -2
- django_cfg/modules/django_unfold/__init__.py +69 -0
- django_cfg/modules/{unfold → django_unfold}/callbacks.py +23 -22
- django_cfg/modules/django_unfold/dashboard.py +278 -0
- django_cfg/modules/django_unfold/icons/README.md +145 -0
- django_cfg/modules/django_unfold/icons/__init__.py +12 -0
- django_cfg/modules/django_unfold/icons/constants.py +2851 -0
- django_cfg/modules/django_unfold/icons/generate_icons.py +486 -0
- django_cfg/modules/django_unfold/models/__init__.py +42 -0
- django_cfg/modules/django_unfold/models/config.py +601 -0
- django_cfg/modules/django_unfold/models/dashboard.py +206 -0
- django_cfg/modules/django_unfold/models/dropdown.py +40 -0
- django_cfg/modules/django_unfold/models/navigation.py +73 -0
- django_cfg/modules/django_unfold/models/tabs.py +25 -0
- django_cfg/modules/{unfold → django_unfold}/system_monitor.py +2 -2
- django_cfg/modules/django_unfold/utils.py +140 -0
- django_cfg/registry/__init__.py +23 -0
- django_cfg/registry/core.py +61 -0
- django_cfg/registry/exceptions.py +11 -0
- django_cfg/registry/modules.py +12 -0
- django_cfg/registry/services.py +26 -0
- django_cfg/registry/third_party.py +52 -0
- django_cfg/routing/__init__.py +19 -0
- django_cfg/routing/callbacks.py +198 -0
- django_cfg/routing/routers.py +48 -0
- django_cfg/templates/admin/layouts/dashboard_with_tabs.html +8 -9
- django_cfg/templatetags/__init__.py +0 -0
- django_cfg/templatetags/django_cfg.py +33 -0
- django_cfg/urls.py +33 -0
- django_cfg/utils/path_resolution.py +1 -1
- django_cfg/utils/smart_defaults.py +7 -61
- django_cfg/utils/toolkit.py +663 -0
- {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/METADATA +83 -86
- django_cfg-1.2.0.dist-info/RECORD +441 -0
- django_cfg/apps/tasks/@docs/README.md +0 -195
- django_cfg/archive/django_sample.zip +0 -0
- django_cfg/models/unfold.py +0 -271
- django_cfg/modules/unfold/__init__.py +0 -29
- django_cfg/modules/unfold/dashboard.py +0 -318
- django_cfg/pyproject.toml +0 -370
- django_cfg/routers.py +0 -83
- django_cfg-1.1.81.dist-info/RECORD +0 -278
- /django_cfg/{exceptions.py → core/exceptions.py} +0 -0
- /django_cfg/modules/{unfold → django_unfold}/models.py +0 -0
- /django_cfg/modules/{unfold → django_unfold}/tailwind.py +0 -0
- /django_cfg/{version_check.py → utils/version_check.py} +0 -0
- {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/WHEEL +0 -0
- {django_cfg-1.1.81.dist-info → django_cfg-1.2.0.dist-info}/entry_points.txt +0 -0
- {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)
|