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

1from functools import wraps 

2 

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 

21 

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 

27 

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 

37 

38 

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) 

45 

46 return wrapper 

47 

48 

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) 

55 

56 return wrapper 

57 

58 

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}} 

63 

64 

65def chat_consent_required(view): 

66 """Ask once how conversations may be used before the first chat.""" 

67 

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) 

73 

74 return wrapper 

75 

76 

77def _wants_fragment(request) -> bool: 

78 return request.headers.get("X-Requested-With") == "fetch" 

79 

80 

81def _own_conversation(request, pk) -> Conversation: 

82 return get_object_or_404(Conversation, pk=pk, user=request.user) 

83 

84 

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} 

112 

113 

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}) 

141 

142 

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 ) 

156 

157 

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) 

185 

186 

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 ) 

206 

207 

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)) 

214 

215 

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 

222 

223 

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) 

248 

249 

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) 

265 

266 

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") 

274 

275 

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}) 

290 

291 

292def _form_error(form) -> str: 

293 return " ".join(str(e) for errors in form.errors.values() for e in errors) 

294 

295 

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) 

321 

322 

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) 

351 

352 

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) 

369 

370 

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