Improve tasks and backdate insights
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user