from __future__ import annotations import datetime import hashlib import json 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.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils import timezone from django.views import View from core.clients.transport import send_message_raw from core.models import ( AnswerSuggestionEvent, Chat, ChatTaskSource, CodexPermissionRequest, CodexRun, DerivedTask, ExternalChatLink, ExternalSyncEvent, Person, PersonIdentifier, PlatformChatLink, TaskCompletionPattern, TaskEpic, TaskProject, TaskProviderConfig, ) from core.tasks.chat_defaults import ( SAFE_TASK_FLAGS_DEFAULTS, ensure_default_source_for_chat, normalize_channel_identifier, ) from core.tasks.codex_approval import queue_codex_event_with_pre_approval from core.tasks.codex_support import resolve_external_chat_id from core.tasks.engine import create_task_record_and_sync from core.tasks.providers import get_provider def _upsert_task_source( *, user, service: str, channel_identifier: str, project, epic=None, enabled: bool = True, settings: dict | None = None, ): service_key = str(service or "").strip().lower() normalized_identifier = normalize_channel_identifier(service_key, channel_identifier) if not service_key or not normalized_identifier: return None, False source, created = ChatTaskSource.objects.get_or_create( user=user, service=service_key, channel_identifier=normalized_identifier, defaults={ "project": project, "epic": epic, "enabled": bool(enabled), "settings": dict(settings or {}), }, ) changed_fields = [] if source.project_id != getattr(project, "id", None): source.project = project changed_fields.append("project") if source.epic_id != getattr(epic, "id", None): source.epic = epic changed_fields.append("epic") if bool(source.enabled) != bool(enabled): source.enabled = bool(enabled) changed_fields.append("enabled") settings_payload = dict(settings or {}) if dict(source.settings or {}) != settings_payload: source.settings = settings_payload changed_fields.append("settings") if changed_fields: source.save(update_fields=changed_fields + ["updated_at"]) return source, created 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: def key(name: str) -> str: return 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 _format_task_event_payload(raw_payload): payload = raw_payload if payload is None: payload = {} if isinstance(payload, str): text = payload.strip() if not text: return { "summary_items": [], "pretty_text": "{}", "is_mapping": False, } try: parsed = json.loads(text) payload = parsed except Exception: return { "summary_items": [("text", text[:140])], "pretty_text": text, "is_mapping": False, } if isinstance(payload, dict): summary = [] preferred = ( "source", "reason", "reaction", "emoji", "presence", "last_seen_ts", ) for key in preferred: if key in payload: summary.append((key, str(payload.get(key)))) if not summary: for key in list(payload.keys())[:4]: summary.append((str(key), str(payload.get(key)))) pretty = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) return { "summary_items": summary, "pretty_text": pretty, "is_mapping": True, } if isinstance(payload, (list, tuple)): pretty = json.dumps(list(payload), indent=2, ensure_ascii=False) return { "summary_items": [("items", str(len(payload)))], "pretty_text": pretty, "is_mapping": False, } text = str(payload) return { "summary_items": [("value", text[:140])], "pretty_text": text, "is_mapping": False, } 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 []) person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {} def _resolve_person_identifier(service_key: str, sender_identifier: str): key = ( str(service_key or "").strip().lower(), str(sender_identifier or "").strip(), ) if key in person_identifier_cache: return person_identifier_cache[key] variants = _person_identifier_scope_variants(key[0], key[1]) row = ( PersonIdentifier.objects.filter( user=user, service=key[0], identifier__in=variants or [key[1]], ) .select_related("person") .first() ) person_identifier_cache[key] = row return row for row in rows: origin = getattr(row, "origin_message", None) service_key = str(getattr(row, "source_service", "") or "").strip().lower() sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip() row.creator_label = _creator_label_for_message(user, service_key, origin) row.creator_identifier = sender_identifier row.creator_compose_href = "" if sender_identifier and service_key: person_identifier = _resolve_person_identifier( service_key, sender_identifier ) compose_service = service_key compose_identifier = sender_identifier compose_person_id = "" if person_identifier is not None: compose_identifier = ( str(getattr(person_identifier, "identifier", "") or "").strip() or sender_identifier ) compose_person_id = str( getattr(person_identifier, "person_id", "") or "" ) query = { "service": compose_service, "identifier": compose_identifier, } if compose_person_id: query["person"] = compose_person_id row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}" 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 _codex_settings_with_defaults(raw: dict | None) -> dict: row = dict(raw or {}) timeout_raw = str(row.get("timeout_seconds") or "60").strip() try: timeout_seconds = max(1, int(timeout_raw)) except Exception: timeout_seconds = 60 return { "command": str(row.get("command") or "codex").strip() or "codex", "workspace_root": str(row.get("workspace_root") or "").strip(), "default_profile": str(row.get("default_profile") or "").strip(), "timeout_seconds": timeout_seconds, "chat_link_mode": "task-sync", "instance_label": str(row.get("instance_label") or "default").strip() or "default", "approver_service": str(row.get("approver_service") or "").strip().lower(), "approver_identifier": str(row.get("approver_identifier") or "").strip(), "approver_mode": "channel", } def _claude_settings_with_defaults(raw: dict | None) -> dict: row = dict(raw or {}) timeout_raw = str(row.get("timeout_seconds") or "60").strip() try: timeout_seconds = max(1, int(timeout_raw)) except Exception: timeout_seconds = 60 return { "command": str(row.get("command") or "claude").strip() or "claude", "workspace_root": str(row.get("workspace_root") or "").strip(), "default_profile": str(row.get("default_profile") or "").strip(), "timeout_seconds": timeout_seconds, "approver_service": str(row.get("approver_service") or "").strip().lower(), "approver_identifier": str(row.get("approver_identifier") or "").strip(), } def _enqueue_codex_task_submission( *, user, task: DerivedTask, source_service: str, source_channel: str, mode: str = "default", command_text: str = "", source_message=None, provider: str = "codex_cli", ) -> CodexRun: provider = str(provider or "codex_cli").strip() or "codex_cli" external_chat_id = resolve_external_chat_id( user=user, provider=provider, service=source_service, channel=source_channel, ) provider_payload = { "task_id": str(task.id), "reference_code": str(task.reference_code or ""), "title": str(task.title or ""), "external_key": str(task.external_key or ""), "project_name": str(getattr(task.project, "name", "") or ""), "epic_name": str(getattr(task.epic, "name", "") or ""), "source_service": str(source_service or ""), "source_channel": str(source_channel or ""), "external_chat_id": external_chat_id, "origin_message_id": str(getattr(task, "origin_message_id", "") or ""), "trigger_message_id": str(getattr(source_message, "id", "") or ""), "mode": str(mode or "default"), } if command_text: provider_payload["command_text"] = str(command_text) run = CodexRun.objects.create( user=user, task=task, source_message=source_message, project=task.project, epic=task.epic, source_service=str(source_service or ""), source_channel=str(source_channel or ""), external_chat_id=external_chat_id, status="waiting_approval", request_payload={ "action": "append_update", "provider_payload": dict(provider_payload), }, result_payload={}, error="", ) provider_payload["codex_run_id"] = str(run.id) run.request_payload = { "action": "append_update", "provider_payload": dict(provider_payload), } run.save(update_fields=["request_payload", "updated_at"]) idempotency_key = f"codex_submit:{task.id}:{mode}:{hashlib.sha1(str(command_text or '').encode('utf-8')).hexdigest()[:10]}:{run.id}" queue_codex_event_with_pre_approval( user=user, run=run, task=task, task_event=None, action="append_update", provider_payload=dict(provider_payload), idempotency_key=idempotency_key, provider=provider, ) return run 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 _notify_epic_created_in_project_chats( *, project: TaskProject, epic: TaskEpic ) -> None: rows = ( ChatTaskSource.objects.filter(project=project, enabled=True) .order_by("service", "channel_identifier") .values_list("service", "channel_identifier") ) seen: set[tuple[str, str]] = set() for service, channel_identifier in rows: svc = str(service or "").strip().lower() chan = str(channel_identifier or "").strip() if not svc or not chan: continue key = (svc, chan) if key in seen: continue seen.add(key) try: async_to_sync(send_message_raw)( svc, chan, text=( f"[epic] Created '{epic.name}' in project '{project.name}'.\n" "WhatsApp usage:\n" "- create epic: epic: (or .epic )\n" "- add task to epic: task: [epic:]\n" "- list tasks: .l list tasks\n" "- undo latest task: .undo" ), attachments=[], metadata={"origin": "task_epic_announce"}, ) except Exception: continue def _reseed_chat_sources_for_deleted_project( user, service_channel_rows: list[tuple[str, str]] ) -> int: restored = 0 seen: set[tuple[str, str]] = set() for service, channel_identifier in service_channel_rows: service_key = str(service or "").strip().lower() channel = str(channel_identifier or "").strip() if not service_key or not channel: continue pair = (service_key, channel) if pair in seen: continue seen.add(pair) source = ensure_default_source_for_chat( user=user, service=service_key, channel_identifier=channel, ) if source is not None: restored += 1 return restored 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) show_empty = bool( str(request.GET.get("show_empty") or "").strip() in {"1", "true", "yes", "on"} ) all_projects = ( TaskProject.objects.filter(user=request.user) .annotate( task_count=Count("derived_tasks"), epic_count=Count("epics", distinct=True), source_count=Count("chat_sources", distinct=True), ) .order_by("name") ) projects = all_projects if show_empty else all_projects.filter(task_count__gt=0) 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 = all_projects.filter( 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, } ) enabled_providers = list( TaskProviderConfig.objects.filter(user=request.user, enabled=True) .exclude(provider="mock") .values_list("provider", flat=True) .order_by("provider") ) return { "projects": projects, "project_choices": all_projects, "epic_choices": TaskEpic.objects.filter( project__user=request.user ).select_related("project").order_by("project__name", "name"), "tasks": tasks, "scope": scope, "person_identifier_rows": person_identifier_rows, "selected_project": selected_project, "show_empty_projects": show_empty, "enabled_providers": enabled_providers, } 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 == "task_create": 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, id=epic_id, project=project) title = str(request.POST.get("title") or "").strip() if not title: messages.error(request, "Task title is required.") return redirect("tasks_hub") scope = self._scope(request) source_service = str(scope.get("service") or "").strip().lower() or "web" source_channel = str(scope.get("identifier") or "").strip() due_raw = str(request.POST.get("due_date") or "").strip() due_date = None if due_raw: try: due_date = datetime.date.fromisoformat(due_raw) except Exception: messages.error(request, "Due date must be YYYY-MM-DD.") return redirect("tasks_hub") task, _event = async_to_sync(create_task_record_and_sync)( user=request.user, project=project, epic=epic, title=title, source_service=source_service, source_channel=source_channel, actor_identifier=str(request.user.username or request.user.id), due_date=due_date, assignee_identifier=str( request.POST.get("assignee_identifier") or "" ).strip(), immutable_payload={ "source": "tasks_hub_manual_create", "person_id": scope["person_id"], "service": source_service, "identifier": source_channel, }, event_payload={ "source": "tasks_hub_manual_create", "via": "web_ui", }, ) messages.success( request, f"Created task #{task.reference_code} in '{project.name}'.", ) return redirect("tasks_task", task_id=str(task.id)) 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, ) confirm_name = str(request.POST.get("confirm_name") or "").strip() expected = str(project.name or "").strip() if confirm_name != expected: messages.error( request, f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}", ) return redirect("tasks_hub") mapped_channels = list( project.chat_sources.values_list("service", "channel_identifier") ) deleted_name = str(project.name or "").strip() or "Project" project.delete() restored = _reseed_chat_sources_for_deleted_project( request.user, mapped_channels ) if restored > 0: messages.success( request, f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.", ) else: 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}'.") _notify_epic_created_in_project_chats(project=project, epic=epic) 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 == "task_set_epic": task = get_object_or_404( DerivedTask, id=request.POST.get("task_id"), user=request.user, project=project, ) epic_id = str(request.POST.get("epic_id") or "").strip() epic = None if epic_id: epic = get_object_or_404(TaskEpic, id=epic_id, project=project) task.epic = epic task.save(update_fields=["epic"]) if epic is None: messages.success( request, f"Cleared epic for task #{task.reference_code}." ) else: messages.success( request, f"Assigned task #{task.reference_code} to epic '{epic.name}'.", ) return redirect("tasks_project", project_id=str(project.id)) if action == "project_delete": confirm_name = str(request.POST.get("confirm_name") or "").strip() expected = str(project.name or "").strip() if confirm_name != expected: messages.error( request, f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}", ) return redirect("tasks_project", project_id=str(project.id)) mapped_channels = list( project.chat_sources.values_list("service", "channel_identifier") ) deleted_name = str(project.name or "").strip() or "Project" project.delete() restored = _reseed_chat_sources_for_deleted_project( request.user, mapped_channels ) if restored > 0: messages.success( request, f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.", ) else: 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") mappings = list(mappings) if not mappings: seeded = ensure_default_source_for_chat( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], ) if seeded is not None: mappings = list( ChatTaskSource.objects.filter(id=seeded.id).select_related( "project", "epic" ) ) for row in mappings: row_channel = _resolve_channel_display( request.user, str(getattr(row, "service", "") or ""), str(getattr(row, "channel_identifier", "") or ""), ) row.display_service_label = row_channel.get( "service_label" ) or _service_label(str(getattr(row, "service", "") or "")) row.display_channel_name = ( str(row_channel.get("display_name") or "").strip() or str(channel.get("display_name") or "").strip() or "Unknown chat" ) 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, "primary_project": mappings[0].project if mappings else None, "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.") elif action == "group_project_rename": current = ( ChatTaskSource.objects.filter( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], enabled=True, ) .select_related("project") .order_by("-updated_at") .first() ) if current is None: current = ensure_default_source_for_chat( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], ) new_name = str(request.POST.get("project_name") or "").strip() if current is None or current.project is None: messages.error(request, "No mapped project found for this chat.") elif not new_name: messages.error(request, "Project name is required.") elif ( TaskProject.objects.filter(user=request.user, name=new_name) .exclude(id=current.project_id) .exists() ): messages.error(request, f"Project '{new_name}' already exists.") else: current.project.name = new_name current.project.save(update_fields=["name", "updated_at"]) messages.success(request, f"Renamed project to '{new_name}'.") 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 row.payload_view = _format_task_event_payload(getattr(row, "payload", None)) 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") codex_runs = task.codex_runs.select_related("source_message").order_by( "-created_at" ) permission_requests = ( CodexPermissionRequest.objects.filter(codex_run__task=task) .select_related("codex_run", "external_sync_event") .order_by("-requested_at") ) return render( request, self.template_name, { "task": task, "events": events, "sync_events": sync_events, "codex_runs": codex_runs, "permission_requests": permission_requests, }, ) 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 = _codex_settings_with_defaults( dict(getattr(codex_cfg, "settings", {}) or {}) ) claude_cfg = provider_map.get("claude_cli") claude_settings = _claude_settings_with_defaults( dict(getattr(claude_cfg, "settings", {}) or {}) ) mock_cfg = provider_map.get("mock") codex_provider = get_provider("codex_cli") claude_provider = get_provider("claude_cli") codex_healthcheck = ( codex_provider.healthcheck(codex_settings) if codex_cfg else None ) claude_healthcheck = ( claude_provider.healthcheck(claude_settings) if claude_cfg else None ) codex_queue_counts = { "pending": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="pending" ).count(), "waiting_approval": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="waiting_approval" ).count(), "failed": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="failed" ).count(), "ok": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="ok" ).count(), } claude_queue_counts = { "pending": ExternalSyncEvent.objects.filter( user=request.user, provider="claude_cli", status="pending" ).count(), "waiting_approval": ExternalSyncEvent.objects.filter( user=request.user, provider="claude_cli", status="waiting_approval" ).count(), "failed": ExternalSyncEvent.objects.filter( user=request.user, provider="claude_cli", status="failed" ).count(), "ok": ExternalSyncEvent.objects.filter( user=request.user, provider="claude_cli", status="ok" ).count(), } codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by( "-created_at" )[:10] latest_worker_event = ( ExternalSyncEvent.objects.filter( user=request.user, provider__in=["codex_cli", "claude_cli"], ) .filter(status__in=["ok", "failed", "waiting_approval", "retrying"]) .order_by("-updated_at") .first() ) worker_heartbeat_at = getattr(latest_worker_event, "updated_at", None) worker_heartbeat_age = "" if worker_heartbeat_at is not None: delta_seconds = max( 0, int((timezone.now() - worker_heartbeat_at).total_seconds()) ) worker_heartbeat_age = f"{delta_seconds}s ago" 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" ), "instance_label": str( codex_settings.get("instance_label") or "default" ), "approver_service": str(codex_settings.get("approver_service") or ""), "approver_identifier": str( codex_settings.get("approver_identifier") or "" ), "approver_mode": "channel", }, "codex_compact_summary": { "healthcheck_ok": bool(getattr(codex_healthcheck, "ok", False)), "healthcheck_error": str(getattr(codex_healthcheck, "error", "") or ""), "healthcheck_payload": dict( getattr(codex_healthcheck, "payload", {}) or {} ), "worker_heartbeat_at": worker_heartbeat_at, "worker_heartbeat_age": worker_heartbeat_age, "queue_counts": codex_queue_counts, "recent_runs": codex_recent_runs, }, "claude_provider_config": claude_cfg, "claude_provider_settings": { "command": str(claude_settings.get("command") or "claude"), "workspace_root": str(claude_settings.get("workspace_root") or ""), "default_profile": str(claude_settings.get("default_profile") or ""), "timeout_seconds": int(claude_settings.get("timeout_seconds") or 60), "approver_service": str(claude_settings.get("approver_service") or ""), "approver_identifier": str( claude_settings.get("approver_identifier") or "" ), }, "claude_compact_summary": { "healthcheck_ok": bool(getattr(claude_healthcheck, "ok", False)), "healthcheck_error": str( getattr(claude_healthcheck, "error", "") or "" ), "healthcheck_payload": dict( getattr(claude_healthcheck, "payload", {}) or {} ), "queue_counts": claude_queue_counts, }, "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 ) epic = 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"), ) _notify_epic_created_in_project_chats(project=project, epic=epic) 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 ) source, _ = _upsert_task_source( 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_"), ) if source is None: messages.error(request, "Invalid channel identifier.") 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 = _upsert_task_source( user=request.user, service=service, channel_identifier=channel_identifier, project=project, epic=epic, enabled=True, settings=_flags_from_post(request, prefix="source_"), ) if source is None: messages.error(request, "Invalid channel identifier.") 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": settings_payload = _codex_settings_with_defaults( { "command": request.POST.get("command"), "workspace_root": request.POST.get("workspace_root"), "default_profile": request.POST.get("default_profile"), "timeout_seconds": request.POST.get("timeout_seconds"), "instance_label": request.POST.get("instance_label"), "approver_service": request.POST.get("approver_service"), "approver_identifier": request.POST.get("approver_identifier"), "approver_mode": "channel", } ) elif provider == "claude_cli": settings_payload = _claude_settings_with_defaults( { "command": request.POST.get("command"), "workspace_root": request.POST.get("workspace_root"), "default_profile": request.POST.get("default_profile"), "timeout_seconds": request.POST.get("timeout_seconds"), "approver_service": request.POST.get("approver_service"), "approver_identifier": request.POST.get("approver_identifier"), } ) 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) _ALLOWED_SUBMIT_PROVIDERS = {"codex_cli", "claude_cli"} class TaskCodexSubmit(LoginRequiredMixin, View): def post(self, request): task_id = str(request.POST.get("task_id") or "").strip() next_url = str(request.POST.get("next") or reverse("tasks_hub")).strip() provider = str(request.POST.get("provider") or "codex_cli").strip().lower() if provider not in _ALLOWED_SUBMIT_PROVIDERS: provider = "codex_cli" task = get_object_or_404( DerivedTask.objects.select_related("project", "epic", "origin_message"), id=task_id, user=request.user, ) cfg = TaskProviderConfig.objects.filter( user=request.user, provider=provider, enabled=True, ).first() provider_label = "Claude" if provider == "claude_cli" else "Codex" if cfg is None: messages.error( request, f"{provider_label} provider is disabled. Enable it in Task Automation first.", ) return redirect(next_url) run = _enqueue_codex_task_submission( user=request.user, task=task, source_service=str(task.source_service or ""), source_channel=str(task.source_channel or ""), mode="default", source_message=getattr(task, "origin_message", None), provider=provider, ) messages.success( request, f"Queued approval for task #{task.reference_code} before {provider_label} run {run.id}.", ) return redirect(next_url) class CodexSettingsPage(LoginRequiredMixin, View): template_name = "pages/codex-settings.html" def _context(self, request): cfg = TaskProviderConfig.objects.filter( user=request.user, provider="codex_cli" ).first() settings_payload = _codex_settings_with_defaults( dict(getattr(cfg, "settings", {}) or {}) ) provider = get_provider("codex_cli") health = provider.healthcheck(settings_payload) if cfg else None status_filter = str(request.GET.get("status") or "").strip().lower() service_filter = str(request.GET.get("service") or "").strip().lower() channel_filter = str(request.GET.get("channel") or "").strip() project_filter = str(request.GET.get("project") or "").strip() date_from = str(request.GET.get("date_from") or "").strip() runs = ( CodexRun.objects.filter(user=request.user) .select_related("task", "project", "epic") .order_by("-created_at") ) if status_filter: runs = runs.filter(status=status_filter) if service_filter: runs = runs.filter(source_service=service_filter) if channel_filter: runs = runs.filter(source_channel=channel_filter) if project_filter: runs = runs.filter(project_id=project_filter) if date_from: runs = runs.filter(created_at__date__gte=date_from) runs = runs[:200] queue_counts = { "pending": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="pending" ).count(), "waiting_approval": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="waiting_approval" ).count(), "failed": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="failed" ).count(), "ok": ExternalSyncEvent.objects.filter( user=request.user, provider="codex_cli", status="ok" ).count(), } permission_requests = ( CodexPermissionRequest.objects.filter(user=request.user) .select_related("codex_run", "codex_run__task", "external_sync_event") .order_by("-requested_at")[:200] ) return { "provider_config": cfg, "provider_settings": settings_payload, "health": health, "runs": runs, "queue_counts": queue_counts, "permission_requests": permission_requests, "projects": TaskProject.objects.filter(user=request.user).order_by("name"), "filters": { "status": status_filter, "service": service_filter, "channel": channel_filter, "project": project_filter, "date_from": date_from, }, } def get(self, request): return render(request, self.template_name, self._context(request)) class CodexApprovalAction(LoginRequiredMixin, View): def post(self, request): request_id = str(request.POST.get("request_id") or "").strip() decision = str(request.POST.get("decision") or "").strip().lower() row = get_object_or_404( CodexPermissionRequest.objects.select_related( "codex_run", "external_sync_event" ), id=request_id, user=request.user, ) if row.status != "pending": return redirect("codex_settings") now = timezone.now() if decision == "approve": row.status = "approved" row.resolved_at = now row.resolved_by_identifier = "settings_ui" row.resolution_note = "approved via settings ui" row.save( update_fields=[ "status", "resolved_at", "resolved_by_identifier", "resolution_note", ] ) if row.external_sync_event_id: ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update( status="ok", error="", ) run = row.codex_run run.status = "approved_waiting_resume" run.error = "" run.save(update_fields=["status", "error", "updated_at"]) resume_payload = dict(row.resume_payload or {}) resume_action = str(resume_payload.get("action") or "").strip().lower() resume_provider_payload = dict(resume_payload.get("provider_payload") or {}) if resume_action and resume_provider_payload: provider_payload = dict(resume_provider_payload) provider_payload["codex_run_id"] = str(run.id) event_action = resume_action resume_idempotency_key = str( resume_payload.get("idempotency_key") or "" ).strip() resume_event_key = ( resume_idempotency_key if resume_idempotency_key else f"codex_approval:{row.approval_key}:approved" ) else: provider_payload = dict( run.request_payload.get("provider_payload") or {} ) provider_payload.update( { "mode": "approval_response", "approval_key": row.approval_key, "resume_payload": dict(row.resume_payload or {}), "codex_run_id": str(run.id), } ) event_action = "append_update" resume_event_key = f"codex_approval:{row.approval_key}:approved" ExternalSyncEvent.objects.update_or_create( idempotency_key=resume_event_key, defaults={ "user": request.user, "task": run.task, "task_event": run.derived_task_event, "provider": "codex_cli", "status": "pending", "payload": { "action": event_action, "provider_payload": provider_payload, }, "error": "", }, ) messages.success( request, f"Approved {row.approval_key}. Resume event queued." ) return redirect("codex_settings") row.status = "denied" row.resolved_at = now row.resolved_by_identifier = "settings_ui" row.resolution_note = "denied via settings ui" row.save( update_fields=[ "status", "resolved_at", "resolved_by_identifier", "resolution_note", ] ) run = row.codex_run run.status = "denied" run.error = "approval_denied" run.save(update_fields=["status", "error", "updated_at"]) ExternalSyncEvent.objects.update_or_create( idempotency_key=f"codex_approval:{row.approval_key}:denied", defaults={ "user": request.user, "task": run.task, "task_event": run.derived_task_event, "provider": "codex_cli", "status": "failed", "payload": { "action": "append_update", "provider_payload": { "mode": "approval_response", "approval_key": row.approval_key, "codex_run_id": str(run.id), }, }, "error": "approval_denied", }, ) if row.external_sync_event_id: ExternalSyncEvent.objects.filter(id=row.external_sync_event_id).update( status="failed", error="approval_denied", ) messages.warning(request, f"Denied {row.approval_key}.") return redirect("codex_settings") 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"})