commit 2c7fb7d030e296728aea8961b1ca0d395581f78a Author: Pirmin Hinderling (fedora) Date: Tue Jul 7 13:02:49 2026 +0200 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb5b2dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.venv/ +__pycache__/ +*.py[cod] +*.sqlite3 +db.sqlite3 +.env +*.egg-info/ +dist/ +build/ +.DS_Store +staticfiles/ +media/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1736f2 --- /dev/null +++ b/README.md @@ -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. diff --git a/claims/__init__.py b/claims/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/admin.py b/claims/admin.py new file mode 100644 index 0000000..82569b6 --- /dev/null +++ b/claims/admin.py @@ -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] diff --git a/claims/apps.py b/claims/apps.py new file mode 100644 index 0000000..085eece --- /dev/null +++ b/claims/apps.py @@ -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' diff --git a/claims/management/__init__.py b/claims/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/management/commands/__init__.py b/claims/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/management/commands/load_sample_data.py b/claims/management/commands/load_sample_data.py new file mode 100644 index 0000000..2cb068d --- /dev/null +++ b/claims/management/commands/load_sample_data.py @@ -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.', + ) diff --git a/claims/migrations/0001_initial.py b/claims/migrations/0001_initial.py new file mode 100644 index 0000000..b761aa6 --- /dev/null +++ b/claims/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/claims/migrations/0002_evidencefile.py b/claims/migrations/0002_evidencefile.py new file mode 100644 index 0000000..d0bb0f7 --- /dev/null +++ b/claims/migrations/0002_evidencefile.py @@ -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'], + }, + ), + ] diff --git a/claims/migrations/__init__.py b/claims/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/models.py b/claims/models.py new file mode 100644 index 0000000..62b754a --- /dev/null +++ b/claims/models.py @@ -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] diff --git a/claims/templatetags/__init__.py b/claims/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/claims/templatetags/claims_extras.py b/claims/templatetags/claims_extras.py new file mode 100644 index 0000000..f81d014 --- /dev/null +++ b/claims/templatetags/claims_extras.py @@ -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) diff --git a/claims/tests.py b/claims/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/claims/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/claims/urls.py b/claims/urls.py new file mode 100644 index 0000000..dda4a2f --- /dev/null +++ b/claims/urls.py @@ -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//', views.category_detail, name='category_detail'), + path('behauptung//', views.claim_detail, name='claim_detail'), +] diff --git a/claims/views.py b/claims/views.py new file mode 100644 index 0000000..830588d --- /dev/null +++ b/claims/views.py @@ -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) diff --git a/faktenkompass/__init__.py b/faktenkompass/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/faktenkompass/asgi.py b/faktenkompass/asgi.py new file mode 100644 index 0000000..3ef6281 --- /dev/null +++ b/faktenkompass/asgi.py @@ -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() diff --git a/faktenkompass/settings.py b/faktenkompass/settings.py new file mode 100644 index 0000000..e9a0fb9 --- /dev/null +++ b/faktenkompass/settings.py @@ -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' diff --git a/faktenkompass/urls.py b/faktenkompass/urls.py new file mode 100644 index 0000000..753166a --- /dev/null +++ b/faktenkompass/urls.py @@ -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) diff --git a/faktenkompass/wsgi.py b/faktenkompass/wsgi.py new file mode 100644 index 0000000..e9ba7f4 --- /dev/null +++ b/faktenkompass/wsgi.py @@ -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() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..bc6e64b --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..af9b7e1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +Django>=5.0,<6.0 diff --git a/static/css/custom.css b/static/css/custom.css new file mode 100644 index 0000000..efb18fc --- /dev/null +++ b/static/css/custom.css @@ -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; + } +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..2f6ac40 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,40 @@ +{% load static %} + + + + + + + {% block title %}Faktenkompass{% endblock %} + + + + + + + +
+
+ {% block content %}{% endblock %} +
+
+ +
+
+

© {% now "Y" %} Faktenkompass

