Files
GIA/core/views/tasks.py

1456 lines
54 KiB
Python

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: <Epic name> (or .epic <Epic name>)\n"
"- add task to epic: task: <description> [epic:<Epic name>]\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"})