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:
@@ -0,0 +1,6 @@
|
|||||||
|
# OpenAI API-Schlüssel für den Fakten-Assistenten im Admin
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
|
||||||
|
# Optional: Modelle anpassen
|
||||||
|
# OPENAI_MODEL=gpt-4o-mini
|
||||||
|
# OPENAI_MODEL_VISION=gpt-4o
|
||||||
@@ -80,6 +80,163 @@ export DB_PORT=5432
|
|||||||
|
|
||||||
Installieren Sie zusätzlich `psycopg2-binary` und führen Sie `migrate` erneut aus.
|
Installieren Sie zusätzlich `psycopg2-binary` und führen Sie `migrate` erneut aus.
|
||||||
|
|
||||||
|
## Produktion (Daphne + Nginx)
|
||||||
|
|
||||||
|
Für den Serverbetrieb wird [Daphne](https://github.com/django/daphne) als ASGI-Server empfohlen. Nginx dient als Reverse Proxy und liefert statische Dateien sowie Uploads aus.
|
||||||
|
|
||||||
|
### 1. Umgebungsvariablen
|
||||||
|
|
||||||
|
Legen Sie z. B. `/etc/faktenkompass.env` an:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DJANGO_SETTINGS_MODULE=faktenkompass.settings
|
||||||
|
DJANGO_DEBUG=False
|
||||||
|
DJANGO_SECRET_KEY=lange-zufaellige-zeichenkette
|
||||||
|
ALLOWED_HOSTS=faktenkompass.example.com,www.faktenkompass.example.com
|
||||||
|
|
||||||
|
DB_ENGINE=django.db.backends.postgresql
|
||||||
|
DB_NAME=faktenkompass
|
||||||
|
DB_USER=faktenkompass
|
||||||
|
DB_PASSWORD=geheim
|
||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional in `faktenkompass/settings.py` für HTTPS hinter Nginx ergänzen:
|
||||||
|
|
||||||
|
```python
|
||||||
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||||
|
CSRF_TRUSTED_ORIGINS = ['https://faktenkompass.example.com']
|
||||||
|
```
|
||||||
|
|
||||||
|
`SECRET_KEY`, `DEBUG` und `ALLOWED_HOSTS` sollten in der Produktion aus Umgebungsvariablen gelesen werden.
|
||||||
|
|
||||||
|
### 2. Deployment vorbereiten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /pfad/zu/ArgumentAtlas
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
python manage.py createsuperuser # falls noch nicht vorhanden
|
||||||
|
```
|
||||||
|
|
||||||
|
Stellen Sie sicher, dass der Prozessnutzer Schreibrechte für `media/` und die Datenbank hat.
|
||||||
|
|
||||||
|
### 3. Daphne manuell testen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a && source /etc/faktenkompass.env && set +a
|
||||||
|
daphne -b 127.0.0.1 -p 8001 faktenkompass.asgi:application
|
||||||
|
```
|
||||||
|
|
||||||
|
Die App lauscht dann nur lokal auf Port `8001` – der öffentliche Zugriff erfolgt über Nginx.
|
||||||
|
|
||||||
|
### 4. systemd-Dienst
|
||||||
|
|
||||||
|
Datei `/etc/systemd/system/faktenkompass.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Faktenkompass (Daphne ASGI)
|
||||||
|
After=network.target postgresql.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
Group=www-data
|
||||||
|
WorkingDirectory=/pfad/zu/ArgumentAtlas
|
||||||
|
EnvironmentFile=/etc/faktenkompass.env
|
||||||
|
ExecStart=/pfad/zu/ArgumentAtlas/.venv/bin/daphne \
|
||||||
|
-b 127.0.0.1 \
|
||||||
|
-p 8001 \
|
||||||
|
--access-log /var/log/faktenkompass/access.log \
|
||||||
|
--proxy-headers \
|
||||||
|
faktenkompass.asgi:application
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Log-Verzeichnis anlegen und Dienst aktivieren:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /var/log/faktenkompass
|
||||||
|
sudo chown www-data:www-data /var/log/faktenkompass
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now faktenkompass
|
||||||
|
sudo systemctl status faktenkompass
|
||||||
|
```
|
||||||
|
|
||||||
|
`--proxy-headers` setzt voraus, dass Nginx die Header `X-Forwarded-For`, `X-Forwarded-Proto` und `Host` korrekt weitergibt.
|
||||||
|
|
||||||
|
### 5. Nginx-Konfiguration
|
||||||
|
|
||||||
|
Datei `/etc/nginx/sites-available/faktenkompass`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
upstream faktenkompass {
|
||||||
|
server 127.0.0.1:8001;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name faktenkompass.example.com;
|
||||||
|
|
||||||
|
client_max_body_size 25M;
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /pfad/zu/ArgumentAtlas/staticfiles/;
|
||||||
|
expires 30d;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /pfad/zu/ArgumentAtlas/media/;
|
||||||
|
expires 7d;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://faktenkompass;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_redirect off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Aktivieren und testen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/faktenkompass /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
Für HTTPS: Certbot/Let's Encrypt verwenden (`certbot --nginx`).
|
||||||
|
|
||||||
|
### 6. Updates einspielen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /pfad/zu/ArgumentAtlas
|
||||||
|
source .venv/bin/activate
|
||||||
|
git pull
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py collectstatic --noinput
|
||||||
|
sudo systemctl restart faktenkompass
|
||||||
|
```
|
||||||
|
|
||||||
## Projektstruktur
|
## Projektstruktur
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -123,6 +280,32 @@ Optional: Titel, Bildunterschrift und Reihenfolge. Hochgeladene Dateien erschein
|
|||||||
|
|
||||||
Dateien werden unter `media/evidence/` gespeichert. In der Produktion muss der Webserver Medien-Dateien ausliefern (z. B. über Nginx).
|
Dateien werden unter `media/evidence/` gespeichert. In der Produktion muss der Webserver Medien-Dateien ausliefern (z. B. über Nginx).
|
||||||
|
|
||||||
|
### Fakten-Assistent (KI im Admin)
|
||||||
|
|
||||||
|
Der Assistent hilft beim Anlegen neuer Behauptungen per ChatGPT. **Nichts wird automatisch veröffentlicht** – jeder Entwurf muss zuerst geprüft werden.
|
||||||
|
|
||||||
|
**Einrichtung:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OPENAI_API_KEY=sk-...
|
||||||
|
# optional: OPENAI_MODEL=gpt-4o-mini, OPENAI_MODEL_VISION=gpt-4o
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nutzung:**
|
||||||
|
|
||||||
|
1. Admin öffnen → **Fakten-Assistent** (Startseite oder unter Fakten-Entwürfe)
|
||||||
|
2. Artikel-URL, Beschreibung und/oder Bild/PDF eingeben
|
||||||
|
3. **Entwurf generieren** – die KI schlägt Kategorie, Behauptung, Quellen und Gegenargumente vor
|
||||||
|
4. Entwurf prüfen, bearbeiten, ggf. **KI erneut fragen**
|
||||||
|
5. Erst mit **Veröffentlichen** wird der Eintrag live geschaltet
|
||||||
|
|
||||||
|
**Sicherheit:**
|
||||||
|
|
||||||
|
- Nur für eingeloggte Staff-Nutzer
|
||||||
|
- Artikel-URLs: SSRF-Schutz (keine internen/privaten Adressen)
|
||||||
|
- API-Schlüssel nur serverseitig (Umgebungsvariable), nie im Frontend
|
||||||
|
- Hochgeladene Dateien werden erst nach Freigabe als Nachweis verknüpft
|
||||||
|
|
||||||
## Lizenz
|
## Lizenz
|
||||||
|
|
||||||
Projekt für den internen Gebrauch / Bildungszwecke.
|
Projekt für den internen Gebrauch / Bildungszwecke.
|
||||||
|
|||||||
+165
-17
@@ -1,36 +1,101 @@
|
|||||||
from django.contrib import admin
|
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
|
model = EvidenceFile
|
||||||
|
form = EvidenceFileInlineForm
|
||||||
extra = 1
|
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
|
model = Source
|
||||||
|
form = SourceInlineForm
|
||||||
extra = 1
|
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
|
model = CounterArgument
|
||||||
|
form = CounterArgumentInlineForm
|
||||||
extra = 1
|
extra = 1
|
||||||
|
fields = ('argument', 'response')
|
||||||
|
classes = ('collapse-open',)
|
||||||
|
verbose_name = 'Gegenargument'
|
||||||
|
verbose_name_plural = 'Gegenargumente'
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Tag)
|
@admin.register(Tag)
|
||||||
class TagAdmin(admin.ModelAdmin):
|
class TagAdmin(FaktenAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'slug')
|
list_display = ('name', 'slug')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
search_fields = ('name',)
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Category)
|
@admin.register(Category)
|
||||||
class CategoryAdmin(admin.ModelAdmin):
|
class CategoryAdmin(FaktenAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('name', 'slug', 'claim_count', 'created_at')
|
list_display = ('name', 'slug', 'claim_count', 'created_at')
|
||||||
prepopulated_fields = {'slug': ('name',)}
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
search_fields = ('name', 'description')
|
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')
|
@admin.display(description='Anzahl Behauptungen')
|
||||||
def claim_count(self, obj):
|
def claim_count(self, obj):
|
||||||
@@ -38,43 +103,81 @@ class CategoryAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
@admin.register(Claim)
|
@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 = ('title', 'category', 'status', 'evidence_level', 'updated_at')
|
||||||
|
list_display_links = ('title',)
|
||||||
list_filter = ('category', 'status', 'evidence_level', 'tags')
|
list_filter = ('category', 'status', 'evidence_level', 'tags')
|
||||||
search_fields = ('title', 'short_answer', 'detailed_explanation')
|
search_fields = ('title', 'short_answer', 'detailed_explanation')
|
||||||
prepopulated_fields = {'slug': ('title',)}
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
filter_horizontal = ('tags',)
|
filter_horizontal = ('tags',)
|
||||||
|
autocomplete_fields = ('category',)
|
||||||
|
save_on_top = True
|
||||||
|
date_hierarchy = 'updated_at'
|
||||||
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
|
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
|
||||||
|
readonly_fields = ('public_link', 'created_at', 'updated_at')
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
('Einordnung', {
|
||||||
'fields': ('category', 'title', 'slug', 'status', 'evidence_level', 'tags'),
|
'fields': ('category', 'status', 'evidence_level', 'tags'),
|
||||||
}),
|
}),
|
||||||
('Inhalt', {
|
('Behauptung', {
|
||||||
|
'fields': ('title', 'slug', 'public_link'),
|
||||||
|
}),
|
||||||
|
('Texte', {
|
||||||
'fields': ('short_answer', 'detailed_explanation'),
|
'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)
|
@admin.register(EvidenceFile)
|
||||||
class EvidenceFileAdmin(admin.ModelAdmin):
|
class EvidenceFileAdmin(FaktenAdminMixin, admin.ModelAdmin):
|
||||||
list_display = ('display_title', 'claim', 'file_extension_display', 'order', 'created_at')
|
list_display = ('preview_thumb', 'display_title', 'claim', 'order', 'created_at')
|
||||||
list_filter = ('claim__category',)
|
list_filter = ('claim__category',)
|
||||||
search_fields = ('title', 'caption', 'claim__title')
|
search_fields = ('title', 'caption', 'claim__title')
|
||||||
|
readonly_fields = ('preview',)
|
||||||
|
fields = ('claim', 'preview', 'file', 'title', 'caption', 'order')
|
||||||
|
|
||||||
@admin.display(description='Typ')
|
@admin.display(description='')
|
||||||
def file_extension_display(self, obj):
|
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()
|
return obj.file_extension.upper()
|
||||||
|
|
||||||
|
@admin.display(description='Vorschau')
|
||||||
|
def preview(self, obj):
|
||||||
|
return EvidenceFileInline.preview(self, obj)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Source)
|
@admin.register(Source)
|
||||||
class SourceAdmin(admin.ModelAdmin):
|
class SourceAdmin(FaktenAdminMixin, admin.ModelAdmin):
|
||||||
|
form = SourceInlineForm
|
||||||
list_display = ('title', 'organization', 'claim')
|
list_display = ('title', 'organization', 'claim')
|
||||||
list_filter = ('claim__category',)
|
list_filter = ('claim__category',)
|
||||||
search_fields = ('title', 'organization', 'description')
|
search_fields = ('title', 'organization', 'description')
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CounterArgument)
|
@admin.register(CounterArgument)
|
||||||
class CounterArgumentAdmin(admin.ModelAdmin):
|
class CounterArgumentAdmin(FaktenAdminMixin, admin.ModelAdmin):
|
||||||
|
form = CounterArgumentInlineForm
|
||||||
list_display = ('argument_preview', 'claim')
|
list_display = ('argument_preview', 'claim')
|
||||||
list_filter = ('claim__category',)
|
list_filter = ('claim__category',)
|
||||||
search_fields = ('argument', 'response')
|
search_fields = ('argument', 'response')
|
||||||
@@ -82,3 +185,48 @@ class CounterArgumentAdmin(admin.ModelAdmin):
|
|||||||
@admin.display(description='Gegenargument')
|
@admin.display(description='Gegenargument')
|
||||||
def argument_preview(self, obj):
|
def argument_preview(self, obj):
|
||||||
return obj.argument[:80]
|
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.core.validators import FileExtensionValidator
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
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']
|
EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
|
||||||
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
|
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
|
||||||
|
DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
|
||||||
|
|
||||||
|
|
||||||
class Tag(models.Model):
|
class Tag(models.Model):
|
||||||
@@ -143,7 +148,11 @@ class Claim(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
def evidence_upload_path(instance, filename):
|
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):
|
class EvidenceFile(models.Model):
|
||||||
@@ -157,6 +166,7 @@ class EvidenceFile(models.Model):
|
|||||||
file = models.FileField(
|
file = models.FileField(
|
||||||
'Datei',
|
'Datei',
|
||||||
upload_to=evidence_upload_path,
|
upload_to=evidence_upload_path,
|
||||||
|
max_length=500,
|
||||||
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
|
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
|
||||||
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
|
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
|
||||||
)
|
)
|
||||||
@@ -190,6 +200,10 @@ class EvidenceFile(models.Model):
|
|||||||
def is_pdf(self):
|
def is_pdf(self):
|
||||||
return self.file_extension == 'pdf'
|
return self.file_extension == 'pdf'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def image_alt(self):
|
||||||
|
return self.caption or 'Nachweis-Grafik'
|
||||||
|
|
||||||
|
|
||||||
class Source(models.Model):
|
class Source(models.Model):
|
||||||
claim = models.ForeignKey(
|
claim = models.ForeignKey(
|
||||||
@@ -229,3 +243,112 @@ class CounterArgument(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.argument[:80]
|
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):
|
def evidence_label(value):
|
||||||
from claims.models import Claim
|
from claims.models import Claim
|
||||||
return dict(Claim.EvidenceLevel.choices).get(value, value)
|
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
|
from .models import Category, Claim
|
||||||
|
|
||||||
|
|
||||||
|
def _is_htmx_partial(request):
|
||||||
|
return request.headers.get('X-Requested-With') == 'XMLHttpRequest'
|
||||||
|
|
||||||
|
|
||||||
def home(request):
|
def home(request):
|
||||||
query = request.GET.get('q', '').strip()
|
query = request.GET.get('q', '').strip()
|
||||||
categories = Category.objects.all()
|
categories = Category.objects.all()
|
||||||
@@ -18,6 +22,8 @@ def home(request):
|
|||||||
'query': query,
|
'query': query,
|
||||||
'total_claims': Claim.objects.count(),
|
'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)
|
return render(request, 'claims/home.html', context)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,6 +51,8 @@ def category_detail(request, slug):
|
|||||||
'status': status,
|
'status': status,
|
||||||
'status_choices': Claim.Status.choices,
|
'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)
|
return render(request, 'claims/category_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
@@ -99,4 +107,6 @@ def search(request):
|
|||||||
'status_choices': Claim.Status.choices,
|
'status_choices': Claim.Status.choices,
|
||||||
'result_count': claims.count() if query or category_slug or status else 0,
|
'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)
|
return render(request, 'claims/search.html', context)
|
||||||
|
|||||||
@@ -21,12 +21,19 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = 'django-insecure-^&jd*%g0yb!yj#do6!pt!xqftz%m9zuzc6g@o0i&dji5cy7!qw'
|
SECRET_KEY = os.environ.get(
|
||||||
|
'DJANGO_SECRET_KEY',
|
||||||
|
'django-insecure-^&jd*%g0yb!yj#do6!pt!xqftz%m9zuzc6g@o0i&dji5cy7!qw',
|
||||||
|
)
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = os.environ.get('DJANGO_DEBUG', 'True').lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = [
|
||||||
|
host.strip()
|
||||||
|
for host in os.environ.get('ALLOWED_HOSTS', '').split(',')
|
||||||
|
if host.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
@@ -122,10 +129,22 @@ USE_TZ = True
|
|||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = 'static/'
|
||||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
MEDIA_URL = 'media/'
|
MEDIA_URL = 'media/'
|
||||||
MEDIA_ROOT = BASE_DIR / 'media'
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# OpenAI / Fakten-Assistent
|
||||||
|
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY', '')
|
||||||
|
OPENAI_MODEL = os.environ.get('OPENAI_MODEL', 'gpt-4o-mini')
|
||||||
|
OPENAI_MODEL_VISION = os.environ.get('OPENAI_MODEL_VISION', 'gpt-4o')
|
||||||
|
OPENAI_TIMEOUT = int(os.environ.get('OPENAI_TIMEOUT', '60'))
|
||||||
|
ARTICLE_FETCH_TIMEOUT = int(os.environ.get('ARTICLE_FETCH_TIMEOUT', '15'))
|
||||||
|
ARTICLE_FETCH_MAX_BYTES = int(os.environ.get('ARTICLE_FETCH_MAX_BYTES', '2000000'))
|
||||||
|
ARTICLE_FETCH_TARGET_CHARS = int(os.environ.get('ARTICLE_FETCH_TARGET_CHARS', '8000'))
|
||||||
|
ARTICLE_FETCH_OUTPUT_CHARS = int(os.environ.get('ARTICLE_FETCH_OUTPUT_CHARS', '12000'))
|
||||||
|
ARTICLE_FETCH_PARTIAL_BYTES = int(os.environ.get('ARTICLE_FETCH_PARTIAL_BYTES', '96000'))
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ from django.conf.urls.static import static
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
|
|
||||||
admin.site.site_header = 'Faktenkompass Verwaltung'
|
admin.site.site_header = 'Faktenkompass Redaktion'
|
||||||
admin.site.site_title = 'Faktenkompass Admin'
|
admin.site.site_title = 'Redaktion'
|
||||||
admin.site.index_title = 'Inhalte verwalten'
|
admin.site.index_title = 'Inhalte verwalten'
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -1 +1,8 @@
|
|||||||
Django>=5.0,<6.0
|
Django>=5.0,<6.0
|
||||||
|
daphne>=4.0.0
|
||||||
|
openai>=1.40.0
|
||||||
|
requests>=2.31.0
|
||||||
|
beautifulsoup4>=4.12.0
|
||||||
|
cairosvg>=2.7.0
|
||||||
|
Pillow>=10.0.0
|
||||||
|
psycopg2-binary>=2.9.0
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/* Faktenkompass – Redaktions-Admin */
|
||||||
|
|
||||||
|
.fk-admin-dashboard {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-admin-card {
|
||||||
|
display: block;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111827;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-admin-card:hover {
|
||||||
|
border-color: #0d9488;
|
||||||
|
box-shadow: 0 2px 8px rgba(13, 148, 136, 0.12);
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-admin-card strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-admin-card span {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-form .fk-admin-wide,
|
||||||
|
.change-form .fk-admin-textarea,
|
||||||
|
.change-form textarea.vLargeTextField,
|
||||||
|
.change-form textarea.large {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 0.95rem !important;
|
||||||
|
line-height: 1.5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-form .fk-admin-textarea,
|
||||||
|
.change-form textarea.vLargeTextField,
|
||||||
|
.change-form textarea.large {
|
||||||
|
min-height: 6rem;
|
||||||
|
padding: 0.65rem 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.change-form .fk-admin-wide {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module h2,
|
||||||
|
.inline-group h2 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-group .tabular tr.has_original td {
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Behauptung bearbeiten */
|
||||||
|
.fk-claim-help {
|
||||||
|
margin: 0 0 1.25rem;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
border-left: 4px solid #0d9488;
|
||||||
|
background: #f0fdfa;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-claim-meta-links {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-claim-meta-links a {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-evidence-preview {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-evidence-preview img {
|
||||||
|
display: block;
|
||||||
|
max-width: min(100%, 360px);
|
||||||
|
max-height: 240px;
|
||||||
|
object-fit: contain;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f9fafb;
|
||||||
|
padding: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-evidence-preview a {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-evidence-preview-live {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-evidence-preview-live img {
|
||||||
|
max-width: min(100%, 280px);
|
||||||
|
max-height: 200px;
|
||||||
|
border: 1px dashed #9ca3af;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-related fieldset.module {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-group .stacked .inline-related {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-group .stacked .inline-related h3 {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0.35rem 0 0.75rem;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-public_link readonly {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.change-form .form-row .field-short_answer textarea,
|
||||||
|
.change-form .form-row .field-detailed_explanation textarea {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Changelist: Suche über der Tabelle, Filter rechts */
|
||||||
|
#changelist {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist .changelist-form-container {
|
||||||
|
display: block !important;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist #toolbar,
|
||||||
|
#changelist .toplinks,
|
||||||
|
#changelist #changelist-form,
|
||||||
|
#changelist .results,
|
||||||
|
#changelist .paginator {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist #changelist-search {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist-filter {
|
||||||
|
flex: 0 0 240px;
|
||||||
|
order: 1;
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
#changelist {
|
||||||
|
flex-direction: column !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist-filter {
|
||||||
|
flex-basis: auto;
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.5rem 0 0 !important;
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
+486
-37
@@ -1,39 +1,172 @@
|
|||||||
:root {
|
:root {
|
||||||
--fk-primary: #1e4d7b;
|
--fk-accent: #0d9488;
|
||||||
--fk-primary-dark: #163a5f;
|
--fk-accent-hover: #0f766e;
|
||||||
--fk-bg: #f4f6f8;
|
--fk-bg: #f5f5f7;
|
||||||
|
--fk-surface: #ffffff;
|
||||||
|
--fk-border: #e5e7eb;
|
||||||
|
--fk-text: #111827;
|
||||||
|
--fk-muted: #6b7280;
|
||||||
|
--fk-nav-bg: rgba(255, 255, 255, 0.85);
|
||||||
|
--fk-radius: 12px;
|
||||||
|
--fk-font: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--fk-accent: #2dd4bf;
|
||||||
|
--fk-accent-hover: #5eead4;
|
||||||
|
--fk-bg: #0f1117;
|
||||||
|
--fk-surface: #1a1d27;
|
||||||
|
--fk-border: #2d3348;
|
||||||
|
--fk-text: #f3f4f6;
|
||||||
|
--fk-muted: #9ca3af;
|
||||||
|
--fk-nav-bg: rgba(15, 17, 23, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
font-family: var(--fk-font);
|
||||||
background-color: var(--fk-bg);
|
background-color: var(--fk-bg);
|
||||||
|
color: var(--fk-text);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar.bg-primary {
|
/* Navigation */
|
||||||
background-color: var(--fk-primary) !important;
|
.fk-nav {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1030;
|
||||||
|
background: var(--fk-nav-bg);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--fk-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.fk-nav-inner {
|
||||||
background-color: var(--fk-primary);
|
display: flex;
|
||||||
border-color: var(--fk-primary);
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover,
|
.fk-brand {
|
||||||
.btn-primary:focus {
|
display: inline-flex;
|
||||||
background-color: var(--fk-primary-dark);
|
align-items: center;
|
||||||
border-color: var(--fk-primary-dark);
|
gap: 0.5rem;
|
||||||
}
|
|
||||||
|
|
||||||
.text-primary {
|
|
||||||
color: var(--fk-primary) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header h1 {
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: var(--fk-text);
|
||||||
|
text-decoration: none;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-brand:hover {
|
||||||
|
color: var(--fk-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-brand-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
color: var(--fk-accent);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-nav-links {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-nav-link {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-nav-link:hover {
|
||||||
|
color: var(--fk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-nav-link.active {
|
||||||
|
color: var(--fk-accent);
|
||||||
|
border-bottom-color: var(--fk-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-theme-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--fk-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--fk-surface);
|
||||||
|
color: var(--fk-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-theme-btn:hover {
|
||||||
|
color: var(--fk-text);
|
||||||
|
border-color: var(--fk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
.fk-main {
|
||||||
|
padding: 1.25rem 0 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-page-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-footer {
|
||||||
|
border-top: 1px solid var(--fk-border);
|
||||||
|
padding: 1rem 0;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-footer p {
|
||||||
|
color: var(--fk-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.fk-card,
|
||||||
|
.category-tile,
|
||||||
|
.claim-card,
|
||||||
|
.card {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
border: 1px solid var(--fk-border);
|
||||||
|
border-radius: var(--fk-radius);
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tile,
|
.category-tile,
|
||||||
.claim-card {
|
.claim-card {
|
||||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover .category-tile,
|
||||||
|
a:hover .claim-card {
|
||||||
|
border-color: var(--fk-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tile {
|
.category-tile {
|
||||||
@@ -41,48 +174,360 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-tile-icon {
|
.category-tile-icon {
|
||||||
width: 2.75rem;
|
width: 2.5rem;
|
||||||
height: 2.75rem;
|
height: 2.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: rgba(30, 77, 123, 0.08);
|
color: var(--fk-accent);
|
||||||
color: var(--fk-primary);
|
font-size: 1.2rem;
|
||||||
border-radius: 50%;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tile .card-title {
|
.category-tile .card-title {
|
||||||
line-height: 1.25;
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fk-text);
|
||||||
|
line-height: 1.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.claim-card .card-body {
|
.claim-card .card-body {
|
||||||
padding: 0.875rem 1rem;
|
padding: 0.875rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.claim-card .claim-title {
|
||||||
|
color: var(--fk-text);
|
||||||
|
font-size: 0.925rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-control,
|
||||||
|
.form-select {
|
||||||
|
background-color: var(--fk-surface);
|
||||||
|
border-color: var(--fk-border);
|
||||||
|
color: var(--fk-text);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
background-color: var(--fk-surface);
|
||||||
|
border-color: var(--fk-accent);
|
||||||
|
color: var(--fk-text);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--fk-accent) 18%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group-text {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
border-color: var(--fk-border);
|
||||||
|
color: var(--fk-muted);
|
||||||
|
border-radius: 10px 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--fk-accent);
|
||||||
|
border-color: var(--fk-accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background-color: var(--fk-accent-hover);
|
||||||
|
border-color: var(--fk-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--fk-accent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--fk-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-dark {
|
||||||
|
color: var(--fk-text) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Breadcrumb */
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
--bs-breadcrumb-divider-color: var(--fk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a {
|
||||||
|
color: var(--fk-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item a:hover {
|
||||||
|
color: var(--fk-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-item.active {
|
||||||
|
color: var(--fk-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status & meta */
|
||||||
|
.fk-meta-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-status,
|
||||||
|
.fk-evidence {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-status-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-status--falsch .fk-status-dot { background: #ef4444; }
|
||||||
|
.fk-status--irrefuehrend .fk-status-dot { background: #f59e0b; }
|
||||||
|
.fk-status--teilweise_richtig .fk-status-dot { background: #3b82f6; }
|
||||||
|
.fk-status--unbelegt .fk-status-dot { background: #9ca3af; }
|
||||||
|
.fk-status--wissenschaftlicher_konsens .fk-status-dot { background: #10b981; }
|
||||||
|
.fk-status--offene_frage .fk-status-dot { background: #8b5cf6; }
|
||||||
|
|
||||||
|
.fk-topic {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Detail sections */
|
||||||
|
.fk-section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-section-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
margin: 0 0 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-section-body {
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-section-body p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-answer-card {
|
||||||
|
border-left: 3px solid var(--fk-accent);
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-answer-card p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-claim-title {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Evidence */
|
||||||
|
.fk-img-open {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
.evidence-img {
|
.evidence-img {
|
||||||
display: block;
|
display: block;
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #fff;
|
background: var(--fk-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.evidence-card {
|
.evidence-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.fk-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.88);
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 2;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 2.5rem;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox-img {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: min(100%, 1200px);
|
||||||
|
max-height: calc(100vh - 4rem);
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 2;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(90%, 40rem);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
color: #fff;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin-bottom: 0.75rem;
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.fk-lightbox-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Accordion */
|
||||||
|
.fk-section .accordion {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-accordion {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-accordion .accordion-item {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
border: 1px solid var(--fk-border);
|
||||||
|
border-radius: var(--fk-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-accordion .accordion-button {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
color: var(--fk-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-radius: var(--fk-radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-accordion .accordion-button:not(.collapsed) {
|
||||||
|
background: color-mix(in srgb, var(--fk-accent) 8%, var(--fk-surface));
|
||||||
|
color: var(--fk-text);
|
||||||
|
border-radius: var(--fk-radius) var(--fk-radius) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-accordion .accordion-body {
|
||||||
|
color: var(--fk-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
background: var(--fk-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-item {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
border-color: var(--fk-border) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button {
|
||||||
|
background: var(--fk-surface);
|
||||||
|
color: var(--fk-text);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
box-shadow: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button:not(.collapsed) {
|
.accordion-button:not(.collapsed) {
|
||||||
background-color: rgba(30, 77, 123, 0.06);
|
background: color-mix(in srgb, var(--fk-accent) 8%, var(--fk-surface));
|
||||||
color: var(--fk-primary);
|
color: var(--fk-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button:focus {
|
.accordion-body {
|
||||||
box-shadow: 0 0 0 0.2rem rgba(30, 77, 123, 0.15);
|
color: var(--fk-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.fk-tag {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--fk-muted);
|
||||||
|
border: 1px solid var(--fk-border);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
background: var(--fk-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Source links */
|
||||||
|
.fk-source-link {
|
||||||
|
color: var(--fk-text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-source-link:hover {
|
||||||
|
color: var(--fk-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 576px) {
|
@media (max-width: 576px) {
|
||||||
@@ -91,12 +536,16 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.category-tile-icon {
|
.category-tile-icon {
|
||||||
width: 2.25rem;
|
width: 2rem;
|
||||||
height: 2.25rem;
|
height: 2rem;
|
||||||
font-size: 1.1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.category-tile .card-title {
|
.category-tile .card-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fk-nav-link {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
(function () {
|
||||||
|
function showFilePreview(input) {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file || !file.type.startsWith('image/')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var row = input.closest('.inline-related') || input.closest('.form-row');
|
||||||
|
if (!row) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var box = row.querySelector('.fk-evidence-preview-live');
|
||||||
|
if (!box) {
|
||||||
|
box = document.createElement('div');
|
||||||
|
box.className = 'fk-evidence-preview-live';
|
||||||
|
input.parentNode.appendChild(box);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (event) {
|
||||||
|
box.innerHTML = '<img src="' + event.target.result + '" alt="Vorschau">';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function (event) {
|
||||||
|
if (event.target.type === 'file' && event.target.name && event.target.name.indexOf('evidence_files') !== -1) {
|
||||||
|
showFilePreview(event.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
(function () {
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var lightbox = document.getElementById('fk-lightbox');
|
||||||
|
if (!lightbox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var img = lightbox.querySelector('.fk-lightbox-img');
|
||||||
|
var caption = lightbox.querySelector('.fk-lightbox-caption');
|
||||||
|
var closeBtn = lightbox.querySelector('.fk-lightbox-close');
|
||||||
|
var backdrop = lightbox.querySelector('.fk-lightbox-backdrop');
|
||||||
|
|
||||||
|
function openLightbox(src, alt) {
|
||||||
|
img.src = src;
|
||||||
|
img.alt = alt || '';
|
||||||
|
caption.textContent = alt || '';
|
||||||
|
caption.hidden = !alt;
|
||||||
|
lightbox.hidden = false;
|
||||||
|
document.body.classList.add('fk-lightbox-open');
|
||||||
|
closeBtn.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
lightbox.hidden = true;
|
||||||
|
img.src = '';
|
||||||
|
document.body.classList.remove('fk-lightbox-open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.fk-img-open').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
openLightbox(button.dataset.src, button.dataset.caption);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeLightbox);
|
||||||
|
backdrop.addEventListener('click', closeLightbox);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape' && !lightbox.hidden) {
|
||||||
|
closeLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
(function () {
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
function debounce(fn, delay) {
|
||||||
|
let timer;
|
||||||
|
return function (...args) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn.apply(this, args), delay);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUrl(form) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
new FormData(form).forEach((value, key) => {
|
||||||
|
if (value) {
|
||||||
|
params.append(key, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const action = form.getAttribute('action') || window.location.pathname;
|
||||||
|
const query = params.toString();
|
||||||
|
return query ? action + '?' + query : action;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch(form) {
|
||||||
|
const target = document.querySelector(form.dataset.liveTarget);
|
||||||
|
if (!target) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = buildUrl(form);
|
||||||
|
fetch(url, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('search failed');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
})
|
||||||
|
.then((html) => {
|
||||||
|
target.innerHTML = html;
|
||||||
|
history.replaceState(null, '', url);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
document.querySelectorAll('form[data-live-search]').forEach(function (form) {
|
||||||
|
const search = debounce(function () {
|
||||||
|
runSearch(form);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
|
||||||
|
form.querySelectorAll('input, select').forEach(function (field) {
|
||||||
|
field.addEventListener('input', search);
|
||||||
|
field.addEventListener('change', search);
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
runSearch(form);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
(function () {
|
||||||
|
const storageKey = 'fk-theme';
|
||||||
|
|
||||||
|
function applyTheme(theme) {
|
||||||
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
const sun = document.querySelector('.fk-icon-sun');
|
||||||
|
const moon = document.querySelector('.fk-icon-moon');
|
||||||
|
if (sun && moon) {
|
||||||
|
sun.hidden = theme === 'dark';
|
||||||
|
moon.hidden = theme !== 'dark';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function preferredTheme() {
|
||||||
|
const stored = localStorage.getItem(storageKey);
|
||||||
|
if (stored === 'light' || stored === 'dark') {
|
||||||
|
return stored;
|
||||||
|
}
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
applyTheme(preferredTheme());
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const toggle = document.getElementById('fk-theme-toggle');
|
||||||
|
if (!toggle) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toggle.addEventListener('click', function () {
|
||||||
|
const next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
||||||
|
localStorage.setItem(storageKey, next);
|
||||||
|
applyTheme(next);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
|
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | Faktenkompass Redaktion{% endblock %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
{{ block.super }}
|
||||||
|
<link rel="stylesheet" href="{% static 'css/admin_custom.css' %}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extrahead %}
|
||||||
|
{{ block.super }}
|
||||||
|
<script src="{% static 'js/admin_custom.js' %}" defer></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block branding %}
|
||||||
|
<div id="site-name"><a href="{% url 'admin:index' %}">Faktenkompass Redaktion</a></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
.assistant-wrap { max-width: 820px; }
|
||||||
|
.chat-box {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
min-height: 120px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.chat-hint { color: #6c757d; font-size: 0.9rem; }
|
||||||
|
.assistant-card {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">Start</a>
|
||||||
|
› <a href="{% url 'admin:claims_claimdraft_changelist' %}">Fakten-Entwürfe</a>
|
||||||
|
› Assistent
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="assistant-wrap">
|
||||||
|
<h1>Fakten-Assistent</h1>
|
||||||
|
<p class="chat-hint">
|
||||||
|
Link, Bild und/oder Beschreibung eingeben. Die KI erstellt einen <strong>Entwurf zur Prüfung</strong> –
|
||||||
|
nichts wird automatisch veröffentlicht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="chat-box">
|
||||||
|
<p class="mb-1"><strong>So funktioniert es:</strong></p>
|
||||||
|
<ol class="mb-0 ps-3">
|
||||||
|
<li>Eingaben senden (Artikel-URL, Beschreibung, optional Bild/PDF)</li>
|
||||||
|
<li>KI analysiert den Kontext und schlägt Kategorie & Fakteneintrag vor</li>
|
||||||
|
<li>Sie prüfen, bearbeiten und veröffentlichen erst nach Freigabe</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="assistant-card">
|
||||||
|
<form method="post" enctype="multipart/form-data" novalidate>
|
||||||
|
{% csrf_token %}
|
||||||
|
{% if form.non_field_errors %}
|
||||||
|
<div class="alert alert-danger">{{ form.non_field_errors }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.article_url.id_for_label }}">{{ form.article_url.label }}</label>
|
||||||
|
{{ form.article_url }}
|
||||||
|
{% if form.article_url.errors %}<div class="text-danger small">{{ form.article_url.errors }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.user_notes.id_for_label }}">{{ form.user_notes.label }}</label>
|
||||||
|
{{ form.user_notes }}
|
||||||
|
{% if form.user_notes.errors %}<div class="text-danger small">{{ form.user_notes.errors }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label" for="{{ form.uploaded_file.id_for_label }}">{{ form.uploaded_file.label }}</label>
|
||||||
|
{{ form.uploaded_file }}
|
||||||
|
<div class="form-text">JPG, PNG, GIF, WebP, SVG oder PDF. SVG wird für die KI intern in PNG umgewandelt.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="button default">Entwurf generieren</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if recent_drafts %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<h2 class="h6 text-muted">Letzte Entwürfe</h2>
|
||||||
|
<ul>
|
||||||
|
{% for draft in recent_drafts %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:claims_claimdraft_review' draft.pk %}">
|
||||||
|
{{ draft.title|default:"(ohne Titel)" }}
|
||||||
|
</a>
|
||||||
|
– {{ draft.get_status_display }}
|
||||||
|
<span class="text-muted">({{ draft.created_at|date:"d.m.Y H:i" }})</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% extends "admin/change_form.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if original and original.pk %}
|
||||||
|
<div class="fk-claim-meta-links">
|
||||||
|
<a href="{{ original.get_absolute_url }}" target="_blank" rel="noopener">↗ Auf Website ansehen</a>
|
||||||
|
<a href="{% url 'admin:claims_claimdraft_assistant' %}">+ Fakten-Assistent</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="fk-claim-help">
|
||||||
|
Neue Behauptung anlegen: zuerst Titel und Kurzantwort ausfüllen, dann speichern.
|
||||||
|
Nachweise, Quellen und Gegenargumente können danach ergänzt werden.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends "admin/change_list.html" %}
|
||||||
|
|
||||||
|
{% block object-tools-items %}
|
||||||
|
<li>
|
||||||
|
<a href="{% url 'admin:claims_claimdraft_assistant' %}" class="addlink">Fakten-Assistent</a>
|
||||||
|
</li>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block extrastyle %}
|
||||||
|
{{ block.super }}
|
||||||
|
<style>
|
||||||
|
.review-grid { display: grid; grid-template-columns: 1fr 320px; gap: 1.5rem; }
|
||||||
|
@media (max-width: 960px) { .review-grid { grid-template-columns: 1fr; } }
|
||||||
|
.preview-card, .chat-panel {
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.chat-panel { background: #f8f9fa; max-height: 420px; overflow-y: auto; }
|
||||||
|
.chat-msg { margin-bottom: 0.75rem; font-size: 0.92rem; }
|
||||||
|
.chat-msg.user { color: #0d3d66; }
|
||||||
|
.chat-msg.assistant { color: #333; }
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
.field-box { margin-bottom: 1rem; }
|
||||||
|
.field-box label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.actions-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="{% url 'admin:index' %}">Start</a>
|
||||||
|
› <a href="{% url 'admin:claims_claimdraft_changelist' %}">Fakten-Entwürfe</a>
|
||||||
|
› <a href="{% url 'admin:claims_claimdraft_assistant' %}">Assistent</a>
|
||||||
|
› Prüfen
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Entwurf prüfen</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Status: <span class="status-badge">{{ draft.get_status_display }}</span>
|
||||||
|
{% if draft.published_claim %}
|
||||||
|
· <a href="{% url 'admin:claims_claim_change' draft.published_claim.pk %}">Veröffentlichte Behauptung öffnen</a>
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if draft.error_message %}
|
||||||
|
<div class="alert alert-danger">{{ draft.error_message }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="review-grid">
|
||||||
|
<div>
|
||||||
|
<form method="post" class="preview-card">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="save" id="form-action">
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.category_choice.id_for_label }}">{{ form.category_choice.label }}</label>
|
||||||
|
{{ form.category_choice }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.new_category_name.id_for_label }}">Neue Kategorie (falls leer oben)</label>
|
||||||
|
{{ form.new_category_name }}
|
||||||
|
{{ form.new_category_description }}
|
||||||
|
{{ form.new_category_icon }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr>
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.title.id_for_label }}">{{ form.title.label }}</label>
|
||||||
|
{{ form.title }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.claim_status.id_for_label }}">{{ form.claim_status.label }}</label>
|
||||||
|
{{ form.claim_status }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.evidence_level.id_for_label }}">{{ form.evidence_level.label }}</label>
|
||||||
|
{{ form.evidence_level }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.short_answer.id_for_label }}">{{ form.short_answer.label }}</label>
|
||||||
|
{{ form.short_answer }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.detailed_explanation.id_for_label }}">{{ form.detailed_explanation.label }}</label>
|
||||||
|
{{ form.detailed_explanation }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.tags_text.id_for_label }}">{{ form.tags_text.label }}</label>
|
||||||
|
{{ form.tags_text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.sources_json.id_for_label }}">{{ form.sources_json.label }}</label>
|
||||||
|
{{ form.sources_json }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-box">
|
||||||
|
<label for="{{ form.counter_arguments_json.id_for_label }}">{{ form.counter_arguments_json.label }}</label>
|
||||||
|
{{ form.counter_arguments_json }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-bar">
|
||||||
|
<button type="submit" class="button" onclick="document.getElementById('form-action').value='save'">Speichern</button>
|
||||||
|
{% if draft.status != 'published' %}
|
||||||
|
<button type="submit" class="button default" onclick="document.getElementById('form-action').value='publish'">
|
||||||
|
Veröffentlichen
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="button" formaction="." formmethod="post" name="action" value="reject"
|
||||||
|
onclick="return confirm('Entwurf wirklich verwerfen?')">Verwerfen</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="chat-panel mb-3">
|
||||||
|
<h2 class="h6">Konversation</h2>
|
||||||
|
{% for msg in draft.conversation %}
|
||||||
|
<div class="chat-msg {{ msg.role }}">
|
||||||
|
<strong>{% if msg.role == 'user' %}Sie{% else %}Assistent{% endif %}:</strong>
|
||||||
|
{{ msg.content|linebreaksbr }}
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted mb-0">Noch keine Nachrichten.</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if draft.uploaded_file %}
|
||||||
|
<p class="small text-muted mb-2">Datei: {{ draft.uploaded_file.name }}</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if draft.article_url %}
|
||||||
|
<p class="small mb-3"><a href="{{ draft.article_url }}" target="_blank" rel="noopener">Artikel öffnen</a></p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if draft.status != 'published' %}
|
||||||
|
<form method="post" class="preview-card">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="action" value="refine">
|
||||||
|
<h2 class="h6">Überarbeiten lassen</h2>
|
||||||
|
{{ refine_form.refinement }}
|
||||||
|
<button type="submit" class="button mt-2">KI erneut fragen</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "admin/index.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="fk-admin-dashboard">
|
||||||
|
<a class="fk-admin-card" href="{% url 'admin:claims_claim_add' %}">
|
||||||
|
<strong>+ Neue Behauptung</strong>
|
||||||
|
<span>Manuell anlegen und Texte, Quellen sowie Nachweise einpflegen.</span>
|
||||||
|
</a>
|
||||||
|
<a class="fk-admin-card" href="{% url 'admin:claims_claim_changelist' %}">
|
||||||
|
<strong>Behauptungen bearbeiten</strong>
|
||||||
|
<span>Bestehende Einträge suchen, aktualisieren und veröffentlichen.</span>
|
||||||
|
</a>
|
||||||
|
<a class="fk-admin-card" href="{% url 'admin:claims_claimdraft_assistant' %}">
|
||||||
|
<strong>Fakten-Assistent (KI)</strong>
|
||||||
|
<span>Aus Artikel oder Grafik einen Entwurf erstellen und prüfen.</span>
|
||||||
|
</a>
|
||||||
|
<a class="fk-admin-card" href="{% url 'admin:claims_category_changelist' %}">
|
||||||
|
<strong>Themen / Kategorien</strong>
|
||||||
|
<span>Kategorien verwalten, Icons und Beschreibungen anpassen.</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{ block.super }}
|
||||||
|
{% endblock %}
|
||||||
+32
-12
@@ -6,35 +6,55 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="description" content="{% block meta_description %}Faktenkompass – Überprüfbare Fakten und Quellen.{% endblock %}">
|
<meta name="description" content="{% block meta_description %}Faktenkompass – Überprüfbare Fakten und Quellen.{% endblock %}">
|
||||||
<title>{% block title %}Faktenkompass{% endblock %}</title>
|
<title>{% block title %}Faktenkompass{% endblock %}</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
|
||||||
<link href="{% static 'css/custom.css' %}" rel="stylesheet">
|
<link href="{% static 'css/custom.css' %}" rel="stylesheet">
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var t = localStorage.getItem('fk-theme');
|
||||||
|
if (!t) {
|
||||||
|
t = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
document.documentElement.setAttribute('data-theme', t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="{% static 'js/theme.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/live-search.js' %}" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="d-flex flex-column min-vh-100">
|
<body class="d-flex flex-column min-vh-100">
|
||||||
<nav class="navbar navbar-dark bg-primary">
|
<header class="fk-nav">
|
||||||
<div class="container">
|
<div class="container fk-nav-inner">
|
||||||
<a class="navbar-brand" href="{% url 'claims:home' %}">
|
<a class="fk-brand" href="{% url 'claims:home' %}">
|
||||||
<i class="bi bi-compass me-1"></i>Faktenkompass
|
<span class="fk-brand-icon"><i class="bi bi-compass"></i></span>
|
||||||
|
Faktenkompass
|
||||||
</a>
|
</a>
|
||||||
<div class="d-flex gap-2">
|
<nav class="fk-nav-links" aria-label="Hauptnavigation">
|
||||||
<a class="btn btn-sm btn-outline-light{% if request.resolver_match.url_name == 'home' %} active{% endif %}" href="{% url 'claims:home' %}">Themen</a>
|
<a class="fk-nav-link{% if request.resolver_match.url_name == 'home' %} active{% endif %}" href="{% url 'claims:home' %}">Themen</a>
|
||||||
<a class="btn btn-sm btn-outline-light{% if request.resolver_match.url_name == 'search' %} active{% endif %}" href="{% url 'claims:search' %}">Suche</a>
|
<a class="fk-nav-link{% if request.resolver_match.url_name == 'search' %} active{% endif %}" href="{% url 'claims:search' %}">Suche</a>
|
||||||
</div>
|
<button type="button" class="fk-theme-btn" id="fk-theme-toggle" aria-label="Farbschema umschalten">
|
||||||
|
<i class="bi bi-sun fk-icon-sun" aria-hidden="true"></i>
|
||||||
|
<i class="bi bi-moon fk-icon-moon" hidden aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</header>
|
||||||
|
|
||||||
<main class="flex-grow-1 py-3">
|
<main class="flex-grow-1 fk-main">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="border-top py-2 mt-auto">
|
<footer class="fk-footer">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<p class="text-muted text-center mb-0 small">© {% now "Y" %} Faktenkompass</p>
|
<p>© {% now "Y" %} Faktenkompass</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -10,21 +10,21 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-4">
|
||||||
<div class="category-tile-icon flex-shrink-0">
|
<div class="category-tile-icon flex-shrink-0">
|
||||||
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
|
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 overflow-hidden">
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
<h1 class="h5 mb-0 text-truncate">{{ category.name }}</h1>
|
<h1 class="fk-page-title mb-0 text-truncate">{{ category.name }}</h1>
|
||||||
<span class="small text-muted">{{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %}</span>
|
<span class="small text-muted">{{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="get" class="row g-2 mb-3">
|
<form method="get" class="row g-2 mb-4" data-live-search data-live-target="#category-results">
|
||||||
<div class="col-12 col-sm">
|
<div class="col-12 col-sm">
|
||||||
<input type="search" id="q" name="q" class="form-control form-control-sm" placeholder="Suchen…" value="{{ query }}">
|
<input type="search" id="q" name="q" class="form-control form-control-sm" placeholder="Suchen…" value="{{ query }}" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-7 col-sm-auto">
|
<div class="col-12 col-sm-auto">
|
||||||
<select id="status" name="status" class="form-select form-select-sm">
|
<select id="status" name="status" class="form-select form-select-sm">
|
||||||
<option value="">Alle Status</option>
|
<option value="">Alle Status</option>
|
||||||
{% for value, label in status_choices %}
|
{% for value, label in status_choices %}
|
||||||
@@ -32,23 +32,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-5 col-sm-auto">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm w-100">Filtern</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="d-flex flex-column gap-2">
|
<div id="category-results">
|
||||||
{% for claim in claims %}
|
{% include "claims/partials/claim_list.html" %}
|
||||||
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
|
|
||||||
<div class="card claim-card">
|
|
||||||
<div class="card-body d-flex align-items-start gap-2">
|
|
||||||
<span class="badge text-bg-{{ claim.get_status_badge_class }} flex-shrink-0 mt-1">{{ claim.get_status_display }}</span>
|
|
||||||
<span class="text-dark">{{ claim.title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% empty %}
|
|
||||||
<p class="text-muted text-center py-4 mb-0">Keine Behauptungen gefunden.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
{% load static claims_extras %}
|
||||||
|
|
||||||
{% block title %}{{ claim.title }} – Faktenkompass{% endblock %}
|
{% block title %}{{ claim.title }} – Faktenkompass{% endblock %}
|
||||||
{% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %}
|
{% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %}
|
||||||
@@ -13,43 +14,42 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<article>
|
<article>
|
||||||
<div class="d-flex flex-wrap gap-1 mb-2">
|
<h1 class="fk-claim-title">{{ claim.title }}</h1>
|
||||||
<span class="badge text-bg-{{ claim.get_status_badge_class }}">{{ claim.get_status_display }}</span>
|
|
||||||
<span class="badge text-bg-light border text-dark">Evidenz: {{ claim.get_evidence_level_display }}</span>
|
<div class="fk-meta-stack mb-3">
|
||||||
|
{% status_badge claim %}
|
||||||
|
{% evidence_badge claim %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 class="h5 fw-semibold mb-3">{{ claim.title }}</h1>
|
<div class="fk-card fk-answer-card">
|
||||||
|
<p class="fk-section-label mb-2">Kurzantwort</p>
|
||||||
<div class="card mb-3 border-start border-3 border-primary">
|
<p>{{ claim.short_answer }}</p>
|
||||||
<div class="card-body py-3">
|
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-1">Kurzantwort</p>
|
|
||||||
<p class="mb-0">{{ claim.short_answer }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if claim.evidence_files.all %}
|
{% if claim.evidence_files.all %}
|
||||||
<section class="mb-3">
|
<section class="fk-section">
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-2">Nachweise</p>
|
<p class="fk-section-label">Nachweise</p>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
{% for evidence in claim.evidence_files.all %}
|
{% for evidence in claim.evidence_files.all %}
|
||||||
{% if evidence.is_image %}
|
{% if evidence.is_image %}
|
||||||
<figure class="card mb-0 evidence-card">
|
<figure class="fk-card evidence-card mb-0">
|
||||||
<img src="{{ evidence.file.url }}" alt="{{ evidence.caption|default:evidence.display_title }}"
|
<button type="button" class="fk-img-open" data-src="{{ evidence.file.url }}"
|
||||||
class="evidence-img w-100" loading="lazy">
|
data-caption="{{ evidence.caption|default:evidence.image_alt }}"
|
||||||
{% if evidence.title or evidence.caption %}
|
aria-label="Grafik im Vollbild anzeigen">
|
||||||
<figcaption class="card-body py-2 px-3 small">
|
<img src="{{ evidence.file.url }}" alt="{{ evidence.image_alt }}"
|
||||||
{% if evidence.title %}<strong class="d-block">{{ evidence.title }}</strong>{% endif %}
|
class="evidence-img w-100" loading="lazy">
|
||||||
{% if evidence.caption %}<span class="text-muted">{{ evidence.caption }}</span>{% endif %}
|
</button>
|
||||||
</figcaption>
|
{% if evidence.caption %}
|
||||||
|
<figcaption class="card-body py-2 px-3 small text-muted">{{ evidence.caption }}</figcaption>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</figure>
|
</figure>
|
||||||
{% elif evidence.is_pdf %}
|
{% elif evidence.is_pdf %}
|
||||||
<a href="{{ evidence.file.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
|
<a href="{{ evidence.file.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
|
||||||
<div class="card evidence-card">
|
<div class="fk-card evidence-card">
|
||||||
<div class="card-body py-2 px-3 d-flex align-items-center gap-3">
|
<div class="card-body py-2 px-3 d-flex align-items-center gap-3">
|
||||||
<i class="bi bi-file-earmark-pdf text-danger fs-3 flex-shrink-0"></i>
|
<i class="bi bi-file-earmark-pdf fs-3 flex-shrink-0" style="color:#ef4444"></i>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<span class="small fw-medium text-dark d-block">{{ evidence.display_title }}</span>
|
<span class="small fw-medium d-block" style="color:var(--fk-text)">{{ evidence.display_title }}</span>
|
||||||
{% if evidence.caption %}<span class="small text-muted">{{ evidence.caption }}</span>{% endif %}
|
{% if evidence.caption %}<span class="small text-muted">{{ evidence.caption }}</span>{% endif %}
|
||||||
<span class="small text-primary">PDF öffnen</span>
|
<span class="small text-primary">PDF öffnen</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,29 +63,27 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if claim.detailed_explanation %}
|
{% if claim.detailed_explanation %}
|
||||||
<section class="card mb-3">
|
<section class="fk-section">
|
||||||
<div class="card-body py-3">
|
<p class="fk-section-label">Erklärung</p>
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-1">Erklärung</p>
|
<div class="fk-card fk-section-body">
|
||||||
<div class="small">{{ claim.detailed_explanation|linebreaks }}</div>
|
{{ claim.detailed_explanation|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if claim.sources.all %}
|
{% if claim.sources.all %}
|
||||||
<section class="mb-3">
|
<section class="fk-section">
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-2">Quellen</p>
|
<p class="fk-section-label">Quellen</p>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
{% for source in claim.sources.all %}
|
{% for source in claim.sources.all %}
|
||||||
<div class="card">
|
<div class="fk-card fk-section-body">
|
||||||
<div class="card-body py-2 px-3">
|
<a href="{{ source.url }}" target="_blank" rel="noopener noreferrer" class="fk-source-link">
|
||||||
<a href="{{ source.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none fw-medium small">
|
{{ source.title }} <i class="bi bi-box-arrow-up-right"></i>
|
||||||
{{ source.title }} <i class="bi bi-box-arrow-up-right"></i>
|
</a>
|
||||||
</a>
|
<p class="text-muted small mb-0">{{ source.organization }}</p>
|
||||||
<p class="text-muted small mb-0">{{ source.organization }}</p>
|
{% if source.description %}
|
||||||
{% if source.description %}
|
<p class="small mb-0 mt-1">{{ source.description }}</p>
|
||||||
<p class="small mb-0 mt-1">{{ source.description }}</p>
|
{% endif %}
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
@@ -93,11 +91,11 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if claim.counter_arguments.all %}
|
{% if claim.counter_arguments.all %}
|
||||||
<section class="mb-3">
|
<section class="fk-section">
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-2">Gegenargumente</p>
|
<p class="fk-section-label">Gegenargumente</p>
|
||||||
<div class="accordion accordion-flush" id="counterArgs">
|
<div class="accordion fk-accordion" id="counterArgs">
|
||||||
{% for ca in claim.counter_arguments.all %}
|
{% for ca in claim.counter_arguments.all %}
|
||||||
<div class="accordion-item border mb-1 rounded">
|
<div class="accordion-item">
|
||||||
<h2 class="accordion-header">
|
<h2 class="accordion-header">
|
||||||
<button class="accordion-button collapsed py-2 small" type="button"
|
<button class="accordion-button collapsed py-2 small" type="button"
|
||||||
data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
|
data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
|
||||||
@@ -106,7 +104,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse"
|
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse"
|
||||||
data-bs-parent="#counterArgs">
|
data-bs-parent="#counterArgs">
|
||||||
<div class="accordion-body py-2 small text-muted">
|
<div class="accordion-body py-2">
|
||||||
{{ ca.response|linebreaks }}
|
{{ ca.response|linebreaks }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,22 +117,22 @@
|
|||||||
{% if claim.tags.all %}
|
{% if claim.tags.all %}
|
||||||
<div class="d-flex flex-wrap gap-1">
|
<div class="d-flex flex-wrap gap-1">
|
||||||
{% for tag in claim.tags.all %}
|
{% for tag in claim.tags.all %}
|
||||||
<span class="badge text-bg-light border text-secondary">#{{ tag.name }}</span>
|
<span class="fk-tag">#{{ tag.name }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
{% if related_claims %}
|
{% if related_claims %}
|
||||||
<aside class="mt-4 pt-3 border-top">
|
<aside class="mt-4 pt-4" style="border-top:1px solid var(--fk-border)">
|
||||||
<p class="small text-muted text-uppercase fw-semibold mb-2">Mehr zu {{ claim.category.name }}</p>
|
<p class="fk-section-label">Mehr zu {{ claim.category.name }}</p>
|
||||||
<div class="d-flex flex-column gap-2">
|
<div class="d-flex flex-column gap-2">
|
||||||
{% for related in related_claims %}
|
{% for related in related_claims %}
|
||||||
<a href="{{ related.get_absolute_url }}" class="text-decoration-none">
|
<a href="{{ related.get_absolute_url }}" class="text-decoration-none">
|
||||||
<div class="card claim-card">
|
<div class="claim-card">
|
||||||
<div class="card-body py-2 d-flex align-items-start gap-2">
|
<div class="card-body py-2">
|
||||||
<span class="badge text-bg-{{ related.get_status_badge_class }} flex-shrink-0">{{ related.get_status_display }}</span>
|
<div class="mb-1">{% status_badge related %}</div>
|
||||||
<span class="small text-dark">{{ related.title }}</span>
|
<span class="claim-title small d-block">{{ related.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
@@ -142,4 +140,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="fk-lightbox" class="fk-lightbox" hidden role="dialog" aria-modal="true" aria-label="Grafik Vollbild">
|
||||||
|
<button type="button" class="fk-lightbox-backdrop" aria-label="Schließen"></button>
|
||||||
|
<button type="button" class="fk-lightbox-close" aria-label="Schließen">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
<img class="fk-lightbox-img" src="" alt="">
|
||||||
|
<p class="fk-lightbox-caption"></p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{% static 'js/image-lightbox.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
{% block title %}Faktenkompass{% endblock %}
|
{% block title %}Faktenkompass{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<header class="page-header mb-3">
|
<header class="mb-4">
|
||||||
<h1 class="h4 mb-3">Themen</h1>
|
<h1 class="fk-page-title">Themen</h1>
|
||||||
<form method="get" action="{% url 'claims:home' %}" class="mb-2">
|
<form method="get" action="{% url 'claims:home' %}" class="mb-2" data-live-search data-live-target="#home-results">
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
<span class="input-group-text border-end-0"><i class="bi bi-search"></i></span>
|
||||||
<input type="search" name="q" class="form-control border-start-0 ps-0" placeholder="Suchen…" value="{{ query }}" aria-label="Kategorien durchsuchen">
|
<input type="search" name="q" class="form-control border-start-0 ps-0" placeholder="Suchen…" value="{{ query }}" aria-label="Kategorien durchsuchen" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
@@ -17,31 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if query and not categories %}
|
<div id="home-results">
|
||||||
<p class="text-muted small mb-3">Keine Kategorien für „{{ query }}".</p>
|
{% include "claims/partials/category_list.html" %}
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="row g-2 g-sm-3">
|
|
||||||
{% for category in categories %}
|
|
||||||
<div class="col-6 col-md-4 col-lg-3">
|
|
||||||
<a href="{{ category.get_absolute_url }}" class="text-decoration-none d-block h-100">
|
|
||||||
<div class="card category-tile h-100 text-center">
|
|
||||||
<div class="card-body d-flex flex-column align-items-center justify-content-center py-3 py-sm-4">
|
|
||||||
<div class="category-tile-icon mb-2">
|
|
||||||
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
|
|
||||||
</div>
|
|
||||||
<h2 class="h6 card-title text-dark mb-0">{{ category.name }}</h2>
|
|
||||||
<span class="small text-muted mt-1">{{ category.claim_count }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% empty %}
|
|
||||||
{% if not query %}
|
|
||||||
<div class="col-12">
|
|
||||||
<p class="text-muted text-center py-5 mb-0">Noch keine Kategorien vorhanden.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% if query and not categories %}
|
||||||
|
<p class="text-muted small mb-3">Keine Kategorien für „{{ query }}".</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="row g-2 g-sm-3">
|
||||||
|
{% for category in categories %}
|
||||||
|
<div class="col-6 col-md-4 col-lg-3">
|
||||||
|
<a href="{{ category.get_absolute_url }}" class="text-decoration-none d-block h-100">
|
||||||
|
<div class="category-tile h-100 text-center">
|
||||||
|
<div class="card-body d-flex flex-column align-items-center justify-content-center py-3 py-sm-4">
|
||||||
|
<div class="category-tile-icon mb-2">
|
||||||
|
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="h6 card-title mb-0">{{ category.name }}</h2>
|
||||||
|
<span class="small text-muted mt-1">{{ category.claim_count }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
{% if not query %}
|
||||||
|
<div class="col-12">
|
||||||
|
<p class="text-muted text-center py-5 mb-0">Noch keine Kategorien vorhanden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{% load claims_extras %}
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for claim in claims %}
|
||||||
|
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
|
||||||
|
<div class="claim-card">
|
||||||
|
<div class="card-body">
|
||||||
|
{% if show_category %}
|
||||||
|
<div class="mb-1">
|
||||||
|
<span class="fk-topic">{{ claim.category.name }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mb-1">{% status_badge claim %}</div>
|
||||||
|
<span class="claim-title d-block">{{ claim.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% empty %}
|
||||||
|
<p class="text-muted text-center py-4 mb-0">{{ empty_message|default:"Keine Behauptungen gefunden." }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<span class="fk-evidence">
|
||||||
|
<i class="bi bi-bar-chart" aria-hidden="true"></i>
|
||||||
|
Evidenz: {{ claim.get_evidence_level_display }}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% if claims is not None %}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<span class="small text-muted">{{ result_count }} Ergebnis{% if result_count != 1 %}se{% endif %}</span>
|
||||||
|
{% if query or category_slug or status %}
|
||||||
|
<a href="{% url 'claims:search' %}" class="small text-muted text-decoration-none">Zurücksetzen</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% include "claims/partials/claim_list.html" with claims=claims show_category=True empty_message="Keine Ergebnisse." %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted text-center py-4 mb-0 small">Suchbegriff oder Filter eingeben…</p>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<span class="fk-status fk-status--{{ claim.status }}">
|
||||||
|
<span class="fk-status-dot" aria-hidden="true"></span>
|
||||||
|
{{ claim.get_status_display }}
|
||||||
|
</span>
|
||||||
@@ -3,12 +3,12 @@
|
|||||||
{% block title %}Suche – Faktenkompass{% endblock %}
|
{% block title %}Suche – Faktenkompass{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="h5 mb-3">Suche</h1>
|
<h1 class="fk-page-title">Suche</h1>
|
||||||
|
|
||||||
<form method="get" class="row g-2 mb-3">
|
<form method="get" action="{% url 'claims:search' %}" class="row g-2 mb-4" data-live-search data-live-target="#search-results">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<input type="search" id="q" name="q" class="form-control form-control-sm"
|
<input type="search" id="q" name="q" class="form-control form-control-sm"
|
||||||
placeholder="Behauptung suchen…" value="{{ query }}" autofocus>
|
placeholder="Behauptung suchen…" value="{{ query }}" autofocus autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<select id="kategorie" name="kategorie" class="form-select form-select-sm">
|
<select id="kategorie" name="kategorie" class="form-select form-select-sm">
|
||||||
@@ -26,35 +26,9 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
|
||||||
<button type="submit" class="btn btn-primary btn-sm w-100">Suchen</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if claims is not None %}
|
<div id="search-results">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
{% include "claims/partials/search_results.html" %}
|
||||||
<span class="small text-muted">{{ result_count }} Ergebnis{% if result_count != 1 %}se{% endif %}</span>
|
|
||||||
{% if query or category_slug or status %}
|
|
||||||
<a href="{% url 'claims:search' %}" class="small text-muted">Zurücksetzen</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex flex-column gap-2">
|
|
||||||
{% for claim in claims %}
|
|
||||||
<a href="{{ claim.get_absolute_url }}" class="text-decoration-none">
|
|
||||||
<div class="card claim-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
|
||||||
<span class="badge text-bg-secondary">{{ claim.category.name }}</span>
|
|
||||||
<span class="badge text-bg-{{ claim.get_status_badge_class }}">{{ claim.get_status_display }}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-dark">{{ claim.title }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
{% empty %}
|
|
||||||
<p class="text-muted text-center py-4 mb-0">Keine Ergebnisse.</p>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user