+
+
+ + + + diff --git a/templates/claims/category_detail.html b/templates/claims/category_detail.html new file mode 100644 index 0000000..cd9deb6 --- /dev/null +++ b/templates/claims/category_detail.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} + +{% block title %}{{ category.name }} – Faktenkompass{% endblock %} + +{% block content %} + + +
+
+ +
+
+

{{ category.name }}

+ {{ claims|length }} Behauptung{% if claims|length != 1 %}en{% endif %} +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+ {% for claim in claims %} + +
+
+ {{ claim.get_status_display }} + {{ claim.title }} +
+
+
+ {% empty %} +

Keine Behauptungen gefunden.

+ {% endfor %} +
+{% endblock %} diff --git a/templates/claims/claim_detail.html b/templates/claims/claim_detail.html new file mode 100644 index 0000000..d976e92 --- /dev/null +++ b/templates/claims/claim_detail.html @@ -0,0 +1,145 @@ +{% extends "base.html" %} + +{% block title %}{{ claim.title }} – Faktenkompass{% endblock %} +{% block meta_description %}{{ claim.short_answer|truncatewords:30 }}{% endblock %} + +{% block content %} + + +
+
+ {{ claim.get_status_display }} + Evidenz: {{ claim.get_evidence_level_display }} +
+ +

{{ claim.title }}

+ +
+
+

Kurzantwort

+

{{ claim.short_answer }}

+
+
+ + {% if claim.evidence_files.all %} +
+

Nachweise

+
+ {% for evidence in claim.evidence_files.all %} + {% if evidence.is_image %} +
+ {{ evidence.caption|default:evidence.display_title }} + {% if evidence.title or evidence.caption %} +
+ {% if evidence.title %}{{ evidence.title }}{% endif %} + {% if evidence.caption %}{{ evidence.caption }}{% endif %} +
+ {% endif %} +
+ {% elif evidence.is_pdf %} + +
+
+ +
+ {{ evidence.display_title }} + {% if evidence.caption %}{{ evidence.caption }}{% endif %} + PDF öffnen +
+
+
+
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + + {% if claim.detailed_explanation %} +
+
+

Erklärung

+
{{ claim.detailed_explanation|linebreaks }}
+
+
+ {% endif %} + + {% if claim.sources.all %} +
+

Quellen

+
+ {% for source in claim.sources.all %} +
+
+ + {{ source.title }} + +

{{ source.organization }}

+ {% if source.description %} +

{{ source.description }}

+ {% endif %} +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if claim.counter_arguments.all %} +
+

Gegenargumente

+
+ {% for ca in claim.counter_arguments.all %} +
+

+ +

+
+
+ {{ ca.response|linebreaks }} +
+
+
+ {% endfor %} +
+
+ {% endif %} + + {% if claim.tags.all %} +
+ {% for tag in claim.tags.all %} + #{{ tag.name }} + {% endfor %} +
+ {% endif %} +
+ +{% if related_claims %} + +{% endif %} +{% endblock %} diff --git a/templates/claims/home.html b/templates/claims/home.html new file mode 100644 index 0000000..1a89048 --- /dev/null +++ b/templates/claims/home.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}Faktenkompass{% endblock %} + +{% block content %} + + +{% if query and not categories %} +

Keine Kategorien für „{{ query }}".

+{% endif %} + +
+ {% for category in categories %} + + {% empty %} + {% if not query %} +
+

Noch keine Kategorien vorhanden.

+
+ {% endif %} + {% endfor %} +
+{% endblock %} diff --git a/templates/claims/search.html b/templates/claims/search.html new file mode 100644 index 0000000..73f3c03 --- /dev/null +++ b/templates/claims/search.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} + +{% block title %}Suche – Faktenkompass{% endblock %} + +{% block content %} +

Suche

+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +{% if claims is not None %} +
+ {{ result_count }} Ergebnis{% if result_count != 1 %}se{% endif %} + {% if query or category_slug or status %} + Zurücksetzen + {% endif %} +
+ +
+ {% for claim in claims %} + +
+
+
+ {{ claim.category.name }} + {{ claim.get_status_display }} +
+ {{ claim.title }} +
+
+
+ {% empty %} +

Keine Ergebnisse.

+ {% endfor %} +
+{% endif %} +{% endblock %}