Increase security and reformat
This commit is contained in:
@@ -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__)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user