Increase security and reformat

This commit is contained in:
2026-03-07 20:52:13 +00:00
parent 10588a18b9
commit bca4d6898f
144 changed files with 6735 additions and 3960 deletions

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import AIForm
from core.models import AI
from core.util import logs
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -11,7 +11,11 @@ from django.urls import reverse
from django.utils import timezone
from django.views import View
from core.commands.policies import BP_VARIANT_KEYS, BP_VARIANT_META, ensure_variant_policies_for_profile
from core.commands.policies import (
BP_VARIANT_KEYS,
BP_VARIANT_META,
ensure_variant_policies_for_profile,
)
from core.models import (
AIRunLog,
BusinessPlanDocument,
@@ -56,7 +60,9 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
@staticmethod
def _redirect_with_scope(request):
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
service = str(
request.GET.get("service") or request.POST.get("service") or ""
).strip()
identifier = str(
request.GET.get("identifier") or request.POST.get("identifier") or ""
).strip()
@@ -94,11 +100,14 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
{
"variant_key": key,
"variant_label": str(meta.get("name") or key),
"trigger_token": str(meta.get("trigger_token") or profile.trigger_token or ""),
"trigger_token": str(
meta.get("trigger_token") or profile.trigger_token or ""
),
"template_supported": bool(meta.get("template_supported")),
"warn_verbatim_plan": bool(
key in {"bp", "bp_set_range"}
and str(getattr(row, "generation_mode", "") or "") == "verbatim"
and str(getattr(row, "generation_mode", "") or "")
== "verbatim"
and bool(getattr(row, "send_plan_to_egress", False))
),
"row": row,
@@ -119,7 +128,9 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
for row in bindings
if str(row.direction or "").strip() == "egress" and bool(row.enabled)
]
profile.preview_mode = preview_profile_id and str(profile.id) == preview_profile_id
profile.preview_mode = (
preview_profile_id and str(profile.id) == preview_profile_id
)
documents = BusinessPlanDocument.objects.filter(user=request.user).order_by(
"-updated_at"
)[:30]
@@ -147,7 +158,9 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
if action == "profile_create":
slug = (
str(request.POST.get("command_slug") or request.POST.get("slug") or "bp")
str(
request.POST.get("command_slug") or request.POST.get("slug") or "bp"
)
.strip()
.lower()
or "bp"
@@ -156,7 +169,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
user=request.user,
slug=slug,
defaults={
"name": str(request.POST.get("name") or ("Codex" if slug == "codex" else "Business Plan")).strip()
"name": str(
request.POST.get("name")
or ("Codex" if slug == "codex" else "Business Plan")
).strip()
or ("Codex" if slug == "codex" else "Business Plan"),
"enabled": True,
"trigger_token": str(
@@ -167,10 +183,14 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
"template_text": str(request.POST.get("template_text") or ""),
},
)
profile.name = str(request.POST.get("name") or profile.name).strip() or profile.name
profile.name = (
str(request.POST.get("name") or profile.name).strip() or profile.name
)
if slug == "bp":
profile.trigger_token = ".bp"
profile.template_text = str(request.POST.get("template_text") or profile.template_text or "")
profile.template_text = str(
request.POST.get("template_text") or profile.template_text or ""
)
if slug == "codex":
profile.trigger_token = ".codex"
profile.reply_required = False
@@ -317,11 +337,17 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
variant_key=variant_key,
)
policy.enabled = bool(request.POST.get("enabled"))
mode = str(request.POST.get("generation_mode") or "verbatim").strip().lower()
mode = (
str(request.POST.get("generation_mode") or "verbatim").strip().lower()
)
policy.generation_mode = mode if mode in {"ai", "verbatim"} else "verbatim"
policy.send_plan_to_egress = bool(request.POST.get("send_plan_to_egress"))
policy.send_status_to_source = bool(request.POST.get("send_status_to_source"))
policy.send_status_to_egress = bool(request.POST.get("send_status_to_egress"))
policy.send_status_to_source = bool(
request.POST.get("send_status_to_source")
)
policy.send_status_to_egress = bool(
request.POST.get("send_status_to_egress")
)
policy.store_document = bool(request.POST.get("store_document"))
policy.save()
return self._redirect_with_scope(request)
@@ -343,7 +369,9 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
user=request.user,
)
ensure_variant_policies_for_profile(profile)
service = str(request.GET.get("service") or request.POST.get("service") or "").strip()
service = str(
request.GET.get("service") or request.POST.get("service") or ""
).strip()
identifier = str(
request.GET.get("identifier") or request.POST.get("identifier") or ""
).strip()
@@ -391,18 +419,14 @@ class TranslationSettings(LoginRequiredMixin, View):
request.POST.get("a_channel_identifier") or ""
).strip(),
a_language=str(
request.POST.get("a_language")
or inferred.get("a_language")
or "en"
request.POST.get("a_language") or inferred.get("a_language") or "en"
).strip(),
b_service=str(request.POST.get("b_service") or "web").strip(),
b_channel_identifier=str(
request.POST.get("b_channel_identifier") or ""
).strip(),
b_language=str(
request.POST.get("b_language")
or inferred.get("b_language")
or "en"
request.POST.get("b_language") or inferred.get("b_language") or "en"
).strip(),
direction=str(request.POST.get("direction") or "bidirectional").strip(),
quick_mode_title=quick_title,
@@ -434,7 +458,9 @@ class AIExecutionLogSettings(LoginRequiredMixin, View):
total_ok = runs_qs.filter(status="ok").count()
total_failed = runs_qs.filter(status="failed").count()
avg_ms = runs_qs.aggregate(v=Avg("duration_ms")).get("v") or 0
success_rate = (float(total_ok) / float(total_runs) * 100.0) if total_runs else 0.0
success_rate = (
(float(total_ok) / float(total_runs) * 100.0) if total_runs else 0.0
)
usage_totals = runs_qs.aggregate(
prompt_chars_total=Sum("prompt_chars"),
@@ -531,6 +557,53 @@ class AIExecutionRunDetailTabView(LoginRequiredMixin, View):
)
class BusinessPlanInbox(LoginRequiredMixin, View):
template_name = "pages/business-plan-inbox.html"
def get(self, request):
status_filter = str(request.GET.get("status") or "").strip().lower()
service_filter = str(request.GET.get("service") or "").strip().lower()
query = str(request.GET.get("q") or "").strip()
rows = (
BusinessPlanDocument.objects.filter(user=request.user)
.select_related("command_profile")
.annotate(revision_count=Count("revisions"))
.order_by("-updated_at")
)
if status_filter in {"draft", "final"}:
rows = rows.filter(status=status_filter)
if service_filter in {"xmpp", "whatsapp", "signal", "instagram", "web"}:
rows = rows.filter(source_service=service_filter)
if query:
rows = rows.filter(
Q(title__icontains=query)
| Q(source_channel_identifier__icontains=query)
| Q(command_profile__name__icontains=query)
)
stats = BusinessPlanDocument.objects.filter(user=request.user).aggregate(
total=Count("id"),
draft=Count("id", filter=Q(status="draft")),
final=Count("id", filter=Q(status="final")),
)
return render(
request,
self.template_name,
{
"documents": rows[:250],
"filters": {
"status": status_filter,
"service": service_filter,
"q": query,
},
"stats": stats,
"service_choices": ("xmpp", "whatsapp", "signal", "instagram", "web"),
},
)
class BusinessPlanEditor(LoginRequiredMixin, View):
template_name = "pages/business-plan-editor.html"

