from __future__ import annotations import datetime import json from urllib.parse import urlencode from asgiref.sync import async_to_sync from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db import IntegrityError from django.db.models import Count from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views import View from core.clients.transport import send_message_raw from core.models import ( AnswerSuggestionEvent, Chat, ChatTaskSource, DerivedTask, Person, PersonIdentifier, PlatformChatLink, TaskCompletionPattern, TaskEpic, TaskProject, TaskProviderConfig, ) from core.tasks.chat_defaults import ( SAFE_TASK_FLAGS_DEFAULTS, ensure_default_source_for_chat, normalize_channel_identifier, ) from core.tasks.engine import create_task_record_and_sync def _upsert_task_source( *, user, service: str, channel_identifier: str, project, epic=None, enabled: bool = True, settings: dict | None = None, ): service_key = str(service or "").strip().lower() normalized_identifier = normalize_channel_identifier(service_key, channel_identifier) if not service_key or not normalized_identifier: return None, False source, created = ChatTaskSource.objects.get_or_create( user=user, service=service_key, channel_identifier=normalized_identifier, defaults={ "project": project, "epic": epic, "enabled": bool(enabled), "settings": dict(settings or {}), }, ) changed_fields = [] if source.project_id != getattr(project, "id", None): source.project = project changed_fields.append("project") if source.epic_id != getattr(epic, "id", None): source.epic = epic changed_fields.append("epic") if bool(source.enabled) != bool(enabled): source.enabled = bool(enabled) changed_fields.append("enabled") settings_payload = dict(settings or {}) if dict(source.settings or {}) != settings_payload: source.settings = settings_payload changed_fields.append("settings") if changed_fields: source.save(update_fields=changed_fields + ["updated_at"]) return source, created def _to_bool(raw, default=False) -> bool: if raw is None: return bool(default) value = str(raw).strip().lower() if value in {"1", "true", "yes", "on", "y"}: return True if value in {"0", "false", "no", "off", "n"}: return False return bool(default) def _parse_prefixes(value: str) -> list[str]: text = str(value or "").strip() if not text: return ["task:", "todo:"] rows = [] for row in text.split(","): item = str(row or "").strip().lower() if item and item not in rows: rows.append(item) 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 key(name: str) -> str: return f"{prefix}{name}" if prefix else name defaults = dict(SAFE_TASK_FLAGS_DEFAULTS) return { "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: return _normalized_safe_flags(raw) def _settings_redirect(request): service = str( request.POST.get("prefill_service") or request.GET.get("service") or "" ).strip() identifier = str( request.POST.get("prefill_identifier") or request.GET.get("identifier") or "" ).strip() if service and identifier: return redirect( f"{request.path}?{urlencode({'service': service, 'identifier': identifier})}" ) return redirect("tasks_settings") def _ensure_default_completion_patterns(user) -> None: defaults = ("done", "completed", "fixed") existing = set( str(row or "").strip().lower() for row in TaskCompletionPattern.objects.filter(user=user).values_list( "phrase", flat=True ) ) next_pos = TaskCompletionPattern.objects.filter(user=user).count() for phrase in defaults: if phrase in existing: continue TaskCompletionPattern.objects.create( user=user, phrase=phrase, enabled=True, position=next_pos, ) next_pos += 1 def _service_label(service: str) -> str: key = str(service or "").strip().lower() labels = { "signal": "Signal", "whatsapp": "WhatsApp", "instagram": "Instagram", "xmpp": "XMPP", "web": "Web", } return labels.get(key, key.title() if key else "Unknown") def _format_task_event_payload(raw_payload): payload = raw_payload if payload is None: payload = {} if isinstance(payload, str): text = payload.strip() if not text: return { "summary_items": [], "pretty_text": "{}", "is_mapping": False, } try: parsed = json.loads(text) payload = parsed except Exception: return { "summary_items": [("text", text[:140])], "pretty_text": text, "is_mapping": False, } if isinstance(payload, dict): summary = [] preferred = ( "source", "reason", "reaction", "emoji", "presence", "last_seen_ts", ) for key in preferred: if key in payload: summary.append((key, str(payload.get(key)))) if not summary: for key in list(payload.keys())[:4]: summary.append((str(key), str(payload.get(key)))) pretty = json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True) return { "summary_items": summary, "pretty_text": pretty, "is_mapping": True, } if isinstance(payload, (list, tuple)): pretty = json.dumps(list(payload), indent=2, ensure_ascii=False) return { "summary_items": [("items", str(len(payload)))], "pretty_text": pretty, "is_mapping": False, } text = str(payload) return { "summary_items": [("value", text[:140])], "pretty_text": text, "is_mapping": False, } def _creator_label_for_message(user, service: str, message) -> str: msg = message if msg is None: return "Unknown" author_raw = str(getattr(msg, "custom_author", "") or "").strip() author_key = author_raw.upper() sender_identifier = str(getattr(msg, "sender_uuid", "") or "").strip() if author_key == "USER": return "You" if author_key == "BOT": return "System Bot" if sender_identifier: variants = _person_identifier_scope_variants(service, sender_identifier) person_identifier = ( PersonIdentifier.objects.filter( user=user, service=str(service or "").strip().lower(), identifier__in=variants or [sender_identifier], ) .select_related("person") .first() ) if person_identifier is not None: person_name = str( getattr(person_identifier.person, "name", "") or "" ).strip() if person_name: return person_name return sender_identifier if author_raw: if author_key == "OTHER": return "Other Participant" return author_raw return "Unknown" def _apply_task_creator_labels(user, task_rows): rows = list(task_rows or []) person_identifier_cache: dict[tuple[str, str], PersonIdentifier | None] = {} def _resolve_person_identifier(service_key: str, sender_identifier: str): key = ( str(service_key or "").strip().lower(), str(sender_identifier or "").strip(), ) if key in person_identifier_cache: return person_identifier_cache[key] variants = _person_identifier_scope_variants(key[0], key[1]) row = ( PersonIdentifier.objects.filter( user=user, service=key[0], identifier__in=variants or [key[1]], ) .select_related("person") .first() ) person_identifier_cache[key] = row return row for row in rows: origin = getattr(row, "origin_message", None) service_key = str(getattr(row, "source_service", "") or "").strip().lower() sender_identifier = str(getattr(origin, "sender_uuid", "") or "").strip() row.creator_label = _creator_label_for_message(user, service_key, origin) row.creator_identifier = sender_identifier row.creator_compose_href = "" if sender_identifier and service_key: person_identifier = _resolve_person_identifier( service_key, sender_identifier ) compose_service = service_key compose_identifier = sender_identifier compose_person_id = "" if person_identifier is not None: compose_identifier = ( str(getattr(person_identifier, "identifier", "") or "").strip() or sender_identifier ) compose_person_id = str( getattr(person_identifier, "person_id", "") or "" ) query = { "service": compose_service, "identifier": compose_identifier, } if compose_person_id: query["person"] = compose_person_id row.creator_compose_href = f"{reverse('compose_page')}?{urlencode(query)}" return rows def _provider_row_map(user): return { str(row.provider or "").strip().lower(): row for row in TaskProviderConfig.objects.filter(user=user).order_by("provider") } def _upsert_group_source( *, user, service: str, channel_identifier: str, project, epic=None ): normalized_service = str(service or "").strip().lower() normalized_identifier = normalize_channel_identifier(service, channel_identifier) if not normalized_service or not normalized_identifier: return None source, created = ChatTaskSource.objects.get_or_create( user=user, service=normalized_service, channel_identifier=normalized_identifier, project=project, defaults={ "epic": epic, "enabled": True, "settings": _flags_with_defaults({}), }, ) if not created: next_fields = [] if source.epic_id != getattr(epic, "id", None): source.epic = epic next_fields.append("epic") if not source.enabled: source.enabled = True next_fields.append("enabled") if next_fields: next_fields.append("updated_at") source.save(update_fields=next_fields) return source def _notify_epic_created_in_project_chats( *, project: TaskProject, epic: TaskEpic ) -> None: rows = ( ChatTaskSource.objects.filter(project=project, enabled=True) .order_by("service", "channel_identifier") .values_list("service", "channel_identifier") ) seen: set[tuple[str, str]] = set() for service, channel_identifier in rows: svc = str(service or "").strip().lower() chan = str(channel_identifier or "").strip() if not svc or not chan: continue key = (svc, chan) if key in seen: continue seen.add(key) try: async_to_sync(send_message_raw)( svc, chan, text=( f"[epic] Created '{epic.name}' in project '{project.name}'.\n" "WhatsApp usage:\n" "- create epic: epic: (or .epic )\n" "- add task to epic: task: [epic:]\n" "- list tasks: .l list tasks\n" "- undo latest task: .undo" ), attachments=[], metadata={"origin": "task_epic_announce"}, ) except Exception: continue def _reseed_chat_sources_for_deleted_project( user, service_channel_rows: list[tuple[str, str]] ) -> int: restored = 0 seen: set[tuple[str, str]] = set() for service, channel_identifier in service_channel_rows: service_key = str(service or "").strip().lower() channel = str(channel_identifier or "").strip() if not service_key or not channel: continue pair = (service_key, channel) if pair in seen: continue seen.add(pair) source = ensure_default_source_for_chat( user=user, service=service_key, channel_identifier=channel, ) if source is not None: restored += 1 return restored def _person_identifier_scope_variants(service: str, identifier: str) -> list[str]: service_key = str(service or "").strip().lower() raw_identifier = str(identifier or "").strip() if not service_key or not raw_identifier: return [] variants: list[str] = [raw_identifier] bare_identifier = raw_identifier.split("@", 1)[0].strip() if bare_identifier and bare_identifier not in variants: variants.append(bare_identifier) if service_key == "whatsapp" and bare_identifier: group_identifier = f"{bare_identifier}@g.us" direct_identifier = f"{bare_identifier}@s.whatsapp.net" if group_identifier not in variants: variants.append(group_identifier) if direct_identifier not in variants: variants.append(direct_identifier) if service_key == "signal": digits = "".join(ch for ch in raw_identifier if ch.isdigit()) if digits and digits not in variants: variants.append(digits) if digits: plus_variant = f"+{digits}" if plus_variant not in variants: variants.append(plus_variant) return [row for row in variants if row] def _resolve_channel_display(user, service: str, identifier: str) -> dict: service_key = str(service or "").strip().lower() raw_identifier = str(identifier or "").strip() bare_identifier = raw_identifier.split("@", 1)[0].strip() variants = [raw_identifier] if bare_identifier and bare_identifier not in variants: variants.append(bare_identifier) if service_key == "whatsapp": direct_identifier = ( raw_identifier if raw_identifier.endswith("@s.whatsapp.net") else "" ) if direct_identifier and direct_identifier not in variants: variants.append(direct_identifier) if bare_identifier: direct_bare = f"{bare_identifier}@s.whatsapp.net" if direct_bare not in variants: variants.append(direct_bare) group_identifier = f"{bare_identifier}@g.us" if bare_identifier else "" if group_identifier and group_identifier not in variants: variants.append(group_identifier) if service_key == "signal": digits = "".join(ch for ch in raw_identifier if ch.isdigit()) if digits and digits not in variants: variants.append(digits) if digits: plus = f"+{digits}" if plus not in variants: variants.append(plus) if raw_identifier: companion_numbers = list( Chat.objects.filter(source_uuid=raw_identifier) .exclude(source_number__isnull=True) .exclude(source_number="") .values_list("source_number", flat=True)[:200] ) companion_uuids = list( Chat.objects.filter(source_number=raw_identifier) .exclude(source_uuid__isnull=True) .exclude(source_uuid="") .values_list("source_uuid", flat=True)[:200] ) for candidate in companion_numbers + companion_uuids: candidate_str = str(candidate or "").strip() if not candidate_str: continue if candidate_str not in variants: variants.append(candidate_str) candidate_digits = "".join(ch for ch in candidate_str if ch.isdigit()) if candidate_digits and candidate_digits not in variants: variants.append(candidate_digits) if candidate_digits: plus_variant = f"+{candidate_digits}" if plus_variant not in variants: variants.append(plus_variant) group_link = None if bare_identifier: group_link = ( PlatformChatLink.objects.filter( user=user, service=service_key, chat_identifier=bare_identifier, is_group=True, ) .order_by("-id") .first() ) person_identifier = ( PersonIdentifier.objects.filter( user=user, service=service_key, identifier__in=variants, ) .select_related("person") .order_by("-id") .first() ) display_name = "" if group_link and str(group_link.chat_name or "").strip(): display_name = str(group_link.chat_name or "").strip() elif person_identifier and person_identifier.person_id: display_name = str(person_identifier.person.name or "").strip() if not display_name: display_name = raw_identifier or bare_identifier or "Unknown chat" display_identifier = raw_identifier if group_link: display_identifier = str(group_link.chat_jid or "").strip() or ( f"{bare_identifier}@g.us" if bare_identifier else raw_identifier ) return { "service_key": service_key, "service_label": _service_label(service_key), "display_name": display_name, "display_identifier": display_identifier or raw_identifier, "variants": [row for row in variants if row], } class TasksHub(LoginRequiredMixin, View): template_name = "pages/tasks-hub.html" def _scope(self, request): person_id = str( request.GET.get("person") or request.POST.get("person") or "" ).strip() person = None if person_id: person = Person.objects.filter(user=request.user, id=person_id).first() return { "person": person, "person_id": str(getattr(person, "id", "") or ""), "service": str( request.GET.get("service") or request.POST.get("service") or "" ) .strip() .lower(), "identifier": str( request.GET.get("identifier") or request.POST.get("identifier") or "" ).strip(), "selected_project_id": str( request.GET.get("project") or request.POST.get("project_id") or "" ).strip(), } def _context(self, request): scope = self._scope(request) show_empty = bool( str(request.GET.get("show_empty") or "").strip() in {"1", "true", "yes", "on"} ) all_projects = ( TaskProject.objects.filter(user=request.user) .annotate( task_count=Count("derived_tasks"), epic_count=Count("epics", distinct=True), source_count=Count("chat_sources", distinct=True), ) .order_by("name") ) projects = all_projects if show_empty else all_projects.filter(task_count__gt=0) tasks = ( DerivedTask.objects.filter(user=request.user) .select_related("project", "epic", "origin_message") .order_by("-created_at")[:200] ) tasks = _apply_task_creator_labels(request.user, tasks) selected_project = None if scope["selected_project_id"]: selected_project = all_projects.filter( id=scope["selected_project_id"] ).first() person_identifiers = [] person_identifier_rows = [] if scope["person"] is not None: person_identifiers = list( PersonIdentifier.objects.filter( user=request.user, person=scope["person"], ).order_by("service", "identifier") ) mapping_pairs = set( ChatTaskSource.objects.filter(user=request.user).values_list( "project_id", "service", "channel_identifier" ) ) for row in person_identifiers: mapped = False if selected_project is not None: mapped = ( selected_project.id, str(row.service or "").strip(), str(row.identifier or "").strip(), ) in mapping_pairs person_identifier_rows.append( { "id": row.id, "identifier": str(row.identifier or "").strip(), "service": str(row.service or "").strip(), "mapped": mapped, } ) return { "projects": projects, "project_choices": all_projects, "epic_choices": TaskEpic.objects.filter( project__user=request.user ).select_related("project").order_by("project__name", "name"), "tasks": tasks, "scope": scope, "person_identifier_rows": person_identifier_rows, "selected_project": selected_project, "show_empty_projects": show_empty, } def get(self, request): return render(request, self.template_name, self._context(request)) def post(self, request): action = str(request.POST.get("action") or "").strip().lower() if action == "project_create": name = str(request.POST.get("name") or "").strip() if not name: messages.error(request, "Project name is required.") return redirect("tasks_hub") scope = self._scope(request) external_key = str(request.POST.get("external_key") or "").strip() try: project, created = TaskProject.objects.get_or_create( user=request.user, name=name, defaults={"external_key": external_key}, ) except IntegrityError: messages.error( request, "Could not create project due to duplicate name." ) return redirect("tasks_hub") if created: messages.success(request, f"Created project '{project.name}'.") else: messages.info(request, f"Project '{project.name}' already exists.") query = {} if scope["person_id"]: query["person"] = scope["person_id"] if scope["service"]: query["service"] = scope["service"] if scope["identifier"]: query["identifier"] = scope["identifier"] query["project"] = str(project.id) if query: return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}") return redirect("tasks_hub") if action == "task_create": project = get_object_or_404( TaskProject, user=request.user, id=request.POST.get("project_id"), ) epic = None epic_id = str(request.POST.get("epic_id") or "").strip() if epic_id: epic = get_object_or_404(TaskEpic, id=epic_id, project=project) title = str(request.POST.get("title") or "").strip() if not title: messages.error(request, "Task title is required.") return redirect("tasks_hub") scope = self._scope(request) source_service = str(scope.get("service") or "").strip().lower() or "web" source_channel = str(scope.get("identifier") or "").strip() due_raw = str(request.POST.get("due_date") or "").strip() due_date = None if due_raw: try: due_date = datetime.date.fromisoformat(due_raw) except Exception: messages.error(request, "Due date must be YYYY-MM-DD.") return redirect("tasks_hub") task, _event = async_to_sync(create_task_record_and_sync)( user=request.user, project=project, epic=epic, title=title, source_service=source_service, source_channel=source_channel, actor_identifier=str(request.user.username or request.user.id), due_date=due_date, assignee_identifier=str( request.POST.get("assignee_identifier") or "" ).strip(), immutable_payload={ "source": "tasks_hub_manual_create", "person_id": scope["person_id"], "service": source_service, "identifier": source_channel, }, event_payload={ "source": "tasks_hub_manual_create", "via": "web_ui", }, ) messages.success( request, f"Created task #{task.reference_code} in '{project.name}'.", ) return redirect("tasks_task", task_id=str(task.id)) if action == "project_map_identifier": project = get_object_or_404( TaskProject, user=request.user, id=request.POST.get("project_id"), ) identifier_row = get_object_or_404( PersonIdentifier, user=request.user, id=request.POST.get("person_identifier_id"), ) _upsert_group_source( user=request.user, service=identifier_row.service, channel_identifier=identifier_row.identifier, project=project, epic=None, ) messages.success( request, f"Mapped {identifier_row.service} ยท {identifier_row.identifier} to '{project.name}'.", ) scope = self._scope(request) query = { "project": str(project.id), } if scope["person_id"]: query["person"] = scope["person_id"] if scope["service"]: query["service"] = scope["service"] if scope["identifier"]: query["identifier"] = scope["identifier"] return redirect(f"{reverse('tasks_hub')}?{urlencode(query)}") if action == "project_delete": project = get_object_or_404( TaskProject, id=request.POST.get("project_id"), user=request.user, ) confirm_name = str(request.POST.get("confirm_name") or "").strip() expected = str(project.name or "").strip() if confirm_name != expected: messages.error( request, f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}", ) return redirect("tasks_hub") mapped_channels = list( project.chat_sources.values_list("service", "channel_identifier") ) deleted_name = str(project.name or "").strip() or "Project" project.delete() restored = _reseed_chat_sources_for_deleted_project( request.user, mapped_channels ) if restored > 0: messages.success( request, f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.", ) else: messages.success(request, f"Deleted project '{deleted_name}'.") return redirect("tasks_hub") return redirect("tasks_hub") class TaskProjectDetail(LoginRequiredMixin, View): template_name = "pages/tasks-project.html" def _context(self, request, project): tasks = ( DerivedTask.objects.filter(user=request.user, project=project) .select_related("epic", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) epics = ( TaskEpic.objects.filter(project=project) .annotate(task_count=Count("derived_tasks")) .order_by("name") ) return { "project": project, "tasks": tasks, "epics": epics, } def get(self, request, project_id): project = get_object_or_404(TaskProject, id=project_id, user=request.user) return render(request, self.template_name, self._context(request, project)) def post(self, request, project_id): project = get_object_or_404(TaskProject, id=project_id, user=request.user) action = str(request.POST.get("action") or "").strip().lower() if action == "epic_create": name = str(request.POST.get("name") or "").strip() if not name: messages.error(request, "Epic name is required.") return redirect("tasks_project", project_id=str(project.id)) external_key = str(request.POST.get("external_key") or "").strip() try: epic, created = TaskEpic.objects.get_or_create( project=project, name=name, defaults={"external_key": external_key}, ) except IntegrityError: messages.error(request, "Could not create epic due to duplicate name.") return redirect("tasks_project", project_id=str(project.id)) if created: messages.success(request, f"Created epic '{epic.name}'.") _notify_epic_created_in_project_chats(project=project, epic=epic) else: messages.info(request, f"Epic '{epic.name}' already exists.") return redirect("tasks_project", project_id=str(project.id)) if action == "epic_delete": epic = get_object_or_404( TaskEpic, id=request.POST.get("epic_id"), project=project ) deleted_name = str(epic.name or "").strip() or "Epic" epic.delete() messages.success(request, f"Deleted epic '{deleted_name}'.") return redirect("tasks_project", project_id=str(project.id)) if action == "task_set_epic": task = get_object_or_404( DerivedTask, id=request.POST.get("task_id"), user=request.user, project=project, ) epic_id = str(request.POST.get("epic_id") or "").strip() epic = None if epic_id: epic = get_object_or_404(TaskEpic, id=epic_id, project=project) task.epic = epic task.save(update_fields=["epic"]) if epic is None: messages.success( request, f"Cleared epic for task #{task.reference_code}." ) else: messages.success( request, f"Assigned task #{task.reference_code} to epic '{epic.name}'.", ) return redirect("tasks_project", project_id=str(project.id)) if action == "project_delete": confirm_name = str(request.POST.get("confirm_name") or "").strip() expected = str(project.name or "").strip() if confirm_name != expected: messages.error( request, f"Delete cancelled. Type the project name exactly to confirm deletion: {expected}", ) return redirect("tasks_project", project_id=str(project.id)) mapped_channels = list( project.chat_sources.values_list("service", "channel_identifier") ) deleted_name = str(project.name or "").strip() or "Project" project.delete() restored = _reseed_chat_sources_for_deleted_project( request.user, mapped_channels ) if restored > 0: messages.success( request, f"Deleted project '{deleted_name}'. Restored {restored} chat mapping(s) with default projects.", ) else: messages.success(request, f"Deleted project '{deleted_name}'.") return redirect("tasks_hub") return redirect("tasks_project", project_id=str(project.id)) class TaskEpicDetail(LoginRequiredMixin, View): template_name = "pages/tasks-epic.html" def get(self, request, epic_id): epic = get_object_or_404(TaskEpic, id=epic_id, project__user=request.user) tasks = ( DerivedTask.objects.filter(user=request.user, epic=epic) .select_related("project", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) return render(request, self.template_name, {"epic": epic, "tasks": tasks}) class TaskGroupDetail(LoginRequiredMixin, View): template_name = "pages/tasks-group.html" def get(self, request, service, identifier): channel = _resolve_channel_display(request.user, service, identifier) variants = list(channel.get("variants") or [str(identifier or "").strip()]) service_keys = [channel["service_key"]] if channel["service_key"] != "web": service_keys.append("web") mappings = ChatTaskSource.objects.filter( user=request.user, service__in=service_keys, channel_identifier__in=variants, ).select_related("project", "epic") mappings = list(mappings) if not mappings: seeded = ensure_default_source_for_chat( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], ) if seeded is not None: mappings = list( ChatTaskSource.objects.filter(id=seeded.id).select_related( "project", "epic" ) ) for row in mappings: row_channel = _resolve_channel_display( request.user, str(getattr(row, "service", "") or ""), str(getattr(row, "channel_identifier", "") or ""), ) row.display_service_label = row_channel.get( "service_label" ) or _service_label(str(getattr(row, "service", "") or "")) row.display_channel_name = ( str(row_channel.get("display_name") or "").strip() or str(channel.get("display_name") or "").strip() or "Unknown chat" ) tasks = ( DerivedTask.objects.filter( user=request.user, source_service__in=service_keys, source_channel__in=variants, ) .select_related("project", "epic", "origin_message") .order_by("-created_at") ) tasks = _apply_task_creator_labels(request.user, tasks) return render( request, self.template_name, { "service": channel["service_key"], "service_label": channel["service_label"], "identifier": channel["display_identifier"], "channel_display_name": channel["display_name"], "projects": TaskProject.objects.filter(user=request.user).order_by( "name" ), "mappings": mappings, "primary_project": mappings[0].project if mappings else None, "tasks": tasks, }, ) def post(self, request, service, identifier): channel = _resolve_channel_display(request.user, service, identifier) action = str(request.POST.get("action") or "").strip().lower() if action == "group_project_create": project_name = str(request.POST.get("project_name") or "").strip() if not project_name: messages.error(request, "Project name is required.") return redirect( "tasks_group", service=channel["service_key"], identifier=channel["display_identifier"], ) epic_name = str(request.POST.get("epic_name") or "").strip() project, _ = TaskProject.objects.get_or_create( user=request.user, name=project_name, ) epic = None if epic_name: epic, _ = TaskEpic.objects.get_or_create( project=project, name=epic_name ) _upsert_group_source( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], project=project, epic=epic, ) messages.success( request, f"Project '{project.name}' mapped to this group.", ) elif action == "group_map_existing_project": project = get_object_or_404( TaskProject, user=request.user, id=request.POST.get("project_id"), ) epic = None epic_id = str(request.POST.get("epic_id") or "").strip() if epic_id: epic = get_object_or_404(TaskEpic, project=project, id=epic_id) _upsert_group_source( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], project=project, epic=epic, ) messages.success(request, f"Mapped '{project.name}' to this group.") elif action == "group_project_rename": current = ( ChatTaskSource.objects.filter( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], enabled=True, ) .select_related("project") .order_by("-updated_at") .first() ) if current is None: current = ensure_default_source_for_chat( user=request.user, service=channel["service_key"], channel_identifier=channel["display_identifier"], ) new_name = str(request.POST.get("project_name") or "").strip() if current is None or current.project is None: messages.error(request, "No mapped project found for this chat.") elif not new_name: messages.error(request, "Project name is required.") elif ( TaskProject.objects.filter(user=request.user, name=new_name) .exclude(id=current.project_id) .exists() ): messages.error(request, f"Project '{new_name}' already exists.") else: current.project.name = new_name current.project.save(update_fields=["name", "updated_at"]) messages.success(request, f"Renamed project to '{new_name}'.") return redirect( "tasks_group", service=channel["service_key"], identifier=channel["display_identifier"], ) class TaskDetail(LoginRequiredMixin, View): template_name = "pages/tasks-detail.html" def get(self, request, task_id): task = get_object_or_404( DerivedTask.objects.select_related("project", "epic", "origin_message"), id=task_id, user=request.user, ) events = task.events.select_related("source_message").order_by("-created_at") for row in events: service_hint = ( str(getattr(task, "source_service", "") or "").strip().lower() ) event_source_message = getattr(row, "source_message", None) row.actor_display = _creator_label_for_message( request.user, service_hint, event_source_message, ) if row.actor_display == "Unknown": raw_actor = str(getattr(row, "actor_identifier", "") or "").strip() if raw_actor: row.actor_display = raw_actor row.payload_view = _format_task_event_payload(getattr(row, "payload", None)) task.creator_label = _creator_label_for_message( request.user, str(getattr(task, "source_service", "") or "").strip().lower(), getattr(task, "origin_message", None), ) return render( request, self.template_name, { "task": task, "events": events, }, ) class TaskSettings(LoginRequiredMixin, View): template_name = "pages/tasks-settings.html" def _context(self, request): _apply_safe_defaults_for_user(request.user) _ensure_default_completion_patterns(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")) for row in projects: row.settings_effective = _flags_with_defaults(row.settings) row.allowed_prefixes_csv = ",".join( row.settings_effective["allowed_prefixes"] ) sources = list( ChatTaskSource.objects.filter(user=request.user) .select_related("project", "epic") .order_by("service", "channel_identifier") ) for row in sources: row.settings_effective = _flags_with_defaults(row.settings) row.allowed_prefixes_csv = ",".join( row.settings_effective["allowed_prefixes"] ) provider_map = _provider_row_map(request.user) mock_cfg = provider_map.get("mock") return { "projects": projects, "epics": TaskEpic.objects.filter(project__user=request.user) .select_related("project") .order_by("project__name", "name"), "sources": sources, "patterns": TaskCompletionPattern.objects.filter( user=request.user ).order_by("position", "created_at"), "mock_provider_config": mock_cfg, "prefill_service": prefill_service, "prefill_identifier": prefill_identifier, } def get(self, request): return render(request, self.template_name, self._context(request)) def post(self, request): action = str(request.POST.get("action") or "").strip() if action == "project_create": TaskProject.objects.create( user=request.user, name=str(request.POST.get("name") or "Project").strip() or "Project", external_key=str(request.POST.get("external_key") or "").strip(), active=bool(request.POST.get("active") or "1"), settings=_flags_from_post(request), ) return _settings_redirect(request) if action == "epic_create": project = get_object_or_404( TaskProject, id=request.POST.get("project_id"), user=request.user ) epic = TaskEpic.objects.create( project=project, name=str(request.POST.get("name") or "Epic").strip() or "Epic", external_key=str(request.POST.get("external_key") or "").strip(), active=bool(request.POST.get("active") or "1"), ) _notify_epic_created_in_project_chats(project=project, epic=epic) return _settings_redirect(request) if action == "source_create": project = get_object_or_404( TaskProject, id=request.POST.get("project_id"), user=request.user ) epic = None epic_id = str(request.POST.get("epic_id") or "").strip() if epic_id: epic = get_object_or_404( TaskEpic, id=epic_id, project__user=request.user ) source, _ = _upsert_task_source( user=request.user, service=str(request.POST.get("service") or "web").strip(), channel_identifier=str( request.POST.get("channel_identifier") or "" ).strip(), project=project, epic=epic, enabled=bool(request.POST.get("enabled") or "1"), settings=_flags_from_post(request, prefix="source_"), ) if source is None: messages.error(request, "Invalid channel identifier.") return _settings_redirect(request) if action == "quick_setup": service = str(request.POST.get("service") or "web").strip().lower() or "web" channel_identifier = str( request.POST.get("channel_identifier") or "" ).strip() project_name = ( str(request.POST.get("project_name") or "").strip() or "General" ) epic_name = str(request.POST.get("epic_name") or "").strip() project, _ = TaskProject.objects.get_or_create( user=request.user, name=project_name, defaults={"settings": _flags_from_post(request)}, ) if not project.settings: project.settings = _flags_from_post(request) project.save(update_fields=["settings", "updated_at"]) epic = None if epic_name: epic, _ = TaskEpic.objects.get_or_create( project=project, name=epic_name ) if channel_identifier: source, created = _upsert_task_source( user=request.user, service=service, channel_identifier=channel_identifier, project=project, epic=epic, enabled=True, settings=_flags_from_post(request, prefix="source_"), ) if source is None: messages.error(request, "Invalid channel identifier.") return _settings_redirect(request) if action == "project_flags_update": project = get_object_or_404( TaskProject, id=request.POST.get("project_id"), user=request.user ) project.settings = _flags_from_post(request) project.save(update_fields=["settings", "updated_at"]) return _settings_redirect(request) if action == "source_flags_update": source = get_object_or_404( ChatTaskSource, id=request.POST.get("source_id"), user=request.user ) source.settings = _flags_from_post(request, prefix="source_") source.save(update_fields=["settings", "updated_at"]) return _settings_redirect(request) if action == "source_delete": source = get_object_or_404( ChatTaskSource, id=request.POST.get("source_id"), user=request.user, ) source.delete() return _settings_redirect(request) if action == "pattern_create": phrase = str(request.POST.get("phrase") or "").strip() if phrase: TaskCompletionPattern.objects.get_or_create( user=request.user, phrase=phrase, defaults={ "enabled": True, "position": TaskCompletionPattern.objects.filter( user=request.user ).count(), }, ) return _settings_redirect(request) if action == "provider_update": provider = str(request.POST.get("provider") or "mock").strip() or "mock" if provider != "mock": messages.error(request, "Only the mock task provider is available.") return _settings_redirect(request) row, _ = TaskProviderConfig.objects.get_or_create( user=request.user, provider=provider, defaults={"enabled": False, "settings": {}}, ) row.enabled = bool(request.POST.get("enabled")) row.settings = dict(row.settings or {}) row.save(update_fields=["enabled", "settings", "updated_at"]) return _settings_redirect(request) return _settings_redirect(request) class AnswerSuggestionSend(LoginRequiredMixin, View): def post(self, request): event = get_object_or_404( AnswerSuggestionEvent.objects.select_related("candidate_answer", "message"), id=request.POST.get("suggestion_id"), user=request.user, status="suggested", ) decision = str(request.POST.get("decision") or "accept").strip().lower() if decision == "dismiss": event.status = "dismissed" event.save(update_fields=["status", "updated_at"]) return JsonResponse({"ok": True, "status": "dismissed"}) text = str(getattr(event.candidate_answer, "answer_text", "") or "").strip() msg = event.message if not text: return JsonResponse( {"ok": False, "error": "empty_candidate_answer"}, status=400 ) ok = async_to_sync(send_message_raw)( msg.source_service or "web", msg.source_chat_id or "", text=text, attachments=[], metadata={"origin": "repeat_answer_suggestion"}, ) event.status = "accepted" if ok else "suggested" event.save(update_fields=["status", "updated_at"]) if not ok: messages.error(request, "Failed to send suggestion message.") return JsonResponse({"ok": False, "error": "send_failed"}, status=502) return JsonResponse({"ok": True, "status": "accepted"})