Improve tasks and align page elements
This commit is contained in:
6
Makefile
6
Makefile
@@ -28,7 +28,11 @@ compose-log:
|
||||
docker-compose --env-file=stack.env logs -f --names
|
||||
|
||||
test:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"
|
||||
@if command -v docker-compose >/dev/null 2>&1; then \
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
||||
else \
|
||||
podman exec gia sh -lc "cd /code && . /venv/bin/activate && python manage.py test $(MODULES) -v 2"; \
|
||||
fi
|
||||
|
||||
migrate:
|
||||
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"
|
||||
|
||||
18
core/migrations/0030_chattasksource_settings.py
Normal file
18
core/migrations/0030_chattasksource_settings.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-02 12:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0029_answermemory_answersuggestionevent_chattasksource_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='chattasksource',
|
||||
name='settings',
|
||||
field=models.JSONField(blank=True, default=dict),
|
||||
),
|
||||
]
|
||||
@@ -2045,6 +2045,7 @@ class ChatTaskSource(models.Model):
|
||||
related_name="chat_sources",
|
||||
)
|
||||
enabled = models.BooleanField(default=True)
|
||||
settings = models.JSONField(default=dict, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ def _normalize_flags(raw: dict | None) -> dict:
|
||||
"allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")),
|
||||
"completion_enabled": _to_bool(row.get("completion_enabled"), True),
|
||||
"ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True),
|
||||
"announce_task_id": _to_bool(row.get("announce_task_id"), True),
|
||||
"announce_task_id": _to_bool(row.get("announce_task_id"), False),
|
||||
"min_chars": max(1, int(row.get("min_chars") or 3)),
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ def _normalize_partial_flags(raw: dict | None) -> dict:
|
||||
if "ai_title_enabled" in row:
|
||||
out["ai_title_enabled"] = _to_bool(row.get("ai_title_enabled"), True)
|
||||
if "announce_task_id" in row:
|
||||
out["announce_task_id"] = _to_bool(row.get("announce_task_id"), True)
|
||||
out["announce_task_id"] = _to_bool(row.get("announce_task_id"), False)
|
||||
if "min_chars" in row:
|
||||
out["min_chars"] = max(1, int(row.get("min_chars") or 3))
|
||||
return out
|
||||
@@ -327,7 +327,7 @@ async def process_inbound_task_intelligence(message: Message) -> None:
|
||||
payload={"origin_text": text},
|
||||
)
|
||||
await _emit_sync_event(task, event, "create")
|
||||
if bool(flags.get("announce_task_id", True)):
|
||||
if bool(flags.get("announce_task_id", False)):
|
||||
try:
|
||||
await send_message_raw(
|
||||
message.source_service or "web",
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<p><strong>Group Mapping</strong>: binds a chat channel (service + channel identifier) to a project and optional epic. Task extraction only runs where mappings exist.</p>
|
||||
<p><strong>Matching Hierarchy</strong>: channel mapping flags override project flags. Project flags are defaults; mapping flags are per-chat precision controls.</p>
|
||||
<p><strong>False-Positive Controls</strong>: defaults are safe: <code>match_mode=strict</code>, <code>require_prefix=true</code>, and prefixes <code>task:</code>/<code>todo:</code>. Freeform matching is off by default.</p>
|
||||
<p><strong>Task ID Announcements</strong>: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example <code>#17</code>).</p>
|
||||
<p><strong>Task ID Announcements</strong>: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example <code>#17</code>). Default is off.</p>
|
||||
<p><strong>Legacy Backfill</strong>: opening this page applies safe defaults to older project and mapping rows created before strict prefix-only matching.</p>
|
||||
<p><strong>Completion Phrases</strong>: explicit trigger words used to detect completion markers like <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</p>
|
||||
<p><strong>Provider</strong>: external sync adapter toggle. In current setup, mock provider validates append-only sync flow and retry behavior.</p>
|
||||
<p><strong>Sync Event Log</strong>: audit of provider sync attempts and outcomes. Retry replays the event without mutating immutable task source records.</p>
|
||||
@@ -24,14 +25,14 @@
|
||||
<article class="box">
|
||||
<h2 class="title is-6">Quick Setup For Current Chat</h2>
|
||||
<p class="help">Prefilled from compose for <code>{{ prefill_service }}</code> · <code>{{ prefill_identifier }}</code>. Create/update project + epic + channel mapping in one step.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="quick_setup">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="quick_setup">
|
||||
<input type="hidden" name="service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="channel_identifier" value="{{ prefill_identifier }}">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="columns">
|
||||
<div class="columns tasks-settings-inline-columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Project</label>
|
||||
<input class="input is-small" name="project_name" placeholder="Project name">
|
||||
@@ -58,7 +59,7 @@
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="source_require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
||||
<button class="button is-small is-link" type="submit" style="margin-left: 0.75rem;">Apply Quick Setup</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -95,11 +96,23 @@
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1"> Announce Task ID</label>
|
||||
<button class="button is-small is-link" type="submit">Add Project</button>
|
||||
</form>
|
||||
<ul class="tasks-settings-list">
|
||||
{% for row in projects %}<li>{{ row.name }}</li>{% empty %}<li>No projects.</li>{% endfor %}
|
||||
{% for row in projects %}
|
||||
<li>
|
||||
{{ row.name }}
|
||||
<span class="has-text-grey">
|
||||
mode={{ row.settings_effective.match_mode }},
|
||||
prefixes={{ row.allowed_prefixes_csv }},
|
||||
require_prefix={{ row.settings_effective.require_prefix }},
|
||||
announce_id={{ row.settings_effective.announce_task_id }}
|
||||
</span>
|
||||
</li>
|
||||
{% empty %}
|
||||
<li>No projects.</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
@@ -139,7 +152,7 @@
|
||||
<input type="hidden" name="action" value="source_create">
|
||||
<input type="hidden" name="prefill_service" value="{{ prefill_service }}">
|
||||
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
|
||||
<div class="columns">
|
||||
<div class="columns tasks-settings-inline-columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Service</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
@@ -176,7 +189,7 @@
|
||||
<button class="button is-small is-link" type="submit" style="margin-top: 1.8rem;">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="columns">
|
||||
<div class="columns tasks-settings-inline-columns">
|
||||
<div class="column">
|
||||
<label class="label is-size-7">Match Mode</label>
|
||||
<div class="select is-small is-fullwidth">
|
||||
@@ -200,15 +213,21 @@
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
||||
</form>
|
||||
<table class="table is-fullwidth is-size-7">
|
||||
<thead><tr><th>Chat</th><th>Project</th><th>Epic</th></tr></thead>
|
||||
<thead><tr><th>Chat</th><th>Project</th><th>Epic</th><th>Match</th><th>Announce</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in sources %}
|
||||
<tr><td>{{ row.service }} · {{ row.channel_identifier }}</td><td>{{ row.project.name }}</td><td>{{ row.epic.name }}</td></tr>
|
||||
<tr>
|
||||
<td>{{ row.service }} · {{ row.channel_identifier }}</td>
|
||||
<td>{{ row.project.name }}</td>
|
||||
<td>{{ row.epic.name }}</td>
|
||||
<td>{{ row.settings_effective.match_mode }}{% if row.settings_effective.require_prefix %} +prefix{% endif %}</td>
|
||||
<td>{{ row.settings_effective.announce_task_id }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="3">No mappings.</td></tr>
|
||||
<tr><td colspan="5">No mappings.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -255,7 +274,7 @@
|
||||
<label class="checkbox is-size-7"><input type="checkbox" name="require_prefix" value="1" checked> Require Prefix</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="announce_task_id" value="1"> Announce Task ID</label>
|
||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Project Flags</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -300,7 +319,7 @@
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_derive_enabled" value="1" checked> Derivation Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_completion_enabled" value="1" checked> Completion Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_ai_title_enabled" value="1" checked> AI Title Enabled</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1" checked> Announce Task ID</label>
|
||||
<label class="checkbox is-size-7" style="margin-left: 0.75rem;"><input type="checkbox" name="source_announce_task_id" value="1"> Announce Task ID</label>
|
||||
<button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Channel Flags</button>
|
||||
</form>
|
||||
</article>
|
||||
@@ -376,6 +395,18 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-inline-columns {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-inline-columns > .column {
|
||||
padding-left: 0;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-inline-columns > .column:last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
.tasks-settings-page .tasks-settings-list {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
163
core/tests/test_tasks_settings_and_toggle.py
Normal file
163
core/tests/test_tasks_settings_and_toggle.py
Normal file
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from core.models import (
|
||||
ChatSession,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
Message,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
TaskProject,
|
||||
User,
|
||||
)
|
||||
from core.tasks.engine import process_inbound_task_intelligence
|
||||
from core.views.compose import _command_options_for_channel, _toggle_task_announce_for_channel
|
||||
from core.views.tasks import _apply_safe_defaults_for_user
|
||||
|
||||
|
||||
class TaskSettingsBackfillTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("defaults-user", "defaults@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Defaults Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.project = TaskProject.objects.create(
|
||||
user=self.user,
|
||||
name="Legacy Project",
|
||||
settings={
|
||||
"match_mode": "balanced",
|
||||
"require_prefix": False,
|
||||
"allowed_prefixes": ["task:", "todo:", "action:"],
|
||||
"min_chars": 8,
|
||||
},
|
||||
)
|
||||
self.source = ChatTaskSource.objects.create(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=self.project,
|
||||
settings={
|
||||
"match_mode": "balanced",
|
||||
"require_prefix": False,
|
||||
"allowed_prefixes": ["task:", "todo:", "action:"],
|
||||
"min_chars": 8,
|
||||
},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def test_backfill_applies_safe_defaults_for_legacy_rows(self):
|
||||
_apply_safe_defaults_for_user(self.user)
|
||||
self.project.refresh_from_db()
|
||||
self.source.refresh_from_db()
|
||||
self.assertEqual("strict", self.project.settings.get("match_mode"))
|
||||
self.assertTrue(bool(self.project.settings.get("require_prefix")))
|
||||
self.assertEqual(["task:", "todo:"], self.project.settings.get("allowed_prefixes"))
|
||||
self.assertFalse(bool(self.project.settings.get("announce_task_id")))
|
||||
self.assertEqual("strict", self.source.settings.get("match_mode"))
|
||||
self.assertTrue(bool(self.source.settings.get("require_prefix")))
|
||||
|
||||
|
||||
class TaskAnnounceToggleTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("toggle-user", "toggle@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Toggle Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Toggle Project")
|
||||
self.source = ChatTaskSource.objects.create(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=self.project,
|
||||
settings={"announce_task_id": False},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def test_toggle_task_announce_updates_source_settings(self):
|
||||
ok, err = _toggle_task_announce_for_channel(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215",
|
||||
enabled=True,
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual("", err)
|
||||
self.source.refresh_from_db()
|
||||
self.assertTrue(bool(self.source.settings.get("announce_task_id")))
|
||||
|
||||
def test_command_options_include_task_announce_state(self):
|
||||
options = _command_options_for_channel(
|
||||
self.user,
|
||||
"whatsapp",
|
||||
"120363402761690215",
|
||||
)
|
||||
row = [opt for opt in options if opt.get("slug") == "task_announce"][0]
|
||||
self.assertFalse(bool(row.get("enabled_here")))
|
||||
|
||||
|
||||
@override_settings(TASK_DERIVATION_USE_AI=False)
|
||||
class TaskAnnounceRuntimeTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user("runtime-user", "runtime@example.com", "x")
|
||||
self.person = Person.objects.create(user=self.user, name="Runtime Person")
|
||||
self.identifier = PersonIdentifier.objects.create(
|
||||
user=self.user,
|
||||
person=self.person,
|
||||
service="whatsapp",
|
||||
identifier="120363402761690215@g.us",
|
||||
)
|
||||
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
|
||||
self.project = TaskProject.objects.create(user=self.user, name="Runtime Project")
|
||||
|
||||
def _seed_source(self, announce_enabled: bool):
|
||||
return ChatTaskSource.objects.create(
|
||||
user=self.user,
|
||||
service="whatsapp",
|
||||
channel_identifier="120363402761690215@g.us",
|
||||
project=self.project,
|
||||
settings={
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"announce_task_id": announce_enabled,
|
||||
},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _msg(self, text: str, ts: int = 1000):
|
||||
return Message.objects.create(
|
||||
user=self.user,
|
||||
session=self.session,
|
||||
sender_uuid="peer",
|
||||
text=text,
|
||||
ts=ts,
|
||||
source_service="whatsapp",
|
||||
source_chat_id="120363402761690215@g.us",
|
||||
)
|
||||
|
||||
def test_no_announce_send_when_disabled(self):
|
||||
self._seed_source(False)
|
||||
with patch("core.tasks.engine.send_message_raw", new=AsyncMock()) as mocked_send:
|
||||
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
|
||||
self.assertTrue(DerivedTask.objects.exists())
|
||||
mocked_send.assert_not_awaited()
|
||||
|
||||
def test_announce_send_when_enabled(self):
|
||||
self._seed_source(True)
|
||||
with patch("core.tasks.engine.send_message_raw", new=AsyncMock(return_value=True)) as mocked_send:
|
||||
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
|
||||
self.assertTrue(DerivedTask.objects.exists())
|
||||
mocked_send.assert_awaited()
|
||||
@@ -41,6 +41,7 @@ from core.models import (
|
||||
Message,
|
||||
MessageEvent,
|
||||
PatternMitigationPlan,
|
||||
ChatTaskSource,
|
||||
Person,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
@@ -1787,9 +1788,66 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
|
||||
"profile_enabled": bool(profile.enabled),
|
||||
}
|
||||
)
|
||||
task_announce_enabled = False
|
||||
if variants:
|
||||
source = (
|
||||
ChatTaskSource.objects.filter(
|
||||
user=user,
|
||||
service=service_key,
|
||||
channel_identifier__in=list(variants),
|
||||
enabled=True,
|
||||
)
|
||||
.order_by("-updated_at")
|
||||
.first()
|
||||
)
|
||||
settings_row = dict(getattr(source, "settings", {}) or {}) if source else {}
|
||||
task_announce_enabled = str(settings_row.get("announce_task_id", "")).strip().lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
options.append(
|
||||
{
|
||||
"slug": "task_announce",
|
||||
"name": "Announce Task IDs",
|
||||
"trigger_token": "",
|
||||
"enabled_here": bool(task_announce_enabled),
|
||||
"profile_enabled": True,
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
def _toggle_task_announce_for_channel(
|
||||
*,
|
||||
user,
|
||||
service: str,
|
||||
identifier: str,
|
||||
enabled: bool,
|
||||
) -> tuple[bool, str]:
|
||||
service_key = _default_service(service)
|
||||
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)
|
||||
rows = list(
|
||||
ChatTaskSource.objects.filter(
|
||||
user=user,
|
||||
service=service_key,
|
||||
channel_identifier__in=list(variants),
|
||||
).order_by("-updated_at")
|
||||
)
|
||||
if not rows:
|
||||
return (False, "task_source_mapping_missing")
|
||||
for row in rows:
|
||||
settings_row = dict(row.settings or {})
|
||||
settings_row["announce_task_id"] = bool(enabled)
|
||||
row.settings = settings_row
|
||||
row.save(update_fields=["settings", "updated_at"])
|
||||
return (True, "")
|
||||
|
||||
|
||||
def _compose_urls(service, identifier, person_id):
|
||||
service_key = _default_service(service)
|
||||
identifier_value = str(identifier or "").strip()
|
||||
@@ -3314,13 +3372,21 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
|
||||
"yes",
|
||||
"on",
|
||||
}
|
||||
ok, error = _toggle_command_for_channel(
|
||||
user=request.user,
|
||||
service=service,
|
||||
identifier=channel_identifier,
|
||||
slug=slug,
|
||||
enabled=enabled,
|
||||
)
|
||||
if slug == "task_announce":
|
||||
ok, error = _toggle_task_announce_for_channel(
|
||||
user=request.user,
|
||||
service=service,
|
||||
identifier=channel_identifier,
|
||||
enabled=enabled,
|
||||
)
|
||||
else:
|
||||
ok, error = _toggle_command_for_channel(
|
||||
user=request.user,
|
||||
service=service,
|
||||
identifier=channel_identifier,
|
||||
slug=slug,
|
||||
enabled=enabled,
|
||||
)
|
||||
if not ok:
|
||||
return JsonResponse(
|
||||
{
|
||||
@@ -3347,7 +3413,11 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
|
||||
"slug": slug,
|
||||
"enabled": bool(enabled),
|
||||
"command_options": command_options,
|
||||
"settings_url": reverse("command_routing"),
|
||||
"settings_url": (
|
||||
f"{reverse('tasks_settings')}?{urlencode({'service': service, 'identifier': channel_identifier})}"
|
||||
if slug == "task_announce"
|
||||
else reverse("command_routing")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ from core.models import (
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
|
||||
SAFE_TASK_FLAGS_DEFAULTS = {
|
||||
"derive_enabled": True,
|
||||
"match_mode": "strict",
|
||||
"require_prefix": True,
|
||||
"allowed_prefixes": ["task:", "todo:"],
|
||||
"completion_enabled": True,
|
||||
"ai_title_enabled": True,
|
||||
"announce_task_id": False,
|
||||
"min_chars": 3,
|
||||
}
|
||||
|
||||
|
||||
def _to_bool(raw, default=False) -> bool:
|
||||
if raw is None:
|
||||
@@ -50,32 +61,73 @@ def _parse_prefixes(value: str) -> list[str]:
|
||||
return rows or ["task:", "todo:"]
|
||||
|
||||
|
||||
def _looks_like_old_risky_defaults(raw: dict) -> bool:
|
||||
row = dict(raw or {})
|
||||
mode = str(row.get("match_mode") or "").strip().lower()
|
||||
require_prefix = _to_bool(row.get("require_prefix"), False)
|
||||
prefixes = _parse_prefixes(",".join(list(row.get("allowed_prefixes") or [])))
|
||||
min_chars = int(row.get("min_chars") or 8)
|
||||
return (
|
||||
mode in {"", "balanced"}
|
||||
and (not require_prefix)
|
||||
and prefixes == ["task:", "todo:", "action:"]
|
||||
and min_chars >= 8
|
||||
)
|
||||
|
||||
|
||||
def _normalized_safe_flags(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
defaults = dict(SAFE_TASK_FLAGS_DEFAULTS)
|
||||
if _looks_like_old_risky_defaults(row):
|
||||
return defaults
|
||||
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"]),
|
||||
"min_chars": max(1, int(row.get("min_chars") or defaults["min_chars"])),
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def _apply_safe_defaults_for_user(user) -> None:
|
||||
projects = list(TaskProject.objects.filter(user=user).only("id", "settings"))
|
||||
for row in projects:
|
||||
normalized = _normalized_safe_flags(row.settings)
|
||||
if dict(row.settings or {}) != normalized:
|
||||
row.settings = normalized
|
||||
row.save(update_fields=["settings", "updated_at"])
|
||||
sources = list(ChatTaskSource.objects.filter(user=user).only("id", "settings"))
|
||||
for row in sources:
|
||||
normalized = _normalized_safe_flags(row.settings)
|
||||
if dict(row.settings or {}) != normalized:
|
||||
row.settings = normalized
|
||||
row.save(update_fields=["settings", "updated_at"])
|
||||
|
||||
|
||||
def _flags_from_post(request, prefix: str = "") -> dict:
|
||||
key = lambda name: f"{prefix}{name}" if prefix else name
|
||||
defaults = dict(SAFE_TASK_FLAGS_DEFAULTS)
|
||||
return {
|
||||
"derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), True),
|
||||
"match_mode": str(request.POST.get(key("match_mode")) or "strict").strip().lower() or "strict",
|
||||
"require_prefix": _to_bool(request.POST.get(key("require_prefix")), True),
|
||||
"allowed_prefixes": _parse_prefixes(str(request.POST.get(key("allowed_prefixes")) or "")),
|
||||
"completion_enabled": _to_bool(request.POST.get(key("completion_enabled")), True),
|
||||
"ai_title_enabled": _to_bool(request.POST.get(key("ai_title_enabled")), True),
|
||||
"announce_task_id": _to_bool(request.POST.get(key("announce_task_id")), True),
|
||||
"min_chars": max(1, int(str(request.POST.get(key("min_chars")) or "3").strip() or "3")),
|
||||
"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"]))),
|
||||
}
|
||||
|
||||
|
||||
def _flags_with_defaults(raw: dict | None) -> dict:
|
||||
row = dict(raw or {})
|
||||
return {
|
||||
"derive_enabled": _to_bool(row.get("derive_enabled"), True),
|
||||
"match_mode": str(row.get("match_mode") or "strict").strip().lower() or "strict",
|
||||
"require_prefix": _to_bool(row.get("require_prefix"), True),
|
||||
"allowed_prefixes": _parse_prefixes(",".join(list(row.get("allowed_prefixes") or []))),
|
||||
"completion_enabled": _to_bool(row.get("completion_enabled"), True),
|
||||
"ai_title_enabled": _to_bool(row.get("ai_title_enabled"), True),
|
||||
"announce_task_id": _to_bool(row.get("announce_task_id"), True),
|
||||
"min_chars": max(1, int(row.get("min_chars") or 3)),
|
||||
}
|
||||
return _normalized_safe_flags(raw)
|
||||
|
||||
|
||||
def _settings_redirect(request):
|
||||
@@ -277,6 +329,7 @@ class TaskSettings(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-settings.html"
|
||||
|
||||
def _context(self, request):
|
||||
_apply_safe_defaults_for_user(request.user)
|
||||
prefill_service = str(request.GET.get("service") or "").strip().lower()
|
||||
prefill_identifier = str(request.GET.get("identifier") or "").strip()
|
||||
projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))
|
||||
|
||||
Reference in New Issue
Block a user