Increase security and reformat
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
@@ -9,40 +9,40 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Count
|
||||
from django.urls import reverse
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.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,
|
||||
DerivedTaskEvent,
|
||||
ExternalChatLink,
|
||||
ExternalSyncEvent,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProject,
|
||||
TaskProviderConfig,
|
||||
PersonIdentifier,
|
||||
Person,
|
||||
PlatformChatLink,
|
||||
Chat,
|
||||
ExternalChatLink,
|
||||
)
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
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.providers import get_provider
|
||||
|
||||
|
||||
def _to_bool(raw, default=False) -> bool:
|
||||
if raw is None:
|
||||
return bool(default)
|
||||
@@ -88,13 +88,30 @@ def _normalized_safe_flags(raw: dict | None) -> dict:
|
||||
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"]),
|
||||
"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"])),
|
||||
}
|
||||
)
|
||||
@@ -117,17 +134,45 @@ def _apply_safe_defaults_for_user(user) -> None:
|
||||
|
||||
|
||||
def _flags_from_post(request, prefix: str = "") -> dict:
|
||||
key = lambda name: f"{prefix}{name}" if prefix else name
|
||||
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"]))),
|
||||
"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"])
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -136,10 +181,16 @@ def _flags_with_defaults(raw: dict | None) -> dict:
|
||||
|
||||
|
||||
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()
|
||||
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(
|
||||
f"{request.path}?{urlencode({'service': service, 'identifier': identifier})}"
|
||||
)
|
||||
return redirect("tasks_settings")
|
||||
|
||||
|
||||
@@ -147,7 +198,9 @@ 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)
|
||||
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:
|
||||
@@ -198,7 +251,14 @@ def _format_task_event_payload(raw_payload):
|
||||
|
||||
if isinstance(payload, dict):
|
||||
summary = []
|
||||
preferred = ("source", "reason", "reaction", "emoji", "presence", "last_seen_ts")
|
||||
preferred = (
|
||||
"source",
|
||||
"reason",
|
||||
"reaction",
|
||||
"emoji",
|
||||
"presence",
|
||||
"last_seen_ts",
|
||||
)
|
||||
for key in preferred:
|
||||
if key in payload:
|
||||
summary.append((key, str(payload.get(key))))
|
||||
@@ -253,7 +313,9 @@ def _creator_label_for_message(user, service: str, message) -> str:
|
||||
.first()
|
||||
)
|
||||
if person_identifier is not None:
|
||||
person_name = str(getattr(person_identifier.person, "name", "") or "").strip()
|
||||
person_name = str(
|
||||
getattr(person_identifier.person, "name", "") or ""
|
||||
).strip()
|
||||
if person_name:
|
||||
return person_name
|
||||
return sender_identifier
|
||||
@@ -270,7 +332,10 @@ def _apply_task_creator_labels(user, task_rows):
|
||||
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())
|
||||
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])
|
||||
@@ -294,13 +359,20 @@ def _apply_task_creator_labels(user, task_rows):
|
||||
row.creator_identifier = sender_identifier
|
||||
row.creator_compose_href = ""
|
||||
if sender_identifier and service_key:
|
||||
person_identifier = _resolve_person_identifier(service_key, sender_identifier)
|
||||
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 "")
|
||||
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,
|
||||
@@ -331,7 +403,8 @@ def _codex_settings_with_defaults(raw: dict | None) -> dict:
|
||||
"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",
|
||||
"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",
|
||||
@@ -399,16 +472,20 @@ def _enqueue_codex_task_submission(
|
||||
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)},
|
||||
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.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}"
|
||||
)
|
||||
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,
|
||||
@@ -422,7 +499,9 @@ def _enqueue_codex_task_submission(
|
||||
return run
|
||||
|
||||
|
||||
def _upsert_group_source(*, user, service: str, channel_identifier: str, project, epic=None):
|
||||
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:
|
||||
@@ -452,7 +531,9 @@ def _upsert_group_source(*, user, service: str, channel_identifier: str, project
|
||||
return source
|
||||
|
||||
|
||||
def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpic) -> None:
|
||||
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")
|
||||
@@ -487,7 +568,9 @@ def _notify_epic_created_in_project_chats(*, project: TaskProject, epic: TaskEpi
|
||||
continue
|
||||
|
||||
|
||||
def _reseed_chat_sources_for_deleted_project(user, service_channel_rows: list[tuple[str, str]]) -> int:
|
||||
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:
|
||||
@@ -641,9 +724,8 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
|
||||
|
||||
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)
|
||||
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,
|
||||
@@ -658,15 +740,23 @@ 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_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(),
|
||||
"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(),
|
||||
@@ -674,7 +764,10 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
|
||||
def _context(self, request):
|
||||
scope = self._scope(request)
|
||||
show_empty = bool(str(request.GET.get("show_empty") or "").strip() in {"1", "true", "yes", "on"})
|
||||
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(
|
||||
@@ -693,7 +786,9 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
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()
|
||||
selected_project = all_projects.filter(
|
||||
id=scope["selected_project_id"]
|
||||
).first()
|
||||
person_identifiers = []
|
||||
person_identifier_rows = []
|
||||
if scope["person"] is not None:
|
||||
@@ -704,8 +799,9 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
).order_by("service", "identifier")
|
||||
)
|
||||
mapping_pairs = set(
|
||||
ChatTaskSource.objects.filter(user=request.user)
|
||||
.values_list("project_id", "service", "channel_identifier")
|
||||
ChatTaskSource.objects.filter(user=request.user).values_list(
|
||||
"project_id", "service", "channel_identifier"
|
||||
)
|
||||
)
|
||||
for row in person_identifiers:
|
||||
mapped = False
|
||||
@@ -759,7 +855,9 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
defaults={"external_key": external_key},
|
||||
)
|
||||
except IntegrityError:
|
||||
messages.error(request, "Could not create project due to duplicate name.")
|
||||
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}'.")
|
||||
@@ -825,10 +923,14 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
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"))
|
||||
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)
|
||||
restored = _reseed_chat_sources_for_deleted_project(
|
||||
request.user, mapped_channels
|
||||
)
|
||||
if restored > 0:
|
||||
messages.success(
|
||||
request,
|
||||
@@ -893,7 +995,9 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
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)
|
||||
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}'.")
|
||||
@@ -913,7 +1017,9 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
task.epic = epic
|
||||
task.save(update_fields=["epic"])
|
||||
if epic is None:
|
||||
messages.success(request, f"Cleared epic for task #{task.reference_code}.")
|
||||
messages.success(
|
||||
request, f"Cleared epic for task #{task.reference_code}."
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
@@ -930,10 +1036,14 @@ class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
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"))
|
||||
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)
|
||||
restored = _reseed_chat_sources_for_deleted_project(
|
||||
request.user, mapped_channels
|
||||
)
|
||||
if restored > 0:
|
||||
messages.success(
|
||||
request,
|
||||
@@ -983,8 +1093,9 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
)
|
||||
if seeded is not None:
|
||||
mappings = list(
|
||||
ChatTaskSource.objects.filter(id=seeded.id)
|
||||
.select_related("project", "epic")
|
||||
ChatTaskSource.objects.filter(id=seeded.id).select_related(
|
||||
"project", "epic"
|
||||
)
|
||||
)
|
||||
for row in mappings:
|
||||
row_channel = _resolve_channel_display(
|
||||
@@ -992,9 +1103,9 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
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_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()
|
||||
@@ -1018,7 +1129,9 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
"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"),
|
||||
"projects": TaskProject.objects.filter(user=request.user).order_by(
|
||||
"name"
|
||||
),
|
||||
"mappings": mappings,
|
||||
"primary_project": mappings[0].project if mappings else None,
|
||||
"tasks": tasks,
|
||||
@@ -1044,7 +1157,9 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
)
|
||||
epic = None
|
||||
if epic_name:
|
||||
epic, _ = TaskEpic.objects.get_or_create(project=project, name=epic_name)
|
||||
epic, _ = TaskEpic.objects.get_or_create(
|
||||
project=project, name=epic_name
|
||||
)
|
||||
_upsert_group_source(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
@@ -1097,7 +1212,11 @@ class TaskGroupDetail(LoginRequiredMixin, View):
|
||||
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():
|
||||
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
|
||||
@@ -1121,7 +1240,9 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
)
|
||||
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()
|
||||
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,
|
||||
@@ -1139,10 +1260,14 @@ class TaskDetail(LoginRequiredMixin, View):
|
||||
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")
|
||||
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,
|
||||
@@ -1167,7 +1292,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
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"])
|
||||
row.allowed_prefixes_csv = ",".join(
|
||||
row.settings_effective["allowed_prefixes"]
|
||||
)
|
||||
sources = list(
|
||||
ChatTaskSource.objects.filter(user=request.user)
|
||||
.select_related("project", "epic")
|
||||
@@ -1175,17 +1302,27 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
)
|
||||
for row in sources:
|
||||
row.settings_effective = _flags_with_defaults(row.settings)
|
||||
row.allowed_prefixes_csv = ",".join(row.settings_effective["allowed_prefixes"])
|
||||
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 {}))
|
||||
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 {}))
|
||||
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_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"
|
||||
@@ -1214,7 +1351,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
user=request.user, provider="claude_cli", status="ok"
|
||||
).count(),
|
||||
}
|
||||
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by("-created_at")[:10]
|
||||
codex_recent_runs = CodexRun.objects.filter(user=request.user).order_by(
|
||||
"-created_at"
|
||||
)[:10]
|
||||
latest_worker_event = (
|
||||
ExternalSyncEvent.objects.filter(
|
||||
user=request.user,
|
||||
@@ -1227,12 +1366,14 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
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()))
|
||||
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]
|
||||
ExternalChatLink.objects.filter(user=request.user)
|
||||
.select_related("person", "person_identifier")
|
||||
.order_by("-updated_at")[:200]
|
||||
)
|
||||
person_identifiers = (
|
||||
PersonIdentifier.objects.filter(user=request.user)
|
||||
@@ -1252,9 +1393,13 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
|
||||
return {
|
||||
"projects": projects,
|
||||
"epics": TaskEpic.objects.filter(project__user=request.user).select_related("project").order_by("project__name", "name"),
|
||||
"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"),
|
||||
"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,
|
||||
@@ -1263,16 +1408,24 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
"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"),
|
||||
"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_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 {}),
|
||||
"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,
|
||||
@@ -1285,12 +1438,18 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
"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 ""),
|
||||
"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 {}),
|
||||
"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,
|
||||
@@ -1298,7 +1457,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
"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],
|
||||
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by(
|
||||
"-updated_at"
|
||||
)[:100],
|
||||
"prefill_service": prefill_service,
|
||||
"prefill_identifier": prefill_identifier,
|
||||
}
|
||||
@@ -1320,7 +1481,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "epic_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
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",
|
||||
@@ -1331,15 +1494,21 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "source_create":
|
||||
project = get_object_or_404(TaskProject, id=request.POST.get("project_id"), user=request.user)
|
||||
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)
|
||||
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(),
|
||||
channel_identifier=str(
|
||||
request.POST.get("channel_identifier") or ""
|
||||
).strip(),
|
||||
project=project,
|
||||
epic=epic,
|
||||
enabled=bool(request.POST.get("enabled") or "1"),
|
||||
@@ -1349,8 +1518,12 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
|
||||
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"
|
||||
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,
|
||||
@@ -1362,7 +1535,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
project.save(update_fields=["settings", "updated_at"])
|
||||
epic = None
|
||||
if epic_name:
|
||||
epic, _ = TaskEpic.objects.get_or_create(project=project, name=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,
|
||||
@@ -1380,17 +1555,29 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
source.epic = epic
|
||||
source.enabled = True
|
||||
source.settings = _flags_from_post(request, prefix="source_")
|
||||
source.save(update_fields=["project", "epic", "enabled", "settings", "updated_at"])
|
||||
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 = 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 = 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)
|
||||
@@ -1410,7 +1597,12 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
TaskCompletionPattern.objects.get_or_create(
|
||||
user=request.user,
|
||||
phrase=phrase,
|
||||
defaults={"enabled": True, "position": TaskCompletionPattern.objects.filter(user=request.user).count()},
|
||||
defaults={
|
||||
"enabled": True,
|
||||
"position": TaskCompletionPattern.objects.filter(
|
||||
user=request.user
|
||||
).count(),
|
||||
},
|
||||
)
|
||||
return _settings_redirect(request)
|
||||
|
||||
@@ -1452,14 +1644,27 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "external_chat_link_upsert":
|
||||
provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli"
|
||||
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()
|
||||
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 ""
|
||||
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.")
|
||||
@@ -1512,7 +1717,9 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "sync_retry":
|
||||
event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user)
|
||||
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"
|
||||
@@ -1555,7 +1762,7 @@ class TaskCodexSubmit(LoginRequiredMixin, View):
|
||||
if cfg is None:
|
||||
messages.error(
|
||||
request,
|
||||
f"{provider_label} provider is disabled. Enable it in Task Settings first.",
|
||||
f"{provider_label} provider is disabled. Enable it in Task Automation first.",
|
||||
)
|
||||
return redirect(next_url)
|
||||
run = _enqueue_codex_task_submission(
|
||||
@@ -1578,8 +1785,12 @@ 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 {}))
|
||||
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
|
||||
|
||||
@@ -1589,7 +1800,11 @@ class CodexSettingsPage(LoginRequiredMixin, View):
|
||||
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")
|
||||
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:
|
||||
@@ -1647,7 +1862,9 @@ class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
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"),
|
||||
CodexPermissionRequest.objects.select_related(
|
||||
"codex_run", "external_sync_event"
|
||||
),
|
||||
id=request_id,
|
||||
user=request.user,
|
||||
)
|
||||
@@ -1683,14 +1900,18 @@ class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
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_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 = dict(
|
||||
run.request_payload.get("provider_payload") or {}
|
||||
)
|
||||
provider_payload.update(
|
||||
{
|
||||
"mode": "approval_response",
|
||||
@@ -1709,18 +1930,30 @@ class CodexApprovalAction(LoginRequiredMixin, View):
|
||||
"task_event": run.derived_task_event,
|
||||
"provider": "codex_cli",
|
||||
"status": "pending",
|
||||
"payload": {"action": event_action, "provider_payload": provider_payload},
|
||||
"payload": {
|
||||
"action": event_action,
|
||||
"provider_payload": provider_payload,
|
||||
},
|
||||
"error": "",
|
||||
},
|
||||
)
|
||||
messages.success(request, f"Approved {row.approval_key}. Resume event queued.")
|
||||
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"])
|
||||
row.save(
|
||||
update_fields=[
|
||||
"status",
|
||||
"resolved_at",
|
||||
"resolved_by_identifier",
|
||||
"resolution_note",
|
||||
]
|
||||
)
|
||||
run = row.codex_run
|
||||
run.status = "denied"
|
||||
run.error = "approval_denied"
|
||||
@@ -1770,7 +2003,9 @@ class AnswerSuggestionSend(LoginRequiredMixin, View):
|
||||
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)
|
||||
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 "",
|
||||
|
||||
Reference in New Issue
Block a user