Coverage for baibu/chat/views.py: 100%
269 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
1from functools import wraps
3from django.conf import settings
4from django.contrib import messages
5from django.contrib.auth.decorators import login_required
6from django.db import transaction
7from django.http import Http404
8from django.http import HttpRequest
9from django.http import HttpResponse
10from django.http import HttpResponseBadRequest
11from django.shortcuts import get_object_or_404
12from django.shortcuts import redirect
13from django.shortcuts import render
14from django.urls import reverse
15from django.utils.http import url_has_allowed_host_and_scheme
16from django.utils.translation import gettext_lazy as _
17from django.views.decorators.cache import cache_control
18from django.views.decorators.http import require_GET
19from django.views.decorators.http import require_http_methods
20from django.views.decorators.http import require_POST
22from baibu.core import storage
23from baibu.users.consent import consent_state
24from baibu.users.consent import latest_consent
25from baibu.users.consent import record_consent
26from baibu.users.forms import ConsentForm
28from . import services
29from . import speech
30from .forms import MessageForm
31from .forms import ReportForm
32from .forms import VoiceForm
33from .models import AudioClip
34from .models import Conversation
35from .models import Message
36from .models import Run
39def chat_enabled(view):
40 @wraps(view)
41 def wrapper(request, *args, **kwargs):
42 if not settings.CHAT_ENABLED:
43 raise Http404
44 return view(request, *args, **kwargs)
46 return wrapper
49def voice_enabled(view):
50 @wraps(view)
51 def wrapper(request, *args, **kwargs):
52 if not speech.voice_enabled():
53 raise Http404
54 return view(request, *args, **kwargs)
56 return wrapper
59def _voice_context(action: str) -> dict:
60 if not speech.voice_enabled():
61 return {}
62 return {"voice": {"url": action, "max_seconds": settings.CHAT_VOICE_MAX_SECONDS}}
65def chat_consent_required(view):
66 """Ask once how conversations may be used before the first chat."""
68 @wraps(view)
69 def wrapper(request, *args, **kwargs):
70 if latest_consent(user=request.user, scope=settings.CHAT_CONSENT_SCOPE) is None:
71 return redirect(f"{reverse('chat:consent')}?next={request.get_full_path()}")
72 return view(request, *args, **kwargs)
74 return wrapper
77def _wants_fragment(request) -> bool:
78 return request.headers.get("X-Requested-With") == "fetch"
81def _own_conversation(request, pk) -> Conversation:
82 return get_object_or_404(Conversation, pk=pk, user=request.user)
85def _messages_context(conversation: Conversation) -> dict:
86 runs = list(conversation.runs.select_related("reply"))
87 active = next((run for run in runs if run.status in Run.ACTIVE_STATUSES), None)
88 clips = {clip.message_id: clip for clip in conversation.audio_clips.all()}
89 transcribing = any(clip.status in AudioClip.ACTIVE_STATUSES for clip in clips.values())
90 busy = active is not None or transcribing
91 latest_by_message = {}
92 for run in runs:
93 latest_by_message[run.triggering_message_id] = run
94 failed = {message_id: run for message_id, run in latest_by_message.items() if run.status == Run.Status.FAILED}
95 items = []
96 for message in conversation.messages.all():
97 failed_run = failed.get(message.pk)
98 clip = clips.get(message.pk)
99 items.append(
100 {
101 "message": message,
102 "clip": clip,
103 "can_retry_voice": bool(clip)
104 and clip.status == AudioClip.Status.FAILED
105 and clip.attempt < settings.CHAT_MAX_ATTEMPTS
106 and not busy,
107 "failed_run": failed_run,
108 "can_retry": bool(failed_run) and failed_run.attempt < settings.CHAT_MAX_ATTEMPTS and not busy,
109 }
110 )
111 return {"conversation": conversation, "items": items, "pending": busy, "replying": active is not None}
114@login_required
115@chat_enabled
116@cache_control(private=True, no_store=True)
117@require_http_methods(["GET", "POST"])
118def consent_view(request: HttpRequest) -> HttpResponse:
119 scope = settings.CHAT_CONSENT_SCOPE
120 state = consent_state(user=request.user, scope=scope)
121 form = ConsentForm(request.POST or None, initial={"tier": state.tier if state.latest_record else None})
122 form.fields["tier"].label = _("How may your conversations be used?")
123 if request.method == "POST" and form.is_valid():
124 if state.latest_record is None or form.cleaned_data["tier"] != state.tier:
125 record_consent(
126 user=request.user,
127 tier=form.cleaned_data["tier"],
128 source="chat",
129 scope=scope,
130 metadata={
131 "locale": getattr(request, "LANGUAGE_CODE", settings.LANGUAGE_CODE),
132 "consent_text_version": settings.CONSENT_TEXT_VERSION,
133 },
134 )
135 messages.success(request, _("Your choice was saved."))
136 next_url = request.GET.get("next", "")
137 if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
138 return redirect(next_url)
139 return redirect("chat:home")
140 return render(request, "chat/consent.html", {"form": form, "consent_state": state})
143@login_required
144@chat_enabled
145@chat_consent_required
146@cache_control(private=True, no_store=True)
147@require_GET
148def home(request: HttpRequest) -> HttpResponse:
149 conversations = Conversation.objects.filter(user=request.user)[:50]
150 form = MessageForm(initial={"idempotency_key": services.new_idempotency_key()})
151 return render(
152 request,
153 "chat/home.html",
154 {"conversations": conversations, "form": form, **_voice_context(reverse("chat:voice_new"))},
155 )
158@login_required
159@chat_enabled
160@chat_consent_required
161@require_POST
162def new_conversation(request: HttpRequest) -> HttpResponse:
163 form = MessageForm(request.POST)
164 if not form.is_valid():
165 conversations = Conversation.objects.filter(user=request.user)[:50]
166 return render(
167 request,
168 "chat/home.html",
169 {"conversations": conversations, "form": form, **_voice_context(reverse("chat:voice_new"))},
170 status=400,
171 )
172 # A repeated first send must not start a second conversation.
173 existing = Conversation.objects.filter(
174 user=request.user, messages__idempotency_key=form.cleaned_data["idempotency_key"]
175 ).first()
176 if existing:
177 return redirect("chat:conversation", pk=existing.pk)
178 conversation = services.start_conversation(user=request.user, language_code=request.LANGUAGE_CODE)
179 services.send_message(
180 conversation=conversation,
181 content=form.cleaned_data["content"],
182 idempotency_key=form.cleaned_data["idempotency_key"],
183 )
184 return redirect("chat:conversation", pk=conversation.pk)
187@login_required
188@chat_enabled
189@chat_consent_required
190@cache_control(private=True, no_store=True)
191@require_GET
192def conversation_view(request: HttpRequest, pk) -> HttpResponse:
193 conversation = _own_conversation(request, pk)
194 form = MessageForm(initial={"idempotency_key": services.new_idempotency_key()})
195 conversations = Conversation.objects.filter(user=request.user)[:50]
196 return render(
197 request,
198 "chat/conversation.html",
199 {
200 **_messages_context(conversation),
201 "form": form,
202 "conversations": conversations,
203 **_voice_context(reverse("chat:voice", args=[conversation.pk])),
204 },
205 )
208@login_required
209@chat_enabled
210@cache_control(private=True, no_store=True)
211@require_GET
212def messages_view(request: HttpRequest, pk) -> HttpResponse:
213 return _fragment(request, _own_conversation(request, pk))
216def _fragment(request, conversation, *, status=200, error="") -> HttpResponse:
217 context = _messages_context(conversation)
218 response = render(request, "chat/_messages.html", {**context, "error": error}, status=status)
219 response["X-Chat-Pending"] = "1" if context["pending"] else "0"
220 response["X-Chat-Next-Key"] = services.new_idempotency_key()
221 return response
224@login_required
225@chat_enabled
226@chat_consent_required
227@require_POST
228def send_view(request: HttpRequest, pk) -> HttpResponse:
229 conversation = _own_conversation(request, pk)
230 form = MessageForm(request.POST)
231 error = ""
232 if form.is_valid():
233 try:
234 services.send_message(
235 conversation=conversation,
236 content=form.cleaned_data["content"],
237 idempotency_key=form.cleaned_data["idempotency_key"],
238 )
239 except services.ChatError as exc:
240 error = str(exc)
241 else:
242 error = " ".join(str(e) for errors in form.errors.values() for e in errors)
243 if _wants_fragment(request):
244 return _fragment(request, conversation, status=400 if error else 200, error=error)
245 if error:
246 messages.error(request, error)
247 return redirect("chat:conversation", pk=conversation.pk)
250@login_required
251@chat_enabled
252@require_POST
253def retry_view(request: HttpRequest, run_id) -> HttpResponse:
254 run = get_object_or_404(Run, pk=run_id, conversation__user=request.user)
255 error = ""
256 try:
257 services.retry_run(run)
258 except services.ChatError as exc:
259 error = str(exc)
260 if _wants_fragment(request):
261 return _fragment(request, run.conversation, status=400 if error else 200, error=error)
262 if error:
263 messages.error(request, error)
264 return redirect("chat:conversation", pk=run.conversation_id)
267@login_required
268@chat_enabled
269@require_POST
270def delete_view(request: HttpRequest, pk) -> HttpResponse:
271 _own_conversation(request, pk).delete()
272 messages.success(request, _("The conversation was deleted."))
273 return redirect("chat:home")
276@login_required
277@chat_enabled
278@cache_control(private=True, no_store=True)
279@require_http_methods(["GET", "POST"])
280def report_view(request: HttpRequest, message_id) -> HttpResponse:
281 message = get_object_or_404(Message, pk=message_id, conversation__user=request.user, role=Message.Role.ASSISTANT)
282 form = ReportForm(request.POST or None)
283 if request.method == "POST" and form.is_valid():
284 services.report_message(
285 message=message, user=request.user, reason=form.cleaned_data["reason"], note=form.cleaned_data["note"]
286 )
287 messages.success(request, _("Thank you. Someone on our team will look at this reply."))
288 return redirect("chat:conversation", pk=message.conversation_id)
289 return render(request, "chat/report.html", {"form": form, "message": message})
292def _form_error(form) -> str:
293 return " ".join(str(e) for errors in form.errors.values() for e in errors)
296@login_required
297@chat_enabled
298@voice_enabled
299@chat_consent_required
300@require_POST
301def new_voice_view(request: HttpRequest) -> HttpResponse:
302 """Start a conversation with a voice message."""
303 form = VoiceForm(request.POST, request.FILES)
304 if not form.is_valid():
305 error = _form_error(form)
306 if _wants_fragment(request):
307 return HttpResponseBadRequest(error, content_type="text/plain; charset=utf-8")
308 messages.error(request, error)
309 return redirect("chat:home")
310 key = form.cleaned_data["idempotency_key"]
311 existing = Conversation.objects.filter(user=request.user, messages__idempotency_key=key).first()
312 if existing:
313 return redirect("chat:conversation", pk=existing.pk)
314 audio = form.cleaned_data["audio"]
315 with transaction.atomic():
316 conversation = services.start_conversation(user=request.user, language_code=request.LANGUAGE_CODE)
317 services.send_voice(
318 conversation=conversation, audio=audio.read(), content_type=audio.clean_content_type, idempotency_key=key
319 )
320 return redirect("chat:conversation", pk=conversation.pk)
323@login_required
324@chat_enabled
325@voice_enabled
326@chat_consent_required
327@require_POST
328def voice_view(request: HttpRequest, pk) -> HttpResponse:
329 """Add a voice message to a conversation."""
330 conversation = _own_conversation(request, pk)
331 form = VoiceForm(request.POST, request.FILES)
332 error = ""
333 if form.is_valid():
334 audio = form.cleaned_data["audio"]
335 try:
336 services.send_voice(
337 conversation=conversation,
338 audio=audio.read(),
339 content_type=audio.clean_content_type,
340 idempotency_key=form.cleaned_data["idempotency_key"],
341 )
342 except services.ChatError as exc:
343 error = str(exc)
344 else:
345 error = _form_error(form)
346 if _wants_fragment(request):
347 return _fragment(request, conversation, status=400 if error else 200, error=error)
348 if error:
349 messages.error(request, error)
350 return redirect("chat:conversation", pk=conversation.pk)
353@login_required
354@chat_enabled
355@voice_enabled
356@require_POST
357def retry_voice_view(request: HttpRequest, clip_id) -> HttpResponse:
358 clip = get_object_or_404(AudioClip, pk=clip_id, conversation__user=request.user)
359 error = ""
360 try:
361 services.retry_transcription(clip)
362 except services.ChatError as exc:
363 error = str(exc)
364 if _wants_fragment(request):
365 return _fragment(request, clip.conversation, status=400 if error else 200, error=error)
366 if error:
367 messages.error(request, error)
368 return redirect("chat:conversation", pk=clip.conversation_id)
371@login_required
372@chat_enabled
373@cache_control(private=True, no_store=True)
374@require_GET
375def audio_view(request: HttpRequest, clip_id) -> HttpResponse:
376 """Play back the user's own recording from private storage."""
377 clip = get_object_or_404(AudioClip, pk=clip_id, conversation__user=request.user)
378 data = storage.read_bytes(clip.storage_key)
379 if data is None:
380 raise Http404
381 response = HttpResponse(data, content_type=clip.content_type)
382 response["Content-Disposition"] = "inline"
383 response["X-Content-Type-Options"] = "nosniff"
384 return response