Pull groups from WhatsApp

This commit is contained in:
2026-02-18 21:22:45 +00:00
parent 521692c458
commit c400c46e7d
12 changed files with 643 additions and 136 deletions

View File

@@ -1465,27 +1465,41 @@ class WhatsAppClient(ClientBase):
# NOTE: Neonize get_all_contacts has crashed some runtime builds with a Go panic.
# Read contact-like rows directly from the session sqlite DB instead.
contacts, source, lid_map = await self._sync_contacts_from_sqlite()
if not contacts:
groups, groups_source = await self._sync_groups_from_client()
now_ts = int(time.time())
if contacts:
self.log.debug(
"whatsapp contacts synced: count=%s source=%s",
len(contacts),
source or "unknown",
)
self._publish_state(
contacts=contacts,
lid_map=lid_map,
contacts_synced_at=now_ts,
contacts_sync_count=len(contacts),
last_event="contacts_synced",
contacts_source=source or "unknown",
last_error="",
)
else:
self.log.debug("whatsapp contacts sync empty (%s)", source or "unknown")
self._publish_state(
last_event="contacts_sync_empty",
contacts_source=source or "unknown",
)
return
self.log.debug(
"whatsapp contacts synced: count=%s source=%s",
len(contacts),
source or "unknown",
)
self._publish_state(
contacts=contacts,
lid_map=lid_map,
contacts_synced_at=int(time.time()),
contacts_sync_count=len(contacts),
last_event="contacts_synced",
contacts_source=source or "unknown",
last_error="",
)
if groups_source:
event_name = "groups_synced" if groups else "groups_sync_empty"
self._publish_state(
groups=groups,
groups_source=groups_source,
groups_sync_count=len(groups),
groups_synced_at=now_ts,
last_event=event_name,
last_error="" if groups else "",
)
async def _sync_contacts_from_sqlite(self):
def _extract():
@@ -1700,6 +1714,51 @@ class WhatsAppClient(ClientBase):
return await asyncio.to_thread(_extract)
async def _sync_groups_from_client(self):
if self._client is None:
return [], "client_missing"
getter = getattr(self._client, "get_joined_groups", None)
if getter is None:
return [], "get_joined_groups_missing"
try:
group_rows = await self._maybe_await(getter())
except Exception as exc:
self._publish_state(
last_event="groups_sync_failed",
last_error=str(exc),
)
return [], "get_joined_groups_failed"
out = []
now_ts = int(time.time())
for group in group_rows or []:
jid_value = self._jid_to_identifier(
self._pluck(group, "JID") or self._pluck(group, "jid")
)
identifier = (
jid_value.split("@", 1)[0].strip() if jid_value else ""
)
if not identifier:
continue
name = (
str(self._pluck(group, "GroupName", "Name") or "").strip()
or str(self._pluck(group, "GroupTopic", "Topic") or "").strip()
or identifier
)
out.append(
{
"identifier": identifier,
"jid": jid_value or f"{identifier}@g.us",
"name": name,
"chat": name,
"type": "group",
"seen_at": now_ts,
}
)
if len(out) >= 500:
break
return out, "get_joined_groups"
async def _is_contact_sync_ready(self) -> bool:
if self._client is None:
return False

View File

@@ -0,0 +1,56 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0024_workspacemetricsnapshot"),
]
operations = [
migrations.CreateModel(
name="PlatformChatLink",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("service", models.CharField(choices=[("signal", "Signal"), ("whatsapp", "WhatsApp"), ("xmpp", "XMPP"), ("instagram", "Instagram")], max_length=255)),
("chat_identifier", models.CharField(max_length=255)),
("chat_jid", models.CharField(blank=True, max_length=255, null=True)),
("chat_name", models.CharField(blank=True, max_length=255, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"person",
models.ForeignKey(on_delete=models.deletion.CASCADE, to="core.person"),
),
(
"person_identifier",
models.ForeignKey(blank=True, null=True, on_delete=models.deletion.SET_NULL, to="core.personidentifier"),
),
(
"user",
models.ForeignKey(on_delete=models.deletion.CASCADE, to="core.user"),
),
],
),
migrations.AddConstraint(
model_name="platformchatlink",
constraint=models.UniqueConstraint(
fields=("user", "service", "chat_identifier"),
name="unique_platform_chat_link",
),
),
migrations.AddIndex(
model_name="platformchatlink",
index=models.Index(
fields=["user", "service", "chat_identifier"],
name="core_platfo_user_id_0436ca_idx",
),
),
]

