Improve tasks and backdate insights

This commit is contained in:
2026-03-03 17:21:06 +00:00
parent 9c14e51b43
commit 2898d9e832
18 changed files with 1617 additions and 264 deletions

View File

@@ -5,7 +5,9 @@ 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.urls import reverse
from django.http import JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.views import View
@@ -22,6 +24,7 @@ from core.models import (
TaskProject,
TaskProviderConfig,
PersonIdentifier,
Person,
PlatformChatLink,
Chat,
ExternalChatLink,
@@ -178,6 +181,103 @@ def _provider_row_map(user):
}
def _normalize_channel_identifier(service: str, identifier: str) -> str:
service_key = str(service or "").strip().lower()
value = str(identifier or "").strip()
if not value:
return ""
if service_key == "whatsapp":
bare = value.split("@", 1)[0].strip()
if bare:
if value.endswith("@g.us"):
return f"{bare}@g.us"
if value.endswith("@s.whatsapp.net"):
return f"{bare}@s.whatsapp.net"
return f"{bare}@g.us"
if service_key == "signal":
return value
if service_key == "xmpp":
return value
if service_key == "instagram":
return value
if service_key == "web":
return value
return value
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 _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 _scoped_person_identifier_rows(user, service: str, identifier: str):
service_key = str(service or "").strip().lower()
variants = _person_identifier_scope_variants(service_key, identifier)
if not service_key or not variants:
return PersonIdentifier.objects.none()
return (
PersonIdentifier.objects.filter(
user=user,
service=service_key,
identifier__in=variants,
)
.select_related("person")
.order_by("person__name", "service", "identifier")
)
def _resolve_channel_display(user, service: str, identifier: str) -> dict:
service_key = str(service or "").strip().lower()
raw_identifier = str(identifier or "").strip()
@@ -283,45 +383,227 @@ def _resolve_channel_display(user, service: str, identifier: str) -> dict:
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")
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)
projects = (
TaskProject.objects.filter(user=request.user)
.annotate(
task_count=Count("derived_tasks"),
epic_count=Count("epics", distinct=True),
)
.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,
},
selected_project = None
if scope["selected_project_id"]:
selected_project = TaskProject.objects.filter(
user=request.user,
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,
"tasks": tasks,
"scope": scope,
"person_identifier_rows": person_identifier_rows,
"selected_project": selected_project,
}
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 == "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,
)
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
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 get(self, request, project_id):
project = get_object_or_404(TaskProject, id=project_id, user=request.user)
def _context(self, request, project):
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,
},
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}'.")
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 == "project_delete":
deleted_name = str(project.name or "").strip() or "Project"
project.delete()
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):
@@ -368,11 +650,67 @@ class TaskGroupDetail(LoginRequiredMixin, View):
"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,
"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.")
return redirect(
"tasks_group",
service=channel["service_key"],
identifier=channel["display_identifier"],
)
class TaskDetail(LoginRequiredMixin, View):
template_name = "pages/tasks-detail.html"
@@ -425,6 +763,21 @@ class TaskSettings(LoginRequiredMixin, View):
"person", "person_identifier"
).order_by("-updated_at")[:200]
)
person_identifiers = (
PersonIdentifier.objects.filter(user=request.user)
.select_related("person")
.order_by("person__name", "service", "identifier")[:600]
)
external_link_scoped = bool(prefill_service and prefill_identifier)
external_link_scope_label = ""
external_link_person_identifiers = person_identifiers
if external_link_scoped:
external_link_scope_label = f"{prefill_service} · {prefill_identifier}"
external_link_person_identifiers = _scoped_person_identifier_rows(
request.user,
prefill_service,
prefill_identifier,
)
return {
"projects": projects,
@@ -441,7 +794,10 @@ class TaskSettings(LoginRequiredMixin, View):
"timeout_seconds": int(codex_settings.get("timeout_seconds") or 60),
"chat_link_mode": str(codex_settings.get("chat_link_mode") or "task-sync"),
},
"person_identifiers": PersonIdentifier.objects.filter(user=request.user).select_related("person").order_by("person__name", "service", "identifier")[:600],
"person_identifiers": person_identifiers,
"external_link_person_identifiers": external_link_person_identifiers,
"external_link_scoped": external_link_scoped,
"external_link_scope_label": external_link_scope_label,
"external_chat_links": external_chat_links,
"sync_events": ExternalSyncEvent.objects.filter(user=request.user).order_by("-updated_at")[:100],
"prefill_service": prefill_service,
@@ -588,6 +944,12 @@ class TaskSettings(LoginRequiredMixin, View):
provider = str(request.POST.get("provider") or "codex_cli").strip().lower() or "codex_cli"
external_chat_id = str(request.POST.get("external_chat_id") or "").strip()
person_identifier_id = str(request.POST.get("person_identifier_id") or "").strip()
prefill_service = str(
request.POST.get("prefill_service") or request.GET.get("service") or ""
).strip().lower()
prefill_identifier = str(
request.POST.get("prefill_identifier") or request.GET.get("identifier") or ""
).strip()
if not external_chat_id:
messages.error(request, "External chat ID is required.")
return _settings_redirect(request)
@@ -598,6 +960,18 @@ class TaskSettings(LoginRequiredMixin, View):
user=request.user,
id=person_identifier_id,
)
if prefill_service and prefill_identifier:
allowed_ids = set(
_scoped_person_identifier_rows(
request.user, prefill_service, prefill_identifier
).values_list("id", flat=True)
)
if identifier.id not in allowed_ids:
messages.error(
request,
"Selected contact is outside the current scoped chat.",
)
return _settings_redirect(request)
row, _ = ExternalChatLink.objects.update_or_create(
user=request.user,
provider=provider,