Fix all integrations
This commit is contained in:
@@ -143,6 +143,7 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
"command_choices": (
|
||||
("bp", "Business Plan (bp)"),
|
||||
("codex", "Codex (codex)"),
|
||||
("claude", "Claude (claude)"),
|
||||
),
|
||||
"scope_service": scope_service,
|
||||
"scope_identifier": scope_identifier,
|
||||
@@ -165,21 +166,27 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
.lower()
|
||||
or "bp"
|
||||
)
|
||||
default_name = {
|
||||
"bp": "Business Plan",
|
||||
"codex": "Codex",
|
||||
"claude": "Claude",
|
||||
}.get(slug, "Business Plan")
|
||||
default_trigger = {
|
||||
"bp": ".bp",
|
||||
"codex": ".codex",
|
||||
"claude": ".claude",
|
||||
}.get(slug, ".bp")
|
||||
profile, _ = CommandProfile.objects.get_or_create(
|
||||
user=request.user,
|
||||
slug=slug,
|
||||
defaults={
|
||||
"name": str(
|
||||
request.POST.get("name")
|
||||
or ("Codex" if slug == "codex" else "Business Plan")
|
||||
).strip()
|
||||
or ("Codex" if slug == "codex" else "Business Plan"),
|
||||
"name": str(request.POST.get("name") or default_name).strip()
|
||||
or default_name,
|
||||
"enabled": True,
|
||||
"trigger_token": str(
|
||||
request.POST.get("trigger_token")
|
||||
or (".codex" if slug == "codex" else ".bp")
|
||||
request.POST.get("trigger_token") or default_trigger
|
||||
).strip()
|
||||
or (".codex" if slug == "codex" else ".bp"),
|
||||
or default_trigger,
|
||||
"template_text": str(request.POST.get("template_text") or ""),
|
||||
},
|
||||
)
|
||||
@@ -195,6 +202,10 @@ class CommandRoutingSettings(LoginRequiredMixin, View):
|
||||
profile.trigger_token = ".codex"
|
||||
profile.reply_required = False
|
||||
profile.exact_match_only = False
|
||||
if slug == "claude":
|
||||
profile.trigger_token = ".claude"
|
||||
profile.reply_required = False
|
||||
profile.exact_match_only = False
|
||||
profile.save(
|
||||
update_fields=[
|
||||
"name",
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from datetime import datetime
|
||||
from datetime import timezone as dt_timezone
|
||||
from difflib import SequenceMatcher
|
||||
from urllib.parse import quote_plus, urlencode, urlparse
|
||||
from urllib.parse import urlencode, urlparse
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.conf import settings
|
||||
@@ -136,6 +136,13 @@ def _identifier_variants(service: str, identifier: str) -> list[str]:
|
||||
return variants
|
||||
|
||||
|
||||
def _group_channel_identifier(service: str, group_link: PlatformChatLink, bare_id: str) -> str:
|
||||
service_key = _default_service(service)
|
||||
if service_key == "whatsapp":
|
||||
return str(group_link.chat_jid or f"{bare_id}@g.us").strip()
|
||||
return bare_id
|
||||
|
||||
|
||||
def _safe_limit(raw) -> int:
|
||||
try:
|
||||
value = int(raw or 40)
|
||||
@@ -424,7 +431,7 @@ def _extract_attachment_image_urls(blob) -> list[str]:
|
||||
direct_urls.append(normalized)
|
||||
urls.extend(direct_urls)
|
||||
blob_key = str(blob.get("blob_key") or "").strip()
|
||||
# Prefer source-hosted URLs (for example share.zm.is) and use blob fallback only
|
||||
# Prefer source-hosted URLs and use blob fallback only
|
||||
# when no usable direct URL exists.
|
||||
if blob_key and image_hint and not direct_urls:
|
||||
urls.append(f"/compose/media/blob/?key={quote_plus(blob_key)}")
|
||||
@@ -1783,6 +1790,11 @@ def _context_base(user, service, identifier, person):
|
||||
service=service,
|
||||
identifier__in=identifier_variants or [identifier],
|
||||
).first()
|
||||
if person_identifier is None and identifier and person is None:
|
||||
person_identifier = PersonIdentifier.objects.filter(
|
||||
user=user,
|
||||
identifier__in=identifier_variants or [identifier],
|
||||
).first()
|
||||
|
||||
if person_identifier:
|
||||
service = person_identifier.service
|
||||
@@ -1811,7 +1823,7 @@ def _context_base(user, service, identifier, person):
|
||||
return {
|
||||
"person_identifier": None,
|
||||
"service": service,
|
||||
"identifier": f"{bare_id}@g.us",
|
||||
"identifier": _group_channel_identifier(service, group_link, bare_id),
|
||||
"person": None,
|
||||
"is_group": True,
|
||||
"group_name": group_link.chat_name or bare_id,
|
||||
@@ -2426,6 +2438,63 @@ def _signal_identifier_shape(value: str) -> str:
|
||||
return "other"
|
||||
|
||||
|
||||
def _preferred_signal_identifier(identifiers: list[str], *, is_group: bool) -> str:
|
||||
cleaned = []
|
||||
for value in identifiers:
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate not in cleaned:
|
||||
cleaned.append(candidate)
|
||||
if not cleaned:
|
||||
return ""
|
||||
if is_group:
|
||||
for candidate in cleaned:
|
||||
if candidate.startswith("group."):
|
||||
return candidate
|
||||
return cleaned[0]
|
||||
for candidate in cleaned:
|
||||
if _signal_identifier_shape(candidate) == "phone":
|
||||
return candidate
|
||||
for candidate in cleaned:
|
||||
if _signal_identifier_shape(candidate) == "uuid":
|
||||
return candidate
|
||||
return cleaned[0]
|
||||
|
||||
|
||||
def _signal_runtime_alias_map() -> dict[str, set[str]]:
|
||||
state = transport.get_runtime_state("signal") or {}
|
||||
alias_map: dict[str, set[str]] = {}
|
||||
for bucket_name in ("contacts", "groups"):
|
||||
rows = state.get(bucket_name) or []
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for item in rows:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
identifiers = []
|
||||
candidates = item.get("identifiers")
|
||||
if isinstance(candidates, list):
|
||||
identifiers.extend(candidates)
|
||||
identifiers.extend(
|
||||
[
|
||||
item.get("identifier"),
|
||||
item.get("number"),
|
||||
item.get("uuid"),
|
||||
item.get("id"),
|
||||
item.get("internal_id"),
|
||||
]
|
||||
)
|
||||
unique = []
|
||||
for value in identifiers:
|
||||
candidate = str(value or "").strip()
|
||||
if candidate and candidate not in unique:
|
||||
unique.append(candidate)
|
||||
if len(unique) < 2:
|
||||
continue
|
||||
for identifier in unique:
|
||||
alias_map[identifier] = {value for value in unique if value != identifier}
|
||||
return alias_map
|
||||
|
||||
|
||||
def _manual_contact_rows(user):
|
||||
rows = []
|
||||
seen = set()
|
||||
@@ -2493,6 +2562,8 @@ def _manual_contact_rows(user):
|
||||
)
|
||||
|
||||
for row in identifiers:
|
||||
if _default_service(row.service) == "signal":
|
||||
continue
|
||||
add_row(
|
||||
service=row.service,
|
||||
identifier=row.identifier,
|
||||
@@ -2500,6 +2571,27 @@ def _manual_contact_rows(user):
|
||||
source="linked",
|
||||
)
|
||||
|
||||
group_links = (
|
||||
PlatformChatLink.objects.filter(user=user, is_group=True)
|
||||
.order_by("service", "chat_name", "chat_identifier")
|
||||
)
|
||||
for link in group_links:
|
||||
if _default_service(link.service) == "signal":
|
||||
continue
|
||||
group_identifier = _group_channel_identifier(
|
||||
str(link.service or "").strip(),
|
||||
link,
|
||||
str(link.chat_identifier or "").strip(),
|
||||
)
|
||||
if not group_identifier:
|
||||
continue
|
||||
add_row(
|
||||
service=link.service,
|
||||
identifier=group_identifier,
|
||||
source=f"{_default_service(link.service)}_group",
|
||||
detected_name=str(link.chat_name or "").strip(),
|
||||
)
|
||||
|
||||
signal_links = {
|
||||
str(row.identifier): row
|
||||
for row in (
|
||||
@@ -2508,6 +2600,163 @@ def _manual_contact_rows(user):
|
||||
.order_by("id")
|
||||
)
|
||||
}
|
||||
signal_state = transport.get_runtime_state("signal") or {}
|
||||
signal_accounts = [
|
||||
str(value or "").strip()
|
||||
for value in (signal_state.get("accounts") or [])
|
||||
if str(value or "").strip()
|
||||
]
|
||||
signal_account_set = set(signal_accounts)
|
||||
signal_entities = {}
|
||||
signal_alias_index = {}
|
||||
|
||||
def _signal_entity_key(identifiers_list: list[str], *, is_group: bool) -> str:
|
||||
preferred = _preferred_signal_identifier(identifiers_list, is_group=is_group)
|
||||
if is_group:
|
||||
return f"group:{preferred}"
|
||||
for candidate in identifiers_list:
|
||||
if _signal_identifier_shape(candidate) == "uuid":
|
||||
return f"contact:uuid:{candidate.lower()}"
|
||||
for candidate in identifiers_list:
|
||||
if _signal_identifier_shape(candidate) == "phone":
|
||||
return f"contact:phone:{candidate}"
|
||||
return f"contact:other:{preferred.lower()}"
|
||||
|
||||
def _resolve_signal_entity_key(candidate: str) -> str:
|
||||
cleaned = str(candidate or "").strip()
|
||||
if not cleaned:
|
||||
return ""
|
||||
for variant in _identifier_variants("signal", cleaned):
|
||||
entity_key = signal_alias_index.get(variant)
|
||||
if entity_key:
|
||||
return entity_key
|
||||
return ""
|
||||
|
||||
def _register_signal_entity(
|
||||
*,
|
||||
identifiers_list,
|
||||
is_group: bool,
|
||||
detected_name="",
|
||||
person=None,
|
||||
source="signal_runtime",
|
||||
):
|
||||
unique_identifiers = []
|
||||
for value in identifiers_list or []:
|
||||
cleaned = str(value or "").strip()
|
||||
if (
|
||||
not cleaned
|
||||
or cleaned in unique_identifiers
|
||||
or cleaned in signal_account_set
|
||||
):
|
||||
continue
|
||||
unique_identifiers.append(cleaned)
|
||||
if not unique_identifiers:
|
||||
return None
|
||||
entity_key = ""
|
||||
for identifier in unique_identifiers:
|
||||
entity_key = _resolve_signal_entity_key(identifier)
|
||||
if entity_key:
|
||||
break
|
||||
if not entity_key:
|
||||
entity_key = _signal_entity_key(unique_identifiers, is_group=is_group)
|
||||
entity = signal_entities.get(entity_key)
|
||||
if entity is None:
|
||||
entity = {
|
||||
"is_group": bool(is_group),
|
||||
"identifiers": [],
|
||||
"detected_name": _clean_detected_name(detected_name or ""),
|
||||
"person": person,
|
||||
"sources": set(),
|
||||
}
|
||||
signal_entities[entity_key] = entity
|
||||
for identifier in unique_identifiers:
|
||||
if identifier not in entity["identifiers"]:
|
||||
entity["identifiers"].append(identifier)
|
||||
for variant in _identifier_variants("signal", identifier):
|
||||
signal_alias_index[variant] = entity_key
|
||||
cleaned_name = _clean_detected_name(detected_name or "")
|
||||
if cleaned_name and not entity["detected_name"]:
|
||||
entity["detected_name"] = cleaned_name
|
||||
if person is not None and entity.get("person") is None:
|
||||
entity["person"] = person
|
||||
entity["sources"].add(str(source or "").strip() or "signal_runtime")
|
||||
return entity
|
||||
|
||||
for row in signal_links.values():
|
||||
_register_signal_entity(
|
||||
identifiers_list=[row.identifier],
|
||||
is_group=False,
|
||||
detected_name=(str(row.person.name or "").strip() if row.person else ""),
|
||||
person=row.person,
|
||||
source="linked",
|
||||
)
|
||||
|
||||
signal_group_links = (
|
||||
PlatformChatLink.objects.filter(user=user, service="signal", is_group=True)
|
||||
.order_by("chat_name", "chat_identifier")
|
||||
)
|
||||
for link in signal_group_links:
|
||||
group_identifier = _group_channel_identifier(
|
||||
"signal",
|
||||
link,
|
||||
str(link.chat_identifier or "").strip(),
|
||||
)
|
||||
if not group_identifier:
|
||||
continue
|
||||
_register_signal_entity(
|
||||
identifiers_list=[group_identifier],
|
||||
is_group=True,
|
||||
detected_name=str(link.chat_name or "").strip(),
|
||||
source="signal_group",
|
||||
)
|
||||
|
||||
signal_contacts = signal_state.get("contacts") or []
|
||||
if isinstance(signal_contacts, list):
|
||||
for item in signal_contacts:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
candidate_identifiers = item.get("identifiers")
|
||||
if not isinstance(candidate_identifiers, list):
|
||||
candidate_identifiers = [
|
||||
item.get("identifier"),
|
||||
item.get("number"),
|
||||
item.get("uuid"),
|
||||
]
|
||||
linked = None
|
||||
for candidate in candidate_identifiers:
|
||||
cleaned = str(candidate or "").strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
linked = signal_links.get(cleaned)
|
||||
if linked is not None:
|
||||
break
|
||||
_register_signal_entity(
|
||||
identifiers_list=candidate_identifiers,
|
||||
is_group=False,
|
||||
detected_name=str(item.get("name") or "").strip(),
|
||||
person=(linked.person if linked else None),
|
||||
source="signal_runtime",
|
||||
)
|
||||
|
||||
signal_groups = signal_state.get("groups") or []
|
||||
if isinstance(signal_groups, list):
|
||||
for item in signal_groups:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
candidate_identifiers = item.get("identifiers")
|
||||
if not isinstance(candidate_identifiers, list):
|
||||
candidate_identifiers = [
|
||||
item.get("identifier"),
|
||||
item.get("id"),
|
||||
item.get("internal_id"),
|
||||
]
|
||||
_register_signal_entity(
|
||||
identifiers_list=candidate_identifiers,
|
||||
is_group=True,
|
||||
detected_name=str(item.get("name") or "").strip(),
|
||||
source="signal_group_raw",
|
||||
)
|
||||
|
||||
signal_chats = Chat.objects.all().order_by("-id")[:500]
|
||||
for chat in signal_chats:
|
||||
uuid_candidate = str(chat.source_uuid or "").strip()
|
||||
@@ -2517,20 +2766,45 @@ def _manual_contact_rows(user):
|
||||
fallback_linked = signal_links.get(uuid_candidate)
|
||||
if fallback_linked is None and number_candidate:
|
||||
fallback_linked = signal_links.get(number_candidate)
|
||||
for candidate in (uuid_candidate, number_candidate):
|
||||
if not candidate:
|
||||
continue
|
||||
linked = signal_links.get(candidate) or fallback_linked
|
||||
add_row(
|
||||
service="signal",
|
||||
identifier=candidate,
|
||||
person=(linked.person if linked else None),
|
||||
source="signal_chat",
|
||||
account=str(chat.account or ""),
|
||||
detected_name=_clean_detected_name(
|
||||
chat.source_name or chat.account or ""
|
||||
),
|
||||
)
|
||||
linked = fallback_linked
|
||||
if linked is None:
|
||||
for candidate in (uuid_candidate, number_candidate):
|
||||
linked = signal_links.get(candidate)
|
||||
if linked is not None:
|
||||
break
|
||||
_register_signal_entity(
|
||||
identifiers_list=[uuid_candidate, number_candidate],
|
||||
is_group=False,
|
||||
detected_name=_clean_detected_name(chat.source_name or chat.account or ""),
|
||||
person=(linked.person if linked else None),
|
||||
source="signal_chat",
|
||||
)
|
||||
|
||||
for entity in signal_entities.values():
|
||||
entity_identifiers = list(entity.get("identifiers") or [])
|
||||
identifier_value = _preferred_signal_identifier(
|
||||
entity_identifiers,
|
||||
is_group=bool(entity.get("is_group")),
|
||||
)
|
||||
if not identifier_value:
|
||||
continue
|
||||
add_row(
|
||||
service="signal",
|
||||
identifier=identifier_value,
|
||||
person=entity.get("person"),
|
||||
source=",".join(sorted(entity.get("sources") or {"signal_runtime"})),
|
||||
account=entity.get("detected_name") or "",
|
||||
detected_name=entity.get("detected_name") or "",
|
||||
)
|
||||
if rows:
|
||||
rows[-1]["identifier_aliases"] = [
|
||||
candidate
|
||||
for candidate in entity_identifiers
|
||||
if str(candidate or "").strip() and candidate != identifier_value
|
||||
]
|
||||
rows[-1]["identifier_search"] = " ".join(
|
||||
[rows[-1]["identifier"]] + rows[-1]["identifier_aliases"]
|
||||
).strip()
|
||||
|
||||
whatsapp_links = {
|
||||
str(row.identifier): row
|
||||
@@ -3225,8 +3499,11 @@ class ComposeContactMatch(LoginRequiredMixin, View):
|
||||
value = str(identifier or "").strip()
|
||||
if not value:
|
||||
return set()
|
||||
source_shape = _signal_identifier_shape(value)
|
||||
companions = set()
|
||||
runtime_aliases = _signal_runtime_alias_map()
|
||||
for variant in _identifier_variants("signal", value):
|
||||
companions.update(runtime_aliases.get(variant) or set())
|
||||
source_shape = _signal_identifier_shape(value)
|
||||
signal_rows = Chat.objects.filter(source_uuid=value) | Chat.objects.filter(
|
||||
source_number=value
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import requests
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db.models import Q
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
@@ -141,7 +142,12 @@ class SignalAccountUnlink(SuperUserRequiredMixin, View):
|
||||
else:
|
||||
messages.error(
|
||||
request,
|
||||
"Signal relink failed to clear current device state. Try relink again.",
|
||||
(
|
||||
"Signal relink could not verify that the current device state was "
|
||||
"cleared. The account may still be linked in signal-cli-rest-api. "
|
||||
"Try relink again, and if it still fails, restart the Signal service "
|
||||
"before requesting a new QR code."
|
||||
),
|
||||
)
|
||||
else:
|
||||
messages.warning(request, "No Signal account selected to relink.")
|
||||
@@ -324,15 +330,16 @@ class SignalChatsList(SuperUserRequiredMixin, ObjectList):
|
||||
group_id = str(link.chat_identifier or "").strip()
|
||||
if not group_id:
|
||||
continue
|
||||
query = urlencode({"service": "signal", "identifier": group_id})
|
||||
rows.append(
|
||||
{
|
||||
"chat": None,
|
||||
"compose_page_url": "",
|
||||
"compose_widget_url": "",
|
||||
"compose_page_url": f"{reverse('compose_page')}?{query}",
|
||||
"compose_widget_url": f"{reverse('compose_widget')}?{query}",
|
||||
"ai_url": reverse("ai_workspace"),
|
||||
"person_name": "",
|
||||
"manual_icon_class": "fa-solid fa-users",
|
||||
"can_compose": False,
|
||||
"can_compose": True,
|
||||
"match_url": "",
|
||||
"is_group": True,
|
||||
"name": link.chat_name or group_id,
|
||||
@@ -412,7 +419,21 @@ class SignalAccountAdd(SuperUserRequiredMixin, CustomObjectRead):
|
||||
def get_object(self, **kwargs):
|
||||
form_args = self.request.POST.dict()
|
||||
device_name = form_args["device"]
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
try:
|
||||
image_bytes = transport.get_link_qr(self.service, device_name)
|
||||
except Exception as exc:
|
||||
return render(
|
||||
self.request,
|
||||
"mixins/wm/modal.html",
|
||||
{
|
||||
"window_content": "mixins/partials/notify.html",
|
||||
"message": (
|
||||
"Signal QR link is unavailable right now. "
|
||||
f"signal-cli-rest-api did not return a QR in time: {exc}"
|
||||
),
|
||||
"class": "danger",
|
||||
},
|
||||
)
|
||||
base64_image = transport.image_bytes_to_base64(image_bytes)
|
||||
|
||||
return base64_image
|
||||
|
||||
@@ -40,6 +40,14 @@ from core.models import (
|
||||
WorkspaceConversation,
|
||||
WorkspaceMetricSnapshot,
|
||||
)
|
||||
from core.security.capabilities import (
|
||||
CAPABILITY_SCOPES,
|
||||
)
|
||||
from core.security.capabilities import GLOBAL_SCOPE_KEY as COMMAND_GLOBAL_SCOPE_KEY
|
||||
from core.security.capabilities import GROUP_LABELS as CAPABILITY_GROUP_LABELS
|
||||
from core.security.capabilities import (
|
||||
scope_record,
|
||||
)
|
||||
from core.transports.capabilities import capability_snapshot
|
||||
from core.views.manage.permissions import SuperUserRequiredMixin
|
||||
|
||||
@@ -528,7 +536,7 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
|
||||
template_name = "pages/security.html"
|
||||
page_mode = "encryption"
|
||||
GLOBAL_SCOPE_KEY = "global.override"
|
||||
GLOBAL_SCOPE_KEY = COMMAND_GLOBAL_SCOPE_KEY
|
||||
# Allowed Services list used by both Global Scope Override and local scopes.
|
||||
# Keep this in sync with the UI text on the Security page.
|
||||
POLICY_SERVICES = ["xmpp", "whatsapp", "signal", "instagram", "web"]
|
||||
@@ -541,47 +549,7 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
"require_omemo",
|
||||
"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.",
|
||||
),
|
||||
("command.codex", "Codex command", "Controls Codex command execution."),
|
||||
("command.claude", "Claude command", "Controls Claude command execution."),
|
||||
]
|
||||
POLICY_GROUP_LABELS = {
|
||||
"gateway": "Gateway",
|
||||
"tasks": "Tasks",
|
||||
"command": "Commands",
|
||||
"agentic": "Agentic",
|
||||
"other": "Other",
|
||||
}
|
||||
POLICY_GROUP_LABELS = CAPABILITY_GROUP_LABELS
|
||||
|
||||
def _show_encryption(self) -> bool:
|
||||
return str(getattr(self, "page_mode", "encryption")).strip().lower() in {
|
||||
@@ -774,8 +742,10 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
)
|
||||
}
|
||||
payload = []
|
||||
for scope_key, label, description in self.POLICY_SCOPES:
|
||||
key = str(scope_key or "").strip().lower()
|
||||
for scope in CAPABILITY_SCOPES:
|
||||
if not bool(scope.configurable):
|
||||
continue
|
||||
key = str(scope.key or "").strip().lower()
|
||||
item = rows.get(key)
|
||||
raw_allowed_services = [
|
||||
str(value or "").strip().lower()
|
||||
@@ -797,8 +767,8 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
payload.append(
|
||||
{
|
||||
"scope_key": key,
|
||||
"label": label,
|
||||
"description": description,
|
||||
"label": scope.label,
|
||||
"description": scope.description,
|
||||
"enabled": self._apply_global_override(
|
||||
bool(getattr(item, "enabled", True)),
|
||||
global_overrides["scope_enabled"],
|
||||
@@ -827,38 +797,20 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
return payload
|
||||
|
||||
def _scope_group_key(self, scope_key: str) -> str:
|
||||
key = str(scope_key or "").strip().lower()
|
||||
if key in {"tasks.commands", "gateway.tasks"}:
|
||||
return "tasks"
|
||||
if key in {"command.codex", "command.claude"}:
|
||||
return "agentic"
|
||||
if key.startswith("gateway."):
|
||||
return "command"
|
||||
if key.startswith("tasks."):
|
||||
if key == "tasks.submit":
|
||||
return "tasks"
|
||||
return "command"
|
||||
if key.startswith("command."):
|
||||
return "command"
|
||||
if ".commands" in key:
|
||||
return "command"
|
||||
if ".approval" in key:
|
||||
return "command"
|
||||
if ".totp" in key:
|
||||
return "command"
|
||||
if ".task" in key:
|
||||
return "tasks"
|
||||
return "other"
|
||||
row = scope_record(scope_key)
|
||||
return row.group if row is not None else "other"
|
||||
|
||||
def _grouped_scope_rows(self, request):
|
||||
rows = self._scope_rows(request)
|
||||
grouped: dict[str, list[dict]] = {key: [] for key in self.POLICY_GROUP_LABELS}
|
||||
grouped: dict[str, list[dict]] = {
|
||||
key: [] for key in self.POLICY_GROUP_LABELS.keys()
|
||||
}
|
||||
for row in rows:
|
||||
group_key = self._scope_group_key(row.get("scope_key"))
|
||||
grouped.setdefault(group_key, [])
|
||||
grouped[group_key].append(row)
|
||||
payload = []
|
||||
for group_key in ("tasks", "command", "agentic", "other"):
|
||||
for group_key in ("gateway", "tasks", "command", "agentic", "other"):
|
||||
items = grouped.get(group_key) or []
|
||||
if not items:
|
||||
continue
|
||||
@@ -875,6 +827,10 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
row = self._security_settings(request)
|
||||
if str(request.POST.get("encryption_settings_submit") or "").strip() == "1":
|
||||
row.require_omemo = _to_bool(request.POST.get("require_omemo"), False)
|
||||
row.encrypt_component_messages_with_omemo = _to_bool(
|
||||
request.POST.get("encrypt_component_messages_with_omemo"),
|
||||
True,
|
||||
)
|
||||
row.encrypt_contact_messages_with_omemo = _to_bool(
|
||||
request.POST.get("encrypt_contact_messages_with_omemo"),
|
||||
False,
|
||||
@@ -882,6 +838,7 @@ class SecurityPage(LoginRequiredMixin, View):
|
||||
row.save(
|
||||
update_fields=[
|
||||
"require_omemo",
|
||||
"encrypt_component_messages_with_omemo",
|
||||
"encrypt_contact_messages_with_omemo",
|
||||
"updated_at",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
from urllib.parse import urlencode
|
||||
@@ -40,6 +41,7 @@ from core.tasks.chat_defaults import (
|
||||
)
|
||||
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
|
||||
from core.tasks.codex_support import resolve_external_chat_id
|
||||
from core.tasks.engine import create_task_record_and_sync
|
||||
from core.tasks.providers import get_provider
|
||||
|
||||
|
||||
@@ -828,6 +830,9 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
return {
|
||||
"projects": projects,
|
||||
"project_choices": all_projects,
|
||||
"epic_choices": TaskEpic.objects.filter(
|
||||
project__user=request.user
|
||||
).select_related("project").order_by("project__name", "name"),
|
||||
"tasks": tasks,
|
||||
"scope": scope,
|
||||
"person_identifier_rows": person_identifier_rows,
|
||||
@@ -875,6 +880,60 @@ class TasksHub(LoginRequiredMixin, View):
|
||||
return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}")
|
||||
return redirect("tasks_hub")
|
||||
|
||||
if action == "task_create":
|
||||
project = get_object_or_404(
|
||||
TaskProject,
|
||||
user=request.user,
|
||||
id=request.POST.get("project_id"),
|
||||
)
|
||||
epic = None
|
||||
epic_id = str(request.POST.get("epic_id") or "").strip()
|
||||
if epic_id:
|
||||
epic = get_object_or_404(TaskEpic, id=epic_id, project=project)
|
||||
title = str(request.POST.get("title") or "").strip()
|
||||
if not title:
|
||||
messages.error(request, "Task title is required.")
|
||||
return redirect("tasks_hub")
|
||||
scope = self._scope(request)
|
||||
source_service = str(scope.get("service") or "").strip().lower() or "web"
|
||||
source_channel = str(scope.get("identifier") or "").strip()
|
||||
due_raw = str(request.POST.get("due_date") or "").strip()
|
||||
due_date = None
|
||||
if due_raw:
|
||||
try:
|
||||
due_date = datetime.date.fromisoformat(due_raw)
|
||||
except Exception:
|
||||
messages.error(request, "Due date must be YYYY-MM-DD.")
|
||||
return redirect("tasks_hub")
|
||||
task, _event = async_to_sync(create_task_record_and_sync)(
|
||||
user=request.user,
|
||||
project=project,
|
||||
epic=epic,
|
||||
title=title,
|
||||
source_service=source_service,
|
||||
source_channel=source_channel,
|
||||
actor_identifier=str(request.user.username or request.user.id),
|
||||
due_date=due_date,
|
||||
assignee_identifier=str(
|
||||
request.POST.get("assignee_identifier") or ""
|
||||
).strip(),
|
||||
immutable_payload={
|
||||
"source": "tasks_hub_manual_create",
|
||||
"person_id": scope["person_id"],
|
||||
"service": source_service,
|
||||
"identifier": source_channel,
|
||||
},
|
||||
event_payload={
|
||||
"source": "tasks_hub_manual_create",
|
||||
"via": "web_ui",
|
||||
},
|
||||
)
|
||||
messages.success(
|
||||
request,
|
||||
f"Created task #{task.reference_code} in '{project.name}'.",
|
||||
)
|
||||
return redirect("tasks_task", task_id=str(task.id))
|
||||
|
||||
if action == "project_map_identifier":
|
||||
project = get_object_or_404(
|
||||
TaskProject,
|
||||
|
||||
Reference in New Issue
Block a user