Improve tasks and align page elements

This commit is contained in:
2026-03-02 13:33:04 +00:00
parent e1de6d016d
commit 56c620473f
8 changed files with 387 additions and 47 deletions

View File

@@ -28,7 +28,11 @@ compose-log:
docker-compose --env-file=stack.env logs -f --names docker-compose --env-file=stack.env logs -f --names
test: 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: migrate:
docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate" docker-compose --env-file=stack.env run --rm app sh -c ". /venv/bin/activate && python manage.py migrate"

View 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),
),
]

View File

@@ -2045,6 +2045,7 @@ class ChatTaskSource(models.Model):
related_name="chat_sources", related_name="chat_sources",
) )
enabled = models.BooleanField(default=True) enabled = models.BooleanField(default=True)
settings = models.JSONField(default=dict, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)

View File

@@ -87,7 +87,7 @@ def _normalize_flags(raw: dict | None) -> dict:
"allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")), "allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")),
"completion_enabled": _to_bool(row.get("completion_enabled"), True), "completion_enabled": _to_bool(row.get("completion_enabled"), True),
"ai_title_enabled": _to_bool(row.get("ai_title_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)), "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: if "ai_title_enabled" in row:
out["ai_title_enabled"] = _to_bool(row.get("ai_title_enabled"), True) out["ai_title_enabled"] = _to_bool(row.get("ai_title_enabled"), True)
if "announce_task_id" in row: 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: if "min_chars" in row:
out["min_chars"] = max(1, int(row.get("min_chars") or 3)) out["min_chars"] = max(1, int(row.get("min_chars") or 3))
return out return out
@@ -327,7 +327,7 @@ async def process_inbound_task_intelligence(message: Message) -> None:
payload={"origin_text": text}, payload={"origin_text": text},
) )
await _emit_sync_event(task, event, "create") await _emit_sync_event(task, event, "create")
if bool(flags.get("announce_task_id", True)): if bool(flags.get("announce_task_id", False)):
try: try:
await send_message_raw( await send_message_raw(
message.source_service or "web", message.source_service or "web",

View File

@@ -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>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>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>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>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>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> <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"> <article class="box">
<h2 class="title is-6">Quick Setup For Current Chat</h2> <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> <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"> <form method="post">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="action" value="quick_setup"> <input type="hidden" name="action" value="quick_setup">
<input type="hidden" name="service" value="{{ prefill_service }}"> <input type="hidden" name="service" value="{{ prefill_service }}">
<input type="hidden" name="channel_identifier" value="{{ prefill_identifier }}"> <input type="hidden" name="channel_identifier" value="{{ prefill_identifier }}">
<input type="hidden" name="prefill_service" value="{{ prefill_service }}"> <input type="hidden" name="prefill_service" value="{{ prefill_service }}">
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}"> <input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
<div class="columns"> <div class="columns tasks-settings-inline-columns">
<div class="column"> <div class="column">
<label class="label is-size-7">Project</label> <label class="label is-size-7">Project</label>
<input class="input is-small" name="project_name" placeholder="Project name"> <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"><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_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_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> <button class="button is-small is-link" type="submit" style="margin-left: 0.75rem;">Apply Quick Setup</button>
</form> </form>
</article> </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"><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="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="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> <button class="button is-small is-link" type="submit">Add Project</button>
</form> </form>
<ul class="tasks-settings-list"> <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> </ul>
</article> </article>
</div> </div>
@@ -139,7 +152,7 @@
<input type="hidden" name="action" value="source_create"> <input type="hidden" name="action" value="source_create">
<input type="hidden" name="prefill_service" value="{{ prefill_service }}"> <input type="hidden" name="prefill_service" value="{{ prefill_service }}">
<input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}"> <input type="hidden" name="prefill_identifier" value="{{ prefill_identifier }}">
<div class="columns"> <div class="columns tasks-settings-inline-columns">
<div class="column"> <div class="column">
<label class="label is-size-7">Service</label> <label class="label is-size-7">Service</label>
<div class="select is-small is-fullwidth"> <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> <button class="button is-small is-link" type="submit" style="margin-top: 1.8rem;">Add</button>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns tasks-settings-inline-columns">
<div class="column"> <div class="column">
<label class="label is-size-7">Match Mode</label> <label class="label is-size-7">Match Mode</label>
<div class="select is-small is-fullwidth"> <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_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_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_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> </form>
<table class="table is-fullwidth is-size-7"> <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> <tbody>
{% for row in sources %} {% 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 %} {% empty %}
<tr><td colspan="3">No mappings.</td></tr> <tr><td colspan="5">No mappings.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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"><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="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="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> <button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Project Flags</button>
</form> </form>
</article> </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_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_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_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> <button class="button is-small is-link is-light" type="submit" style="margin-left: 0.75rem;">Save Channel Flags</button>
</form> </form>
</article> </article>
@@ -376,6 +395,18 @@
display: flex; display: flex;
flex-direction: column; 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 { .tasks-settings-page .tasks-settings-list {
margin-top: 0.75rem; margin-top: 0.75rem;
} }