View File

@@ -4,6 +4,7 @@ import uuid
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractUser
from django.core.exceptions import ValidationError
from django.db import models
from core.clients import transport
@@ -170,6 +171,62 @@ class PersonIdentifier(models.Model):
)
class PlatformChatLink(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
person = models.ForeignKey(Person, on_delete=models.CASCADE)
person_identifier = models.ForeignKey(
PersonIdentifier,
on_delete=models.SET_NULL,
null=True,
blank=True,
)
service = models.CharField(choices=SERVICE_CHOICES, max_length=255)
chat_identifier = models.CharField(max_length=255)
chat_jid = models.CharField(max_length=255, blank=True, null=True)
chat_name = models.CharField(max_length=255, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "service", "chat_identifier"],
name="unique_platform_chat_link",
)
]
indexes = [
models.Index(fields=["user", "service", "chat_identifier"]),
]
def clean(self):
if self.person_id and self.user_id and self.person.user_id != self.user_id:
raise ValidationError("Person must belong to the same user.")
if self.person_identifier_id:
if self.person_identifier.user_id != self.user_id:
raise ValidationError(
"Person identifier must belong to the same user."
)
if self.person_identifier.person_id != self.person_id:
raise ValidationError(
"Person identifier must belong to the selected person."
)
if self.person_identifier.service != self.service:
raise ValidationError(
"Chat links cannot be linked across platforms."
)
def save(self, *args, **kwargs):
value = str(self.chat_identifier or "").strip()
if "@" in value:
value = value.split("@", 1)[0]
self.chat_identifier = value
self.full_clean()
return super().save(*args, **kwargs)
def __str__(self):
return f"{self.person.name} ({self.service}: {self.chat_identifier})"
class ChatSession(models.Model):
"""Represents an ongoing chat session for persisted message history."""

View File

