Implement tasks
This commit is contained in:
@@ -2427,7 +2427,19 @@ def _panel_context(
|
||||
"compose_quick_insights_url": reverse("compose_quick_insights"),
|
||||
"compose_history_sync_url": reverse("compose_history_sync"),
|
||||
"compose_toggle_command_url": reverse("compose_toggle_command"),
|
||||
"compose_answer_suggestion_send_url": reverse("compose_answer_suggestion_send"),
|
||||
"compose_ws_url": ws_url,
|
||||
"tasks_hub_url": reverse("tasks_hub"),
|
||||
"tasks_group_url": reverse(
|
||||
"tasks_group",
|
||||
kwargs={
|
||||
"service": base["service"],
|
||||
"identifier": base["identifier"] or "_",
|
||||
},
|
||||
),
|
||||
"tasks_settings_scoped_url": (
|
||||
f"{reverse('tasks_settings')}?{urlencode({'service': base['service'], 'identifier': base['identifier'] or ''})}"
|
||||
),
|
||||
"ai_workspace_url": (
|
||||
f"{reverse('ai_workspace')}?person={base['person'].id}"
|
||||
if base["person"]
|
||||
|
||||
461
core/views/tasks.py
Normal file
461
core/views/tasks.py
Normal file
@@ -0,0 +1,461 @@
|
||||
from __future__ import annotations
|
||||
|
||||
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.models import Count
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views import View
|
||||
|
||||
from core.clients.transport import send_message_raw
|
||||
from core.models import (
|
||||
AnswerSuggestionEvent,
|
||||
ChatTaskSource,
|
||||
DerivedTask,
|
||||
DerivedTaskEvent,
|
||||
ExternalSyncEvent,
|
||||
TaskCompletionPattern,
|
||||
TaskEpic,
|
||||
TaskProject,
|
||||
TaskProviderConfig,
|
||||
PersonIdentifier,
|
||||
PlatformChatLink,
|
||||
)
|
||||
from core.tasks.providers.mock import get_provider
|
||||
|
||||
|
||||
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 _flags_from_post(request, prefix: str = "") -> dict:
|
||||
key = lambda name: f"{prefix}{name}" if prefix else name
|
||||
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")),
|
||||
}
|
||||
|
||||
|
||||
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)),
|
||||
}
|
||||
|
||||
|
||||
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 _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 _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":
|
||||
group_identifier = f"{bare_identifier}@g.us" if bare_identifier else ""
|
||||
if group_identifier and group_identifier not in variants:
|
||||
variants.append(group_identifier)
|
||||
|
||||
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)
|
||||
)
|
||||
elif service_key == "whatsapp" and bare_identifier and not raw_identifier.endswith("@g.us"):
|
||||
display_identifier = f"{bare_identifier}@g.us"
|
||||
|
||||
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 get(self, request):
|
||||
projects = TaskProject.objects.filter(user=request.user).annotate(
|
||||
task_count=Count("derived_tasks")
|
||||
).order_by("name")
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user)
|
||||
.select_related("project", "epic")
|
||||
.order_by("-created_at")[:200]
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"projects": projects,
|
||||
"tasks": tasks,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskProjectDetail(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-project.html"
|
||||
|
||||
def get(self, request, project_id):
|
||||
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(user=request.user, project=project)
|
||||
.select_related("epic")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
epics = TaskEpic.objects.filter(project=project).order_by("name")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"project": project,
|
||||
"tasks": tasks,
|
||||
"epics": epics,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
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()])
|
||||
mappings = ChatTaskSource.objects.filter(
|
||||
user=request.user,
|
||||
service=channel["service_key"],
|
||||
channel_identifier__in=variants,
|
||||
).select_related("project", "epic")
|
||||
tasks = (
|
||||
DerivedTask.objects.filter(
|
||||
user=request.user,
|
||||
source_service=channel["service_key"],
|
||||
source_channel__in=variants,
|
||||
)
|
||||
.select_related("project", "epic")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
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"],
|
||||
"mappings": mappings,
|
||||
"tasks": tasks,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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"),
|
||||
id=task_id,
|
||||
user=request.user,
|
||||
)
|
||||
events = task.events.select_related("source_message").order_by("-created_at")
|
||||
sync_events = task.external_sync_events.order_by("-created_at")
|
||||
return render(
|
||||
request,
|
||||
self.template_name,
|
||||
{
|
||||
"task": task,
|
||||
"events": events,
|
||||
"sync_events": sync_events,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class TaskSettings(LoginRequiredMixin, View):
|
||||
template_name = "pages/tasks-settings.html"
|
||||
|
||||
def _context(self, request):
|
||||
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"])
|
||||
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"),
|
||||
"provider_configs": TaskProviderConfig.objects.filter(user=request.user).order_by("provider"),
|
||||
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
|
||||
"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)
|
||||
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"),
|
||||
)
|
||||
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)
|
||||
ChatTaskSource.objects.create(
|
||||
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_"),
|
||||
)
|
||||
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 = ChatTaskSource.objects.get_or_create(
|
||||
user=request.user,
|
||||
service=service,
|
||||
channel_identifier=channel_identifier,
|
||||
project=project,
|
||||
defaults={
|
||||
"epic": epic,
|
||||
"enabled": True,
|
||||
"settings": _flags_from_post(request, prefix="source_"),
|
||||
},
|
||||
)
|
||||
if not created:
|
||||
source.project = project
|
||||
source.epic = epic
|
||||
source.enabled = True
|
||||
source.settings = _flags_from_post(request, prefix="source_")
|
||||
source.save(update_fields=["project", "epic", "enabled", "settings", "updated_at"])
|
||||
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 == "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"
|
||||
row, _ = TaskProviderConfig.objects.get_or_create(
|
||||
user=request.user,
|
||||
provider=provider,
|
||||
defaults={"enabled": False, "settings": {}},
|
||||
)
|
||||
row.enabled = bool(request.POST.get("enabled"))
|
||||
row.save(update_fields=["enabled", "updated_at"])
|
||||
return _settings_redirect(request)
|
||||
|
||||
if action == "sync_retry":
|
||||
event = get_object_or_404(ExternalSyncEvent, id=request.POST.get("event_id"), user=request.user)
|
||||
provider = get_provider(event.provider)
|
||||
payload = dict(event.payload or {})
|
||||
result = provider.append_update({}, payload)
|
||||
event.status = "ok" if result.ok else "failed"
|
||||
event.error = str(result.error or "")
|
||||
event.payload = dict(payload, retried=True)
|
||||
event.save(update_fields=["status", "error", "payload", "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"})
|
||||
Reference in New Issue
Block a user