from __future__ import annotations from urllib.parse import urlencode from asgiref.sync import async_to_sync from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Count from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.views import View from core.clients.transport import send_message_raw from core.models import ( AnswerSuggestionEvent, ChatTaskSource, DerivedTask, DerivedTaskEvent, ExternalSyncEvent, TaskCompletionPattern, TaskEpic, TaskProject, TaskProviderConfig, PersonIdentifier, PlatformChatLink, ) from core.tasks.providers.mock import get_provider def _to_bool(raw, default=False) -> bool: if raw is None: return bool(default) value = str(raw).strip().lower() if value in {"1", "true", "yes", "on", "y"}: return True if value in {"0", "false", "no", "off", "n"}: return False return bool(default) def _parse_prefixes(value: str) -> list[str]: text = str(value or "").strip() if not text: return ["task:", "todo:"] rows = [] for row in text.split(","): item = str(row or "").strip().lower() if item and item not in rows: rows.append(item) return rows or ["task:", "todo:"] def _flags_from_post(request, prefix: str = "") -> dict: key = lambda name: f"{prefix}{name}" if prefix else name return { "derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), True), "match_mode": str(request.POST.get(key("match_mode")) or "strict").strip().lower() or "strict", "require_prefix": _to_bool(request.POST.get(key("require_prefix")), True), "allowed_prefixes": _parse_prefixes(str(request.POST.get(key("allowed_prefixes")) or "")), "completion_enabled": _to_bool(request.POST.get(key("completion_enabled")), True), "ai_title_enabled": _to_bool(request.POST.get(key("ai_title_enabled")), True), "announce_task_id": _to_bool(request.POST.get(key("announce_task_id")), True), "min_chars": max(1, int(str(request.POST.get(key("min_chars")) or "3").strip() or "3")), } def _flags_with_defaults(raw: dict | None) -> dict: row = dict(raw or {}) return { "derive_enabled": _to_bool(row.get("derive_enabled"), True), "match_mode": str(row.get("match_mode") or "strict").strip().lower() or "strict", "require_prefix": _to_bool(row.get("require_prefix"), True), "allowed_prefixes": _parse_prefixes(",".join(list(row.get("allowed_prefixes") or []))), "completion_enabled": _to_bool(row.get("completion_enabled"), True), "ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True), "announce_task_id": _to_bool(row.get("announce_task_id"), True), "min_chars": max(1, int(row.get("min_chars") or 3)), } def _settings_redirect(request): service = str(request.POST.get("prefill_service") or request.GET.get("service") or "").strip() identifier = str(request.POST.get("prefill_identifier") or request.GET.get("identifier") or "").strip() if service and identifier: return redirect(f"{request.path}?{urlencode({'service': service, 'identifier': identifier})}") return redirect("tasks_settings") def _service_label(service: str) -> str: key = str(service or "").strip().lower() labels = { "signal": "Signal", "whatsapp": "WhatsApp", "instagram": "Instagram", "xmpp": "XMPP", "web": "Web", } return labels.get(key, key.title() if key else "Unknown") def _resolve_channel_display(user, service: str, identifier: str) -> dict: service_key = str(service or "").strip().lower() raw_identifier = str(identifier or "").strip() bare_identifier = raw_identifier.split("@", 1)[0].strip() variants = [raw_identifier] if bare_identifier and bare_identifier not in variants: variants.append(bare_identifier) if service_key == "whatsapp": group_identifier = f"{bare_identifier}@g.us" if bare_identifier else "" if group_identifier and group_identifier not in variants: variants.append(group_identifier) group_link = None if bare_identifier: group_link = ( PlatformChatLink.objects.filter( user=user, service=service_key, chat_identifier=bare_identifier, is_group=True, ) .order_by("-id") .first() ) person_identifier = ( PersonIdentifier.objects.filter( user=user, service=service_key, identifier__in=variants, ) .select_related("person") .order_by("-id") .first() ) display_name = "" if group_link and str(group_link.chat_name or "").strip(): display_name = str(group_link.chat_name or "").strip() elif person_identifier and person_identifier.person_id: display_name = str(person_identifier.person.name or "").strip() if not display_name: display_name = raw_identifier or bare_identifier or "Unknown chat" display_identifier = raw_identifier if group_link: display_identifier = ( str(group_link.chat_jid or "").strip() or (f"{bare_identifier}@g.us" if bare_identifier else raw_identifier) ) elif service_key == "whatsapp" and bare_identifier and not raw_identifier.endswith("@g.us"): display_identifier = f"{bare_identifier}@g.us" return { "service_key": service_key, "service_label": _service_label(service_key), "display_name": display_name, "display_identifier": display_identifier or raw_identifier, "variants": [row for row in variants if row], } class TasksHub(LoginRequiredMixin, View): template_name = "pages/tasks-hub.html" def get(self, request): projects = TaskProject.objects.filter(user=request.user).annotate( task_count=Count("derived_tasks") ).order_by("name") tasks = ( DerivedTask.objects.filter(user=request.user) .select_related("project", "epic") .order_by("-created_at")[:200] ) return render( request, self.template_name, { "projects": projects, "tasks": tasks, }, ) class TaskProjectDetail(LoginRequiredMixin, View): template_name = "pages/tasks-project.html" def get(self, request, project_id): project = get_object_or_404(TaskProject, id=project_id, user=request.user) tasks = ( DerivedTask.objects.filter(user=request.user, project=project) .select_related("epic") .order_by("-created_at") ) epics = TaskEpic.objects.filter(project=project).order_by("name") return render( request, self.template_name, { "project": project, "tasks": tasks, "epics": epics, }, ) class TaskEpicDetail(LoginRequiredMixin, View): template_name = "pages/tasks-epic.html" def get(self, request, epic_id): epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user) tasks = ( DerivedTask.objects.filter(user=request.user, epic=epic) .select_related("project") .order_by("-created_at") ) return render(request, self.template_name, {"epic": epic, "tasks": tasks}) class TaskGroupDetail(LoginRequiredMixin, View): template_name = "pages/tasks-group.html" def get(self, request, service, identifier): channel = _resolve_channel_display(request.user, service, identifier) variants = list(channel.get("variants") or [str(identifier or "").strip()]) mappings = ChatTaskSource.objects.filter( user=request.user, service=channel["service_key"], channel_identifier__in=variants, ).select_related("project", "epic") tasks = ( DerivedTask.objects.filter( user=request.user, source_service=channel["service_key"], source_channel__in=variants, ) .select_related("project", "epic") .order_by("-created_at") ) return render( request, self.template_name, { "service": channel["service_key"], "service_label": channel["service_label"], "identifier": channel["display_identifier"], "channel_display_name": channel["display_name"], "mappings": mappings, "tasks": tasks, }, ) class TaskDetail(LoginRequiredMixin, View): template_name = "pages/tasks-detail.html" def get(self, request, task_id): task = get_object_or_404( DerivedTask.objects.select_related("project", "epic"), id=task_id, user=request.user, ) events = task.events.select_related("source_message").order_by("-created_at") sync_events = task.external_sync_events.order_by("-created_at") return render( request, self.template_name, { "task": task, "events": events, "sync_events": sync_events, }, ) class TaskSettings(LoginRequiredMixin, View): template_name = "pages/tasks-settings.html" def _context(self, request): prefill_service = str(request.GET.get("service") or "").strip().lower() prefill_identifier = str(request.GET.get("identifier") or "").strip() projects = list(TaskProject.objects.filter(user=request.user).order_by("name")) for row in projects: row.settings_effective = _flags_with_defaults(row.settings) row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"]) sources = list( ChatTaskSource.objects.filter(user=request.user) .select_related("project", "epic") .order_by("service", "channel_identifier") ) for row in sources: row.settings_effective = _flags_with_defaults(row.settings) row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"]) return { "projects": projects, "epics": TaskEpic.objects.filter(project__user=request.user).select_related("project").order_by("project__name", "name"), "sources": sources, "patterns": TaskCompletionPattern.objects.filter(user=request.user).order_by("position", "created_at"), "provider_configs": TaskProviderConfig.objects.filter(user=request.user).order_by("provider"), "sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100], "prefill_service": prefill_service, "prefill_identifier": prefill_identifier, } def get(self, request): return render(request, self.template_name, self._context(request)) def post(self, request): action = str(request.POST.get("action") or "").strip() if action == "project_create": TaskProject.objects.create( user=request.user, name=str(request.POST.get("name") or "Project").strip() or "Project", external_key=str(request.POST.get("external_key") or "").strip(), active=bool(request.POST.get("active") or "1"), settings=_flags_from_post(request), ) return _settings_redirect(request) if action == "epic_create": project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user) TaskEpic.objects.create( project=project, name=str(request.POST.get("name") or "Epic").strip() or "Epic", external_key=str(request.POST.get("external_key") or "").strip(), active=bool(request.POST.get("active") or "1"), ) return _settings_redirect(request) if action == "source_create": project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user) epic = None epic_id = str(request.POST.get("epic_id") or "").strip() if epic_id: epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user) ChatTaskSource.objects.create( user=request.user, service=str(request.POST.get("service") or "web").strip(), channel_identifier=str(request.POST.get("channel_identifier") or "").strip(), project=project, epic=epic, enabled=bool(request.POST.get("enabled") or "1"), settings=_flags_from_post(request, prefix="source_"), ) return _settings_redirect(request) if action == "quick_setup": service = str(request.POST.get("service") or "web").strip().lower() or "web" channel_identifier = str(request.POST.get("channel_identifier") or "").strip() project_name = str(request.POST.get("project_name") or "").strip() or "General" epic_name = str(request.POST.get("epic_name") or "").strip() project, _ = TaskProject.objects.get_or_create( user=request.user, name=project_name, defaults={"settings": _flags_from_post(request)}, ) if not project.settings: project.settings = _flags_from_post(request) project.save(update_fields=["settings", "updated_at"]) epic = None if epic_name: epic, _ = TaskEpic.objects.get_or_create(project=project, name=epic_name) if channel_identifier: source, created = ChatTaskSource.objects.get_or_create( user=request.user, service=service, channel_identifier=channel_identifier, project=project, defaults={ "epic": epic, "enabled": True, "settings": _flags_from_post(request, prefix="source_"), }, ) if not created: source.project = project source.epic = epic source.enabled = True source.settings = _flags_from_post(request, prefix="source_") source.save(update_fields=["project", "epic", "enabled", "settings", "updated_at"]) return _settings_redirect(request) if action == "project_flags_update": project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user) project.settings = _flags_from_post(request) project.save(update_fields=["settings", "updated_at"]) return _settings_redirect(request) if action == "source_flags_update": source = get_object_or_404(ChatTaskSource, id=request.POST.get("source_id"), user=request.user) source.settings = _flags_from_post(request, prefix="source_") source.save(update_fields=["settings", "updated_at"]) return _settings_redirect(request) if action == "pattern_create": phrase = str(request.POST.get("phrase") or "").strip() if phrase: TaskCompletionPattern.objects.get_or_create( user=request.user, phrase=phrase, defaults={"enabled": True, "position": TaskCompletionPattern.objects.filter(user=request.user).count()}, ) return _settings_redirect(request) if action == "provider_update": provider = str(request.POST.get("provider") or "mock").strip() or "mock" row, _ = TaskProviderConfig.objects.get_or_create( user=request.user, provider=provider, defaults={"enabled": False, "settings": {}}, ) row.enabled = bool(request.POST.get("enabled")) row.save(update_fields=["enabled", "updated_at"]) return _settings_redirect(request) if action == "sync_retry": event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user) provider = get_provider(event.provider) payload = dict(event.payload or {}) result = provider.append_update({}, payload) event.status = "ok" if result.ok else "failed" event.error = str(result.error or "") event.payload = dict(payload, retried=True) event.save(update_fields=["status", "error", "payload", "updated_at"]) return _settings_redirect(request) return _settings_redirect(request) class AnswerSuggestionSend(LoginRequiredMixin, View): def post(self, request): event = get_object_or_404( AnswerSuggestionEvent.objects.select_related("candidate_answer", "message"), id=request.POST.get("suggestion_id"), user=request.user, status="suggested", ) decision = str(request.POST.get("decision") or "accept").strip().lower() if decision == "dismiss": event.status = "dismissed" event.save(update_fields=["status", "updated_at"]) return JsonResponse({"ok": True, "status": "dismissed"}) text = str(getattr(event.candidate_answer, "answer_text", "") or "").strip() msg = event.message if not text: return JsonResponse({"ok": False, "error": "empty_candidate_answer"}, status=400) ok = async_to_sync(send_message_raw)( msg.source_service or "web", msg.source_chat_id or "", text=text, attachments=[], metadata={"origin": "repeat_answer_suggestion"}, ) event.status = "accepted" if ok else "suggested" event.save(update_fields=["status", "updated_at"]) if not ok: messages.error(request, "Failed to send suggestion message.") return JsonResponse({"ok": False, "error": "send_failed"}, status=502) return JsonResponse({"ok": True, "status": "accepted"})