@@ -0,0 +1,84 @@
{% extends "index.html" %}
{% block content %}
<section class="section">
<div class="container" style="max-width: 44rem;">
<div class="level" style="margin-bottom: 0.75rem;">
<div class="level-left">
<div>
<h1 class="title is-4" style="margin-bottom: 0.2rem;">WhatsApp Chat Link</h1>
<p class="is-size-7 has-text-grey">Link a WhatsApp chat identifier to a person. This link is WhatsApp-only.</p>
</div>
</div>
<div class="level-right">
<a class="button is-light" href="{% url 'whatsapp' %}">
<span class="icon is-small"><i class="fa-solid fa-arrow-left"></i></span>
<span>Back To WhatsApp</span>
</a>
</div>
</div>
{% if notice_message %}
<article class="notification is-{{ notice_level|default:'info' }} is-light">
{{ notice_message }}
</article>
{% endif %}
<article class="box">
<form method="post">
{% csrf_token %}
<div class="field">
<label class="label is-small">Chat Identifier</label>
<div class="control">
<input class="input" type="text" name="identifier" value="{{ identifier }}" required>
</div>
</div>
<div class="field">
<label class="label is-small">Existing Person</label>
<div class="select is-fullwidth">
<select name="person_id">
<option value="">- Select person -</option>
{% for person in people %}
<option value="{{ person.id }}" {% if existing and existing.person_id == person.id %}selected{% endif %}>{{ person.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label class="label is-small">Or Create Person</label>
<div class="control">
<input class="input" type="text" name="person_name" placeholder="New person name">
</div>
</div>
<div class="field">
<label class="label is-small">Chat JID (optional)</label>
<div class="control">
<input class="input" type="text" name="chat_jid" value="{% if existing %}{{ existing.chat_jid|default:'' }}{% endif %}">
</div>
</div>
<div class="field">
<label class="label is-small">Display Name (optional)</label>
<div class="control">
<input class="input" type="text" name="chat_name" value="{% if existing %}{{ existing.chat_name|default:'' }}{% endif %}">
</div>
</div>
<button class="button is-link" type="submit">
<span class="icon is-small"><i class="fa-solid fa-link"></i></span>
<span>Save WhatsApp Chat Link</span>
</button>
</form>
</article>
{% if existing %}
<article class="notification is-light" style="margin-top: 0.8rem;">
Current link: <strong>{{ existing.person.name }}</strong><code>{{ existing.chat_identifier }}</code>
</article>
{% endif %}
</div>
</section>
{% endblock %}

View File

@@ -2545,18 +2545,6 @@
return response.json();
};
const getJson = async function (url) {
const response = await fetch(url, {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
if (!response.ok) {
throw new Error("Request failed");
}
return response.json();
};
const titleCase = function (value) {
const raw = String(value || "").trim().toLowerCase();
if (!raw) {
@@ -2639,17 +2627,6 @@
}
};
const cardContentNode = function (card) {
return card ? card.querySelector(".compose-ai-content") : null;
};
const setCardMessage = function (card, message) {
const node = cardContentNode(card);
if (node) {
node.textContent = String(message || "");
}
};
const openEngage = function (sourceRef) {
const engageCard = showCard("engage");
if (!engageCard) {
@@ -2669,19 +2646,19 @@
}
setCardLoading(card, true);
try {
const payload = await getJson(
thread.dataset.draftsUrl + "?" + queryParams().toString()
);
const response = await fetch(thread.dataset.draftsUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
setCardMessage(card, payload.error || "Failed to load drafts.");
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load drafts.";
return;
}
const drafts = Array.isArray(payload.drafts) ? payload.drafts : [];
const container = cardContentNode(card);
if (!container) {
return;
}
const container = card.querySelector(".compose-ai-content");
container.innerHTML = "";
const engageButton = document.createElement("button");
engageButton.type = "button";
@@ -2720,7 +2697,7 @@
});
} catch (err) {
setCardLoading(card, false);
setCardMessage(card, "Failed to load drafts.");
card.querySelector(".compose-ai-content").textContent = "Failed to load drafts.";
}
};
@@ -2731,18 +2708,21 @@
}
setCardLoading(card, true);
try {
const payload = await getJson(
thread.dataset.summaryUrl + "?" + queryParams().toString()
);
const response = await fetch(thread.dataset.summaryUrl + "?" + queryParams().toString(), {
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
});
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
setCardMessage(card, payload.error || "Failed to load summary.");
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load summary.";
return;
}
setCardMessage(card, String(payload.summary || ""));
card.querySelector(".compose-ai-content").textContent = String(payload.summary || "");
} catch (err) {
setCardLoading(card, false);
setCardMessage(card, "Failed to load summary.");
card.querySelector(".compose-ai-content").textContent = "Failed to load summary.";
}
};
@@ -2753,14 +2733,17 @@
}
setCardLoading(card, true);
try {
const payload = await getJson(
thread.dataset.quickInsightsUrl + "?" + queryParams().toString()
const response = await fetch(
thread.dataset.quickInsightsUrl + "?" + queryParams().toString(),
{
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
}
);
const payload = await response.json();
setCardLoading(card, false);
const container = cardContentNode(card);
if (!container) {
return;
}
const container = card.querySelector(".compose-ai-content");
if (!payload.ok) {
container.textContent = payload.error || "Failed to load quick insights.";
return;
@@ -3013,7 +2996,8 @@
}
} catch (err) {
setCardLoading(card, false);
setCardMessage(card, "Failed to load quick insights.");
card.querySelector(".compose-ai-content").textContent =
"Failed to load quick insights.";
}
};
@@ -3053,12 +3037,18 @@
if (showCustom && customValue) {
params.set("custom_text", customValue);
}
const payload = await getJson(
thread.dataset.engagePreviewUrl + "?" + params.toString()
const response = await fetch(
thread.dataset.engagePreviewUrl + "?" + params.toString(),
{
method: "GET",
credentials: "same-origin",
headers: { Accept: "application/json" }
}
);
const payload = await response.json();
setCardLoading(card, false);
if (!payload.ok) {
setCardMessage(card, payload.error || "Failed to load engage preview.");
card.querySelector(".compose-ai-content").textContent = payload.error || "Failed to load engage preview.";
panelState.engageToken = "";
return;
}
@@ -3093,11 +3083,11 @@
if (payload.artifact) {
text = text + "\n\nSource: " + String(payload.artifact);
}
setCardMessage(card, text);
card.querySelector(".compose-ai-content").textContent = text;
sendBtn.disabled = !(confirm.checked && panelState.engageToken);
} catch (err) {
setCardLoading(card, false);
setCardMessage(card, "Failed to load engage preview.");
card.querySelector(".compose-ai-content").textContent = "Failed to load engage preview.";
panelState.engageToken = "";
} finally {
if (refreshBtn) {

View File

@@ -7,7 +7,7 @@ from django.views import View
from mixins.views import ObjectList, ObjectRead
from core.clients import transport
from core.models import ChatSession, Message, PersonIdentifier
from core.models import PersonIdentifier
from core.util import logs
from core.views.compose import _compose_urls, _service_icon_class
from core.views.manage.permissions import SuperUserRequiredMixin
@@ -263,84 +263,102 @@ class WhatsAppChatsList(WhatsAppContactsList):
rows = []
seen = set()
state = transport.get_runtime_state("whatsapp")
runtime_contacts = state.get("contacts") or []
runtime_name_map = {}
for item in runtime_contacts:
if not isinstance(item, dict):
continue
identifier = str(item.get("identifier") or "").strip()
runtime_groups = state.get("groups") or []
combined_contacts = []
for item in runtime_contacts + runtime_groups:
if isinstance(item, dict):
combined_contacts.append(item)
contact_index = {}
for item in combined_contacts:
raw_identifier = str(
item.get("identifier") or item.get("jid") or item.get("chat") or ""
).strip()
jid = str(item.get("jid") or "").strip()
name = str(item.get("name") or item.get("chat") or "").strip()
base_id = raw_identifier.split("@", 1)[0].strip()
jid_base = jid.split("@", 1)[0].strip()
for key in {raw_identifier, base_id, jid, jid_base}:
if key:
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 []):
identifier = str(key or "").strip()
if not identifier:
continue
runtime_name_map[identifier] = str(item.get("name") or "").strip()
sessions = (
ChatSession.objects.filter(
user=self.request.user,
identifier__service="whatsapp",
)
.select_related("identifier", "identifier__person")
.order_by("-last_interaction", "-id")
)
for session in sessions:
identifier = str(session.identifier.identifier or "").strip()
if not identifier or identifier in seen:
identifier = identifier.split("@", 1)[0].strip() or identifier
if identifier in seen:
continue
seen.add(identifier)
latest = (
Message.objects.filter(user=self.request.user, session=session)
.order_by("-ts")
.first()
anchor_jid = str((anchor or {}).get("chat_jid") or "").strip()
contact = contact_index.get(identifier) or contact_index.get(anchor_jid)
jid = (contact or {}).get("jid") or anchor_jid or identifier
linked = self._linked_identifier(identifier, jid)
urls = _compose_urls(
"whatsapp",
identifier,
linked.person_id if linked else None,
)
urls = _compose_urls("whatsapp", identifier, session.identifier.person_id)
preview = str((latest.text if latest else "") or "").strip()
if len(preview) > 80:
preview = f"{preview[:77]}..."
display_name = (
preview
or runtime_name_map.get(identifier)
or session.identifier.person.name
name = (
(contact or {}).get("name")
or (linked.person.name if linked else "")
or jid
or identifier
or "WhatsApp Chat"
)
rows.append(
{
"identifier": identifier,
"jid": identifier,
"name": display_name,
"jid": jid,
"name": name,
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": session.identifier.person.name,
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
"compose_widget_url": urls["widget_url"],
"match_url": (
f"{reverse('compose_contact_match')}?"
f"{urlencode({'service': 'whatsapp', 'identifier': identifier})}"
),
"last_ts": int(latest.ts or 0) if latest else 0,
"last_ts": int((anchor or {}).get("ts") or (anchor or {}).get("updated_at") or 0),
}
)
# Fallback: show synced WhatsApp contacts as chat entries even when no
# local message history exists yet.
for item in runtime_contacts:
if not isinstance(item, dict):
continue
identifier = str(item.get("identifier") or item.get("jid") or "").strip()
if rows:
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
return rows
# Fallback: if no anchors yet, surface the runtime contacts (best effort live state)
for item in combined_contacts:
identifier = str(
item.get("identifier") or item.get("jid") or item.get("chat") or ""
).strip()
if not identifier:
continue
identifier = identifier.split("@", 1)[0].strip()
if not identifier or identifier in seen:
continue
seen.add(identifier)
linked = self._linked_identifier(identifier, str(item.get("jid") or ""))
jid = str(item.get("jid") or "").strip()
linked = self._linked_identifier(identifier, jid)
urls = _compose_urls(
"whatsapp",
identifier,
linked.person_id if linked else None,
)
name = (
str(item.get("name") or item.get("chat") or "").strip()
or (linked.person.name if linked else "")
or jid
or identifier
or "WhatsApp Chat"
)
rows.append(
{
"identifier": identifier,
"jid": str(item.get("jid") or identifier).strip(),
"name": str(item.get("name") or "WhatsApp Chat").strip()
or "WhatsApp Chat",
"jid": jid or identifier,
"name": name,
"service_icon_class": _service_icon_class("whatsapp"),
"person_name": linked.person.name if linked else "",
"compose_page_url": urls["page_url"],
@@ -352,10 +370,7 @@ class WhatsAppChatsList(WhatsAppContactsList):
"last_ts": 0,
}
)
if rows:
rows.sort(key=lambda row: row.get("last_ts", 0), reverse=True)
return rows
return super().get_queryset(*args, **kwargs)
return rows
class WhatsAppAccountAdd(SuperUserRequiredMixin, ObjectRead):

View File

@@ -3497,12 +3497,6 @@ def _workspace_nav_urls(person):
}
def _person_plan_or_404(request, person_id, plan_id):
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
return person, plan
class AIWorkspace(LoginRequiredMixin, View):
template_name = "pages/ai-workspace.html"
@@ -4437,7 +4431,12 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(
PatternMitigationPlan,
id=plan_id,
user=request.user,
)
text = (request.POST.get("message") or "").strip()
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="ask_ai"
@@ -4519,11 +4518,14 @@ class AIWorkspaceMitigationChat(LoginRequiredMixin, View):
text=assistant_text,
)
return _render_mitigation_panel(
return render(
request,
person,
plan,
active_tab=active_tab,
"partials/ai-workspace-mitigation-panel.html",
_mitigation_panel_context(
person=person,
plan=plan,
active_tab=active_tab,
),
)
@@ -4534,7 +4536,12 @@ class AIWorkspaceExportArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(
PatternMitigationPlan,
id=plan_id,
user=request.user,
)
artifact_type = (request.POST.get("artifact_type") or "rulebook").strip()
if artifact_type not in {"rulebook", "rules", "games", "corrections"}:
@@ -4581,7 +4588,8 @@ class AIWorkspaceCreateArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4635,7 +4643,8 @@ class AIWorkspaceUpdateArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4710,7 +4719,8 @@ class AIWorkspaceDeleteArtifact(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4749,7 +4759,8 @@ class AIWorkspaceDeleteArtifactList(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
kind_key = (kind or "").strip().lower()
if kind_key not in self.kind_map:
return HttpResponseBadRequest("Invalid artifact kind")
@@ -4786,7 +4797,8 @@ class AIWorkspaceEngageShare(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
source_ref = (request.POST.get("source_ref") or "").strip()
share_target = (request.POST.get("share_target") or "self").strip()
@@ -5051,7 +5063,8 @@ class AIWorkspaceAutoSettings(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
auto_settings = _get_or_create_auto_settings(request.user, plan.conversation)
auto_settings.enabled = _is_truthy(request.POST.get("enabled"))
@@ -5121,7 +5134,8 @@ class AIWorkspaceUpdateFundamentals(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
fundamentals_text = request.POST.get("fundamentals_text") or ""
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="fundamentals"
@@ -5145,7 +5159,8 @@ class AIWorkspaceUpdatePlanMeta(LoginRequiredMixin, View):
if type not in self.allowed_types:
return HttpResponseBadRequest("Invalid type specified")
person, plan = _person_plan_or_404(request, person_id, plan_id)
person = get_object_or_404(Person, pk=person_id, user=request.user)
plan = get_object_or_404(PatternMitigationPlan, id=plan_id, user=request.user)
active_tab = _sanitize_active_tab(
request.POST.get("active_tab"), default="plan_board"
)