Faktenkompass: UI-Rebrand, Admin-Assistent und Produktions-Setup.

Modernisiert die öffentliche Oberfläche mit Inter, Dark Mode und Live-Suche, ergänzt KI-gestützte Entwürfe im Admin mit Nachweis-Uploads und dokumentiert Daphne/Nginx für den Serverbetrieb.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Pirmin Hinderling (fedora)
2026-07-07 15:33:58 +02:00
parent 2c7fb7d030
commit db3558872e
43 changed files with 3014 additions and 208 deletions
+165 -17
View File
@@ -1,36 +1,101 @@
from django.contrib import admin
from django.urls import path
from django.utils.html import format_html
from .models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
from .admin_forms import (
ClaimAdminForm,
CounterArgumentInlineForm,
EvidenceFileInlineForm,
SourceInlineForm,
)
from .admin_views import AssistantView, ReviewDraftView
from .models import Category, Claim, ClaimDraft, CounterArgument, EvidenceFile, Source, Tag
class EvidenceFileInline(admin.TabularInline):
class FaktenAdminMixin:
class Media:
css = {'all': ['css/admin_custom.css']}
js = ['js/admin_custom.js']
class EvidenceFileInline(FaktenAdminMixin, admin.StackedInline):
model = EvidenceFile
form = EvidenceFileInlineForm
extra = 1
fields = ('title', 'file', 'caption', 'order')
fields = ('preview', 'file', 'title', 'caption', 'order')
readonly_fields = ('preview',)
classes = ('collapse-open',)
verbose_name = 'Nachweis'
verbose_name_plural = 'Nachweise (Grafiken / PDFs)'
@admin.display(description='Vorschau')
def preview(self, obj):
if not obj or not obj.pk or not obj.file:
return format_html('<span class="quiet">Nach dem Speichern sichtbar</span>')
if obj.is_image:
return format_html(
'<div class="fk-evidence-preview"><a href="{}" target="_blank" rel="noopener">'
'<img src="{}" alt="{}"></a></div>',
obj.file.url,
obj.file.url,
obj.image_alt,
)
if obj.is_pdf:
return format_html(
'<div class="fk-evidence-preview"><a href="{}" target="_blank" rel="noopener">'
'📄 PDF öffnen</a></div>',
obj.file.url,
)
return ''
class SourceInline(admin.TabularInline):
class SourceInline(FaktenAdminMixin, admin.StackedInline):
model = Source
form = SourceInlineForm
extra = 1
fields = ('title', 'organization', 'url', 'description')
classes = ('collapse-open',)
verbose_name = 'Quelle'
verbose_name_plural = 'Quellen'
class CounterArgumentInline(admin.StackedInline):
class CounterArgumentInline(FaktenAdminMixin, admin.StackedInline):
model = CounterArgument
form = CounterArgumentInlineForm
extra = 1
fields = ('argument', 'response')
classes = ('collapse-open',)
verbose_name = 'Gegenargument'
verbose_name_plural = 'Gegenargumente'
@admin.register(Tag)
class TagAdmin(admin.ModelAdmin):
class TagAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name',)
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
class CategoryAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('name', 'slug', 'claim_count', 'created_at')
prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'description')
fieldsets = (
(None, {
'fields': ('name', 'slug', 'icon'),
}),
('Beschreibung', {
'fields': ('description',),
'classes': ('wide',),
}),
)
def formfield_for_dbfield(self, db_field, request, **kwargs):
formfield = super().formfield_for_dbfield(db_field, request, **kwargs)
if db_field.name == 'description':
formfield.widget.attrs.update({'rows': 4, 'class': 'fk-admin-textarea'})
return formfield
@admin.display(description='Anzahl Behauptungen')
def claim_count(self, obj):
@@ -38,43 +103,81 @@ class CategoryAdmin(admin.ModelAdmin):
@admin.register(Claim)
class ClaimAdmin(admin.ModelAdmin):
class ClaimAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = ClaimAdminForm
change_form_template = 'admin/claims/claim/change_form.html'
list_display = ('title', 'category', 'status', 'evidence_level', 'updated_at')
list_display_links = ('title',)
list_filter = ('category', 'status', 'evidence_level', 'tags')
search_fields = ('title', 'short_answer', 'detailed_explanation')
prepopulated_fields = {'slug': ('title',)}
filter_horizontal = ('tags',)
autocomplete_fields = ('category',)
save_on_top = True
date_hierarchy = 'updated_at'
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
readonly_fields = ('public_link', 'created_at', 'updated_at')
fieldsets = (
(None, {
'fields': ('category', 'title', 'slug', 'status', 'evidence_level', 'tags'),
('Einordnung', {
'fields': ('category', 'status', 'evidence_level', 'tags'),
}),
('Inhalt', {
('Behauptung', {
'fields': ('title', 'slug', 'public_link'),
}),
('Texte', {
'fields': ('short_answer', 'detailed_explanation'),
'classes': ('wide',),
'description': 'Kurzantwort: in ~20 Sekunden lesbar. Ausführliche Erklärung optional.',
}),
('Zeitstempel', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',),
}),
)
@admin.display(description='Website')
def public_link(self, obj):
if not obj.pk:
return ''
return format_html(
'<a href="{}" target="_blank" rel="noopener">Behauptung auf der Website öffnen</a>',
obj.get_absolute_url(),
)
@admin.register(EvidenceFile)
class EvidenceFileAdmin(admin.ModelAdmin):
list_display = ('display_title', 'claim', 'file_extension_display', 'order', 'created_at')
class EvidenceFileAdmin(FaktenAdminMixin, admin.ModelAdmin):
list_display = ('preview_thumb', 'display_title', 'claim', 'order', 'created_at')
list_filter = ('claim__category',)
search_fields = ('title', 'caption', 'claim__title')
readonly_fields = ('preview',)
fields = ('claim', 'preview', 'file', 'title', 'caption', 'order')
@admin.display(description='Typ')
def file_extension_display(self, obj):
@admin.display(description='')
def preview_thumb(self, obj):
if obj.is_image and obj.file:
return format_html(
'<img src="{}" style="max-width:48px;max-height:48px;object-fit:contain">',
obj.file.url,
)
return obj.file_extension.upper()
@admin.display(description='Vorschau')
def preview(self, obj):
return EvidenceFileInline.preview(self, obj)
@admin.register(Source)
class SourceAdmin(admin.ModelAdmin):
class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = SourceInlineForm
list_display = ('title', 'organization', 'claim')
list_filter = ('claim__category',)
search_fields = ('title', 'organization', 'description')
@admin.register(CounterArgument)
class CounterArgumentAdmin(admin.ModelAdmin):
class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
form = CounterArgumentInlineForm
list_display = ('argument_preview', 'claim')
list_filter = ('claim__category',)
search_fields = ('argument', 'response')
@@ -82,3 +185,48 @@ class CounterArgumentAdmin(admin.ModelAdmin):
@admin.display(description='Gegenargument')
def argument_preview(self, obj):
return obj.argument[:80]
@admin.register(ClaimDraft)
class ClaimDraftAdmin(FaktenAdminMixin, admin.ModelAdmin):
change_list_template = 'admin/claims/claimdraft/change_list.html'
list_display = ('title', 'status', 'created_by', 'created_at', 'published_claim')
list_filter = ('status', 'created_at')
search_fields = ('title', 'user_notes', 'article_url')
readonly_fields = (
'created_by', 'status', 'article_url', 'user_notes', 'uploaded_file',
'article_text', 'conversation', 'ai_raw_response', 'error_message',
'published_claim', 'created_at', 'updated_at',
)
def get_queryset(self, request):
qs = super().get_queryset(request)
if request.user.is_superuser:
return qs
return qs.filter(created_by=request.user)
def has_change_permission(self, request, obj=None):
if obj and not request.user.is_superuser and obj.created_by_id != request.user.id:
return False
return super().has_change_permission(request, obj)
def has_delete_permission(self, request, obj=None):
if obj and not request.user.is_superuser and obj.created_by_id != request.user.id:
return False
return super().has_delete_permission(request, obj)
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path(
'assistant/',
self.admin_site.admin_view(AssistantView.as_view()),
name='claims_claimdraft_assistant',
),
path(
'<int:draft_id>/review/',
self.admin_site.admin_view(ReviewDraftView.as_view()),
name='claims_claimdraft_review',
),
]
return custom_urls + urls