2081 lines
81 KiB
Python
2081 lines
81 KiB
Python
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 _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: <Epic name> (or .epic <Epic name>)\n"
|
|
"- add task to epic: task: <description> [epic:<Epic name>]\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
|
|
)
|
|
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":
|
|
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"})
|