View 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()

View File

@@ -41,6 +41,7 @@ from core.models import (
Message, Message,
MessageEvent, MessageEvent,
PatternMitigationPlan, PatternMitigationPlan,
ChatTaskSource,
Person, Person,
PersonIdentifier, PersonIdentifier,
PlatformChatLink, PlatformChatLink,
@@ -1787,9 +1788,66 @@ def _command_options_for_channel(user, service: str, identifier: str) -> list[di
"profile_enabled": bool(profile.enabled), "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 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): def _compose_urls(service, identifier, person_id):
service_key = _default_service(service) service_key = _default_service(service)
identifier_value = str(identifier or "").strip() identifier_value = str(identifier or "").strip()
@@ -3314,13 +3372,21 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
"yes", "yes",
"on", "on",
} }
ok, error = _toggle_command_for_channel( if slug == "task_announce":
user=request.user, ok, error = _toggle_task_announce_for_channel(
service=service, user=request.user,
identifier=channel_identifier, service=service,
slug=slug, identifier=channel_identifier,
enabled=enabled, enabled=enabled,
) )
else:
ok, error = _toggle_command_for_channel(
user=request.user,
service=service,
identifier=channel_identifier,
slug=slug,
enabled=enabled,
)
if not ok: if not ok:
return JsonResponse( return JsonResponse(
{ {
@@ -3347,7 +3413,11 @@ class ComposeToggleCommand(LoginRequiredMixin, View):
"slug": slug, "slug": slug,
"enabled": bool(enabled), "enabled": bool(enabled),
"command_options": command_options, "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")
),
} }
) )

View File

@@ -26,6 +26,17 @@ from core.models import (
) )
from core.tasks.providers.mock import get_provider 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: def _to_bool(raw, default=False) -> bool:
if raw is None: if raw is None:
@@ -50,32 +61,73 @@ def _parse_prefixes(value: str) -> list[str]:
return rows or ["task:", "todo:"] 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: def _flags_from_post(request, prefix: str = "") -> dict:
key = lambda name: f"{prefix}{name}" if prefix else name key = lambda name: f"{prefix}{name}" if prefix else name
defaults = dict(SAFE_TASK_FLAGS_DEFAULTS)
return { return {
"derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), True), "derive_enabled": _to_bool(request.POST.get(key("derive_enabled")), defaults["derive_enabled"]),
"match_mode": str(request.POST.get(key("match_mode")) or "strict").strip().lower() or "strict", "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")), True), "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 "")), "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")), True), "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")), True), "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")), True), "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 "3").strip() or "3")), "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: def _flags_with_defaults(raw: dict | None) -> dict:
row = dict(raw or {}) return _normalized_safe_flags(raw)
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)),
}
def _settings_redirect(request): def _settings_redirect(request):
@@ -277,6 +329,7 @@ class TaskSettings(LoginRequiredMixin, View):
template_name = "pages/tasks-settings.html" template_name = "pages/tasks-settings.html"
def _context(self, request): def _context(self, request):
_apply_safe_defaults_for_user(request.user)
prefill_service = str(request.GET.get("service") or "").strip().lower() prefill_service = str(request.GET.get("service") or "").strip().lower()
prefill_identifier = str(request.GET.get("identifier") or "").strip() prefill_identifier = str(request.GET.get("identifier") or "").strip()
projects = list(TaskProject.objects.filter(user=request.user).order_by("name")) projects = list(TaskProject.objects.filter(user=request.user).order_by("name"))