Coverage for baibu/submissions/services.py: 100%
124 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Creating submissions and applying cleaning results."""
3import logging
4import uuid
6from django.conf import settings
7from django.db import IntegrityError
8from django.db import transaction
9from django.utils import timezone
11from baibu.core import storage
12from baibu.users.consent import consent_state
14from . import text as text_rules
15from .cleaners import CleanerError
16from .cleaners import CleaningResult
17from .cleaners import get_cleaner
18from .models import Submission
20logger = logging.getLogger(__name__)
22RAW_AREA = "submissions/raw"
23CLEAN_AREA = "submissions/clean"
26class DuplicateSubmissionError(Exception):
27 pass
30def create_submission(*, user, raw_text: str, language_code: str, source: str = "web") -> Submission:
31 """Store the full text privately, record the submission and queue cleaning.
33 Raises ``DuplicateSubmissionError`` if the same text was submitted before,
34 and lets storage errors through (nothing is recorded then).
35 """
36 from .tasks import clean_submission
38 raw_text = text_rules.normalise(raw_text)
39 digest = text_rules.content_hash(raw_text)
40 if Submission.objects.filter(content_hash=digest).exists():
41 raise DuplicateSubmissionError
42 consent = consent_state(user=user, scope=settings.SUBMISSION_CONSENT_SCOPE)
43 submission_id = uuid.uuid7()
44 raw_key = storage.save_json(
45 storage.build_key(RAW_AREA, f"{submission_id}.json"),
46 {
47 "schema": "submission.raw.v1",
48 "submission_id": str(submission_id),
49 "language_code": language_code,
50 "source": source,
51 "received_at": timezone.now().isoformat(),
52 "text": raw_text,
53 },
54 )
55 try:
56 with transaction.atomic():
57 submission = Submission.objects.create(
58 id=submission_id,
59 user=user,
60 language_code=language_code,
61 consent_tier=consent.tier,
62 consent_record=consent.latest_record,
63 raw_key=raw_key,
64 content_hash=digest,
65 word_count=text_rules.word_count(raw_text),
66 character_count=len(raw_text),
67 )
68 except IntegrityError as exc:
69 # The same text arrived at the same moment.
70 storage.delete(raw_key)
71 raise DuplicateSubmissionError from exc
72 except Exception:
73 storage.delete(raw_key)
74 raise
75 transaction.on_commit(lambda: clean_submission.delay(str(submission.pk)))
76 return submission
79def decide(submission: Submission, result: CleaningResult) -> tuple[str, str]:
80 """Return the status and reason for a cleaning result."""
81 if not result.text.strip():
82 return Submission.Status.NEEDS_REVIEW, "Empty after cleaning."
83 if result.toxicity_detected:
84 return Submission.Status.NEEDS_REVIEW, "Possibly toxic."
85 minimum = settings.SUBMISSION_MIN_QUALITY
86 if result.quality_score is not None and result.quality_score < minimum:
87 return Submission.Status.NEEDS_REVIEW, f"Quality {result.quality_score} below {minimum}."
88 if result.detected_language and result.detected_language.lower() != submission.language_code.lower():
89 return Submission.Status.NEEDS_REVIEW, f"Detected language {result.detected_language}."
90 return Submission.Status.VERIFIED, ""
93def _mark_issue(submission: Submission, reason: str) -> Submission:
94 logger.warning("Submission %s could not be cleaned: %s", submission.pk, reason)
95 submission.status = Submission.Status.ISSUE
96 submission.decision_reason = reason[:512]
97 submission.save(update_fields=["status", "decision_reason", "updated_at"])
98 return submission
101def run_cleaning(submission: Submission) -> Submission:
102 """Clean a pending submission and record the outcome."""
103 if submission.status != Submission.Status.PENDING:
104 return submission
105 raw = storage.read_json(submission.raw_key)
106 if not raw or not isinstance(raw.get("text"), str):
107 return _mark_issue(submission, "Raw text is missing.")
108 try:
109 result = get_cleaner().clean(raw["text"], language_code=submission.language_code)
110 except CleanerError as exc:
111 return _mark_issue(submission, str(exc))
112 status, reason = decide(submission, result)
113 try:
114 clean_key = storage.save_json(
115 storage.build_key(CLEAN_AREA, f"{submission.pk}.json"),
116 {
117 "schema": "submission.clean.v1",
118 "submission_id": str(submission.pk),
119 "language_code": submission.language_code,
120 "cleaner": result.cleaner,
121 "cleaned_at": timezone.now().isoformat(),
122 "text": result.text,
123 },
124 )
125 except Exception as exc:
126 logger.exception("Could not store cleaned text for submission %s", submission.pk)
127 return _mark_issue(submission, f"Could not store cleaned text: {type(exc).__name__}")
129 old_clean_key = submission.clean_key
130 submission.status = status
131 submission.decision_reason = reason
132 submission.clean_key = clean_key
133 limit = settings.SUBMISSION_EXCERPT_LENGTH
134 submission.excerpt = result.text if len(result.text) <= limit else result.text[:limit].rstrip() + "…"
135 submission.cleaner = result.cleaner
136 submission.redaction_count = result.redaction_count
137 submission.toxicity_detected = result.toxicity_detected
138 submission.quality_score = result.quality_score
139 submission.detected_language = result.detected_language
140 submission.flags = result.flags
141 submission.cleaned_at = timezone.now()
142 try:
143 submission.save()
144 except Exception:
145 storage.delete(clean_key)
146 raise
147 if old_clean_key and old_clean_key != clean_key:
148 transaction.on_commit(lambda: storage.delete(old_clean_key))
149 return submission
152def requeue(submission: Submission) -> bool:
153 """Send a submission back for cleaning ("clean again")."""
154 from .tasks import clean_submission
156 with transaction.atomic():
157 locked = Submission.objects.select_for_update().get(pk=submission.pk)
158 if not locked.can_transition_to(Submission.Status.PENDING):
159 return False
160 submission.status = Submission.Status.PENDING
161 submission.save(update_fields=["status", "updated_at"])
162 transaction.on_commit(lambda: clean_submission.delay(str(submission.pk)))
163 return True
166def review(submission: Submission, status: str, *, reviewer=None) -> bool:
167 """Record a staff decision on a submission and tell the contributor.
169 Returns ``False`` if the change is not allowed from the current status.
170 """
171 from django.urls import reverse
173 from baibu.notifications.services import notify
175 kinds = {Submission.Status.VERIFIED: "submission_accepted", Submission.Status.REJECTED: "submission_rejected"}
176 if status not in kinds:
177 msg = f"Not a review decision: {status}"
178 raise ValueError(msg)
179 with transaction.atomic():
180 # Lock the row so two reviewers cannot both decide.
181 locked = Submission.objects.select_for_update().get(pk=submission.pk)
182 if not locked.can_transition_to(status):
183 submission.status = locked.status
184 return False
185 submission.status = status
186 submission.reviewed_by = reviewer
187 submission.reviewed_at = timezone.now()
188 submission.save(update_fields=["status", "reviewed_by", "reviewed_at", "updated_at"])
189 notify(submission.user, kind=kinds[status], link=reverse("submissions:list"))
190 return True
193def delete_files(submission: Submission) -> None:
194 for key in (submission.raw_key, submission.clean_key):
195 if key:
196 storage.delete(key)