View File

@@ -72,9 +72,7 @@ class AvailabilitySettingsPage(LoginRequiredMixin, View):
.values("person_id", "person__name", "service")
.annotate(
total_events=Count("id"),
available_events=Count(
"id", filter=Q(availability_state="available")
),
available_events=Count("id", filter=Q(availability_state="available")),
fading_events=Count("id", filter=Q(availability_state="fading")),
unavailable_events=Count(
"id", filter=Q(availability_state="unavailable")
@@ -86,13 +84,11 @@ class AvailabilitySettingsPage(LoginRequiredMixin, View):
read_receipt_events=Count("id", filter=Q(source_kind="read_receipt")),
typing_events=Count(
"id",
filter=Q(source_kind="typing_start")
| Q(source_kind="typing_stop"),
filter=Q(source_kind="typing_start") | Q(source_kind="typing_stop"),
),
message_activity_events=Count(
"id",
filter=Q(source_kind="message_in")
| Q(source_kind="message_out"),
filter=Q(source_kind="message_in") | Q(source_kind="message_out"),
),
inferred_timeout_events=Count(
"id", filter=Q(source_kind="inferred_timeout")

View File

@@ -25,27 +25,26 @@ from django.urls import NoReverseMatch, reverse
from django.utils import timezone as dj_timezone
from django.views import View
from core.clients import transport
from core.assist.engine import process_inbound_assist
from core.clients import transport
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.commands.policies import ensure_variant_policies_for_profile
from core.events.ledger import append_event_sync
from core.messaging import ai as ai_runner
from core.messaging import history
from core.messaging import media_bridge
from core.messaging import history, media_bridge
from core.messaging.utils import messages_to_string
from core.models import (
AI,
Chat,
ChatSession,
ChatTaskSource,
CommandAction,
CommandChannelBinding,
CommandProfile,
Message,
MessageEvent,
PatternMitigationPlan,
ChatTaskSource,
Person,
PersonIdentifier,
PlatformChatLink,
@@ -54,8 +53,8 @@ from core.models import (
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people, spans_for_range
from core.realtime.typing_state import get_person_typing_state
from core.transports.capabilities import supports, unsupported_reason
from core.translation.engine import process_inbound_translation
from core.transports.capabilities import supports, unsupported_reason
from core.views.workspace import (
INSIGHT_METRICS,
_build_engage_payload,
@@ -234,11 +233,7 @@ def _compose_availability_payload(
range_end: int,
) -> tuple[bool, list[dict], dict]:
settings_row = get_availability_settings(user)
if (
person is None
or not settings_row.enabled
or not settings_row.show_in_chat
):
if person is None or not settings_row.enabled or not settings_row.show_in_chat:
return False, [], {}
service_key = str(service or "").strip().lower()
@@ -735,7 +730,9 @@ def _serialize_message(msg: Message) -> dict:
),
"reply_preview": reply_preview,
"message_meta": {
"origin_tag": str((getattr(msg, "message_meta", {}) or {}).get("origin_tag") or "")
"origin_tag": str(
(getattr(msg, "message_meta", {}) or {}).get("origin_tag") or ""
)
},
}
@@ -998,9 +995,11 @@ def _build_gap_fragment(is_outgoing_reply, lag_ms, snapshot):
if snapshot is not None:
score_value = getattr(
snapshot,
"outbound_response_score"
if is_outgoing_reply
else "inbound_response_score",
(
"outbound_response_score"
if is_outgoing_reply
else "inbound_response_score"
),
None,
)
if score_value is None:
@@ -1383,9 +1382,9 @@ def _trend_meta(current, previous, higher_is_better=True):
improves = is_up if higher_is_better else not is_up
return {
"direction": "up" if is_up else "down",
"icon": "fa-solid fa-arrow-trend-up"
if is_up
else "fa-solid fa-arrow-trend-down",
"icon": (
"fa-solid fa-arrow-trend-up" if is_up else "fa-solid fa-arrow-trend-down"
),
"class_name": "has-text-success" if improves else "has-text-danger",
"meaning": "Improving signal" if improves else "Risk signal",
}
@@ -1872,7 +1871,9 @@ def _latest_signal_bridge_ref(message: Message | None) -> dict:
return best
def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
def _build_whatsapp_reply_metadata(
reply_to: Message | None, channel_identifier: str
) -> dict:
if reply_to is None:
return {}
target_message_id = ""
@@ -1880,13 +1881,17 @@ def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier:
source_service = str(getattr(reply_to, "source_service", "") or "").strip().lower()
if source_service == "whatsapp":
target_message_id = str(getattr(reply_to, "source_message_id", "") or "").strip()
target_message_id = str(
getattr(reply_to, "source_message_id", "") or ""
).strip()
participant = str(getattr(reply_to, "sender_uuid", "") or "").strip()
if not target_message_id:
bridge_ref = _latest_whatsapp_bridge_ref(reply_to)
target_message_id = str(bridge_ref.get("upstream_message_id") or "").strip()
participant = participant or str(bridge_ref.get("upstream_author") or "").strip()
participant = (
participant or str(bridge_ref.get("upstream_author") or "").strip()
)
if not target_message_id:
return {}
@@ -1902,7 +1907,9 @@ def _build_whatsapp_reply_metadata(reply_to: Message | None, channel_identifier:
}
def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: str) -> dict:
def _build_signal_reply_metadata(
reply_to: Message | None, channel_identifier: str
) -> dict:
if reply_to is None:
return {}
@@ -1933,10 +1940,13 @@ def _build_signal_reply_metadata(reply_to: Message | None, channel_identifier: s
if source_chat_id:
quote_author = source_chat_id
if (
str(getattr(reply_to, "custom_author", "") or "").strip().upper() in {"USER", "BOT"}
str(getattr(reply_to, "custom_author", "") or "").strip().upper()
in {"USER", "BOT"}
or not quote_author
):
quote_author = str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or quote_author
quote_author = (
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or quote_author
)
if not quote_author:
quote_author = source_chat_id
if not quote_author:
@@ -1969,9 +1979,13 @@ def _reaction_actor_key(user_id, service: str) -> str:
return f"web:{int(user_id)}:{str(service or '').strip().lower()}"
def _resolve_reaction_target(message: Message, service: str, channel_identifier: str) -> dict:
def _resolve_reaction_target(
message: Message, service: str, channel_identifier: str
) -> dict:
service_key = _default_service(service)
message_source_service = str(getattr(message, "source_service", "") or "").strip().lower()
message_source_service = (
str(getattr(message, "source_service", "") or "").strip().lower()
)
source_message_id = str(getattr(message, "source_message_id", "") or "").strip()
sender_uuid = str(getattr(message, "sender_uuid", "") or "").strip()
source_chat_id = str(getattr(message, "source_chat_id", "") or "").strip()
@@ -1998,12 +2012,13 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
target_author = sender_uuid
if not target_author:
target_author = str(bridge_ref.get("upstream_author") or "").strip()
if (
str(getattr(message, "custom_author", "") or "").strip().upper()
in {"USER", "BOT"}
):
if str(getattr(message, "custom_author", "") or "").strip().upper() in {
"USER",
"BOT",
}:
target_author = (
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip() or target_author
str(getattr(settings, "SIGNAL_NUMBER", "") or "").strip()
or target_author
)
if not target_author:
target_author = source_chat_id or str(channel_identifier or "").strip()
@@ -2017,7 +2032,9 @@ def _resolve_reaction_target(message: Message, service: str, channel_identifier:
}
if service_key == "whatsapp":
target_message_id = source_message_id if message_source_service == "whatsapp" else ""
target_message_id = (
source_message_id if message_source_service == "whatsapp" else ""
)
target_ts = delivered_ts or local_ts
bridge_ref = _latest_whatsapp_bridge_ref(message)
if not target_message_id:
@@ -2123,7 +2140,9 @@ def _toggle_command_for_channel(
enabled: bool,
) -> tuple[bool, str]:
service_key = _default_service(service)
canonical_identifier = _canonical_command_channel_identifier(service_key, identifier)
canonical_identifier = _canonical_command_channel_identifier(
service_key, identifier
)
if not canonical_identifier:
return (False, "missing_identifier")
@@ -2133,9 +2152,7 @@ def _toggle_command_for_channel(
profile = _ensure_codex_profile(user)
else:
profile = (
CommandProfile.objects.filter(user=user, slug=slug)
.order_by("id")
.first()
CommandProfile.objects.filter(user=user, slug=slug).order_by("id").first()
)
if profile is None:
return (False, f"unknown_command:{slug}")
@@ -2178,9 +2195,7 @@ def _toggle_command_for_channel(
def _command_options_for_channel(user, service: str, identifier: str) -> list[dict]:
service_key = _default_service(service)
variants = _command_channel_identifier_variants(service_key, identifier)
profiles = list(
CommandProfile.objects.filter(user=user).order_by("slug", "id")
)
profiles = list(CommandProfile.objects.filter(user=user).order_by("slug", "id"))
by_slug = {str(row.slug or "").strip(): row for row in profiles}
if "bp" not in by_slug:
by_slug["bp"] = CommandProfile(
@@ -2212,7 +2227,9 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
enabled=True,
).exists()
if slug == "bp":
policies = ensure_variant_policies_for_profile(profile) if profile.id else {}
policies = (
ensure_variant_policies_for_profile(profile) if profile.id else {}
)
label_by_key = {
"bp": "bp",
"bp_set": "bp set",
@@ -2228,7 +2245,11 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": str(
(policies.get("bp").generation_mode if policies.get("bp") else "ai")
(
policies.get("bp").generation_mode
if policies.get("bp")
else "ai"
)
).upper(),
},
{
@@ -2239,7 +2260,11 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"enabled_here": bool(enabled_here),
"profile_enabled": bool(profile.enabled),
"mode_label": str(
(policies.get("bp_set").generation_mode if policies.get("bp_set") else "verbatim")
(
policies.get("bp_set").generation_mode
if policies.get("bp_set")
else "verbatim"
)
).upper(),
},
{
@@ -2261,7 +2286,9 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
)
for row in options:
if row.get("slug") in label_by_key:
row["enabled_label"] = "Enabled" if row.get("enabled_here") else "Disabled"
row["enabled_label"] = (
"Enabled" if row.get("enabled_here") else "Disabled"
)
else:
options.append(
{
@@ -2283,11 +2310,7 @@ def _bp_binding_summary_for_channel(user, service: str, identifier: str) -> dict
variants = _command_channel_identifier_variants(service_key, identifier)
if not variants:
return {"ingress_count": 0, "egress_count": 0}
profile = (
CommandProfile.objects.filter(user=user, slug="bp")
.order_by("id")
.first()
)
profile = CommandProfile.objects.filter(user=user, slug="bp").order_by("id").first()
if profile is None:
return {"ingress_count": 0, "egress_count": 0}
ingress_count = CommandChannelBinding.objects.filter(
@@ -2315,7 +2338,9 @@ def _toggle_task_announce_for_channel(
enabled: bool,
) -> tuple[bool, str]:
service_key = _default_service(service)
canonical_identifier = _canonical_command_channel_identifier(service_key, identifier)
canonical_identifier = _canonical_command_channel_identifier(
service_key, identifier
)
if not canonical_identifier:
return (False, "missing_identifier")
variants = _command_channel_identifier_variants(service_key, canonical_identifier)
@@ -3411,7 +3436,9 @@ class ComposeContactCreateAll(LoginRequiredMixin, View):
skipped_count += 1
if errors:
message = f"Created {created_count} contacts. Errors: {'; '.join(errors[:5])}"
message = (
f"Created {created_count} contacts. Errors: {'; '.join(errors[:5])}"
)
level = "warning"
elif created_count > 0:
message = f"Created {created_count} new contact{'s' if created_count != 1 else ''}. Skipped {skipped_count}."
@@ -3538,7 +3565,9 @@ class ComposeThread(LoginRequiredMixin, View):
counterpart_identifiers = _counterpart_identifiers_for_person(
request.user, base["person"]
)
range_start = int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
range_start = (
int(messages[0].ts or 0) if messages else max(0, int(after_ts or 0))
)
range_end = int(latest_ts or 0)
if range_start <= 0 or range_end <= 0:
now_ts = int(time.time() * 1000)
@@ -3947,7 +3976,9 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
service, str(identifier or "")
)
if not channel_identifier:
return JsonResponse({"ok": False, "error": "missing_identifier"}, status=400)
return JsonResponse(
{"ok": False, "error": "missing_identifier"}, status=400
)
if service not in {"web", "xmpp", "signal", "whatsapp"}:
return JsonResponse(
{"ok": False, "error": f"unsupported_service:{service}"},
@@ -3996,9 +4027,7 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
if enabled
else f"{slug} disabled for this chat."
)
scoped_settings_url = (
f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
)
scoped_settings_url = f"{reverse('command_routing')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
return JsonResponse(
{
"ok": True,
@@ -4014,6 +4043,7 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
}
)
class ComposeBindBP(ComposeToggleCommand):
def post(self, request):
service, identifier, _ = _request_scope(request, "POST")
@@ -4296,9 +4326,11 @@ class ComposeQuickInsights(LoginRequiredMixin, View):
or conversation.get_stability_state_display(),
"stability_state": conversation.get_stability_state_display(),
"thread": conversation.platform_thread_id or "",
"last_event": _format_ts_label(conversation.last_event_ts or 0)
if conversation.last_event_ts
else "",
"last_event": (
_format_ts_label(conversation.last_event_ts or 0)
if conversation.last_event_ts
else ""
),
"last_ai_run": (
dj_timezone.localtime(conversation.last_ai_run_at).strftime(
"%Y-%m-%d %H:%M"
@@ -4607,11 +4639,15 @@ class ComposeSend(LoginRequiredMixin, View):
log_prefix = (
f"[ComposeSend] service={base['service']} identifier={base['identifier']}"
)
if bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)) and not supports(
if bool(
getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)
) and not supports(
str(base["service"] or "").strip().lower(),
"send",
):
reason = unsupported_reason(str(base["service"] or "").strip().lower(), "send")
reason = unsupported_reason(
str(base["service"] or "").strip().lower(), "send"
)
return self._response(
request,
ok=False,
@@ -4786,7 +4822,9 @@ class ComposeSend(LoginRequiredMixin, View):
source_chat_id=str(base["identifier"] or ""),
reply_to=reply_to,
reply_source_service="web" if reply_to is not None else None,
reply_source_message_id=str(reply_to.id) if reply_to is not None else None,
reply_source_message_id=(
str(reply_to.id) if reply_to is not None else None
),
message_meta={},
)
try:
@@ -4858,7 +4896,9 @@ class ComposeReact(LoginRequiredMixin, View):
service_key = _default_service(service)
if service_key not in {"signal", "whatsapp"}:
return JsonResponse({"ok": False, "error": "service_not_supported"})
if bool(getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)) and not supports(service_key, "reactions"):
if bool(
getattr(settings, "CAPABILITY_ENFORCEMENT_ENABLED", True)
) and not supports(service_key, "reactions"):
return JsonResponse(
{
"ok": False,

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import GroupForm
from core.models import Group
from core.util import logs
from core.views.osint import OSINTListBase
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import PersonIdentifierForm
from core.models import Person, PersonIdentifier
from core.util import logs
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import ManipulationForm
from core.models import Manipulation
from core.util import logs
from core.views.osint import OSINTListBase
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import IntegrityError
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import MessageForm
from core.models import Message
from core.util import logs
from mixins.views import AbortSave, ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -1,8 +1,8 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectUpdate
from core.forms import NotificationSettingsForm
from core.models import NotificationSettings
from mixins.views import ObjectUpdate
# Notifications - we create a new notification settings object if there isn't one

View File

@@ -1,9 +1,10 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, datetime, timezone as dt_timezone
from decimal import Decimal, InvalidOperation
import re
from dataclasses import dataclass, field
from datetime import date, datetime
from datetime import timezone as dt_timezone
from decimal import Decimal, InvalidOperation
from typing import Any, Callable
from urllib.parse import urlencode
@@ -17,9 +18,9 @@ from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from django.views import View
from mixins.views import ObjectList
from core.models import Group, Manipulation, Message, Person, PersonIdentifier, Persona
from core.models import Group, Manipulation, Message, Person, Persona, PersonIdentifier
from mixins.views import ObjectList
_QUERY_MAX_LEN = 400
_QUERY_ALLOWED_PATTERN = re.compile(r"[\w\s@\-\+\.:,#/]+", re.UNICODE)
@@ -907,7 +908,9 @@ class OSINTSearch(LoginRequiredMixin, View):
reverse=_safe_query_param(request, "reverse", "") in {"1", "true", "on"},
)
def _parse_date_boundaries(self, plan: "OSINTSearch.SearchPlan") -> tuple[datetime | None, datetime | None]:
def _parse_date_boundaries(
self, plan: "OSINTSearch.SearchPlan"
) -> tuple[datetime | None, datetime | None]:
parsed_from = None
parsed_to = None
if plan.date_from:
@@ -921,7 +924,9 @@ class OSINTSearch(LoginRequiredMixin, View):
except ValueError:
parsed_to = None
if parsed_to is not None:
parsed_to = parsed_to.replace(hour=23, minute=59, second=59, microsecond=999999)
parsed_to = parsed_to.replace(
hour=23, minute=59, second=59, microsecond=999999
)
return parsed_from, parsed_to
def _score_hit(self, query: str, primary: str, secondary: str) -> int:
@@ -1138,7 +1143,9 @@ class OSINTSearch(LoginRequiredMixin, View):
elif scope_key == "identifiers":
queryset = queryset.filter(service=plan.source)
elif scope_key == "people":
queryset = queryset.filter(personidentifier__service=plan.source).distinct()
queryset = queryset.filter(
personidentifier__service=plan.source
).distinct()
if scope_key == "messages":
if date_from is not None:
@@ -1195,7 +1202,9 @@ class OSINTSearch(LoginRequiredMixin, View):
| Q(likes__icontains=query)
| Q(dislikes__icontains=query)
)
for item in people_qs.order_by("-last_interaction", "name")[:per_scope_limit]:
for item in people_qs.order_by("-last_interaction", "name")[
:per_scope_limit
]:
secondary = self._snippet(
f"{item.summary or ''} {item.profile or ''}".strip(),
query if plan.annotate else "",
@@ -1208,13 +1217,17 @@ class OSINTSearch(LoginRequiredMixin, View):
"secondary": secondary or (item.timezone or ""),
"service": "-",
"when": item.last_interaction,
"score": self._score_hit(query, item.name or "", secondary or ""),
"score": self._score_hit(
query, item.name or "", secondary or ""
),
}
)
if "identifiers" in allowed_scopes:
identifiers_qs = self._apply_common_filters(
PersonIdentifier.objects.filter(user=request.user).select_related("person"),
PersonIdentifier.objects.filter(user=request.user).select_related(
"person"
),
"identifiers",
plan,
)
@@ -1224,7 +1237,9 @@ class OSINTSearch(LoginRequiredMixin, View):
| Q(person__name__icontains=query)
| Q(service__icontains=query)
)
for item in identifiers_qs.order_by("person__name", "identifier")[:per_scope_limit]:
for item in identifiers_qs.order_by("person__name", "identifier")[
:per_scope_limit
]:
primary = item.person.name if item.person_id else item.identifier
secondary = item.identifier if item.person_id else ""
base_score = self._score_hit(query, primary or "", secondary or "")
@@ -1261,8 +1276,14 @@ class OSINTSearch(LoginRequiredMixin, View):
continue
if date_to and when_dt and when_dt > date_to:
continue
primary = item.custom_author or item.sender_uuid or (item.source_chat_id or "Message")
secondary = self._snippet(item.text or "", query if plan.annotate else "")
primary = (
item.custom_author
or item.sender_uuid
or (item.source_chat_id or "Message")
)
secondary = self._snippet(
item.text or "", query if plan.annotate else ""
)
base_score = self._score_hit(query, primary or "", item.text or "")
recency_boost = self._message_recency_boost(when_dt)
rows.append(
@@ -1458,11 +1479,41 @@ class OSINTSearch(LoginRequiredMixin, View):
page_obj = paginator.get_page(request.GET.get("page"))
column_context = [
{"key": "scope", "field_name": "scope", "label": "Type", "sortable": False, "kind": "text"},
{"key": "primary", "field_name": "primary", "label": "Primary", "sortable": False, "kind": "text"},
{"key": "secondary", "field_name": "secondary", "label": "Details", "sortable": False, "kind": "text"},
{"key": "service", "field_name": "service", "label": "Service", "sortable": False, "kind": "text"},
{"key": "when", "field_name": "when", "label": "When", "sortable": False, "kind": "datetime"},
{
"key": "scope",
"field_name": "scope",
"label": "Type",
"sortable": False,
"kind": "text",
},
{
"key": "primary",
"field_name": "primary",
"label": "Primary",
"sortable": False,
"kind": "text",
},
{
"key": "secondary",
"field_name": "secondary",
"label": "Details",
"sortable": False,
"kind": "text",
},
{
"key": "service",
"field_name": "service",
"label": "Service",
"sortable": False,
"kind": "text",
},
{
"key": "when",
"field_name": "when",
"label": "When",
"sortable": False,
"kind": "datetime",
},
]
rows = []
for item in list(page_obj.object_list):

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonForm
from core.models import Person
from core.util import logs
from core.views.osint import OSINTListBase
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -1,10 +1,10 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
from core.forms import PersonaForm
from core.models import Persona
from core.util import logs
from core.views.osint import OSINTListBase
from mixins.views import ObjectCreate, ObjectDelete, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -5,13 +5,13 @@ from django.contrib.auth.mixins import LoginRequiredMixin
from django.db import transaction
from django.http import HttpResponse
from django.utils import timezone as dj_timezone
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from rest_framework import status
from rest_framework.views import APIView
from core.forms import QueueForm
from core.models import Message, QueuedMessage
from core.util import logs
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
log = logs.get_logger("queue")
_INLINE_TARGET_RE = re.compile(r"^#queue-inline-editor-[A-Za-z0-9_-]+$")
@@ -77,7 +77,7 @@ class RejectMessageAPI(LoginRequiredMixin, APIView):
class QueueList(LoginRequiredMixin, ObjectList):
list_template = "partials/queue-list.html"
model = QueuedMessage
page_title = "Queue"
page_title = "Approvals Queue"
list_url_name = "queues"
list_url_args = ["type"]

View File

@@ -1,9 +1,9 @@
from django.contrib.auth.mixins import LoginRequiredMixin
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
from core.forms import SessionForm
from core.models import ChatSession
from core.util import logs
from mixins.views import ObjectCreate, ObjectDelete, ObjectList, ObjectUpdate
log = logs.get_logger(__name__)

View File

@@ -8,13 +8,13 @@ from django.db.models import Q
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import Chat, PersonIdentifier, PlatformChatLink
from core.presence import get_settings as get_availability_settings
from core.presence import latest_state_for_people
from core.views.manage.permissions import SuperUserRequiredMixin
from mixins.views import ObjectList, ObjectRead
def _safe_json_list(text_value):
@@ -102,11 +102,13 @@ class SignalAccounts(SuperUserRequiredMixin, ObjectList):
"show_contact_actions": show_contact_actions,
"contacts_url_name": f"{service}_contacts",
"chats_url_name": f"{service}_chats",
"endpoint_base": str(
getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")
).rstrip("/")
if service == "signal"
else "",
"endpoint_base": (
str(getattr(settings, "SIGNAL_HTTP_URL", "http://signal:8080")).rstrip(
"/"
)
if service == "signal"
else ""
),
"service_warning": transport.get_service_warning(service),
}
@@ -392,7 +394,7 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
class SignalMessagesList(SuperUserRequiredMixin, ObjectList):
...
pass
class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):

View File

@@ -7,6 +7,8 @@ from django.urls import reverse
from django.views import View
from core.clients import transport
from core.events.projection import shadow_compare_session
from core.memory.search_backend import backend_status, get_memory_search_backend
from core.models import (
AdapterHealthEvent,
AIRequest,
@@ -14,6 +16,7 @@ from core.models import (
AIResultSignal,
Chat,
ChatSession,
CommandSecurityPolicy,
ConversationEvent,
Group,
MemoryItem,
@@ -30,15 +33,13 @@ from core.models import (
Persona,
PersonIdentifier,
QueuedMessage,
CommandSecurityPolicy,
UserAccessibilitySettings,
UserXmppOmemoState,
UserXmppOmemoTrustedKey,
UserXmppSecuritySettings,
WorkspaceConversation,
WorkspaceMetricSnapshot,
)
from core.events.projection import shadow_compare_session
from core.memory.search_backend import backend_status, get_memory_search_backend
from core.transports.capabilities import capability_snapshot
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -53,7 +54,9 @@ class SystemSettings(SuperUserRequiredMixin, View):
"queued_messages": QueuedMessage.objects.filter(user=user).count(),
"message_events": MessageEvent.objects.filter(user=user).count(),
"conversation_events": ConversationEvent.objects.filter(user=user).count(),
"adapter_health_events": AdapterHealthEvent.objects.filter(user=user).count(),
"adapter_health_events": AdapterHealthEvent.objects.filter(
user=user
).count(),
"workspace_conversations": WorkspaceConversation.objects.filter(
user=user
).count(),
@@ -472,7 +475,41 @@ def _parse_xmpp_jid(jid_str: str) -> dict:
raw = str(jid_str or "").strip()
bare, _, resource = raw.partition("/")
localpart, _, domain = bare.partition("@")
return {"full": raw, "bare": bare, "localpart": localpart, "domain": domain, "resource": resource}
return {
"full": raw,
"bare": bare,
"localpart": localpart,
"domain": domain,
"resource": resource,
}
def _parse_omemo_client_key(client_key: str) -> dict:
raw = str(client_key or "").strip()
parts = {}
for row in raw.split(","):
key, _, value = str(row or "").partition(":")
k = key.strip().lower()
v = value.strip()
if k and v:
parts[k] = v
sid = str(parts.get("sid") or "").strip()
rid = str(parts.get("rid") or "").strip()
return {
"raw": raw,
"sid": sid,
"rid": rid,
"has_ids": bool(sid or rid),
}
def _latest_client_omemo_fingerprint(omemo_row) -> str:
if omemo_row is None:
return ""
details = getattr(omemo_row, "details", {}) or {}
if not isinstance(details, dict):
return ""
return str(details.get("latest_client_fingerprint") or "").strip()
def _to_bool(value, default=False):
@@ -505,12 +542,36 @@ class SecurityPage(LoginRequiredMixin, View):
"require_trusted_fingerprint",
)
POLICY_SCOPES = [
("gateway.tasks", "Gateway .tasks commands", "Handles .tasks list/show/complete/undo over gateway channels."),
("gateway.approval", "Gateway approval commands", "Handles .approval/.codex/.claude approve/deny over gateway channels."),
("gateway.totp", "Gateway TOTP enrollment", "Controls TOTP enrollment/status commands over gateway channels."),
("tasks.submit", "Task submissions from chat", "Controls automatic task creation from inbound messages."),
("tasks.commands", "Task command verbs (.task/.undo/.epic)", "Controls explicit task command verbs."),
("command.bp", "Business plan command", "Controls Business Plan command execution."),
(
"gateway.tasks",
"Gateway .tasks commands",
"Handles .tasks list/show/complete/undo over gateway channels.",
),
(
"gateway.approval",
"Gateway approval commands",
"Handles .approval/.codex/.claude approve/deny over gateway channels.",
),
(
"gateway.totp",
"Gateway TOTP enrollment",
"Controls TOTP enrollment/status commands over gateway channels.",
),
(
"tasks.submit",
"Task submissions from chat",
"Controls automatic task creation from inbound messages.",
),
(
"tasks.commands",
"Task command verbs (.task/.undo/.epic)",
"Controls explicit task command verbs.",
),
(
"command.bp",
"Business plan command",
"Controls Business Plan command execution.",
),
("command.codex", "Codex command", "Controls Codex command execution."),
("command.claude", "Claude command", "Controls Claude command execution."),
]
@@ -538,6 +599,77 @@ class SecurityPage(LoginRequiredMixin, View):
row, _ = UserXmppSecuritySettings.objects.get_or_create(user=request.user)
return row
def _omemo_discovered_keys(self, request, xmpp_state, omemo_row):
discovered = []
sender = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
client_jid = str(sender.get("bare") or "").strip()
client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
client_key = str(getattr(omemo_row, "latest_client_key", "") or "").strip()
if client_jid and client_fingerprint:
discovered.append(
{
"jid": client_jid,
"key_type": "fingerprint",
"key_id": client_fingerprint,
"source": "client_observed",
"label": "Observed client fingerprint",
}
)
elif client_jid and client_key:
discovered.append(
{
"jid": client_jid,
"key_type": "client_key",
"key_id": client_key,
"source": "client_observed",
"label": "Observed client key",
}
)
# De-duplicate while preserving order.
deduped = []
seen = set()
for row in discovered:
key = (
str(row.get("jid") or "").strip().lower(),
str(row.get("key_type") or "").strip().lower(),
str(row.get("key_id") or "").strip(),
)
if key in seen:
continue
seen.add(key)
deduped.append(row)
discovered = deduped
trusted_map = {}
rows = UserXmppOmemoTrustedKey.objects.filter(user=request.user)
for item in rows:
trusted_map[
(
str(item.jid or "").strip().lower(),
str(item.key_type or "").strip().lower(),
str(item.key_id or "").strip(),
)
] = item
payload = []
for row in discovered:
map_key = (
str(row.get("jid") or "").strip().lower(),
str(row.get("key_type") or "").strip().lower(),
str(row.get("key_id") or "").strip(),
)
existing = trusted_map.get(map_key)
payload.append(
{
**row,
"trusted": bool(getattr(existing, "trusted", False)),
"updated_at": getattr(existing, "updated_at", None),
}
)
return payload
def _parse_override_value(self, value):
option = str(value or "").strip().lower()
if option == "inherit":
@@ -604,10 +736,12 @@ class SecurityPage(LoginRequiredMixin, View):
for pattern in patterns:
pattern_text = str(pattern or "").strip()
if pattern_text:
rows.append({
"service": service_name,
"pattern": pattern_text,
})
rows.append(
{
"service": service_name,
"pattern": pattern_text,
}
)
return rows
def _channels_map_from_post(self, request):
@@ -618,9 +752,11 @@ class SecurityPage(LoginRequiredMixin, View):
pattern = str(raw_pattern or "").strip()
if not pattern:
continue
service_name = str(
channel_services[idx] if idx < len(channel_services) else ""
).strip().lower()
service_name = (
str(channel_services[idx] if idx < len(channel_services) else "")
.strip()
.lower()
)
if not service_name:
service_name = "*"
allowed_channels.setdefault(service_name, [])
@@ -652,41 +788,42 @@ class SecurityPage(LoginRequiredMixin, View):
if not channel_rules:
channel_rules = [{"service": "xmpp", "pattern": ""}]
enabled_locked = global_overrides["scope_enabled"] != "per_scope"
require_omemo_locked = (
global_overrides["require_omemo"] != "per_scope"
or bool(security_settings.require_omemo)
)
require_omemo_locked = global_overrides[
"require_omemo"
] != "per_scope" or bool(security_settings.require_omemo)
require_trusted_locked = (
global_overrides["require_trusted_fingerprint"] != "per_scope"
)
payload.append({
"scope_key": key,
"label": label,
"description": description,
"enabled": self._apply_global_override(
bool(getattr(item, "enabled", True)),
global_overrides["scope_enabled"],
),
"require_omemo": self._apply_global_override(
bool(getattr(item, "require_omemo", False)),
global_overrides["require_omemo"],
),
"require_trusted_fingerprint": self._apply_global_override(
bool(getattr(item, "require_trusted_omemo_fingerprint", False)),
global_overrides["require_trusted_fingerprint"],
),
"enabled_locked": enabled_locked,
"require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
"require_omemo_lock_help": (
"Disable 'Require OMEMO encryption' in Encryption settings to edit this field."
if bool(security_settings.require_omemo)
else "Set this field to 'Per Scope' in Global Scope Override to edit it here."
),
"allowed_services": raw_allowed_services,
"channel_rules": channel_rules,
})
payload.append(
{
"scope_key": key,
"label": label,
"description": description,
"enabled": self._apply_global_override(
bool(getattr(item, "enabled", True)),
global_overrides["scope_enabled"],
),
"require_omemo": self._apply_global_override(
bool(getattr(item, "require_omemo", False)),
global_overrides["require_omemo"],
),
"require_trusted_fingerprint": self._apply_global_override(
bool(getattr(item, "require_trusted_omemo_fingerprint", False)),
global_overrides["require_trusted_fingerprint"],
),
"enabled_locked": enabled_locked,
"require_omemo_locked": require_omemo_locked,
"require_trusted_fingerprint_locked": require_trusted_locked,
"lock_help": "Set this field to 'Per Scope' in Global Scope Override to edit it here.",
"require_omemo_lock_help": (
"Disable 'Require OMEMO encryption' in Encryption settings to edit this field."
if bool(security_settings.require_omemo)
else "Set this field to 'Per Scope' in Global Scope Override to edit it here."
),
"allowed_services": raw_allowed_services,
"channel_rules": channel_rules,
}
)
return payload
def _scope_group_key(self, scope_key: str) -> str:
@@ -725,18 +862,52 @@ class SecurityPage(LoginRequiredMixin, View):
items = grouped.get(group_key) or []
if not items:
continue
payload.append({
"key": group_key,
"label": self.POLICY_GROUP_LABELS.get(group_key, group_key.title()),
"rows": items,
})
payload.append(
{
"key": group_key,
"label": self.POLICY_GROUP_LABELS.get(group_key, group_key.title()),
"rows": items,
}
)
return payload
def post(self, request):
row = self._security_settings(request)
if "require_omemo" in request.POST:
if str(request.POST.get("encryption_settings_submit") or "").strip() == "1":
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
row.save(update_fields=["require_omemo", "updated_at"])
row.encrypt_contact_messages_with_omemo = _to_bool(
request.POST.get("encrypt_contact_messages_with_omemo"),
False,
)
row.save(
update_fields=[
"require_omemo",
"encrypt_contact_messages_with_omemo",
"updated_at",
]
)
if "omemo_trust_update" in request.POST:
key_type = (
str(request.POST.get("key_type") or "fingerprint").strip().lower()
)
if key_type not in {"fingerprint", "client_key"}:
key_type = "fingerprint"
jid = str(request.POST.get("jid") or "").strip()
key_id = str(request.POST.get("key_id") or "").strip()
source = str(request.POST.get("source") or "").strip().lower()
trusted = _to_bool(request.POST.get("trusted"), False)
if jid and key_id:
trust_row, _ = UserXmppOmemoTrustedKey.objects.get_or_create(
user=request.user,
jid=jid,
key_type=key_type,
key_id=key_id,
defaults={"source": source},
)
trust_row.trusted = trusted
if source:
trust_row.source = source
trust_row.save(update_fields=["trusted", "source", "updated_at"])
redirect_to = HttpResponseRedirect(request.path)
scope_key = str(request.POST.get("scope_key") or "").strip().lower()
if not self._show_permission():
@@ -787,9 +958,8 @@ class SecurityPage(LoginRequiredMixin, View):
policy.allowed_channels = allowed_channels
if global_overrides["scope_enabled"] == "per_scope":
policy.enabled = _to_bool(request.POST.get("policy_enabled"), True)
if (
global_overrides["require_omemo"] == "per_scope"
and not bool(security_settings.require_omemo)
if global_overrides["require_omemo"] == "per_scope" and not bool(
security_settings.require_omemo
):
policy.require_omemo = _to_bool(
request.POST.get("policy_require_omemo"), False
@@ -821,8 +991,15 @@ class SecurityPage(LoginRequiredMixin, View):
omemo_row = UserXmppOmemoState.objects.get(user=request.user)
except UserXmppOmemoState.DoesNotExist:
omemo_row = None
discovered_omemo_keys = self._omemo_discovered_keys(
request, xmpp_state, omemo_row
)
security_settings = self._security_settings(request)
sender_jid = _parse_xmpp_jid(getattr(omemo_row, "last_sender_jid", "") or "")
omemo_client_fingerprint = _latest_client_omemo_fingerprint(omemo_row)
omemo_client_key_info = _parse_omemo_client_key(
getattr(omemo_row, "latest_client_key", "") if omemo_row else ""
)
omemo_plan = []
if show_encryption:
omemo_plan = [
@@ -847,19 +1024,26 @@ class SecurityPage(LoginRequiredMixin, View):
"hint": "Enable 'Require OMEMO encryption' in Security Policy above to enforce this policy.",
},
]
return render(request, self.template_name, {
"xmpp_state": xmpp_state,
"omemo_row": omemo_row,
"security_settings": security_settings,
"global_override": self._global_override_payload(request),
"policy_services": self.POLICY_SERVICES,
"policy_rows": self._scope_rows(request),
"policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid,
"omemo_plan": omemo_plan,
"show_encryption": show_encryption,
"show_permission": show_permission,
})
return render(
request,
self.template_name,
{
"xmpp_state": xmpp_state,
"omemo_row": omemo_row,
"discovered_omemo_keys": discovered_omemo_keys,
"security_settings": security_settings,
"global_override": self._global_override_payload(request),
"policy_services": self.POLICY_SERVICES,
"policy_rows": self._scope_rows(request),
"policy_groups": self._grouped_scope_rows(request),
"sender_jid": sender_jid,
"omemo_client_fingerprint": omemo_client_fingerprint,
"omemo_client_key_info": omemo_client_key_info,
"omemo_plan": omemo_plan,
"show_encryption": show_encryption,
"show_permission": show_permission,
},
)
class AccessibilitySettings(LoginRequiredMixin, View):
@@ -870,9 +1054,13 @@ class AccessibilitySettings(LoginRequiredMixin, View):
return row
def get(self, request):
return render(request, self.template_name, {
"accessibility_settings": self._row(request),
})
return render(
request,
self.template_name,
{
"accessibility_settings": self._row(request),
},
)
def post(self, request):
row = self._row(request)
@@ -892,20 +1080,26 @@ class _SettingsCategoryPage(LoginRequiredMixin, View):
current_path = str(getattr(self.request, "path", "") or "")
rows = []
for label, href in self.tabs:
rows.append({
"label": label,
"href": href,
"active": current_path == href,
})
rows.append(
{
"label": label,
"href": href,
"active": current_path == href,
}
)
return rows
def get(self, request):
return render(request, self.template_name, {
"category_key": self.category_key,
"category_title": self.category_title,
"category_description": self.category_description,
"category_tabs": self._tab_rows(),
})
return render(
request,
self.template_name,
{
"category_key": self.category_key,
"category_title": self.category_title,
"category_description": self.category_description,
"category_tabs": self._tab_rows(),
},
)
class AISettingsPage(LoginRequiredMixin, View):

View File

@@ -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 "",

View File

@@ -4,7 +4,6 @@ from urllib.parse import urlencode
from django.shortcuts import render
from django.urls import reverse
from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import PersonIdentifier, PlatformChatLink
@@ -13,6 +12,7 @@ from core.presence import latest_state_for_people
from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
from mixins.views import ObjectList, ObjectRead
log = logs.get_logger("whatsapp_view")
@@ -293,7 +293,9 @@ class WhatsAppChatsList(WhatsAppContactsList):
contact_index[key] = {"name": name, "jid": jid}
history_anchors = state.get("history_anchors") or {}
for key, anchor in (history_anchors.items() if isinstance(history_anchors, dict) else []):
for key, anchor in (
history_anchors.items() if isinstance(history_anchors, dict) else []
):
identifier = str(key or "").strip()
if not identifier:
continue
@@ -333,7 +335,11 @@ class WhatsAppChatsList(WhatsAppContactsList):
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
),
"last_ts": int((anchor or {}).get("ts") or (anchor or {}).get("updated_at") or 0),
"last_ts": int(
(anchor or {}).get("ts")
or (anchor or {}).get("updated_at")
or 0
),
}
)

View File

@@ -972,7 +972,9 @@ def _history_points(conversation, field_name, density="medium"):
for row in rows:
source_ts = int(row.get("source_event_ts") or 0)
if source_ts > 0:
x_value = datetime.fromtimestamp(source_ts / 1000, tz=timezone.utc).isoformat()
x_value = datetime.fromtimestamp(
source_ts / 1000, tz=timezone.utc
).isoformat()
else:
x_value = row["computed_at"].isoformat()
raw_points.append(
@@ -995,11 +997,9 @@ def _metric_supports_history(metric_slug, metric_spec):
def _all_graph_payload(conversation, density="medium"):
graphs = []
for spec in INSIGHT_GRAPH_SPECS:
raw_count = (
conversation.metric_snapshots.exclude(
**{f"{spec['field']}__isnull": True}
).count()
)
raw_count = conversation.metric_snapshots.exclude(
**{f"{spec['field']}__isnull": True}
).count()
points = _history_points(conversation, spec["field"], density=density)
graphs.append(
{
@@ -3557,13 +3557,13 @@ class AIWorkspaceContactsWidget(LoginRequiredMixin, View):
{
"person": person,
"message_count": message_qs.count(),
"last_text": (last_message.text or "")[:120]
if last_message
else "",
"last_text": (
(last_message.text or "")[:120] if last_message else ""
),
"last_ts": last_message.ts if last_message else None,
"last_ts_label": _format_unix_ms(last_message.ts)
if last_message
else "",
"last_ts_label": (
_format_unix_ms(last_message.ts) if last_message else ""
),
}
)
rows.sort(key=lambda row: row["last_ts"] or 0, reverse=True)
@@ -4355,7 +4355,7 @@ class AIWorkspaceQueueDraft(LoginRequiredMixin, View):
return _render_send_status(
request,
False,
"No enabled manipulation found for this recipient. Queue entry not created.",
"No enabled manipulation found for this recipient. Approvals queue entry not created.",
"warning",
)
@@ -5099,7 +5099,7 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
request,
person,
plan,
notice_message="No enabled manipulation found for this recipient. Queue entry not created.",
notice_message="No enabled manipulation found for this recipient. Approvals queue entry not created.",
notice_level="warning",
engage_preview=engage_preview,
engage_form=engage_form,