initial commit
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.sqlite3
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
|
staticfiles/
|
||||||
|
media/
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Faktenkompass – Wissensdatenbank für überprüfbare Behauptungen
|
||||||
|
|
||||||
|
Eine Django-Webplattform zum schnellen Nachschlagen von Fakten, Quellen und Gegenargumenten zu häufigen Aussagen in Diskussionen.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Python 3.10 oder höher
|
||||||
|
- pip
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Virtuelle Umgebung erstellen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /pfad/zu/ArgumentAtlas
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate # Linux/macOS
|
||||||
|
# .venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
Falls `python3 -m venv` fehlschlägt, nutzen Sie explizit den System-Python:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/usr/bin/python3 -m venv .venv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Abhängigkeiten installieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Datenbank migrieren
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Beispieldaten laden
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py load_sample_data
|
||||||
|
```
|
||||||
|
|
||||||
|
Dies erstellt 7 Kategorien mit insgesamt 15 Behauptungen, Quellen und Gegenargumenten.
|
||||||
|
|
||||||
|
### 5. Admin-Zugang einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py createsuperuser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Entwicklungsserver starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Anwendung ist unter [http://127.0.0.1:8000/](http://127.0.0.1:8000/) erreichbar.
|
||||||
|
Das Django-Admin-Interface liegt unter [http://127.0.0.1:8000/admin/](http://127.0.0.1:8000/admin/).
|
||||||
|
|
||||||
|
Falls `runserver` mit einem Python-Pfadfehler abbricht, starten Sie mit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py runserver --noreload
|
||||||
|
```
|
||||||
|
|
||||||
|
## PostgreSQL (Produktion)
|
||||||
|
|
||||||
|
Setzen Sie folgende Umgebungsvariablen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DB_ENGINE=django.db.backends.postgresql
|
||||||
|
export DB_NAME=faktenkompass
|
||||||
|
export DB_USER=ihr_benutzer
|
||||||
|
export DB_PASSWORD=ihr_passwort
|
||||||
|
export DB_HOST=localhost
|
||||||
|
export DB_PORT=5432
|
||||||
|
```
|
||||||
|
|
||||||
|
Installieren Sie zusätzlich `psycopg2-binary` und führen Sie `migrate` erneut aus.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
ArgumentAtlas/
|
||||||
|
├── faktenkompass/ # Django-Projektkonfiguration
|
||||||
|
│ ├── settings.py
|
||||||
|
│ └── urls.py
|
||||||
|
├── claims/ # Haupt-App
|
||||||
|
│ ├── models.py # Category, Claim, Source, EvidenceFile, CounterArgument, Tag
|
||||||
|
│ ├── views.py
|
||||||
|
│ ├── urls.py
|
||||||
|
│ ├── admin.py
|
||||||
|
│ └── management/commands/load_sample_data.py
|
||||||
|
├── templates/ # Django-Templates (Bootstrap 5)
|
||||||
|
├── static/css/custom.css # Minimales Custom-Styling
|
||||||
|
├── requirements.txt
|
||||||
|
└── manage.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- **Startseite:** Kategorien als Karten mit Behauptungsanzahl
|
||||||
|
- **Kategorieseite:** Behauptungen filtern und durchsuchen
|
||||||
|
- **Behauptungsdetail:** Status, Kurzantwort, Nachweise (Bilder/PDFs), Evidenzlevel, Quellen, Gegenargumente, Tags
|
||||||
|
- **Globale Suche:** Behauptungen über alle Kategorien hinweg
|
||||||
|
- **Admin:** Alle Inhalte über Django Admin pflegen
|
||||||
|
- **SEO:** Sprechende URLs (`/kategorie/klimawandel/`, `/behauptung/...`)
|
||||||
|
|
||||||
|
## Inhalte verwalten
|
||||||
|
|
||||||
|
Melden Sie sich im Django-Admin an, um Kategorien, Behauptungen, Quellen, Gegenargumente und Schlagwörter anzulegen oder zu bearbeiten. Slugs werden automatisch aus Titeln generiert.
|
||||||
|
|
||||||
|
### Nachweise hochladen
|
||||||
|
|
||||||
|
Bei jeder Behauptung können im Admin unter **Nachweise** Dateien hochgeladen werden:
|
||||||
|
|
||||||
|
- **Bilder:** JPG, PNG, GIF, WebP, SVG (z. B. Grafiken, Screenshots, Diagramme)
|
||||||
|
- **PDFs:** Studien, Berichte, Faktenblätter
|
||||||
|
|
||||||
|
Optional: Titel, Bildunterschrift und Reihenfolge. Hochgeladene Dateien erscheinen auf der Behauptungsdetailseite im Abschnitt „Nachweise“.
|
||||||
|
|
||||||
|
Dateien werden unter `media/evidence/` gespeichert. In der Produktion muss der Webserver Medien-Dateien ausliefern (z. B. über Nginx).
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Projekt für den internen Gebrauch / Bildungszwecke.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import Category, Claim, CounterArgument, EvidenceFile, Source, Tag
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceFileInline(admin.TabularInline):
|
||||||
|
model = EvidenceFile
|
||||||
|
extra = 1
|
||||||
|
fields = ('title', 'file', 'caption', 'order')
|
||||||
|
|
||||||
|
|
||||||
|
class SourceInline(admin.TabularInline):
|
||||||
|
model = Source
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
|
class CounterArgumentInline(admin.StackedInline):
|
||||||
|
model = CounterArgument
|
||||||
|
extra = 1
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Tag)
|
||||||
|
class TagAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'slug')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
search_fields = ('name',)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Category)
|
||||||
|
class CategoryAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'slug', 'claim_count', 'created_at')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
search_fields = ('name', 'description')
|
||||||
|
|
||||||
|
@admin.display(description='Anzahl Behauptungen')
|
||||||
|
def claim_count(self, obj):
|
||||||
|
return obj.claims.count()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Claim)
|
||||||
|
class ClaimAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'category', 'status', 'evidence_level', 'updated_at')
|
||||||
|
list_filter = ('category', 'status', 'evidence_level', 'tags')
|
||||||
|
search_fields = ('title', 'short_answer', 'detailed_explanation')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
filter_horizontal = ('tags',)
|
||||||
|
inlines = [EvidenceFileInline, SourceInline, CounterArgumentInline]
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('category', 'title', 'slug', 'status', 'evidence_level', 'tags'),
|
||||||
|
}),
|
||||||
|
('Inhalt', {
|
||||||
|
'fields': ('short_answer', 'detailed_explanation'),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EvidenceFile)
|
||||||
|
class EvidenceFileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('display_title', 'claim', 'file_extension_display', 'order', 'created_at')
|
||||||
|
list_filter = ('claim__category',)
|
||||||
|
search_fields = ('title', 'caption', 'claim__title')
|
||||||
|
|
||||||
|
@admin.display(description='Typ')
|
||||||
|
def file_extension_display(self, obj):
|
||||||
|
return obj.file_extension.upper()
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Source)
|
||||||
|
class SourceAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('title', 'organization', 'claim')
|
||||||
|
list_filter = ('claim__category',)
|
||||||
|
search_fields = ('title', 'organization', 'description')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(CounterArgument)
|
||||||
|
class CounterArgumentAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('argument_preview', 'claim')
|
||||||
|
list_filter = ('claim__category',)
|
||||||
|
search_fields = ('argument', 'response')
|
||||||
|
|
||||||
|
@admin.display(description='Gegenargument')
|
||||||
|
def argument_preview(self, obj):
|
||||||
|
return obj.argument[:80]
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'claims'
|
||||||
|
verbose_name = 'Faktenkompass Inhalte'
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from claims.models import Category, Claim, CounterArgument, Source, Tag
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Lädt Beispieldaten für Faktenkompass (Kategorien, Behauptungen, Quellen).'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if Category.objects.exists():
|
||||||
|
self.stdout.write(self.style.WARNING('Datenbank enthält bereits Daten. Abbruch.'))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write('Erstelle Beispieldaten…')
|
||||||
|
|
||||||
|
tags = {
|
||||||
|
'impfstoff': Tag.objects.create(name='Impfstoff'),
|
||||||
|
'co2': Tag.objects.create(name='CO₂'),
|
||||||
|
'erneuerbare': Tag.objects.create(name='Erneuerbare Energien'),
|
||||||
|
'migration': Tag.objects.create(name='Migration'),
|
||||||
|
'inflation': Tag.objects.create(name='Inflation'),
|
||||||
|
'forschung': Tag.objects.create(name='Forschung'),
|
||||||
|
'gesundheit': Tag.objects.create(name='Gesundheit'),
|
||||||
|
'klima': Tag.objects.create(name='Klima'),
|
||||||
|
}
|
||||||
|
|
||||||
|
categories = {
|
||||||
|
'corona': Category.objects.create(
|
||||||
|
name='Corona',
|
||||||
|
description='Behauptungen zu COVID-19, Impfungen, Maßnahmen und Langzeitfolgen – mit Quellen aus Medizin und Epidemiologie.',
|
||||||
|
icon='bi-virus',
|
||||||
|
),
|
||||||
|
'klimawandel': Category.objects.create(
|
||||||
|
name='Klimawandel',
|
||||||
|
description='Fakten zur globalen Erwärmung, Treibhausgasen und Klimafolgen auf Basis wissenschaftlicher Erkenntnisse.',
|
||||||
|
icon='bi-thermometer-half',
|
||||||
|
),
|
||||||
|
'energie': Category.objects.create(
|
||||||
|
name='Energie',
|
||||||
|
description='Aussagen zu Stromerzeugung, erneuerbaren Energien, Atomkraft und Energiesicherheit.',
|
||||||
|
icon='bi-lightning-charge',
|
||||||
|
),
|
||||||
|
'migration': Category.objects.create(
|
||||||
|
name='Migration',
|
||||||
|
description='Überprüfbare Informationen zu Zuwanderung, Asyl, Integration und demografischen Entwicklungen.',
|
||||||
|
icon='bi-people',
|
||||||
|
),
|
||||||
|
'wirtschaft': Category.objects.create(
|
||||||
|
name='Wirtschaft',
|
||||||
|
description='Behauptungen zu Inflation, Staatsverschuldung, Arbeitsmarkt und wirtschaftlicher Entwicklung.',
|
||||||
|
icon='bi-graph-up',
|
||||||
|
),
|
||||||
|
'politik': Category.objects.create(
|
||||||
|
name='Politik',
|
||||||
|
description='Häufige politische Aussagen und deren empirische Überprüfung – sachlich und quellenbasiert.',
|
||||||
|
icon='bi-bank',
|
||||||
|
),
|
||||||
|
'wissenschaft': Category.objects.create(
|
||||||
|
name='Wissenschaft',
|
||||||
|
description='Grundlagen wissenschaftlicher Methodik, Peer Review und typische Missverständnisse über Forschung.',
|
||||||
|
icon='bi-mortarboard',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
self._create_corona_claims(categories['corona'], tags)
|
||||||
|
self._create_climate_claims(categories['klimawandel'], tags)
|
||||||
|
self._create_energy_claims(categories['energie'], tags)
|
||||||
|
self._create_migration_claims(categories['migration'], tags)
|
||||||
|
self._create_economy_claims(categories['wirtschaft'], tags)
|
||||||
|
self._create_politics_claims(categories['politik'], tags)
|
||||||
|
self._create_science_claims(categories['wissenschaft'], tags)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f'Fertig: {Category.objects.count()} Kategorien, '
|
||||||
|
f'{Claim.objects.count()} Behauptungen, '
|
||||||
|
f'{Source.objects.count()} Quellen.'
|
||||||
|
))
|
||||||
|
|
||||||
|
def _create_corona_claims(self, category, tags):
|
||||||
|
c1 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='mRNA-Impfstoffe verändern die menschliche DNA dauerhaft',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='mRNA-Impfstoffe gelangen nicht in den Zellkern und können die DNA nicht verändern. Die mRNA wird nach wenigen Tagen abgebaut.',
|
||||||
|
detailed_explanation=(
|
||||||
|
'Die mRNA in COVID-19-Impfstoffen dient als Bauanleitung für das Spike-Protein. '
|
||||||
|
'Sie bleibt im Zytoplasma und wird nicht in den Zellkern transportiert, wo die DNA liegt. '
|
||||||
|
'Integration in das Erbgut wäre ohne spezielle Hilfsmechanismen, die Impfstoffe nicht enthalten, praktisch nicht möglich.'
|
||||||
|
),
|
||||||
|
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
|
||||||
|
)
|
||||||
|
c1.tags.add(tags['impfstoff'], tags['gesundheit'])
|
||||||
|
Source.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
title='Zusammenfassung zur Sicherheit von COVID-19-Impfstoffen',
|
||||||
|
organization='Robert Koch-Institut (RKI)',
|
||||||
|
url='https://www.rki.de/DE/Content/InfAZ/N/Neuartiges_Coronavirus/Impfen/Impfen_node.html',
|
||||||
|
description='Offizielle Informationen des RKI zu Wirkweise und Sicherheit der Impfstoffe.',
|
||||||
|
)
|
||||||
|
CounterArgument.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
argument='Die mRNA ist künstlich und kann deshalb die Zelle dauerhaft verändern.',
|
||||||
|
response='Künstliche mRNA unterscheidet sich in der Wirkung nicht grundlegend von natürlicher mRNA im Körper. Beide werden abgebaut. Es gibt keinen Mechanismus, durch den Impf-mRNA in die DNA integriert wird.',
|
||||||
|
)
|
||||||
|
|
||||||
|
c2 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='COVID-19 ist nicht gefährlicher als eine normale Grippe',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='In den ersten Pandemiejahren war COVID-19 deutlich tödlicher als saisonale Influenza und führte weltweit zu Millionen überzähliger Todesfälle.',
|
||||||
|
detailed_explanation='Vergleiche mit der Grippe vereinfachen oft: COVID-19 verursachte höhere Belastungen für Intensivstationen und wies in vulnerablen Gruppen eine höhere Letalität auf.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
c2.tags.add(tags['gesundheit'])
|
||||||
|
Source.objects.create(
|
||||||
|
claim=c2,
|
||||||
|
title='COVID-19: Fallzahlen und Sterblichkeit in Deutschland',
|
||||||
|
organization='Statistisches Bundesamt (Destatis)',
|
||||||
|
url='https://www.destatis.de/DE/Themen/Gesellschaft-Umwelt/Gesundheit/Todesursachen/_inhalt.html',
|
||||||
|
description='Amtliche Sterblichkeitsstatistiken und Sonderanalysen zur Pandemie.',
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Masken im Alltag schützen nachweislich vor Ansteckung mit respirativen Viren',
|
||||||
|
status=Claim.Status.WISSENSCHAFTLICHER_KONSENS,
|
||||||
|
short_answer='Gut sitzende medizinische Masken und FFP2-Masken reduzieren nachweislich die Übertragung von Tröpfchen und Aerosolen, besonders in Innenräumen.',
|
||||||
|
detailed_explanation='Metaanalysen zeigen, dass Masken die Emission und Aufnahme infektiöser Partikel verringern. Der Schutz hängt von Maskentyp, Passform und Nutzung ab.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_climate_claims(self, category, tags):
|
||||||
|
c1 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Der Klimawandel wird hauptsächlich durch natürliche Zyklen verursacht',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='Die aktuelle Erwärmung lässt sich nicht durch natürliche Zyklen allein erklären. Treibhausgasemissionen sind laut IPCC die dominierende Ursache.',
|
||||||
|
detailed_explanation='Klimamodelle mit nur natürlichen Faktoren reproduzieren die gemessene Temperaturanomalie nicht. Mit anthropogenen Emissionen stimmen Beobachtungen und Modelle überein.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
|
||||||
|
)
|
||||||
|
c1.tags.add(tags['klima'], tags['co2'])
|
||||||
|
Source.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
title='AR6 Synthesis Report: Climate Change 2023',
|
||||||
|
organization='Zwischenstaatlicher Ausschuss für Klimaänderungen (IPCC)',
|
||||||
|
url='https://www.ipcc.ch/report/ar6/syr/',
|
||||||
|
description='Aktueller Synthesebericht des Weltklimarats.',
|
||||||
|
)
|
||||||
|
CounterArgument.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
argument='Früher gab es auch warme und kalte Perioden ohne Industrie.',
|
||||||
|
response='Natürliche Schwankungen sind bekannt, aber die Geschwindigkeit der aktuellen Erwärmung passt zu Treibhausgasanstieg, nicht zu dominanten natürlichen Zyklen.',
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Es gibt keinen wissenschaftlichen Konsens zum menschengemachten Klimawandel',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='Über 97 % der klimabezogenen Fachpublikationen stützen den Konsens, dass der Mensch die Hauptursache der aktuellen Erwärmung ist.',
|
||||||
|
detailed_explanation='Große wissenschaftliche Gesellschaften weltweit befürworten diese Einschätzung.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Extremwetterereignisse nehmen in Häufigkeit und Intensität zu',
|
||||||
|
status=Claim.Status.TEILWEISE_RICHTIG,
|
||||||
|
short_answer='Viele Extremwetterarten zeigen einen Zusammenhang mit dem Klimawandel, aber nicht jedes Ereignis ist direkt darauf zurückzuführen.',
|
||||||
|
detailed_explanation='Hitzewellen und starke Niederschläge werden in vielen Regionen häufiger. Attributionsforschung berechnet Wahrscheinlichkeitsveränderungen für Einzelereignisse.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_energy_claims(self, category, tags):
|
||||||
|
c1 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Deutschland kann seinen Strombedarf ausschließlich mit Solar und Wind decken',
|
||||||
|
status=Claim.Status.TEILWEISE_RICHTIG,
|
||||||
|
short_answer='Langfristig ist ein sehr hoher Anteil erneuerbarer Energien möglich, erfordert aber Speicher, Netzausbau und Flexibilität.',
|
||||||
|
detailed_explanation='Studien zeigen Szenarien mit nahezu 100 % erneuerbarem Strom, erfordern aber Infrastruktur und Sektorkopplung.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.MITTEL,
|
||||||
|
)
|
||||||
|
c1.tags.add(tags['erneuerbare'])
|
||||||
|
CounterArgument.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
argument='Wenn die Sonne nicht scheint und der Wind nicht weht, fällt das Netz aus.',
|
||||||
|
response='Energiesysteme kombinieren Speicher, Netze, Importe und Reservekapazitäten statt auf eine einzige Quelle zu setzen.',
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Atomkraft ist in Deutschland klimaneutral und deshalb unverzichtbar',
|
||||||
|
status=Claim.Status.IRREFUEHREND,
|
||||||
|
short_answer='Atomkraft erzeugt wenig CO₂ im Betrieb, verursacht aber andere Risiken und hohe Folgekosten. Sie ist nicht die einzige klimafreundliche Option.',
|
||||||
|
detailed_explanation='Lebenszyklusanalysen zeigen niedrige CO₂-Emissionen, aber Endlagerung und Unfallrisiken sind zentrale Gegenargumente.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Der Ausbau erneuerbarer Energien treibt die Strompreise allein massiv nach oben',
|
||||||
|
status=Claim.Status.IRREFUEHREND,
|
||||||
|
short_answer='Strompreise werden von Brennstoffkosten, Netzentgelten und Abgaben beeinflusst. Erneuerbare senken Grenzkosten, verursachen aber Netzausbaukosten.',
|
||||||
|
detailed_explanation='In Phasen hoher Gaspreise stiegen Börsenstrompreise stark. Langfristig sind Wind und Solar kostengünstige Neubau-Technologien.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.MITTEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_migration_claims(self, category, tags):
|
||||||
|
c1 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Die Mehrheit der Migranten in Deutschland ist arbeitslos und lebt von Sozialleistungen',
|
||||||
|
status=Claim.Status.IRREFUEHREND,
|
||||||
|
short_answer='Die Erwerbstätigenquote variiert stark nach Herkunft und Aufenthaltsdauer. Pauschale Aussagen sind nicht haltbar.',
|
||||||
|
detailed_explanation='Viele Zuwanderer arbeiten nach mehreren Jahren ähnlich häufig wie die Gesamtbevölkerung. Neuankömmlinge weisen anfangs häufiger Arbeitslosigkeit auf.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
c1.tags.add(tags['migration'])
|
||||||
|
Source.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
title='Zuwanderung und Arbeitsmarkt',
|
||||||
|
organization='Institut für Arbeitsmarkt- und Berufsforschung (IAB)',
|
||||||
|
url='https://www.iab.de/',
|
||||||
|
description='Forschung des IAB zu Migration und Beschäftigung.',
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Deutschland hat die höchste Zuwanderung in Europa',
|
||||||
|
status=Claim.Status.TEILWEISE_RICHTIG,
|
||||||
|
short_answer='Deutschland nimmt absolut viele Zuwanderer auf, liegt pro Kopf aber nicht immer an der Spitze in Europa.',
|
||||||
|
detailed_explanation='Eurostat-Daten zeigen Deutschland unter den Ländern mit höchsten absoluten Zahlen. Relativ zur Bevölkerung liegen andere Länder teils höher.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_economy_claims(self, category, tags):
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Die Inflation in Deutschland wird allein durch Lohnerhöhungen verursacht',
|
||||||
|
status=Claim.Status.IRREFUEHREND,
|
||||||
|
short_answer='Lohnkosten können Preise mitverursachen, aber Energiepreise, Lieferketten und Geldpolitik spielen ebenfalls eine zentrale Rolle.',
|
||||||
|
detailed_explanation='Die Inflation 2022/2023 wurde maßgeblich durch Energie- und Rohstoffpreisschocks getrieben.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Deutschland ist der größte Exporteur der Welt',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='Deutschland ist ein führender Exporteur, lag in den letzten Jahren aber hinter China.',
|
||||||
|
detailed_explanation='Nach WTO-Daten exportierte China mehr Waren als Deutschland. Deutschland bleibt stark im Maschinen- und Fahrzeugbau.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_politics_claims(self, category, tags):
|
||||||
|
Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Wahlbeteiligung in Deutschland sinkt seit Jahrzehnten kontinuierlich',
|
||||||
|
status=Claim.Status.TEILWEISE_RICHTIG,
|
||||||
|
short_answer='Bei Bundestagswahlen schwankt die Beteiligung; langfristig gibt es einen leichten Rückgang, aber keine einheitliche Abwärtsspirale.',
|
||||||
|
detailed_explanation='Historische Daten zeigen Schwankungen zwischen etwa 70 % und 90 % bei Bundestagswahlen.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.STARK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_science_claims(self, category, tags):
|
||||||
|
c1 = Claim.objects.create(
|
||||||
|
category=category,
|
||||||
|
title='Eine einzelne Studie beweist wissenschaftlichen Konsens',
|
||||||
|
status=Claim.Status.FALSCH,
|
||||||
|
short_answer='Einzelstudien sind Ausgangspunkte, kein abschließender Beweis. Konsens entsteht durch Replikation und Synthese vieler Arbeiten.',
|
||||||
|
detailed_explanation='Peer Review filtert Fehler, garantiert aber keine Wahrheit. Metaanalysen fassen viele Studien zusammen.',
|
||||||
|
evidence_level=Claim.EvidenceLevel.SEHR_STARK,
|
||||||
|
)
|
||||||
|
c1.tags.add(tags['forschung'])
|
||||||
|
CounterArgument.objects.create(
|
||||||
|
claim=c1,
|
||||||
|
argument='Wenn eine Studie in einer renommierten Zeitschrift steht, ist sie bewiesen.',
|
||||||
|
response='Renommierte Journale publizieren auch widerlegte Ergebnisse. Die Qualität hängt von Replikation und Methodik ab.',
|
||||||
|
)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-07 10:32
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Category',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=200, verbose_name='Name')),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=220, unique=True, verbose_name='Slug')),
|
||||||
|
('description', models.TextField(verbose_name='Beschreibung')),
|
||||||
|
('icon', models.CharField(blank=True, help_text='z. B. bi-thermometer-half', max_length=50, verbose_name='Icon (Bootstrap Icons Klasse)')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Kategorie',
|
||||||
|
'verbose_name_plural': 'Kategorien',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True, verbose_name='Name')),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=120, unique=True, verbose_name='Slug')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Schlagwort',
|
||||||
|
'verbose_name_plural': 'Schlagwörter',
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Claim',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=300, verbose_name='Titel (Behauptung)')),
|
||||||
|
('slug', models.SlugField(blank=True, max_length=320, unique=True, verbose_name='Slug')),
|
||||||
|
('status', models.CharField(choices=[('falsch', 'Falsch'), ('irrefuehrend', 'Irreführend'), ('teilweise_richtig', 'Teilweise richtig'), ('unbelegt', 'Unbelegt'), ('wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'), ('offene_frage', 'Offene Frage')], default='unbelegt', max_length=40, verbose_name='Status')),
|
||||||
|
('short_answer', models.TextField(verbose_name='Kurzantwort')),
|
||||||
|
('detailed_explanation', models.TextField(blank=True, verbose_name='Ausführliche Erklärung')),
|
||||||
|
('evidence_level', models.CharField(choices=[('sehr_stark', 'Sehr stark'), ('stark', 'Stark'), ('mittel', 'Mittel'), ('schwach', 'Schwach')], default='mittel', max_length=20, verbose_name='Evidenzlevel')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Erstellt am')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Aktualisiert am')),
|
||||||
|
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='claims', to='claims.category', verbose_name='Kategorie')),
|
||||||
|
('tags', models.ManyToManyField(blank=True, to='claims.tag', verbose_name='Schlagwörter')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Behauptung',
|
||||||
|
'verbose_name_plural': 'Behauptungen',
|
||||||
|
'ordering': ['-updated_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CounterArgument',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('argument', models.TextField(verbose_name='Gegenargument')),
|
||||||
|
('response', models.TextField(verbose_name='Antwort')),
|
||||||
|
('claim', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='counter_arguments', to='claims.claim', verbose_name='Behauptung')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Gegenargument',
|
||||||
|
'verbose_name_plural': 'Gegenargumente',
|
||||||
|
'ordering': ['id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Source',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(max_length=300, verbose_name='Titel')),
|
||||||
|
('organization', models.CharField(max_length=200, verbose_name='Autor/Organisation')),
|
||||||
|
('url', models.URLField(max_length=500, verbose_name='Link')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='Beschreibung')),
|
||||||
|
('claim', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sources', to='claims.claim', verbose_name='Behauptung')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Quelle',
|
||||||
|
'verbose_name_plural': 'Quellen',
|
||||||
|
'ordering': ['id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.15 on 2026-07-07 10:59
|
||||||
|
|
||||||
|
import claims.models
|
||||||
|
import django.core.validators
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('claims', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EvidenceFile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('title', models.CharField(blank=True, max_length=200, verbose_name='Titel')),
|
||||||
|
('file', models.FileField(help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF', upload_to=claims.models.evidence_upload_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'])], verbose_name='Datei')),
|
||||||
|
('caption', models.CharField(blank=True, max_length=500, verbose_name='Bildunterschrift')),
|
||||||
|
('order', models.PositiveSmallIntegerField(default=0, verbose_name='Reihenfolge')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Hochgeladen am')),
|
||||||
|
('claim', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evidence_files', to='claims.claim', verbose_name='Behauptung')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Nachweis',
|
||||||
|
'verbose_name_plural': 'Nachweise',
|
||||||
|
'ordering': ['order', 'id'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
from django.core.validators import FileExtensionValidator
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf']
|
||||||
|
IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'}
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(models.Model):
|
||||||
|
name = models.CharField('Name', max_length=100, unique=True)
|
||||||
|
slug = models.SlugField('Slug', max_length=120, unique=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Schlagwort'
|
||||||
|
verbose_name_plural = 'Schlagwörter'
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(models.Model):
|
||||||
|
name = models.CharField('Name', max_length=200)
|
||||||
|
slug = models.SlugField('Slug', max_length=220, unique=True, blank=True)
|
||||||
|
description = models.TextField('Beschreibung')
|
||||||
|
icon = models.CharField(
|
||||||
|
'Icon (Bootstrap Icons Klasse)',
|
||||||
|
max_length=50,
|
||||||
|
blank=True,
|
||||||
|
help_text='z. B. bi-thermometer-half',
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Kategorie'
|
||||||
|
verbose_name_plural = 'Kategorien'
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('claims:category_detail', kwargs={'slug': self.slug})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def claim_count(self):
|
||||||
|
return self.claims.count()
|
||||||
|
|
||||||
|
|
||||||
|
class Claim(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
FALSCH = 'falsch', 'Falsch'
|
||||||
|
IRREFUEHREND = 'irrefuehrend', 'Irreführend'
|
||||||
|
TEILWEISE_RICHTIG = 'teilweise_richtig', 'Teilweise richtig'
|
||||||
|
UNBELEGT = 'unbelegt', 'Unbelegt'
|
||||||
|
WISSENSCHAFTLICHER_KONSENS = 'wissenschaftlicher_konsens', 'Wissenschaftlicher Konsens'
|
||||||
|
OFFENE_FRAGE = 'offene_frage', 'Offene Frage'
|
||||||
|
|
||||||
|
class EvidenceLevel(models.TextChoices):
|
||||||
|
SEHR_STARK = 'sehr_stark', 'Sehr stark'
|
||||||
|
STARK = 'stark', 'Stark'
|
||||||
|
MITTEL = 'mittel', 'Mittel'
|
||||||
|
SCHWACH = 'schwach', 'Schwach'
|
||||||
|
|
||||||
|
category = models.ForeignKey(
|
||||||
|
Category,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='claims',
|
||||||
|
verbose_name='Kategorie',
|
||||||
|
)
|
||||||
|
title = models.CharField('Titel (Behauptung)', max_length=300)
|
||||||
|
slug = models.SlugField('Slug', max_length=320, unique=True, blank=True)
|
||||||
|
status = models.CharField(
|
||||||
|
'Status',
|
||||||
|
max_length=40,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.UNBELEGT,
|
||||||
|
)
|
||||||
|
short_answer = models.TextField('Kurzantwort')
|
||||||
|
detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True)
|
||||||
|
evidence_level = models.CharField(
|
||||||
|
'Evidenzlevel',
|
||||||
|
max_length=20,
|
||||||
|
choices=EvidenceLevel.choices,
|
||||||
|
default=EvidenceLevel.MITTEL,
|
||||||
|
)
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, verbose_name='Schlagwörter')
|
||||||
|
created_at = models.DateTimeField('Erstellt am', auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField('Aktualisiert am', auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Behauptung'
|
||||||
|
verbose_name_plural = 'Behauptungen'
|
||||||
|
ordering = ['-updated_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
base_slug = slugify(self.title)
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while Claim.objects.filter(slug=slug).exclude(pk=self.pk).exists():
|
||||||
|
slug = f'{base_slug}-{counter}'
|
||||||
|
counter += 1
|
||||||
|
self.slug = slug
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('claims:claim_detail', kwargs={'slug': self.slug})
|
||||||
|
|
||||||
|
def get_status_badge_class(self):
|
||||||
|
mapping = {
|
||||||
|
self.Status.FALSCH: 'danger',
|
||||||
|
self.Status.IRREFUEHREND: 'warning',
|
||||||
|
self.Status.TEILWEISE_RICHTIG: 'info',
|
||||||
|
self.Status.UNBELEGT: 'secondary',
|
||||||
|
self.Status.WISSENSCHAFTLICHER_KONSENS: 'success',
|
||||||
|
self.Status.OFFENE_FRAGE: 'primary',
|
||||||
|
}
|
||||||
|
return mapping.get(self.status, 'secondary')
|
||||||
|
|
||||||
|
def get_evidence_badge_class(self):
|
||||||
|
mapping = {
|
||||||
|
self.EvidenceLevel.SEHR_STARK: 'success',
|
||||||
|
self.EvidenceLevel.STARK: 'primary',
|
||||||
|
self.EvidenceLevel.MITTEL: 'info',
|
||||||
|
self.EvidenceLevel.SCHWACH: 'warning',
|
||||||
|
}
|
||||||
|
return mapping.get(self.evidence_level, 'secondary')
|
||||||
|
|
||||||
|
|
||||||
|
def evidence_upload_path(instance, filename):
|
||||||
|
return f'evidence/{instance.claim.slug}/{filename}'
|
||||||
|
|
||||||
|
|
||||||
|
class EvidenceFile(models.Model):
|
||||||
|
claim = models.ForeignKey(
|
||||||
|
Claim,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='evidence_files',
|
||||||
|
verbose_name='Behauptung',
|
||||||
|
)
|
||||||
|
title = models.CharField('Titel', max_length=200, blank=True)
|
||||||
|
file = models.FileField(
|
||||||
|
'Datei',
|
||||||
|
upload_to=evidence_upload_path,
|
||||||
|
validators=[FileExtensionValidator(allowed_extensions=EVIDENCE_FILE_EXTENSIONS)],
|
||||||
|
help_text='Bilder (JPG, PNG, GIF, WebP, SVG) oder PDF',
|
||||||
|
)
|
||||||
|
caption = models.CharField('Bildunterschrift', max_length=500, blank=True)
|
||||||
|
order = models.PositiveSmallIntegerField('Reihenfolge', default=0)
|
||||||
|
created_at = models.DateTimeField('Hochgeladen am', auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Nachweis'
|
||||||
|
verbose_name_plural = 'Nachweise'
|
||||||
|
ordering = ['order', 'id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.display_title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_title(self):
|
||||||
|
if self.title:
|
||||||
|
return self.title
|
||||||
|
return self.file.name.rsplit('/', 1)[-1]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_extension(self):
|
||||||
|
return self.file.name.rsplit('.', 1)[-1].lower()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_image(self):
|
||||||
|
return self.file_extension in IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_pdf(self):
|
||||||
|
return self.file_extension == 'pdf'
|
||||||
|
|
||||||
|
|
||||||
|
class Source(models.Model):
|
||||||
|
claim = models.ForeignKey(
|
||||||
|
Claim,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='sources',
|
||||||
|
verbose_name='Behauptung',
|
||||||
|
)
|
||||||
|
title = models.CharField('Titel', max_length=300)
|
||||||
|
organization = models.CharField('Autor/Organisation', max_length=200)
|
||||||
|
url = models.URLField('Link', max_length=500)
|
||||||
|
description = models.TextField('Beschreibung', blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Quelle'
|
||||||
|
verbose_name_plural = 'Quellen'
|
||||||
|
ordering = ['id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
|
class CounterArgument(models.Model):
|
||||||
|
claim = models.ForeignKey(
|
||||||
|
Claim,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='counter_arguments',
|
||||||
|
verbose_name='Behauptung',
|
||||||
|
)
|
||||||
|
argument = models.TextField('Gegenargument')
|
||||||
|
response = models.TextField('Antwort')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Gegenargument'
|
||||||
|
verbose_name_plural = 'Gegenargumente'
|
||||||
|
ordering = ['id']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.argument[:80]
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def status_label(value):
|
||||||
|
from claims.models import Claim
|
||||||
|
return dict(Claim.Status.choices).get(value, value)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def evidence_label(value):
|
||||||
|
from claims.models import Claim
|
||||||
|
return dict(Claim.EvidenceLevel.choices).get(value, value)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = 'claims'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', views.home, name='home'),
|
||||||
|
path('suche/', views.search, name='search'),
|
||||||
|
path('kategorie/<slug:slug>/', views.category_detail, name='category_detail'),
|
||||||
|
path('behauptung/<slug:slug>/', views.claim_detail, name='claim_detail'),
|
||||||
|
]
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
from django.db.models import Q
|
||||||
|
from django.shortcuts import get_object_or_404, render
|
||||||
|
|
||||||
|
from .models import Category, Claim
|
||||||
|
|
||||||
|
|
||||||
|
def home(request):
|
||||||
|
query = request.GET.get('q', '').strip()
|
||||||
|
categories = Category.objects.all()
|
||||||
|
|
||||||
|
if query:
|
||||||
|
categories = categories.filter(
|
||||||
|
Q(name__icontains=query) | Q(description__icontains=query)
|
||||||
|
)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'categories': categories,
|
||||||
|
'query': query,
|
||||||
|
'total_claims': Claim.objects.count(),
|
||||||
|
}
|
||||||
|
return render(request, 'claims/home.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def category_detail(request, slug):
|
||||||
|
category = get_object_or_404(Category, slug=slug)
|
||||||
|
query = request.GET.get('q', '').strip()
|
||||||
|
status = request.GET.get('status', '').strip()
|
||||||
|
|
||||||
|
claims = category.claims.select_related('category').prefetch_related('tags')
|
||||||
|
|
||||||
|
if query:
|
||||||
|
claims = claims.filter(
|
||||||
|
Q(title__icontains=query)
|
||||||
|
| Q(short_answer__icontains=query)
|
||||||
|
| Q(tags__name__icontains=query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if status and status in dict(Claim.Status.choices):
|
||||||
|
claims = claims.filter(status=status)
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'category': category,
|
||||||
|
'claims': claims,
|
||||||
|
'query': query,
|
||||||
|
'status': status,
|
||||||
|
'status_choices': Claim.Status.choices,
|
||||||
|
}
|
||||||
|
return render(request, 'claims/category_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def claim_detail(request, slug):
|
||||||
|
claim = get_object_or_404(
|
||||||
|
Claim.objects.select_related('category').prefetch_related(
|
||||||
|
'tags', 'sources', 'counter_arguments', 'evidence_files'
|
||||||
|
),
|
||||||
|
slug=slug,
|
||||||
|
)
|
||||||
|
related_claims = (
|
||||||
|
Claim.objects.filter(category=claim.category)
|
||||||
|
.exclude(pk=claim.pk)
|
||||||
|
.select_related('category')[:3]
|
||||||
|
)
|
||||||
|
context = {
|
||||||
|
'claim': claim,
|
||||||
|
'related_claims': related_claims,
|
||||||
|
}
|
||||||
|
return render(request, 'claims/claim_detail.html', context)
|
||||||
|
|
||||||
|
|
||||||
|
def search(request):
|
||||||
|
query = request.GET.get('q', '').strip()
|
||||||
|
category_slug = request.GET.get('kategorie', '').strip()
|
||||||
|
status = request.GET.get('status', '').strip()
|
||||||
|
|
||||||
|
claims = Claim.objects.select_related('category').prefetch_related('tags')
|
||||||
|
|
||||||
|
if query:
|
||||||
|
claims = claims.filter(
|
||||||
|
Q(title__icontains=query)
|
||||||
|
| Q(short_answer__icontains=query)
|
||||||
|
| Q(detailed_explanation__icontains=query)
|
||||||
|
| Q(tags__name__icontains=query)
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
if category_slug:
|
||||||
|
claims = claims.filter(category__slug=category_slug)
|
||||||
|
|
||||||
|
if status and status in dict(Claim.Status.choices):
|
||||||
|
claims = claims.filter(status=status)
|
||||||
|
|
||||||
|
categories = Category.objects.all()
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'claims': claims if query or category_slug or status else None,
|
||||||
|
'query': query,
|
||||||
|
'category_slug': category_slug,
|
||||||
|
'status': status,
|
||||||
|
'categories': categories,
|
||||||
|
'status_choices': Claim.Status.choices,
|
||||||
|
'result_count': claims.count() if query or category_slug or status else 0,
|
||||||
|
}
|
||||||
|
return render(request, 'claims/search.html', context)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
ASGI config for faktenkompass project.
|
||||||
|
|
||||||
|
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'faktenkompass.settings')
|
||||||
|
|
||||||
|
application = get_asgi_application()
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
Django settings for faktenkompass project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.2.15.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
# Quick-start development settings - unsuitable for production
|
||||||
|
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
|
||||||
|
|
||||||
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
|
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!
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = []
|
||||||
|
|
||||||
|
|
||||||
|
# Application definition
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'claims',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'faktenkompass.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'faktenkompass.wsgi.application'
|
||||||
|
|
||||||
|
|
||||||
|
# Database
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': os.environ.get('DB_ENGINE', 'django.db.backends.sqlite3'),
|
||||||
|
'NAME': os.environ.get('DB_NAME', BASE_DIR / 'db.sqlite3'),
|
||||||
|
'USER': os.environ.get('DB_USER', ''),
|
||||||
|
'PASSWORD': os.environ.get('DB_PASSWORD', ''),
|
||||||
|
'HOST': os.environ.get('DB_HOST', ''),
|
||||||
|
'PORT': os.environ.get('DB_PORT', ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
# https://docs.djangoproject.com/en/5.2/topics/i18n/
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'de-de'
|
||||||
|
|
||||||
|
TIME_ZONE = 'Europe/Berlin'
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
|
# Static files (CSS, JavaScript, Images)
|
||||||
|
# https://docs.djangoproject.com/en/5.2/howto/static-files/
|
||||||
|
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
URL configuration for faktenkompass project.
|
||||||
|
|
||||||
|
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||||
|
https://docs.djangoproject.com/en/5.2/topics/http/urls/
|
||||||
|
Examples:
|
||||||
|
Function views
|
||||||
|
1. Add an import: from my_app import views
|
||||||
|
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||||
|
Class-based views
|
||||||
|
1. Add an import: from other_app.views import Home
|
||||||
|
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||||
|
Including another URLconf
|
||||||
|
1. Import the include() function: from django.urls import include, path
|
||||||
|
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
admin.site.site_header = 'Faktenkompass Verwaltung'
|
||||||
|
admin.site.site_title = 'Faktenkompass Admin'
|
||||||
|
admin.site.index_title = 'Inhalte verwalten'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', include('claims.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""
|
||||||
|
WSGI config for faktenkompass project.
|
||||||
|
|
||||||
|
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'faktenkompass.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'faktenkompass.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Django>=5.0,<6.0
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
:root {
|
||||||
|
--fk-primary: #1e4d7b;
|
||||||
|
--fk-primary-dark: #163a5f;
|
||||||
|
--fk-bg: #f4f6f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--fk-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar.bg-primary {
|
||||||
|
background-color: var(--fk-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--fk-primary);
|
||||||
|
border-color: var(--fk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover,
|
||||||
|
.btn-primary:focus {
|
||||||
|
background-color: var(--fk-primary-dark);
|
||||||
|
border-color: var(--fk-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary {
|
||||||
|
color: var(--fk-primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile,
|
||||||
|
.claim-card {
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile {
|
||||||
|
min-height: 7.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-icon {
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(30, 77, 123, 0.08);
|
||||||
|
color: var(--fk-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile .card-title {
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-card .card-body {
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-img {
|
||||||
|
display: block;
|
||||||
|
max-height: 20rem;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evidence-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:not(.collapsed) {
|
||||||
|
background-color: rgba(30, 77, 123, 0.06);
|
||||||
|
color: var(--fk-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accordion-button:focus {
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(30, 77, 123, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.category-tile {
|
||||||
|
min-height: 6.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile-icon {
|
||||||
|
width: 2.25rem;
|
||||||
|
height: 2.25rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-tile .card-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<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 %}">
|
||||||
|
<title>{% block title %}Faktenkompass{% endblock %}</title>
|
||||||
|
<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="{% static 'css/custom.css' %}" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="d-flex flex-column min-vh-100">
|
||||||
|
<nav class="navbar navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="{% url 'claims:home' %}">
|
||||||
|
<i class="bi bi-compass me-1"></i>Faktenkompass
|
||||||
|
</a>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<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="btn btn-sm btn-outline-light{% if request.resolver_match.url_name == 'search' %} active{% endif %}" href="{% url 'claims:search' %}">Suche</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 py-3">
|
||||||
|
<div class="container">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="border-top py-2 mt-auto">
|
||||||
|
<div class="container">
|
||||||
|
<p class="text-muted text-center mb-0 small">© {% now "Y" %} Faktenkompass</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ category.name }} – Faktenkompass{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="Brotkrumen">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'claims:home' %}">Themen</a></li>
|
||||||
|
<li class="breadcrumb-item active">{{ category.name }}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<div class="category-tile-icon flex-shrink-0">
|
||||||
|
<i class="bi {{ category.icon|default:'bi-book' }}"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<h1 class="h5 mb-0 text-truncate">{{ category.name }}</h1>
|
||||||
|
<span class="small text-muted">{{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 mb-3">
|
||||||
|
<div class="col-12 col-sm">
|
||||||
|
<input type="search" id="q" name="q" class="form-control form-control-sm" placeholder="Suchen…" value="{{ query }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-7 col-sm-auto">
|
||||||
|
<select id="status" name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-5 col-sm-auto">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">Filtern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<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 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>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ claim.title }} – Faktenkompass{% endblock %}
|
||||||
|
{% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<nav aria-label="Brotkrumen">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'claims:home' %}">Themen</a></li>
|
||||||
|
<li class="breadcrumb-item"><a href="{{ claim.category.get_absolute_url }}">{{ claim.category.name }}</a></li>
|
||||||
|
<li class="breadcrumb-item active text-truncate" style="max-width: 10rem;">Behauptung</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<article>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-2">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h1 class="h5 fw-semibold mb-3">{{ claim.title }}</h1>
|
||||||
|
|
||||||
|
<div class="card mb-3 border-start border-3 border-primary">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{% if claim.evidence_files.all %}
|
||||||
|
<section class="mb-3">
|
||||||
|
<p class="small text-muted text-uppercase fw-semibold mb-2">Nachweise</p>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for evidence in claim.evidence_files.all %}
|
||||||
|
{% if evidence.is_image %}
|
||||||
|
<figure class="card mb-0 evidence-card">
|
||||||
|
<img src="{{ evidence.file.url }}" alt="{{ evidence.caption|default:evidence.display_title }}"
|
||||||
|
class="evidence-img w-100" loading="lazy">
|
||||||
|
{% if evidence.title or evidence.caption %}
|
||||||
|
<figcaption class="card-body py-2 px-3 small">
|
||||||
|
{% if evidence.title %}<strong class="d-block">{{ evidence.title }}</strong>{% endif %}
|
||||||
|
{% if evidence.caption %}<span class="text-muted">{{ evidence.caption }}</span>{% endif %}
|
||||||
|
</figcaption>
|
||||||
|
{% endif %}
|
||||||
|
</figure>
|
||||||
|
{% elif evidence.is_pdf %}
|
||||||
|
<a href="{{ evidence.file.url }}" target="_blank" rel="noopener noreferrer" class="text-decoration-none">
|
||||||
|
<div class="card evidence-card">
|
||||||
|
<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>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="small fw-medium text-dark d-block">{{ evidence.display_title }}</span>
|
||||||
|
{% if evidence.caption %}<span class="small text-muted">{{ evidence.caption }}</span>{% endif %}
|
||||||
|
<span class="small text-primary">PDF öffnen</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if claim.detailed_explanation %}
|
||||||
|
<section class="card mb-3">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="small text-muted text-uppercase fw-semibold mb-1">Erklärung</p>
|
||||||
|
<div class="small">{{ claim.detailed_explanation|linebreaks }}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if claim.sources.all %}
|
||||||
|
<section class="mb-3">
|
||||||
|
<p class="small text-muted text-uppercase fw-semibold mb-2">Quellen</p>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for source in claim.sources.all %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body py-2 px-3">
|
||||||
|
<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>
|
||||||
|
</a>
|
||||||
|
<p class="text-muted small mb-0">{{ source.organization }}</p>
|
||||||
|
{% if source.description %}
|
||||||
|
<p class="small mb-0 mt-1">{{ source.description }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if claim.counter_arguments.all %}
|
||||||
|
<section class="mb-3">
|
||||||
|
<p class="small text-muted text-uppercase fw-semibold mb-2">Gegenargumente</p>
|
||||||
|
<div class="accordion accordion-flush" id="counterArgs">
|
||||||
|
{% for ca in claim.counter_arguments.all %}
|
||||||
|
<div class="accordion-item border mb-1 rounded">
|
||||||
|
<h2 class="accordion-header">
|
||||||
|
<button class="accordion-button collapsed py-2 small" type="button"
|
||||||
|
data-bs-toggle="collapse" data-bs-target="#collapse{{ forloop.counter }}">
|
||||||
|
{{ ca.argument }}
|
||||||
|
</button>
|
||||||
|
</h2>
|
||||||
|
<div id="collapse{{ forloop.counter }}" class="accordion-collapse collapse"
|
||||||
|
data-bs-parent="#counterArgs">
|
||||||
|
<div class="accordion-body py-2 small text-muted">
|
||||||
|
{{ ca.response|linebreaks }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if claim.tags.all %}
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{% for tag in claim.tags.all %}
|
||||||
|
<span class="badge text-bg-light border text-secondary">#{{ tag.name }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
{% if related_claims %}
|
||||||
|
<aside class="mt-4 pt-3 border-top">
|
||||||
|
<p class="small text-muted text-uppercase fw-semibold mb-2">Mehr zu {{ claim.category.name }}</p>
|
||||||
|
<div class="d-flex flex-column gap-2">
|
||||||
|
{% for related in related_claims %}
|
||||||
|
<a href="{{ related.get_absolute_url }}" class="text-decoration-none">
|
||||||
|
<div class="card claim-card">
|
||||||
|
<div class="card-body py-2 d-flex align-items-start gap-2">
|
||||||
|
<span class="badge text-bg-{{ related.get_status_badge_class }} flex-shrink-0">{{ related.get_status_display }}</span>
|
||||||
|
<span class="small text-dark">{{ related.title }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Faktenkompass{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="page-header mb-3">
|
||||||
|
<h1 class="h4 mb-3">Themen</h1>
|
||||||
|
<form method="get" action="{% url 'claims:home' %}" class="mb-2">
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text bg-white border-end-0"><i class="bi bi-search text-muted"></i></span>
|
||||||
|
<input type="search" name="q" class="form-control border-start-0 ps-0" placeholder="Suchen…" value="{{ query }}" aria-label="Kategorien durchsuchen">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<a href="{% url 'claims:search' %}" class="small text-muted text-decoration-none">Alle Behauptungen</a>
|
||||||
|
{% if total_claims %}<span class="small text-muted">{{ total_claims }} Einträge</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% 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="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>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Suche – Faktenkompass{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1 class="h5 mb-3">Suche</h1>
|
||||||
|
|
||||||
|
<form method="get" class="row g-2 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<input type="search" id="q" name="q" class="form-control form-control-sm"
|
||||||
|
placeholder="Behauptung suchen…" value="{{ query }}" autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<select id="kategorie" name="kategorie" class="form-select form-select-sm">
|
||||||
|
<option value="">Alle Themen</option>
|
||||||
|
{% for cat in categories %}
|
||||||
|
<option value="{{ cat.slug }}"{% if category_slug == cat.slug %} selected{% endif %}>{{ cat.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<select id="status" name="status" class="form-select form-select-sm">
|
||||||
|
<option value="">Alle Status</option>
|
||||||
|
{% for value, label in status_choices %}
|
||||||
|
<option value="{{ value }}"{% if status == value %} selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm w-100">Suchen</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% 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">Zurücksetzen</a>
|
||||||
|
{% endif %}
|
||||||
|
</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 %}
|
||||||
Reference in New Issue
Block a user