From 56c620473f303ee0d685f0d88dfb5b7f913d833c Mon Sep 17 00:00:00 2001 From: Mark Veidemanis Date: Mon, 2 Mar 2026 13:33:04 +0000 Subject: [PATCH] Improve tasks and align page elements --- Makefile | 6 +- .../0030_chattasksource_settings.py | 18 ++ core/models.py | 1 + core/tasks/engine.py | 6 +- core/templates/pages/tasks-settings.html | 63 +++++-- core/tests/test_tasks_settings_and_toggle.py | 163 ++++++++++++++++++ core/views/compose.py | 86 ++++++++- core/views/tasks.py | 91 ++++++++-- 8 files changed, 387 insertions(+), 47 deletions(-) create mode 100644 core/migrations/0030_chattasksource_settings.py create mode 100644 core/tests/test_tasks_settings_and_toggle.py diff --git a/Makefile b/Makefile index 4f7ae52..1cec80d 100644 --- a/Makefile +++ b/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" diff --git a/core/migrations/0030_chattasksource_settings.py b/core/migrations/0030_chattasksource_settings.py new file mode 100644 index 0000000..850c734 --- /dev/null +++ b/core/migrations/0030_chattasksource_settings.py @@ -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), + ), + ] diff --git a/core/models.py b/core/models.py index 8ff5630..d4a98e0 100644 --- a/core/models.py +++ b/core/models.py @@ -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) diff --git a/core/tasks/engine.py b/core/tasks/engine.py index 23fdb9b..8b23d9c 100644 --- a/core/tasks/engine.py +++ b/core/tasks/engine.py @@ -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", diff --git a/core/templates/pages/tasks-settings.html b/core/templates/pages/tasks-settings.html index da7b2c1..06a4461 100644 --- a/core/templates/pages/tasks-settings.html +++ b/core/templates/pages/tasks-settings.html @@ -13,7 +13,8 @@

Group Mapping: binds a chat channel (service + channel identifier) to a project and optional epic. Task extraction only runs where mappings exist.

Matching Hierarchy: channel mapping flags override project flags. Project flags are defaults; mapping flags are per-chat precision controls.

False-Positive Controls: defaults are safe: match_mode=strict, require_prefix=true, and prefixes task:/todo:. Freeform matching is off by default.

-

Task ID Announcements: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example #17).

+

Task ID Announcements: when enabled, newly derived tasks post an in-chat confirmation containing the new task reference (for example #17). Default is off.

+

Legacy Backfill: opening this page applies safe defaults to older project and mapping rows created before strict prefix-only matching.

Completion Phrases: explicit trigger words used to detect completion markers like done #12, completed #12, fixed #12.

Provider: external sync adapter toggle. In current setup, mock provider validates append-only sync flow and retry behavior.

Sync Event Log: audit of provider sync attempts and outcomes. Retry replays the event without mutating immutable task source records.

@@ -24,14 +25,14 @@

Quick Setup For Current Chat

Prefilled from compose for {{ prefill_service }} · {{ prefill_identifier }}. Create/update project + epic + channel mapping in one step.

-
- {% csrf_token %} - + + {% csrf_token %} + -
+
@@ -58,7 +59,7 @@ - +
@@ -95,11 +96,23 @@ - + @@ -139,7 +152,7 @@ -
+
@@ -176,7 +189,7 @@
-
+
@@ -200,15 +213,21 @@ - + - + {% for row in sources %} - + + + + + + + {% empty %} - + {% endfor %}
ChatProjectEpic
ChatProjectEpicMatchAnnounce
{{ row.service }} · {{ row.channel_identifier }}{{ row.project.name }}{{ row.epic.name }}
{{ row.service }} · {{ row.channel_identifier }}{{ row.project.name }}{{ row.epic.name }}{{ row.settings_effective.match_mode }}{% if row.settings_effective.require_prefix %} +prefix{% endif %}{{ row.settings_effective.announce_task_id }}
No mappings.
No mappings.
@@ -255,7 +274,7 @@ - + @@ -300,7 +319,7 @@ - + @@ -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; } diff --git a/core/tests/test_tasks_settings_and_toggle.py b/core/tests/test_tasks_settings_and_toggle.py new file mode 100644 index 0000000..6d36d23 --- /dev/null +++ b/core/tests/test_tasks_settings_and_toggle.py @@ -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() diff --git a/core/views/compose.py b/core/views/compose.py index f92e8a9..b70868f 100644 --- a/core/views/compose.py +++ b/core/views/compose.py @@ -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") + ), } ) diff --git a/core/views/tasks.py b/core/views/tasks.py index 4535a26..c096670 100644 --- a/core/views/tasks.py +++ b/core/views/tasks.py @@ -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"))