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:
+165
-17
@@ -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
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
from django import forms
|
||||
|
||||
from .models import Claim, CounterArgument, EvidenceFile, Source
|
||||
|
||||
|
||||
class ClaimAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Claim
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
|
||||
'short_answer': forms.Textarea(attrs={'rows': 5, 'class': 'fk-admin-textarea'}),
|
||||
'detailed_explanation': forms.Textarea(attrs={'rows': 12, 'class': 'fk-admin-textarea'}),
|
||||
}
|
||||
|
||||
|
||||
class SourceInlineForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Source
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
|
||||
'organization': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
|
||||
'url': forms.URLInput(attrs={'class': 'fk-admin-wide'}),
|
||||
'description': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
|
||||
}
|
||||
|
||||
|
||||
class CounterArgumentInlineForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = CounterArgument
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'argument': forms.Textarea(attrs={'rows': 3, 'class': 'fk-admin-textarea'}),
|
||||
'response': forms.Textarea(attrs={'rows': 4, 'class': 'fk-admin-textarea'}),
|
||||
}
|
||||
|
||||
|
||||
class EvidenceFileInlineForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = EvidenceFile
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'fk-admin-wide'}),
|
||||
'caption': forms.Textarea(attrs={'rows': 2, 'class': 'fk-admin-textarea'}),
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.admin.views.decorators import staff_member_required
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import path, reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views import View
|
||||
|
||||
from claims.forms import AssistantInputForm, RefineDraftForm, ReviewDraftForm
|
||||
from claims.models import ClaimDraft
|
||||
from claims.services.ai_assistant import AIAssistantError
|
||||
from claims.services.article_fetcher import ArticleFetchError
|
||||
from claims.services.draft_processor import process_claim_draft
|
||||
from claims.services.draft_publisher import DraftPublishError, publish_draft
|
||||
|
||||
|
||||
def _get_user_draft(request, draft_id):
|
||||
if request.user.is_superuser:
|
||||
return get_object_or_404(ClaimDraft, pk=draft_id)
|
||||
return get_object_or_404(ClaimDraft, pk=draft_id, created_by=request.user)
|
||||
|
||||
|
||||
@method_decorator(staff_member_required, name='dispatch')
|
||||
class AssistantView(View):
|
||||
template_name = 'admin/claims/assistant.html'
|
||||
|
||||
def get(self, request):
|
||||
form = AssistantInputForm()
|
||||
drafts = ClaimDraft.objects.filter(created_by=request.user).order_by('-created_at')[:10]
|
||||
return render(request, self.template_name, {
|
||||
**admin.site.each_context(request),
|
||||
'form': form,
|
||||
'recent_drafts': drafts,
|
||||
'title': 'Fakten-Assistent',
|
||||
'opts': ClaimDraft._meta,
|
||||
})
|
||||
|
||||
def post(self, request):
|
||||
form = AssistantInputForm(request.POST, request.FILES)
|
||||
if not form.is_valid():
|
||||
return render(request, self.template_name, {
|
||||
**admin.site.each_context(request),
|
||||
'form': form,
|
||||
'recent_drafts': ClaimDraft.objects.filter(created_by=request.user)[:10],
|
||||
'title': 'Fakten-Assistent',
|
||||
'opts': ClaimDraft._meta,
|
||||
})
|
||||
|
||||
draft = ClaimDraft.objects.create(
|
||||
created_by=request.user,
|
||||
article_url=form.cleaned_data.get('article_url') or '',
|
||||
user_notes=form.cleaned_data.get('user_notes') or '',
|
||||
uploaded_file=form.cleaned_data.get('uploaded_file'),
|
||||
)
|
||||
|
||||
try:
|
||||
process_claim_draft(draft)
|
||||
except (ArticleFetchError, AIAssistantError):
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
|
||||
messages.success(request, 'Entwurf wurde erstellt. Bitte vor der Veröffentlichung prüfen.')
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
|
||||
|
||||
@method_decorator(staff_member_required, name='dispatch')
|
||||
class ReviewDraftView(View):
|
||||
template_name = 'admin/claims/review_draft.html'
|
||||
|
||||
def get(self, request, draft_id):
|
||||
draft = _get_user_draft(request, draft_id)
|
||||
return render(request, self.template_name, {
|
||||
**admin.site.each_context(request),
|
||||
'draft': draft,
|
||||
'form': ReviewDraftForm(instance=draft),
|
||||
'refine_form': RefineDraftForm(),
|
||||
'title': f'Entwurf prüfen: {draft.title or draft.pk}',
|
||||
'opts': ClaimDraft._meta,
|
||||
})
|
||||
|
||||
def post(self, request, draft_id):
|
||||
draft = _get_user_draft(request, draft_id)
|
||||
action = request.POST.get('action')
|
||||
|
||||
if action == 'refine':
|
||||
refine_form = RefineDraftForm(request.POST)
|
||||
if refine_form.is_valid():
|
||||
try:
|
||||
process_claim_draft(draft, refinement=refine_form.cleaned_data['refinement'])
|
||||
messages.success(request, 'Entwurf wurde überarbeitet.')
|
||||
except (ArticleFetchError, AIAssistantError) as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
|
||||
if action == 'reject':
|
||||
draft.status = ClaimDraft.DraftStatus.REJECTED
|
||||
draft.save(update_fields=['status', 'updated_at'])
|
||||
messages.info(request, 'Entwurf wurde verworfen.')
|
||||
return redirect('admin:claims_claimdraft_assistant')
|
||||
|
||||
form = ReviewDraftForm(request.POST, instance=draft)
|
||||
if not form.is_valid():
|
||||
return render(request, self.template_name, {
|
||||
**admin.site.each_context(request),
|
||||
'draft': draft,
|
||||
'form': form,
|
||||
'refine_form': RefineDraftForm(),
|
||||
'title': f'Entwurf prüfen: {draft.title or draft.pk}',
|
||||
'opts': ClaimDraft._meta,
|
||||
})
|
||||
|
||||
draft = form.save()
|
||||
|
||||
if action == 'save':
|
||||
draft.status = ClaimDraft.DraftStatus.REVIEW
|
||||
draft.save(update_fields=['status', 'updated_at'])
|
||||
messages.success(request, 'Änderungen gespeichert.')
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
|
||||
if action == 'publish':
|
||||
try:
|
||||
claim = publish_draft(draft)
|
||||
except DraftPublishError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
except SuspiciousFileOperation as exc:
|
||||
messages.error(request, f'Datei konnte nicht gespeichert werden: {exc}')
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
|
||||
messages.success(
|
||||
request,
|
||||
f'Behauptung „{claim.title}" wurde veröffentlicht.',
|
||||
)
|
||||
return redirect(reverse('admin:claims_claim_change', args=[claim.pk]))
|
||||
|
||||
return redirect('admin:claims_claimdraft_review', draft.pk)
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
|
||||
from claims.models import Category, Claim, ClaimDraft
|
||||
|
||||
|
||||
class AssistantInputForm(forms.Form):
|
||||
article_url = forms.URLField(
|
||||
label='Artikel-Link',
|
||||
required=False,
|
||||
widget=forms.URLInput(attrs={
|
||||
'class': 'form-control',
|
||||
'placeholder': 'https://beispiel.de/artikel',
|
||||
}),
|
||||
)
|
||||
user_notes = forms.CharField(
|
||||
label='Beschreibung / Behauptung',
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 4,
|
||||
'placeholder': 'Welche Aussage soll überprüft werden? Kontext, Stichpunkte …',
|
||||
}),
|
||||
)
|
||||
uploaded_file = forms.FileField(
|
||||
label='Bild oder PDF',
|
||||
required=False,
|
||||
widget=forms.ClearableFileInput(attrs={'class': 'form-control', 'accept': '.jpg,.jpeg,.png,.gif,.webp,.svg,.pdf'}),
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
url = cleaned.get('article_url')
|
||||
notes = (cleaned.get('user_notes') or '').strip()
|
||||
uploaded = cleaned.get('uploaded_file')
|
||||
if not url and not notes and not uploaded:
|
||||
raise forms.ValidationError(
|
||||
'Bitte mindestens einen Artikel-Link, eine Beschreibung oder eine Datei angeben.'
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
class ReviewDraftForm(forms.ModelForm):
|
||||
tags_text = forms.CharField(
|
||||
label='Schlagwörter',
|
||||
required=False,
|
||||
help_text='Kommagetrennt',
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
)
|
||||
sources_json = forms.CharField(
|
||||
label='Quellen (JSON)',
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
|
||||
)
|
||||
counter_arguments_json = forms.CharField(
|
||||
label='Gegenargumente (JSON)',
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={'class': 'form-control font-monospace', 'rows': 6}),
|
||||
)
|
||||
category_choice = forms.ModelChoiceField(
|
||||
label='Bestehende Kategorie',
|
||||
queryset=Category.objects.all(),
|
||||
required=False,
|
||||
empty_label='— Neue Kategorie verwenden —',
|
||||
widget=forms.Select(attrs={'class': 'form-select'}),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ClaimDraft
|
||||
fields = [
|
||||
'title',
|
||||
'claim_status',
|
||||
'short_answer',
|
||||
'detailed_explanation',
|
||||
'evidence_level',
|
||||
'new_category_name',
|
||||
'new_category_description',
|
||||
'new_category_icon',
|
||||
]
|
||||
widgets = {
|
||||
'title': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'claim_status': forms.Select(attrs={'class': 'form-select'}),
|
||||
'short_answer': forms.Textarea(attrs={'class': 'form-control', 'rows': 4}),
|
||||
'detailed_explanation': forms.Textarea(attrs={'class': 'form-control', 'rows': 6}),
|
||||
'evidence_level': forms.Select(attrs={'class': 'form-select'}),
|
||||
'new_category_name': forms.TextInput(attrs={'class': 'form-control'}),
|
||||
'new_category_description': forms.Textarea(attrs={'class': 'form-control', 'rows': 2}),
|
||||
'new_category_icon': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'bi-book'}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
instance = kwargs.get('instance') or getattr(self, 'instance', None)
|
||||
if instance and instance.pk:
|
||||
self.fields['tags_text'].initial = ', '.join(instance.tags or [])
|
||||
self.fields['sources_json'].initial = json.dumps(
|
||||
instance.sources or [], ensure_ascii=False, indent=2
|
||||
)
|
||||
self.fields['counter_arguments_json'].initial = json.dumps(
|
||||
instance.counter_arguments or [], ensure_ascii=False, indent=2
|
||||
)
|
||||
if instance.category_slug:
|
||||
category = Category.objects.filter(slug=instance.category_slug).first()
|
||||
if category:
|
||||
self.fields['category_choice'].initial = category
|
||||
|
||||
def clean_tags_text(self):
|
||||
raw = self.cleaned_data.get('tags_text', '')
|
||||
return [part.strip() for part in raw.split(',') if part.strip()]
|
||||
|
||||
def clean_sources_json(self):
|
||||
raw = (self.cleaned_data.get('sources_json') or '').strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise forms.ValidationError('Ungültiges JSON für Quellen.') from exc
|
||||
if not isinstance(data, list):
|
||||
raise forms.ValidationError('Quellen müssen eine JSON-Liste sein.')
|
||||
return data
|
||||
|
||||
def clean_counter_arguments_json(self):
|
||||
raw = (self.cleaned_data.get('counter_arguments_json') or '').strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise forms.ValidationError('Ungültiges JSON für Gegenargumente.') from exc
|
||||
if not isinstance(data, list):
|
||||
raise forms.ValidationError('Gegenargumente müssen eine JSON-Liste sein.')
|
||||
return data
|
||||
|
||||
def save(self, commit=True):
|
||||
draft = super().save(commit=False)
|
||||
draft.tags = self.cleaned_data['tags_text']
|
||||
draft.sources = self.cleaned_data['sources_json']
|
||||
draft.counter_arguments = self.cleaned_data['counter_arguments_json']
|
||||
category = self.cleaned_data.get('category_choice')
|
||||
if category:
|
||||
draft.category_slug = category.slug
|
||||
else:
|
||||
draft.category_slug = ''
|
||||
if commit:
|
||||
draft.save()
|
||||
return draft
|
||||
|
||||
|
||||
class RefineDraftForm(forms.Form):
|
||||
refinement = forms.CharField(
|
||||
label='Überarbeitungswunsch',
|
||||
widget=forms.Textarea(attrs={
|
||||
'class': 'form-control',
|
||||
'rows': 3,
|
||||
'placeholder': 'z. B. „Kurzantwort kürzer formulieren“ oder „mehr Quellen hinzufügen“',
|
||||
}),
|
||||
)
|
||||
@@ -0,0 +1,52 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 11:04
|
||||
|
||||
import django.core.validators
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('claims', '0002_evidencefile'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClaimDraft',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('processing', 'Wird verarbeitet'), ('review', 'Zur Prüfung'), ('published', 'Veröffentlicht'), ('failed', 'Fehlgeschlagen'), ('rejected', 'Abgelehnt')], default='processing', max_length=20, verbose_name='Status')),
|
||||
('article_url', models.URLField(blank=True, max_length=500, verbose_name='Artikel-URL')),
|
||||
('user_notes', models.TextField(blank=True, verbose_name='Eingabe / Beschreibung')),
|
||||
('uploaded_file', models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'])], verbose_name='Hochgeladene Datei')),
|
||||
('article_text', models.TextField(blank=True, verbose_name='Extrahierter Artikeltext')),
|
||||
('category_slug', models.SlugField(blank=True, help_text='Slug einer vorhandenen Kategorie, falls passend', max_length=220, verbose_name='Bestehende Kategorie (Slug)')),
|
||||
('new_category_name', models.CharField(blank=True, max_length=200, verbose_name='Neue Kategorie')),
|
||||
('new_category_description', models.TextField(blank=True, verbose_name='Beschreibung neue Kategorie')),
|
||||
('new_category_icon', models.CharField(blank=True, help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half', max_length=50, verbose_name='Icon neue Kategorie')),
|
||||
('title', models.CharField(blank=True, max_length=300, verbose_name='Behauptung')),
|
||||
('claim_status', models.CharField(blank=True, choices=[('falsch', 'Falsch'), ('irrefuehrend', 'Irreführend'), ('teilweise_richtig', 'Teilweise richtig'), ('unbelegt', 'Unbelegt'), ('wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'), ('offene_frage', 'Offene Frage')], max_length=40, verbose_name='Einordnung')),
|
||||
('short_answer', models.TextField(blank=True, verbose_name='Kurzantwort')),
|
||||
('detailed_explanation', models.TextField(blank=True, verbose_name='Ausführliche Erklärung')),
|
||||
('evidence_level', models.CharField(blank=True, choices=[('sehr_stark', 'Sehr stark'), ('stark', 'Stark'), ('mittel', 'Mittel'), ('schwach', 'Schwach')], max_length=20, verbose_name='Evidenzlevel')),
|
||||
('tags', models.JSONField(blank=True, default=list, verbose_name='Schlagwörter')),
|
||||
('sources', models.JSONField(blank=True, default=list, verbose_name='Quellen')),
|
||||
('counter_arguments', models.JSONField(blank=True, default=list, verbose_name='Gegenargumente')),
|
||||
('conversation', models.JSONField(blank=True, default=list, verbose_name='Konversation')),
|
||||
('ai_raw_response', models.JSONField(blank=True, null=True, verbose_name='KI-Rohantwort')),
|
||||
('error_message', models.TextField(blank=True, verbose_name='Fehlermeldung')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
|
||||
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claim_drafts', to=settings.AUTH_USER_MODEL, verbose_name='Erstellt von')),
|
||||
('published_claim', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='origin_drafts', to='claims.claim', verbose_name='Veröffentlichte Behauptung')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Fakten-Entwurf',
|
||||
'verbose_name_plural': 'Fakten-Entwürfe',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 11:13
|
||||
|
||||
import claims.models
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('claims', '0003_claimdraft'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='claimdraft',
|
||||
name='uploaded_file',
|
||||
field=models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', max_length=500, upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'pdf'])], verbose_name='Hochgeladene Datei'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='evidencefile',
|
||||
name='file',
|
||||
field=models.FileField(help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF', max_length=500, upload_to=claims.models.evidence_upload_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'])], verbose_name='Datei'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-07 12:05
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('claims', '0004_alter_claimdraft_uploaded_file_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='claimdraft',
|
||||
name='uploaded_file',
|
||||
field=models.FileField(blank=True, help_text='Optional: Bild oder PDF als Kontext', max_length=500, upload_to='drafts/uploads/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'])], verbose_name='Hochgeladene Datei'),
|
||||
),
|
||||
]
|
||||
+124
-1
@@ -1,3 +1,7 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -5,6 +9,7 @@ from django.utils.text import slugify
|
||||
|
||||
EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
|
||||
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
|
||||
DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
@@ -143,7 +148,11 @@ class Claim(models.Model):
|
||||
|
||||
|
||||
def evidence_upload_path(instance, filename):
|
||||
return f'evidence/{instance.claim.slug}/{filename}'
|
||||
ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin'
|
||||
if ext not in EVIDENCE_FILE_EXTENSIONS:
|
||||
ext = 'bin'
|
||||
claim_id = instance.claim_id or 'new'
|
||||
return f'evidence/{claim_id}/{uuid.uuid4().hex[:12]}.{ext}'
|
||||
|
||||
|
||||
class EvidenceFile(models.Model):
|
||||
@@ -157,6 +166,7 @@ class EvidenceFile(models.Model):
|
||||
file = models.FileField(
|
||||
'Datei',
|
||||
upload_to=evidence_upload_path,
|
||||
max_length=500,
|
||||
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
|
||||
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
|
||||
)
|
||||
@@ -190,6 +200,10 @@ class EvidenceFile(models.Model):
|
||||
def is_pdf(self):
|
||||
return self.file_extension == 'pdf'
|
||||
|
||||
@property
|
||||
def image_alt(self):
|
||||
return self.caption or 'Nachweis-Grafik'
|
||||
|
||||
|
||||
class Source(models.Model):
|
||||
claim = models.ForeignKey(
|
||||
@@ -229,3 +243,112 @@ class CounterArgument(models.Model):
|
||||
|
||||
def __str__(self):
|
||||
return self.argument[:80]
|
||||
|
||||
|
||||
def draft_upload_path(instance, filename):
|
||||
return f'drafts/{instance.pk or "new"}/{filename}'
|
||||
|
||||
|
||||
class ClaimDraft(models.Model):
|
||||
class DraftStatus(models.TextChoices):
|
||||
PROCESSING = 'processing', 'Wird verarbeitet'
|
||||
REVIEW = 'review', 'Zur Prüfung'
|
||||
PUBLISHED = 'published', 'Veröffentlicht'
|
||||
FAILED = 'failed', 'Fehlgeschlagen'
|
||||
REJECTED = 'rejected', 'Abgelehnt'
|
||||
|
||||
created_by = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='claim_drafts',
|
||||
verbose_name='Erstellt von',
|
||||
)
|
||||
status = models.CharField(
|
||||
'Status',
|
||||
max_length=20,
|
||||
choices=DraftStatus.choices,
|
||||
default=DraftStatus.PROCESSING,
|
||||
)
|
||||
article_url = models.URLField('Artikel-URL', max_length=500, blank=True)
|
||||
user_notes = models.TextField('Eingabe / Beschreibung', blank=True)
|
||||
uploaded_file = models.FileField(
|
||||
'Hochgeladene Datei',
|
||||
upload_to='drafts/uploads/%Y/%m/',
|
||||
max_length=500,
|
||||
blank=True,
|
||||
validators=[FileExtensionValidator(allowed_extensions=DRAFT_UPLOAD_EXTENSIONS)],
|
||||
help_text='Optional: Bild oder PDF als Kontext',
|
||||
)
|
||||
article_text = models.TextField('Extrahierter Artikeltext', blank=True)
|
||||
|
||||
category_slug = models.SlugField(
|
||||
'Bestehende Kategorie (Slug)',
|
||||
max_length=220,
|
||||
blank=True,
|
||||
help_text='Slug einer vorhandenen Kategorie, falls passend',
|
||||
)
|
||||
new_category_name = models.CharField('Neue Kategorie', max_length=200, blank=True)
|
||||
new_category_description = models.TextField('Beschreibung neue Kategorie', blank=True)
|
||||
new_category_icon = models.CharField(
|
||||
'Icon neue Kategorie',
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half',
|
||||
)
|
||||
|
||||
title = models.CharField('Behauptung', max_length=300, blank=True)
|
||||
claim_status = models.CharField(
|
||||
'Einordnung',
|
||||
max_length=40,
|
||||
choices=Claim.Status.choices,
|
||||
blank=True,
|
||||
)
|
||||
short_answer = models.TextField('Kurzantwort', blank=True)
|
||||
detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
|
||||
evidence_level = models.CharField(
|
||||
'Evidenzlevel',
|
||||
max_length=20,
|
||||
choices=Claim.EvidenceLevel.choices,
|
||||
blank=True,
|
||||
)
|
||||
tags = models.JSONField('Schlagwörter', default=list, blank=True)
|
||||
sources = models.JSONField('Quellen', default=list, blank=True)
|
||||
counter_arguments = models.JSONField('Gegenargumente', default=list, blank=True)
|
||||
conversation = models.JSONField('Konversation', default=list, blank=True)
|
||||
ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True)
|
||||
error_message = models.TextField('Fehlermeldung', blank=True)
|
||||
published_claim = models.ForeignKey(
|
||||
Claim,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='origin_drafts',
|
||||
verbose_name='Veröffentlichte Behauptung',
|
||||
)
|
||||
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Fakten-Entwurf'
|
||||
verbose_name_plural = 'Fakten-Entwürfe'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title or f'Entwurf #{self.pk}'
|
||||
|
||||
def get_admin_review_url(self):
|
||||
return reverse('admin:claims_claimdraft_review', args=[self.pk])
|
||||
|
||||
def get_admin_assistant_url(self):
|
||||
return reverse('admin:claims_claimdraft_assistant')
|
||||
|
||||
def add_message(self, role, content):
|
||||
from django.utils import timezone
|
||||
|
||||
messages = list(self.conversation or [])
|
||||
messages.append({
|
||||
'role': role,
|
||||
'content': content,
|
||||
'at': timezone.now().isoformat(),
|
||||
})
|
||||
self.conversation = messages
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from claims.models import Category, Claim
|
||||
|
||||
VALID_CLAIM_STATUSES = {choice[0] for choice in Claim.Status.choices}
|
||||
VALID_EVIDENCE_LEVELS = {choice[0] for choice in Claim.EvidenceLevel.choices}
|
||||
|
||||
SYSTEM_PROMPT = """Du bist ein sachlicher Fakten-Assistent für die Plattform „Faktenkompass“.
|
||||
Deine Aufgabe: Aus Artikeln, Bildern und Nutzereingaben einen überprüfbaren Fakteneintrag erstellen.
|
||||
|
||||
Regeln:
|
||||
- Schreibe auf Deutsch, neutral und wissenschaftlich.
|
||||
- Keine parteipolitische Sprache, keine Übertreibungen.
|
||||
- Die Behauptung (title) formuliert eine häufig geäußerte Aussage, die überprüft wird.
|
||||
- Die Kurzantwort ist in ~20 Sekunden lesbar.
|
||||
- Quellen müssen echte, nachvollziehbare Referenzen sein (URL aus Eingabe bevorzugen).
|
||||
- Wähle eine passende bestehende Kategorie (use_existing_slug) ODER schlage eine neue vor.
|
||||
- Icons für neue Kategorien: Bootstrap Icons Klassen wie bi-thermometer-half, bi-virus, bi-lightning-charge.
|
||||
|
||||
Antworte ausschließlich als gültiges JSON mit dieser Struktur:
|
||||
{
|
||||
"category": {
|
||||
"use_existing_slug": "slug-oder-null",
|
||||
"new_category": {
|
||||
"name": "Name",
|
||||
"description": "Kurzbeschreibung",
|
||||
"icon": "bi-icon-name"
|
||||
}
|
||||
},
|
||||
"claim": {
|
||||
"title": "Die zu prüfende Behauptung",
|
||||
"status": "falsch|irrefuehrend|teilweise_richtig|unbelegt|wissenschaftlicher_konsens|offene_frage",
|
||||
"short_answer": "Kurze Einordnung",
|
||||
"detailed_explanation": "Ausführlichere Erklärung",
|
||||
"evidence_level": "sehr_stark|stark|mittel|schwach"
|
||||
},
|
||||
"tags": ["Schlagwort1", "Schlagwort2"],
|
||||
"sources": [
|
||||
{
|
||||
"title": "Titel",
|
||||
"organization": "Autor/Organisation",
|
||||
"url": "https://...",
|
||||
"description": "Kurzbeschreibung"
|
||||
}
|
||||
],
|
||||
"counter_arguments": [
|
||||
{
|
||||
"argument": "Häufiges Gegenargument",
|
||||
"response": "Sachliche Antwort"
|
||||
}
|
||||
],
|
||||
"assistant_summary": "Kurze Zusammenfassung für den Admin (1-2 Sätze)"
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class AIAssistantError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _categories_context():
|
||||
categories = Category.objects.all().values('name', 'slug', 'description')
|
||||
if not categories:
|
||||
return 'Keine Kategorien vorhanden – neue Kategorie vorschlagen.'
|
||||
lines = []
|
||||
for cat in categories:
|
||||
lines.append(f"- {cat['name']} (slug: {cat['slug']}): {cat['description'][:120]}")
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def _build_user_prompt(*, article_url='', article_text='', user_notes='', refinement=''):
|
||||
parts = [
|
||||
'Bestehende Kategorien:',
|
||||
_categories_context(),
|
||||
'',
|
||||
f'Artikel-URL: {article_url or "(keine)"}',
|
||||
'',
|
||||
'Artikeltext:',
|
||||
article_text or '(nicht verfügbar)',
|
||||
'',
|
||||
'Zusätzliche Hinweise des Admins:',
|
||||
user_notes or '(keine)',
|
||||
]
|
||||
if refinement:
|
||||
parts.extend(['', 'Überarbeitungswunsch:', refinement])
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _svg_to_png_bytes(svg_data):
|
||||
try:
|
||||
import cairosvg
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
try:
|
||||
return cairosvg.svg2png(bytestring=svg_data)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _svg_text_fallback(svg_data):
|
||||
text = svg_data.decode('utf-8', errors='replace')
|
||||
text = re.sub(r'<script[^>]*>.*?</script>', '', text, flags=re.IGNORECASE | re.DOTALL)
|
||||
return text[:4000]
|
||||
|
||||
|
||||
def _image_data_url(data, mime_type):
|
||||
encoded = base64.b64encode(data).decode('ascii')
|
||||
return {
|
||||
'type': 'image_url',
|
||||
'image_url': {'url': f'data:{mime_type};base64,{encoded}'},
|
||||
}
|
||||
|
||||
|
||||
def _image_message_part(uploaded_file):
|
||||
uploaded_file.open('rb')
|
||||
try:
|
||||
data = uploaded_file.read()
|
||||
finally:
|
||||
uploaded_file.close()
|
||||
|
||||
mime_type = mimetypes.guess_type(uploaded_file.name)[0]
|
||||
if mime_type == 'application/pdf' or uploaded_file.name.lower().endswith('.pdf'):
|
||||
return {
|
||||
'type': 'text',
|
||||
'text': '[PDF-Datei hochgeladen – nutze vor allem die Admin-Beschreibung und den Artikeltext.]',
|
||||
}
|
||||
|
||||
if mime_type == 'image/svg+xml' or uploaded_file.name.lower().endswith('.svg'):
|
||||
png_data = _svg_to_png_bytes(data)
|
||||
if png_data:
|
||||
return _image_data_url(png_data, 'image/png')
|
||||
return {
|
||||
'type': 'text',
|
||||
'text': (
|
||||
'[SVG-Grafik hochgeladen – OpenAI unterstützt SVG nicht direkt. '
|
||||
'Nutze diesen XML-Auszug und die Admin-Beschreibung:]\n'
|
||||
+ _svg_text_fallback(data)
|
||||
),
|
||||
}
|
||||
|
||||
mime_type = mime_type or 'image/jpeg'
|
||||
if mime_type not in {'image/png', 'image/jpeg', 'image/gif', 'image/webp'}:
|
||||
mime_type = 'image/jpeg'
|
||||
return _image_data_url(data, mime_type)
|
||||
|
||||
|
||||
def _call_openai(messages, *, has_image=False):
|
||||
api_key = getattr(settings, 'OPENAI_API_KEY', '')
|
||||
if not api_key:
|
||||
raise AIAssistantError(
|
||||
'OPENAI_API_KEY ist nicht gesetzt. Bitte in der Umgebung oder .env konfigurieren.'
|
||||
)
|
||||
|
||||
try:
|
||||
from openai import OpenAI
|
||||
except ImportError as exc:
|
||||
raise AIAssistantError('OpenAI-Paket fehlt. Bitte pip install -r requirements.txt ausführen.') from exc
|
||||
|
||||
model = settings.OPENAI_MODEL_VISION if has_image else settings.OPENAI_MODEL
|
||||
client = OpenAI(api_key=api_key, timeout=settings.OPENAI_TIMEOUT)
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
response_format={'type': 'json_object'},
|
||||
temperature=0.3,
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
if not content:
|
||||
raise AIAssistantError('Leere Antwort von der KI erhalten.')
|
||||
return json.loads(content)
|
||||
|
||||
|
||||
def _validate_payload(payload):
|
||||
claim = payload.get('claim') or {}
|
||||
status = claim.get('status', '')
|
||||
evidence = claim.get('evidence_level', '')
|
||||
if status and status not in VALID_CLAIM_STATUSES:
|
||||
raise AIAssistantError(f'Ungültiger Status von der KI: {status}')
|
||||
if evidence and evidence not in VALID_EVIDENCE_LEVELS:
|
||||
raise AIAssistantError(f'Ungültiges Evidenzlevel von der KI: {evidence}')
|
||||
return payload
|
||||
|
||||
|
||||
def generate_draft_payload(
|
||||
*,
|
||||
article_url='',
|
||||
article_text='',
|
||||
user_notes='',
|
||||
uploaded_file=None,
|
||||
refinement='',
|
||||
previous_payload=None,
|
||||
):
|
||||
user_prompt = _build_user_prompt(
|
||||
article_url=article_url,
|
||||
article_text=article_text,
|
||||
user_notes=user_notes,
|
||||
refinement=refinement,
|
||||
)
|
||||
if previous_payload:
|
||||
user_prompt += '\n\nBisheriger Entwurf (JSON):\n' + json.dumps(previous_payload, ensure_ascii=False)
|
||||
|
||||
user_content = [{'type': 'text', 'text': user_prompt}]
|
||||
has_image = False
|
||||
extra_text = ''
|
||||
if uploaded_file and uploaded_file.name:
|
||||
part = _image_message_part(uploaded_file)
|
||||
if part.get('type') == 'image_url':
|
||||
user_content.append(part)
|
||||
has_image = True
|
||||
elif part.get('type') == 'text':
|
||||
extra_text = part['text']
|
||||
|
||||
if extra_text:
|
||||
user_prompt = f'{user_prompt}\n\n{extra_text}'
|
||||
|
||||
if has_image:
|
||||
user_content[0] = {'type': 'text', 'text': user_prompt}
|
||||
user_message = user_content
|
||||
else:
|
||||
user_message = user_prompt
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': SYSTEM_PROMPT},
|
||||
{'role': 'user', 'content': user_message},
|
||||
]
|
||||
payload = _call_openai(messages, has_image=has_image)
|
||||
return _validate_payload(payload)
|
||||
|
||||
|
||||
def apply_payload_to_draft(draft, payload):
|
||||
category = payload.get('category') or {}
|
||||
claim = payload.get('claim') or {}
|
||||
|
||||
draft.category_slug = category.get('use_existing_slug') or ''
|
||||
new_category = category.get('new_category') or {}
|
||||
draft.new_category_name = new_category.get('name', '')
|
||||
draft.new_category_description = new_category.get('description', '')
|
||||
draft.new_category_icon = new_category.get('icon', '')
|
||||
|
||||
draft.title = claim.get('title', '')
|
||||
draft.claim_status = claim.get('status', Claim.Status.UNBELEGT)
|
||||
draft.short_answer = claim.get('short_answer', '')
|
||||
draft.detailed_explanation = claim.get('detailed_explanation', '')
|
||||
draft.evidence_level = claim.get('evidence_level', Claim.EvidenceLevel.MITTEL)
|
||||
draft.tags = payload.get('tags') or []
|
||||
draft.sources = payload.get('sources') or []
|
||||
draft.counter_arguments = payload.get('counter_arguments') or []
|
||||
draft.ai_raw_response = payload
|
||||
summary = payload.get('assistant_summary', 'Entwurf wurde erstellt.')
|
||||
draft.add_message('assistant', summary)
|
||||
@@ -0,0 +1,203 @@
|
||||
import ipaddress
|
||||
import re
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from django.conf import settings
|
||||
|
||||
BLOCKED_HOSTNAMES = {'localhost', '127.0.0.1', '0.0.0.0', '::1'}
|
||||
MAX_DOWNLOAD_BYTES = getattr(settings, 'ARTICLE_FETCH_MAX_BYTES', 2_000_000)
|
||||
TARGET_TEXT_CHARS = getattr(settings, 'ARTICLE_FETCH_TARGET_CHARS', 8_000)
|
||||
MAX_OUTPUT_CHARS = getattr(settings, 'ARTICLE_FETCH_OUTPUT_CHARS', 12_000)
|
||||
PARTIAL_PARSE_BYTES = getattr(settings, 'ARTICLE_FETCH_PARTIAL_BYTES', 96_000)
|
||||
FETCH_TIMEOUT = getattr(settings, 'ARTICLE_FETCH_TIMEOUT', 15)
|
||||
USER_AGENT = 'FaktenkompassBot/1.0 (+https://faktenkompass.local)'
|
||||
|
||||
REMOVE_TAGS = {
|
||||
'script', 'style', 'nav', 'footer', 'header', 'aside', 'noscript', 'iframe',
|
||||
'svg', 'form', 'button', 'input', 'select', 'textarea', 'menu', 'dialog',
|
||||
'figure', 'picture', 'video', 'audio', 'canvas', 'embed', 'object',
|
||||
}
|
||||
|
||||
NOISE_PATTERN = re.compile(
|
||||
r'comment|sidebar|related|newsletter|cookie|banner|social|share|widget|'
|
||||
r'menu|breadcrumb|promo|advert|tracking|popup|modal|consent|paywall|'
|
||||
r'recommend|trending|tag-cloud|author-box|meta-|sharing|outbrain|taboola',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
MIN_PARAGRAPH_LENGTH = 40
|
||||
|
||||
|
||||
class ArticleFetchError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _hostname_resolves_to_private_ip(hostname):
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise ArticleFetchError(f'Domain konnte nicht aufgelöst werden: {hostname}') from exc
|
||||
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_reserved
|
||||
or ip.is_multicast
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def validate_article_url(url):
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in {'http', 'https'}:
|
||||
raise ArticleFetchError('Nur http- und https-URLs sind erlaubt.')
|
||||
if not parsed.netloc:
|
||||
raise ArticleFetchError('Ungültige URL.')
|
||||
hostname = parsed.hostname or ''
|
||||
if hostname.lower() in BLOCKED_HOSTNAMES:
|
||||
raise ArticleFetchError('Diese URL ist nicht erlaubt.')
|
||||
if _hostname_resolves_to_private_ip(hostname):
|
||||
raise ArticleFetchError('Interne oder private URLs sind aus Sicherheitsgründen blockiert.')
|
||||
|
||||
|
||||
def _remove_noise_elements(soup):
|
||||
for tag_name in REMOVE_TAGS:
|
||||
for tag in soup.find_all(tag_name):
|
||||
tag.decompose()
|
||||
|
||||
to_remove = []
|
||||
for element in soup.find_all(True):
|
||||
element_attrs = getattr(element, 'attrs', None) or {}
|
||||
|
||||
role = element_attrs.get('role')
|
||||
if role in {'navigation', 'banner', 'complementary', 'contentinfo'}:
|
||||
to_remove.append(element)
|
||||
continue
|
||||
|
||||
class_val = element_attrs.get('class', [])
|
||||
if isinstance(class_val, list):
|
||||
class_str = ' '.join(class_val)
|
||||
else:
|
||||
class_str = str(class_val or '')
|
||||
id_str = str(element_attrs.get('id', '') or '')
|
||||
combined = f'{class_str} {id_str}'.strip()
|
||||
if combined and NOISE_PATTERN.search(combined):
|
||||
to_remove.append(element)
|
||||
|
||||
for element in to_remove:
|
||||
element.decompose()
|
||||
|
||||
|
||||
def _find_content_root(soup):
|
||||
for selector in (
|
||||
('article', {}),
|
||||
('main', {}),
|
||||
('div', {'role': 'main'}),
|
||||
('div', {'class': re.compile(r'article|content|post|entry|story', re.I)}),
|
||||
):
|
||||
tag, attrs = selector
|
||||
match = soup.find(tag, attrs=attrs) if attrs else soup.find(tag)
|
||||
if match:
|
||||
return match
|
||||
return soup.body or soup
|
||||
|
||||
|
||||
def _collect_paragraphs(root):
|
||||
paragraphs = []
|
||||
seen = set()
|
||||
for element in root.find_all(['h1', 'h2', 'h3', 'p', 'li', 'blockquote']):
|
||||
text = element.get_text(' ', strip=True)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
if len(text) < MIN_PARAGRAPH_LENGTH:
|
||||
continue
|
||||
key = text.lower()
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
paragraphs.append(text)
|
||||
return paragraphs
|
||||
|
||||
|
||||
def _extract_readable_text(html):
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
_remove_noise_elements(soup)
|
||||
root = _find_content_root(soup)
|
||||
paragraphs = _collect_paragraphs(root)
|
||||
|
||||
if not paragraphs:
|
||||
fallback = root.get_text('\n', strip=True)
|
||||
paragraphs = []
|
||||
for line in fallback.splitlines():
|
||||
line = re.sub(r'\s+', ' ', line.strip())
|
||||
if len(line) >= MIN_PARAGRAPH_LENGTH:
|
||||
paragraphs.append(line)
|
||||
|
||||
content = '\n\n'.join(paragraphs)
|
||||
content = re.sub(r'\n{3,}', '\n\n', content)
|
||||
return content.strip()
|
||||
|
||||
|
||||
def _finalize_text(text, *, partial=False):
|
||||
if len(text) < 100:
|
||||
raise ArticleFetchError('Aus dem Artikel konnte kein ausreichender Text extrahiert werden.')
|
||||
trimmed = text[:MAX_OUTPUT_CHARS]
|
||||
if partial and len(text) > MAX_OUTPUT_CHARS:
|
||||
trimmed += '\n\n[… Text gekürzt …]'
|
||||
return trimmed
|
||||
|
||||
|
||||
def _decode_html(chunks, encoding):
|
||||
return b''.join(chunks).decode(encoding or 'utf-8', errors='replace')
|
||||
|
||||
|
||||
def fetch_article_text(url):
|
||||
validate_article_url(url)
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=FETCH_TIMEOUT,
|
||||
headers={'User-Agent': USER_AGENT},
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise ArticleFetchError(f'Artikel konnte nicht geladen werden: {exc}') from exc
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise ArticleFetchError(f'Artikel nicht erreichbar (HTTP {response.status_code}).')
|
||||
|
||||
final_host = urlparse(response.url).hostname or ''
|
||||
if _hostname_resolves_to_private_ip(final_host):
|
||||
raise ArticleFetchError('Weiterleitung auf eine blockierte URL.')
|
||||
|
||||
chunks = []
|
||||
size = 0
|
||||
encoding = response.encoding
|
||||
truncated_download = False
|
||||
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if not chunk:
|
||||
continue
|
||||
size += len(chunk)
|
||||
if size > MAX_DOWNLOAD_BYTES:
|
||||
truncated_download = True
|
||||
break
|
||||
chunks.append(chunk)
|
||||
|
||||
if size >= PARTIAL_PARSE_BYTES and size % PARTIAL_PARSE_BYTES < 8192:
|
||||
text = _extract_readable_text(_decode_html(chunks, encoding))
|
||||
if len(text) >= TARGET_TEXT_CHARS:
|
||||
return _finalize_text(text, partial=True)
|
||||
|
||||
if not chunks:
|
||||
raise ArticleFetchError('Artikel konnte nicht geladen werden.')
|
||||
|
||||
text = _extract_readable_text(_decode_html(chunks, encoding))
|
||||
return _finalize_text(text, partial=truncated_download)
|
||||
@@ -0,0 +1,52 @@
|
||||
from claims.models import ClaimDraft
|
||||
from claims.services.ai_assistant import (
|
||||
AIAssistantError,
|
||||
apply_payload_to_draft,
|
||||
generate_draft_payload,
|
||||
)
|
||||
from claims.services.article_fetcher import ArticleFetchError, fetch_article_text
|
||||
|
||||
|
||||
def process_claim_draft(draft, *, refinement=''):
|
||||
draft.error_message = ''
|
||||
draft.status = ClaimDraft.DraftStatus.PROCESSING
|
||||
draft.save(update_fields=['error_message', 'status', 'updated_at'])
|
||||
|
||||
user_message_parts = []
|
||||
if draft.article_url:
|
||||
user_message_parts.append(f'Link: {draft.article_url}')
|
||||
if draft.user_notes:
|
||||
user_message_parts.append(draft.user_notes)
|
||||
if draft.uploaded_file:
|
||||
user_message_parts.append(f'Datei: {draft.uploaded_file.name}')
|
||||
if refinement:
|
||||
user_message_parts.append(f'Überarbeitung: {refinement}')
|
||||
|
||||
draft.add_message('user', '\n'.join(user_message_parts) or 'Neuer Entwurf')
|
||||
draft.save(update_fields=['conversation', 'updated_at'])
|
||||
|
||||
try:
|
||||
article_text = draft.article_text
|
||||
if draft.article_url and not refinement:
|
||||
article_text = fetch_article_text(draft.article_url)
|
||||
draft.article_text = article_text
|
||||
draft.save(update_fields=['article_text', 'updated_at'])
|
||||
|
||||
payload = generate_draft_payload(
|
||||
article_url=draft.article_url,
|
||||
article_text=article_text,
|
||||
user_notes=draft.user_notes,
|
||||
uploaded_file=draft.uploaded_file if draft.uploaded_file else None,
|
||||
refinement=refinement,
|
||||
previous_payload=draft.ai_raw_response if refinement else None,
|
||||
)
|
||||
apply_payload_to_draft(draft, payload)
|
||||
draft.status = ClaimDraft.DraftStatus.REVIEW
|
||||
draft.save()
|
||||
return draft
|
||||
except (ArticleFetchError, AIAssistantError) as exc:
|
||||
draft.status = ClaimDraft.DraftStatus.FAILED
|
||||
draft.error_message = str(exc)
|
||||
draft.add_message('assistant', f'Fehler: {exc}')
|
||||
draft.save()
|
||||
raise
|
||||
@@ -0,0 +1,103 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.text import slugify
|
||||
|
||||
from claims.models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
|
||||
|
||||
|
||||
class DraftPublishError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_category(draft):
|
||||
if draft.category_slug:
|
||||
category = Category.objects.filter(slug=draft.category_slug).first()
|
||||
if category:
|
||||
return category
|
||||
|
||||
if draft.new_category_name:
|
||||
category, _ = Category.objects.get_or_create(
|
||||
slug=slugify(draft.new_category_name),
|
||||
defaults={
|
||||
'name': draft.new_category_name,
|
||||
'description': draft.new_category_description or draft.new_category_name,
|
||||
'icon': draft.new_category_icon or 'bi-book',
|
||||
},
|
||||
)
|
||||
return category
|
||||
|
||||
raise DraftPublishError('Bitte eine Kategorie auswählen oder eine neue angeben.')
|
||||
|
||||
|
||||
def _get_or_create_tags(tag_names):
|
||||
tags = []
|
||||
for name in tag_names or []:
|
||||
clean = str(name).strip()
|
||||
if not clean:
|
||||
continue
|
||||
tag, _ = Tag.objects.get_or_create(name=clean, defaults={'slug': slugify(clean)})
|
||||
tags.append(tag)
|
||||
return tags
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def publish_draft(draft):
|
||||
if draft.status == draft.DraftStatus.PUBLISHED and draft.published_claim_id:
|
||||
return draft.published_claim
|
||||
|
||||
if not draft.title or not draft.short_answer:
|
||||
raise DraftPublishError('Titel und Kurzantwort sind erforderlich.')
|
||||
|
||||
category = _resolve_category(draft)
|
||||
claim = Claim.objects.create(
|
||||
category=category,
|
||||
title=draft.title,
|
||||
status=draft.claim_status or Claim.Status.UNBELEGT,
|
||||
short_answer=draft.short_answer,
|
||||
detailed_explanation=draft.detailed_explanation,
|
||||
evidence_level=draft.evidence_level or Claim.EvidenceLevel.MITTEL,
|
||||
)
|
||||
claim.tags.set(_get_or_create_tags(draft.tags))
|
||||
|
||||
for source in draft.sources or []:
|
||||
url = (source.get('url') or '').strip()
|
||||
if not url:
|
||||
continue
|
||||
Source.objects.create(
|
||||
claim=claim,
|
||||
title=source.get('title') or url,
|
||||
organization=source.get('organization') or 'Unbekannt',
|
||||
url=url,
|
||||
description=source.get('description', ''),
|
||||
)
|
||||
|
||||
for counter in draft.counter_arguments or []:
|
||||
argument = (counter.get('argument') or '').strip()
|
||||
response = (counter.get('response') or '').strip()
|
||||
if argument and response:
|
||||
CounterArgument.objects.create(claim=claim, argument=argument, response=response)
|
||||
|
||||
if draft.uploaded_file:
|
||||
_attach_uploaded_file(draft, claim)
|
||||
|
||||
draft.published_claim = claim
|
||||
draft.status = draft.DraftStatus.PUBLISHED
|
||||
draft.save(update_fields=['published_claim', 'status', 'updated_at'])
|
||||
return claim
|
||||
|
||||
|
||||
def _attach_uploaded_file(draft, claim):
|
||||
if not draft.uploaded_file:
|
||||
return
|
||||
|
||||
original_name = os.path.basename(draft.uploaded_file.name)
|
||||
ext = original_name.rsplit('.', 1)[-1].lower() if '.' in original_name else ''
|
||||
if ext not in {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'}:
|
||||
return
|
||||
|
||||
evidence = EvidenceFile(claim=claim, title='')
|
||||
short_name = f'{uuid.uuid4().hex[:12]}.{ext}'
|
||||
with draft.uploaded_file.open('rb') as source_file:
|
||||
evidence.file.save(short_name, source_file, save=True)
|
||||
@@ -13,3 +13,13 @@ def status_label(value):
|
||||
def evidence_label(value):
|
||||
from claims.models import Claim
|
||||
return dict(Claim.EvidenceLevel.choices).get(value, value)
|
||||
|
||||
|
||||
@register.inclusion_tag('claims/partials/status_badge.html')
|
||||
def status_badge(claim):
|
||||
return {'claim': claim}
|
||||
|
||||
|
||||
@register.inclusion_tag('claims/partials/evidence_badge.html')
|
||||
def evidence_badge(claim):
|
||||
return {'claim': claim}
|
||||
|
||||
+76
-2
@@ -1,3 +1,77 @@
|
||||
from django.test import TestCase
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
# Create your tests here.
|
||||
from claims.services.ai_assistant import _image_message_part, _svg_to_png_bytes
|
||||
from claims.services.article_fetcher import (
|
||||
ArticleFetchError,
|
||||
_extract_readable_text,
|
||||
validate_article_url,
|
||||
)
|
||||
|
||||
|
||||
class ArticleFetcherSecurityTests(SimpleTestCase):
|
||||
def test_blocks_file_scheme(self):
|
||||
with self.assertRaises(ArticleFetchError):
|
||||
validate_article_url('file:///etc/passwd')
|
||||
|
||||
def test_blocks_localhost(self):
|
||||
with self.assertRaises(ArticleFetchError):
|
||||
validate_article_url('http://localhost/article')
|
||||
|
||||
def test_allows_https_url(self):
|
||||
validate_article_url('https://example.com/artikel')
|
||||
|
||||
|
||||
class ArticleFetcherExtractionTests(SimpleTestCase):
|
||||
def test_strips_navigation_and_ads(self):
|
||||
html = """
|
||||
<html><body>
|
||||
<nav>Navigation mit vielen Links die ignoriert werden sollen</nav>
|
||||
<div class="sidebar-promo">Werbung für ein tolles Angebot heute nur hier</div>
|
||||
<article>
|
||||
<h1>Echter Artikeltitel über Klimawandel und Energiepolitik</h1>
|
||||
<p>Dies ist der eigentliche Artikeltext mit genügend Inhalt für die Extraktion.</p>
|
||||
<p>Ein zweiter Absatz mit relevanten Fakten zur Erderwärmung und Treibhausgasen.</p>
|
||||
</article>
|
||||
<footer>Impressum und Datenschutz und Cookie-Hinweise überall</footer>
|
||||
</body></html>
|
||||
"""
|
||||
text = _extract_readable_text(html)
|
||||
self.assertIn('Klimawandel', text)
|
||||
self.assertNotIn('Navigation mit vielen', text)
|
||||
self.assertNotIn('Werbung für ein tolles', text)
|
||||
self.assertNotIn('Impressum', text)
|
||||
|
||||
def test_handles_tags_without_attrs(self):
|
||||
html = """
|
||||
<html><body>
|
||||
<article>
|
||||
<h1>Überschrift mit genügend Zeichen für den Parser hier</h1>
|
||||
<p>Absatz mit ausreichend Inhalt damit die Extraktion erfolgreich klappt.</p>
|
||||
</article>
|
||||
</body></html>
|
||||
"""
|
||||
text = _extract_readable_text(html)
|
||||
self.assertIn('Überschrift', text)
|
||||
|
||||
|
||||
class AIAssistantUploadTests(SimpleTestCase):
|
||||
def test_svg_converts_to_png_for_openai(self):
|
||||
svg = b'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
|
||||
<rect width="100" height="100" fill="teal"/>
|
||||
<text x="10" y="50">Test</text>
|
||||
</svg>'''
|
||||
png = _svg_to_png_bytes(svg)
|
||||
self.assertIsNotNone(png)
|
||||
self.assertTrue(png.startswith(b'\x89PNG'))
|
||||
|
||||
def test_svg_message_part_uses_png_data_url(self):
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
|
||||
svg = b'<svg xmlns="http://www.w3.org/2000/svg"><text y="20">Faktencheck</text></svg>'
|
||||
upload = SimpleUploadedFile('chart.svg', svg, content_type='image/svg+xml')
|
||||
part = _image_message_part(upload)
|
||||
if part['type'] == 'image_url':
|
||||
self.assertIn('image/png', part['image_url']['url'])
|
||||
else:
|
||||
self.assertIn('SVG-Grafik', part['text'])
|
||||
|
||||
@@ -4,6 +4,10 @@ from django.shortcuts import get_object_or_404, render
|
||||
from .models import Category, Claim
|
||||
|
||||
|
||||
def _is_htmx_partial(request):
|
||||
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||
|
||||
|
||||
def home(request):
|
||||
query = request.GET.get('q', '').strip()
|
||||
categories = Category.objects.all()
|
||||
@@ -18,6 +22,8 @@ def home(request):
|
||||
'query': query,
|
||||
'total_claims': Claim.objects.count(),
|
||||
}
|
||||
if _is_htmx_partial(request):
|
||||
return render(request, 'claims/partials/category_list.html', context)
|
||||
return render(request, 'claims/home.html', context)
|
||||
|
||||
|
||||
@@ -45,6 +51,8 @@ def category_detail(request, slug):
|
||||
'status': status,
|
||||
'status_choices': Claim.Status.choices,
|
||||
}
|
||||
if _is_htmx_partial(request):
|
||||
return render(request, 'claims/partials/claim_list.html', context)
|
||||
return render(request, 'claims/category_detail.html', context)
|
||||
|
||||
|
||||
@@ -99,4 +107,6 @@ def search(request):
|
||||
'status_choices': Claim.Status.choices,
|
||||
'result_count': claims.count() if query or category_slug or status else 0,
|
||||
}
|
||||
if _is_htmx_partial(request):
|
||||
return render(request, 'claims/partials/search_results.html', context)
|
||||
return render(request, 'claims/search.html', context)
|
||||
|
||||
Reference in New Issue
Block a user