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 import IntegrityError from django.db.models import Count from django.urls import reverse 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, Person, PlatformChatLink, Chat, ExternalChatLink, ) from core.tasks.providers import get_provider SAFE_TASK_FLAGS_DEFAULTS = { "derive_enabled": True, "match_mode": "strict", "require_prefix": True, "allowed_prefixes": ["task:", "todo:"], "completion_enabled": True, "ai_title_enabled": True, "announce_task_id": False, "min_chars": 3, } 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 _looks_like_old_risky_defaults(raw: dict) -> bool: row = dict(raw or {}) mode = str(row.get("match_mode") or "").strip().lower() require_prefix = _to_bool(row.get("require_prefix"), False) prefixes = _parse_prefixes(",".join(list(row.get("allowed_prefixes") or []))) min_chars = int(row.get("min_chars") or 8) return ( mode in {"", "balanced"} and (not require_prefix) and prefixes == ["task:", "todo:", "action:"] and min_chars >= 8 ) def _normalized_safe_flags(raw: dict | None) -> dict: row = dict(raw or {}) defaults = dict(SAFE_TASK_FLAGS_DEFAULTS) if _looks_like_old_risky_defaults(row): return defaults merged = dict(defaults) merged.update( { "derive_enabled": _to_bool(row.get("derive_enabled"), defaults["derive_enabled"]), "match_mode": str(row.get("match_mode") or defaults["match_mode"]).strip().lower() or defaults["match_mode"], "require_prefix": _to_bool(row.get("require_prefix"), defaults["require_prefix"]), "allowed_prefixes": _parse_prefixes(",".join(list(row.get("allowed_prefixes") or defaults["allowed_prefixes"]))), "completion_enabled": _to_bool(row.get("completion_enabled"), defaults["completion_enabled"]), "ai_title_enabled": _to_bool(row.get("ai_title_enabled"), defaults["ai_title_enabled"]), "announce_task_id": _to_bool(row.get("announce_task_id"), defaults["announce_task_id"]), "min_chars": max(1, int(row.get("min_chars") or defaults["min_chars"])), } ) return merged def _apply_safe_defaults_for_user(user) -> None: projects = list(TaskProject.objects.filter(user=user).only("id", "settings")) for row in projects: normalized = _normalized_safe_flags(row.settings) if dict(row.settings or {}) != normalized: row.settings = normalized row.save(update_fields=["settings", "updated_at"]) sources = list(ChatTaskSource.objects.filter(user=user).only("id", "settings")) for row in sources: normalized = _normalized_safe_flags(row.settings) if dict(row.settings or {}) != normalized: row.settings = normalized row.save(update_fields=["settings", "updated_at"]) def _flags_from_post(request, prefix: str = "") -> dict: key = lambda name: f"{prefix}{name}" if prefix else name defaults = dict(SAFE_TASK_FLAGS_DEFAULTS) return { "derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), defaults["derive_enabled"]), "match_mode": str(request.POST.get(key("match_mode")) or defaults["match_mode"]).strip().lower() or defaults["match_mode"], "require_prefix": _to_bool(request.POST.get(key("require_prefix")), defaults["require_prefix"]), "allowed_prefixes": _parse_prefixes(str(request.POST.get(key("allowed_prefixes")) or ",".join(defaults["allowed_prefixes"]))), "completion_enabled": _to_bool(request.POST.get(key("completion_enabled")), defaults["completion_enabled"]), "ai_title_enabled": _to_bool(request.POST.get(key("ai_title_enabled")), defaults["ai_title_enabled"]), "announce_task_id": _to_bool(request.POST.get(key("announce_task_id")), defaults["announce_task_id"]), "min_chars": max(1, int(str(request.POST.get(key("min_chars")) or str(defaults["min_chars"])).strip() or str(defaults["min_chars"]))), } def _flags_with_defaults(raw: dict | None) -> dict: return _normalized_safe_flags(raw) 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 _ensure_default_completion_patterns(user) -> None: defaults = ("done", "completed", "fixed") existing = set( str(row or "").strip().lower() for row in TaskCompletionPattern.objects.filter(user=user).values_list("phrase", flat=True) ) next_pos = TaskCompletionPattern.objects.filter(user=user).count() for phrase in defaults: if phrase in existing: continue TaskCompletionPattern.objects.create( user=user, phrase=phrase, enabled=True, position=next_pos, ) next_pos += 1 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 _creator_label_for_message(user, service: str, message) -> str: msg = message if msg is None: return "Unknown" author_raw = str(getattr(msg, "custom_author", "") or "").strip() author_key = author_raw.upper() sender_identifier = str(getattr(msg, "sender_uuid", "") or "").strip() if author_key == "USER": return "You" if author_key == "BOT": return "System Bot" if sender_identifier: variants = _person_identifier_scope_variants(service, sender_identifier) person_identifier = ( PersonIdentifier.objects.filter( user=user, service=str(service or "").strip().lower(), identifier__in=variants or [sender_identifier], ) .select_related("person") .first() ) if person_identifier is not None: person_name = str(getattr(person_identifier.person, "name", "") or "").strip() if person_name: return person_name return sender_identifier if author_raw: if author_key == "OTHER": return "Other Participant" return author_raw return "Unknown" def _apply_task_creator_labels(user, task_rows): rows = list(task_rows or []) for row in rows: origin = getattr(row, "origin_message", None) service_key = str(getattr(row, "source_service", "") or "").strip().lower() row.creator_label = _creator_label_for_message(user, service_key, origin) row.creator_identifier = str(getattr(origin, "sender_uuid", "") or "").strip() return rows def _provider_row_map(user): return { str(row.provider or "").strip().lower(): row for row in TaskProviderConfig.objects.filter(user=user).order_by("provider") } def _normalize_channel_identifier(service: str, identifier: str) -> str: service_key = str(service or "").strip().lower() value = str(identifier or "").strip() if not value: return "" if service_key == "whatsapp": bare = value.split("@", 1)[0].strip() if bare: if value.endswith("@g.us"): return f"{bare}@g.us" if value.endswith("@s.whatsapp.net"): return f"{bare}@s.whatsapp.net" return f"{bare}@g.us" if service_key == "signal": return value if service_key == "xmpp": return value if service_key == "instagram": return value if service_key == "web": return value return value def _upsert_group_source(*, user, service: str, channel_identifier: str, project, epic=None): normalized_service = str(service or "").strip().lower() normalized_identifier = _normalize_channel_identifier(service, channel_identifier) if not normalized_service or not normalized_identifier: return None source, created = ChatTaskSource.objects.get_or_create( user=user, service=normalized_service, channel_identifier=normalized_identifier, project=project, defaults={ "epic": epic, "enabled": True, "settings": _flags_with_defaults({}), }, ) if not created: next_fields = [] if source.epic_id != getattr(epic, "id", None): source.epic = epic next_fields.append("epic") if not source.enabled: source.enabled = True next_fields.append("enabled") if next_fields: next_fields.append("updated_at") source.save(update_fields=next_fields) return source def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]: service_key = str(service or "").strip().lower() raw_identifier = str(identifier or "").strip() if not service_key or not raw_identifier: return [] variants: list[str] = [raw_identifier] bare_identifier = raw_identifier.split("@", 1)[0].strip() if bare_identifier and bare_identifier not in variants: variants.append(bare_identifier) if service_key == "whatsapp" and bare_identifier: group_identifier = f"{bare_identifier}@g.us" direct_identifier = f"{bare_identifier}@s.whatsapp.net" if group_identifier not in variants: variants.append(group_identifier) if direct_identifier not in variants: variants.append(direct_identifier) if service_key == "signal": digits = "".join(ch for ch in raw_identifier if ch.isdigit()) if digits and digits not in variants: variants.append(digits) if digits: plus_variant = f"+{digits}" if plus_variant not in variants: variants.append(plus_variant) return [row for row in variants if row] def _scoped_person_identifier_rows(user, service: str, identifier: str): service_key = str(service or "").strip().lower() variants = _person_identifier_scope_variants(service_key, identifier) if not service_key or not variants: return PersonIdentifier.objects.none() return ( PersonIdentifier.objects.filter( user=user, service=service_key, identifier__in=variants, ) .select_related("person") .order_by("person__name", "service", "identifier") ) 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": direct_identifier = ( raw_identifier if raw_identifier.endswith("@s.whatsapp.net") else "" ) if direct_identifier and direct_identifier not in variants: variants.append(direct_identifier) if bare_identifier: direct_bare = f"{bare_identifier}@s.whatsapp.net" if direct_bare not in variants: variants.append(direct_bare) group_identifier = f"{bare_identifier}@g.us" if bare_identifier else "" if group_identifier and group_identifier not in variants: variants.append(group_identifier) if service_key == "signal": digits = "".join(ch for ch in raw_identifier if ch.isdigit()) if digits and digits not in variants: variants.append(digits) if digits: plus = f"+{digits}" if plus not in variants: variants.append(plus) if raw_identifier: companion_numbers = list( Chat.objects.filter(source_uuid=raw_identifier) .exclude(source_number__isnull=True) .exclude(source_number="") .values_list("source_number", flat=True)[:200] ) companion_uuids = list( Chat.objects.filter(source_number=raw_identifier) .exclude(source_uuid__isnull=True) .exclude(source_uuid="") .values_list("source_uuid", flat=True)[:200] ) for candidate in companion_numbers + companion_uuids: candidate_str = str(candidate or "").strip() if not candidate_str: continue if candidate_str not in variants: variants.append(candidate_str) candidate_digits = "".join(ch for ch in candidate_str if ch.isdigit()) if candidate_digits and candidate_digits not in variants: variants.append(candidate_digits) if candidate_digits: plus_variant = f"+{candidate_digits}" if plus_variant not in variants: variants.append(plus_variant) 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) ) 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 _scope(self, request): person_id = str(request.GET.get("person") or request.POST.get("person") or "").strip() person = None if person_id: person = Person.objects.filter(user=request.user, id=person_id).first() return { "person": person, "person_id": str(getattr(person, "id", "") or ""), "service": str(request.GET.get("service") or request.POST.get("service") or "").strip().lower(), "identifier": str(request.GET.get("identifier") or request.POST.get("identifier") or "").strip(), "selected_project_id": str( request.GET.get("project") or request.POST.get("project_id") or "" ).strip(), } def _context(self, request): scope = self._scope(request) projects = ( TaskProject.objects.filter(user=request.user) .annotate( task_count=Count("derived_tasks"), epic_count=Count("epics", distinct=True), ) .order_by("name") ) tasks = ( DerivedTask.objects.filter(user=request.user) .select_related("project", "epic", "origin_message") .order_by("-created_at")[:200] ) tasks = _apply_task_creator_labels(request.user, tasks) selected_project = None if scope["selected_project_id"]: selected_project = TaskProject.objects.filter( user=request.user, id=scope["selected_project_id"], ).first() person_identifiers = [] person_identifier_rows = [] if scope["person"] is not None: person_identifiers = list( PersonIdentifier.objects.filter( user=request.user, person=scope["person"], ).order_by("service", "identifier") ) mapping_pairs = set( ChatTaskSource.objects.filter(user=request.user) .values_list("project_id", "service", "channel_identifier") ) for row in person_identifiers: mapped = False if selected_project is not None: mapped = ( selected_project.id, str(row.service or "").strip(), str(row.identifier or "").strip(), ) in mapping_pairs person_identifier_rows.append( { "id": row.id, "identifier": str(row.identifier or "").strip(), "service": str(row.service or "").strip(), "mapped": mapped, } ) return { "projects": projects, "tasks": tasks, "scope": scope, "person_identifier_rows": person_identifier_rows, "selected_project": selected_project, } 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().lower() if action == "project_create": name = str(request.POST.get("name") or "").strip() if not name: messages.error(request, "Project name is required.") return redirect("tasks_hub") scope = self._scope(request) external_key = str(request.POST.get("external_key") or "").strip() try: project, created = TaskProject.objects.get_or_create( user=request.user, name=name, defaults={"external_key": external_key}, ) except IntegrityError: messages.error(request, "Could not create project due to duplicate name.") return redirect("tasks_hub") if created: messages.success(request, f"Created project '{project.name}'.") else: messages.info(request, f"Project '{project.name}' already exists.") query = {} if scope["person_id"]: query["person"] = scope["person_id"] if scope["service"]: query["service"] = scope["service"] if scope["identifier"]: query["identifier"] = scope["identifier"] query["project"] = str(project.id) if query: return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}") return redirect("tasks_hub") if action == "project_map_identifier": project = get_object_or_404( TaskProject, user=request.user, id=request.POST.get("project_id"), ) identifier_row = get_object_or_404( PersonIdentifier, user=request.user, id=request.POST.get("person_identifier_id"), ) _upsert_group_source( user=request.user, service=identifier_row.service, channel_identifier=identifier_row.identifier, project=project, epic=None, ) messages.success( request, f"Mapped {identifier_row.service} · {identifier_row.identifier} to '{project.name}'.", ) scope = self._scope(request) query = { "project": str(project.id), } if scope["person_id"]: query["person"] = scope["person_id"] if scope["service"]: query["service"] = scope["service"] if scope["identifier"]: query["identifier"] = scope["identifier"] return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}") if action == "project_delete": project = get_object_or_404( TaskProject, id=request.POST.get("project_id"), user=request.user, ) deleted_name = str(project.name or "").strip() or "Project" project.delete() messages.success(request, f"Deleted project '{deleted_name}'.") return redirect("tasks_hub") return redirect("tasks_hub") class TaskProjectDetail(LoginRequiredMixin, View): template_name = "pages/tasks-project.html" def _context(self, request, project): tasks = ( DerivedTask.objects.filter(user=request.user, project=project) .select_related("epic", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) epics = ( TaskEpic.objects.filter(project=project) .annotate(task_count=Count("derived_tasks")) .order_by("name") ) return { "project": project, "tasks": tasks, "epics": epics, } def get(self, request, project_id): project = get_object_or_404(TaskProject, id=project_id, user=request.user) return render(request, self.template_name, self._context(request, project)) def post(self, request, project_id): project = get_object_or_404(TaskProject, id=project_id, user=request.user) action = str(request.POST.get("action") or "").strip().lower() if action == "epic_create": name = str(request.POST.get("name") or "").strip() if not name: messages.error(request, "Epic name is required.") return redirect("tasks_project", project_id=str(project.id)) external_key = str(request.POST.get("external_key") or "").strip() try: epic, created = TaskEpic.objects.get_or_create( project=project, name=name, defaults={"external_key": external_key}, ) except IntegrityError: messages.error(request, "Could not create epic due to duplicate name.") return redirect("tasks_project", project_id=str(project.id)) if created: messages.success(request, f"Created epic '{epic.name}'.") else: messages.info(request, f"Epic '{epic.name}' already exists.") return redirect("tasks_project", project_id=str(project.id)) if action == "epic_delete": epic = get_object_or_404(TaskEpic, id=request.POST.get("epic_id"), project=project) deleted_name = str(epic.name or "").strip() or "Epic" epic.delete() messages.success(request, f"Deleted epic '{deleted_name}'.") return redirect("tasks_project", project_id=str(project.id)) if action == "project_delete": deleted_name = str(project.name or "").strip() or "Project" project.delete() messages.success(request, f"Deleted project '{deleted_name}'.") return redirect("tasks_hub") return redirect("tasks_project", project_id=str(project.id)) 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", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) 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()]) service_keys = [channel["service_key"]] if channel["service_key"] != "web": service_keys.append("web") mappings = ChatTaskSource.objects.filter( user=request.user, service__in=service_keys, channel_identifier__in=variants, ).select_related("project", "epic") tasks = ( DerivedTask.objects.filter( user=request.user, source_service__in=service_keys, source_channel__in=variants, ) .select_related("project", "epic", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) 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"], "projects": TaskProject.objects.filter(user=request.user).order_by("name"), "mappings": mappings, "tasks": tasks, }, ) def post(self, request, service, identifier): channel = _resolve_channel_display(request.user, service, identifier) action = str(request.POST.get("action") or "").strip().lower() if action == "group_project_create": project_name = str(request.POST.get("project_name") or "").strip() if not project_name: messages.error(request, "Project name is required.") return redirect( "tasks_group", service=channel["service_key"], identifier=channel["display_identifier"], ) epic_name = str(request.POST.get("epic_name") or "").strip() project, _ = TaskProject.objects.get_or_create( user=request.user, name=project_name, ) epic = None if epic_name: epic, _ = TaskEpic.objects.get_or_create(project=project, name=epic_name) _upsert_group_source( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], project=project, epic=epic, ) messages.success( request, f"Project '{project.name}' mapped to this group.", ) elif action == "group_map_existing_project": project = get_object_or_404( TaskProject, user=request.user, id=request.POST.get("project_id"), ) epic = None epic_id = str(request.POST.get("epic_id") or "").strip() if epic_id: epic = get_object_or_404(TaskEpic, project=project, id=epic_id) _upsert_group_source( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], project=project, epic=epic, ) messages.success(request, f"Mapped '{project.name}' to this group.") return redirect( "tasks_group", service=channel["service_key"], identifier=channel["display_identifier"], ) 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", "origin_message"), id=task_id, user=request.user, ) events = task.events.select_related("source_message").order_by("-created_at") for row in events: service_hint = str(getattr(task, "source_service", "") or "").strip().lower() event_source_message = getattr(row, "source_message", None) row.actor_display = _creator_label_for_message( request.user, service_hint, event_source_message, ) if row.actor_display == "Unknown": raw_actor = str(getattr(row, "actor_identifier", "") or "").strip() if raw_actor: row.actor_display = raw_actor task.creator_label = _creator_label_for_message( request.user, str(getattr(task, "source_service", "") or "").strip().lower(), getattr(task, "origin_message", None), ) 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): _apply_safe_defaults_for_user(request.user) _ensure_default_completion_patterns(request.user) 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"]) provider_map = _provider_row_map(request.user) codex_cfg = provider_map.get("codex_cli") codex_settings = dict(getattr(codex_cfg, "settings", {}) or {}) mock_cfg = provider_map.get("mock") external_chat_links = list( ExternalChatLink.objects.filter(user=request.user).select_related( "person", "person_identifier" ).order_by("-updated_at")[:200] ) person_identifiers = ( PersonIdentifier.objects.filter(user=request.user) .select_related("person") .order_by("person__name", "service", "identifier")[:600] ) external_link_scoped = bool(prefill_service and prefill_identifier) external_link_scope_label = "" external_link_person_identifiers = person_identifiers if external_link_scoped: external_link_scope_label = f"{prefill_service} · {prefill_identifier}" external_link_person_identifiers = _scoped_person_identifier_rows( request.user, prefill_service, prefill_identifier, ) 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": list(provider_map.values()), "mock_provider_config": mock_cfg, "codex_provider_config": codex_cfg, "codex_provider_settings": { "command": str(codex_settings.get("command") or "codex"), "workspace_root": str(codex_settings.get("workspace_root") or ""), "default_profile": str(codex_settings.get("default_profile") or ""), "timeout_seconds": int(codex_settings.get("timeout_seconds") or 60), "chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"), }, "person_identifiers": person_identifiers, "external_link_person_identifiers": external_link_person_identifiers, "external_link_scoped": external_link_scoped, "external_link_scope_label": external_link_scope_label, "external_chat_links": external_chat_links, "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 == "source_delete": source = get_object_or_404( ChatTaskSource, id=request.POST.get("source_id"), user=request.user, ) source.delete() 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")) settings_payload = dict(row.settings or {}) if provider == "codex_cli": timeout_raw = str(request.POST.get("timeout_seconds") or "60").strip() try: timeout_value = max(1, int(timeout_raw)) except Exception: timeout_value = 60 settings_payload = { "command": str(request.POST.get("command") or "codex").strip() or "codex", "workspace_root": str(request.POST.get("workspace_root") or "").strip(), "default_profile": str(request.POST.get("default_profile") or "").strip(), "timeout_seconds": timeout_value, "chat_link_mode": "task-sync", } row.settings = settings_payload row.save(update_fields=["enabled", "settings", "updated_at"]) return _settings_redirect(request) if action == "external_chat_link_upsert": provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli" external_chat_id = str(request.POST.get("external_chat_id") or "").strip() person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip() prefill_service = str( request.POST.get("prefill_service") or request.GET.get("service") or "" ).strip().lower() prefill_identifier = str( request.POST.get("prefill_identifier") or request.GET.get("identifier") or "" ).strip() if not external_chat_id: messages.error(request, "External chat ID is required.") return _settings_redirect(request) identifier = None if person_identifier_id: identifier = get_object_or_404( PersonIdentifier, user=request.user, id=person_identifier_id, ) if prefill_service and prefill_identifier: allowed_ids = set( _scoped_person_identifier_rows( request.user, prefill_service, prefill_identifier ).values_list("id", flat=True) ) if identifier.id not in allowed_ids: messages.error( request, "Selected contact is outside the current scoped chat.", ) return _settings_redirect(request) row, _ = ExternalChatLink.objects.update_or_create( user=request.user, provider=provider, external_chat_id=external_chat_id, defaults={ "person": getattr(identifier, "person", None), "person_identifier": identifier, "enabled": bool(request.POST.get("enabled")), "metadata": { "chat_link_mode": "task-sync", "notes": str(request.POST.get("metadata_notes") or "").strip(), }, }, ) if identifier and row.person_id != identifier.person_id: row.person = identifier.person row.save(update_fields=["person", "updated_at"]) return _settings_redirect(request) if action == "external_chat_link_delete": row = get_object_or_404( ExternalChatLink, id=request.POST.get("external_link_id"), user=request.user, ) row.delete() 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) if bool(getattr(provider, "run_in_worker", False)): event.status = "pending" event.error = "" event.payload = dict(event.payload or {}, retried=True) event.save(update_fields=["status", "error", "payload", "updated_at"]) else: 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"})