28aab3b16a
Drops Evidenzlevel from models, admin, public UI, and draft workflow while keeping Nachweise file uploads unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
328 lines
10 KiB
Python
328 lines
10 KiB
Python
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'
|
|
|
|
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)
|
|
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 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)
|
|
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
|