import os import uuid from django.contrib.auth.models import User from django.core.validators import FileExtensionValidator from django.db import models from django.urls import reverse from django.utils.text import slugify EVIDENCE_FILE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'] IMAGE_EXTENSIONS = {'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'} DRAFT_UPLOAD_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'pdf'] class Tag(models.Model): 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): ext = os.path.splitext(filename)[1].lower().lstrip('.') or 'bin' if ext not in EVIDENCE_FILE_EXTENSIONS: ext = 'bin' claim_id = instance.claim_id or 'new' return f'evidence/{claim_id}/{uuid.uuid4().hex[:12]}.{ext}' class EvidenceFile(models.Model): 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, max_length=500, 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' @property def image_alt(self): return self.caption or 'Nachweis-Grafik' 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] def draft_upload_path(instance, filename): return f'drafts/{instance.pk or "new"}/{filename}' class ClaimDraft(models.Model): class DraftStatus(models.TextChoices): PROCESSING = 'processing', 'Wird verarbeitet' REVIEW = 'review', 'Zur Prüfung' PUBLISHED = 'published', 'Veröffentlicht' FAILED = 'failed', 'Fehlgeschlagen' REJECTED = 'rejected', 'Abgelehnt' created_by = models.ForeignKey( User, on_delete=models.CASCADE, related_name='claim_drafts', verbose_name='Erstellt von', ) status = models.CharField( 'Status', max_length=20, choices=DraftStatus.choices, default=DraftStatus.PROCESSING, ) article_url = models.URLField('Artikel-URL', max_length=500, blank=True) user_notes = models.TextField('Eingabe / Beschreibung', blank=True) uploaded_file = models.FileField( 'Hochgeladene Datei', upload_to='drafts/uploads/%Y/%m/', max_length=500, blank=True, validators=[FileExtensionValidator(allowed_extensions=DRAFT_UPLOAD_EXTENSIONS)], help_text='Optional: Bild oder PDF als Kontext', ) article_text = models.TextField('Extrahierter Artikeltext', blank=True) category_slug = models.SlugField( 'Bestehende Kategorie (Slug)', max_length=220, blank=True, help_text='Slug einer vorhandenen Kategorie, falls passend', ) new_category_name = models.CharField('Neue Kategorie', max_length=200, blank=True) new_category_description = models.TextField('Beschreibung neue Kategorie', blank=True) new_category_icon = models.CharField( 'Icon neue Kategorie', max_length=50, blank=True, help_text='Bootstrap-Icon-Klasse, z. B. bi-thermometer-half', ) title = models.CharField('Behauptung', max_length=300, blank=True) claim_status = models.CharField( 'Einordnung', max_length=40, choices=Claim.Status.choices, blank=True, ) short_answer = models.TextField('Kurzantwort', blank=True) detailed_explanation = models.TextField('Ausführliche Erklärung', blank=True) evidence_level = models.CharField( 'Evidenzlevel', max_length=20, choices=Claim.EvidenceLevel.choices, blank=True, ) tags = models.JSONField('Schlagwörter', default=list, blank=True) sources = models.JSONField('Quellen', default=list, blank=True) counter_arguments = models.JSONField('Gegenargumente', default=list, blank=True) conversation = models.JSONField('Konversation', default=list, blank=True) ai_raw_response = models.JSONField('KI-Rohantwort', blank=True, null=True) error_message = models.TextField('Fehlermeldung', blank=True) published_claim = models.ForeignKey( Claim, on_delete=models.SET_NULL, null=True, blank=True, related_name='origin_drafts', verbose_name='Veröffentlichte Behauptung', ) created_at = models.DateTimeField('Erstellt am', auto_now_add=True) updated_at = models.DateTimeField('Aktualisiert am', auto_now=True) class Meta: verbose_name = 'Fakten-Entwurf' verbose_name_plural = 'Fakten-Entwürfe' ordering = ['-created_at'] def __str__(self): return self.title or f'Entwurf #{self.pk}' def get_admin_review_url(self): return reverse('admin:claims_claimdraft_review', args=[self.pk]) def get_admin_assistant_url(self): return reverse('admin:claims_claimdraft_assistant') def add_message(self, role, content): from django.utils import timezone messages = list(self.conversation or []) messages.append({ 'role': role, 'content': content, 'at': timezone.now().isoformat(), }) self.conversation = messages